diff --git a/CLI/AgentActivitySnapshot.swift b/CLI/AgentActivitySnapshot.swift new file mode 100644 index 000000000000..61104f333e0b --- /dev/null +++ b/CLI/AgentActivitySnapshot.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Query-friendly aggregate over active workloads without hiding workload detail. +struct AgentActivitySnapshot: Codable, Sendable, Equatable { + struct Counts: Codable, Sendable, Equatable { + var foreground = 0 + var backgroundTerminal = 0 + var monitor = 0 + var scheduled = 0 + var subagent = 0 + var tool = 0 + var other = 0 + + enum CodingKeys: String, CodingKey { + case foreground + case backgroundTerminal = "background_terminal" + case monitor + case scheduled + case subagent + case tool + case other + } + + var total: Int { + foreground + backgroundTerminal + monitor + scheduled + subagent + tool + other + } + } + + var state: AgentActivityState + var busy: Bool + var modes: [AgentActivityMode] + var counts: Counts +} diff --git a/CLI/AgentCmuxRuntimeIdentity.swift b/CLI/AgentCmuxRuntimeIdentity.swift new file mode 100644 index 000000000000..ed946378f6c6 --- /dev/null +++ b/CLI/AgentCmuxRuntimeIdentity.swift @@ -0,0 +1,117 @@ +import Foundation + +/// Identifies one running cmux app process without consulting a socket or the +/// process table. Every terminal spawned by that process inherits this value. +struct AgentCmuxRuntimeIdentity: Codable, Sendable, Equatable { + var id: String + var socketPath: String? + var bundleIdentifier: String? + var processId: Int? + var processStartSeconds: Int64? + var processStartMicroseconds: Int64? + + init?(environment: [String: String]) { + guard let id = Self.normalized(environment["CMUX_RUNTIME_ID"]) else { return nil } + self.id = id + socketPath = Self.normalized(environment["CMUX_SOCKET_PATH"]) + ?? Self.normalized(environment["CMUX_SOCKET"]) + bundleIdentifier = Self.normalized(environment["CMUX_BUNDLE_ID"]) + processId = nil + processStartSeconds = nil + processStartMicroseconds = nil + } + + init( + id: String, + socketPath: String?, + bundleIdentifier: String?, + processId: Int? = nil, + processStartSeconds: Int64? = nil, + processStartMicroseconds: Int64? = nil + ) { + self.id = id + self.socketPath = socketPath + self.bundleIdentifier = bundleIdentifier + self.processId = processId + self.processStartSeconds = processStartSeconds + self.processStartMicroseconds = processStartMicroseconds + } + + /// The connected app owns runtime identity. Hook providers may sanitize + /// inherited environment variables, and a surviving process can retain a + /// stale value across an app restart, so socket evidence wins whenever the + /// server advertises it. Older servers fall back to the terminal environment. + static func resolve( + environment: [String: String], + socketCapabilities: [String: Any] + ) -> AgentCmuxRuntimeIdentity? { + if let id = normalized(socketCapabilities["runtime_id"] as? String) { + return AgentCmuxRuntimeIdentity( + id: id, + socketPath: normalized(socketCapabilities["socket_path"] as? String), + bundleIdentifier: normalized(socketCapabilities["bundle_identifier"] as? String) + ) + } + return AgentCmuxRuntimeIdentity(environment: environment) + } + + func applying(to environment: [String: String]) -> [String: String] { + var result = environment + result["CMUX_RUNTIME_ID"] = id + if let socketPath { result["CMUX_SOCKET_PATH"] = socketPath } + if let bundleIdentifier { result["CMUX_BUNDLE_ID"] = bundleIdentifier } + return result + } + + private static func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } + return value + } +} + +/// Chooses current-runtime output without socket I/O. `--all` remains the +/// explicit history view, while shells from older cmux versions retain the +/// legacy evidence filter because they do not carry `CMUX_RUNTIME_ID`. +enum AgentSessionQueryScope: Sendable, Equatable { + case history + case currentRuntime(String) + case legacyUnscoped + + init(includeHistory: Bool, environment: [String: String]) { + if includeHistory { + self = .history + } else if let runtime = AgentCmuxRuntimeIdentity(environment: environment) { + self = .currentRuntime(runtime.id) + } else { + self = .legacyUnscoped + } + } + + func includes( + recordRuntime: AgentCmuxRuntimeIdentity?, + runRuntime: AgentCmuxRuntimeIdentity?, + legacyVisible: Bool + ) -> Bool { + switch self { + case .history: + return true + case let .currentRuntime(runtimeId): + return (runRuntime ?? recordRuntime)?.id == runtimeId + case .legacyUnscoped: + return legacyVisible + } + } + + /// The default view is an operational inventory, not a history log. Keep + /// hibernated and restoring sessions visible because cmux still owns their + /// lifecycle, while completed runs remain available through `--all`. + func includes(projection: AgentSessionStateProjection) -> Bool { + switch self { + case .history: + return true + case .currentRuntime, .legacyUnscoped: + return projection.effective != .ended + } + } +} diff --git a/CLI/AgentHookSessionLineage.swift b/CLI/AgentHookSessionLineage.swift new file mode 100644 index 000000000000..f9c1e592133a --- /dev/null +++ b/CLI/AgentHookSessionLineage.swift @@ -0,0 +1,24 @@ +import CMUXAgentLaunch +import Foundation + +/// The bounded process-lineage result attached to one hook event. +struct AgentHookSessionLineage: Sendable, Equatable { + var runId: String + var pid: Int? + var processStartedAt: TimeInterval? + /// The PID currently resolves to this hook provider's executable, including + /// an allowlisted interpreter entrypoint. Stop events use this with the + /// process start time so PID reuse cannot manufacture a new root run. + var processDescribesAgent: Bool = false + /// Whether the live provider argv is expected to exit after its turn. + /// Replay safety is a separate axis and never controls Stop completion. + var processLaunchMode: AgentProcessLaunchMode = .unknown + /// Exact app-issued claim inherited by a hibernated resume process. + var hibernationResumeAttemptId: UUID? = nil + var cmuxRuntime: AgentCmuxRuntimeIdentity? = nil + var parentRunId: String? + var parentSessionId: String? + var relationship: AgentSessionRelationship? + var restoreAuthority: Bool + var authorityEvidence: AgentSessionAuthorityEvidence? = nil +} diff --git a/CLI/AgentHookSessionLineageResolver.swift b/CLI/AgentHookSessionLineageResolver.swift new file mode 100644 index 000000000000..bd6ee8be3978 --- /dev/null +++ b/CLI/AgentHookSessionLineageResolver.swift @@ -0,0 +1,380 @@ +import CMUXAgentLaunch +import Darwin +import Foundation + +struct AgentHookSessionAuthority: Sendable, Equatable { + var relationship: AgentSessionRelationship? + var restoreAuthority: Bool + var evidence: AgentSessionAuthorityEvidence? +} + +/// Classifies restore ownership from explicit markers and bounded ancestry. +/// Missing PID metadata retains legacy root behavior, while a failed ancestry +/// walk after resolving the process itself fails closed as a child. +struct AgentHookSessionAuthorityPolicy: Sendable { + func classify( + managedChild: Bool, + explicitRelationship: AgentSessionRelationship?, + processIdentityAvailable: Bool, + hasAgentAncestor: Bool, + ancestryProvenAbsent: Bool + ) -> AgentHookSessionAuthority { + let isForkRoot = explicitRelationship == .forked + && !managedChild + && processIdentityAvailable + && !hasAgentAncestor + && ancestryProvenAbsent + let ancestryAmbiguous = processIdentityAvailable + && !hasAgentAncestor + && !ancestryProvenAbsent + let isSpawnedChild = managedChild + || hasAgentAncestor + || ancestryAmbiguous + || explicitRelationship == .spawned + || (explicitRelationship == .forked && !isForkRoot) + let relationship: AgentSessionRelationship? = if isForkRoot { + .forked + } else if isSpawnedChild { + .spawned + } else { + explicitRelationship + } + let evidence: AgentSessionAuthorityEvidence? = if isForkRoot { + .verifiedForkRoot + } else if managedChild { + .managedChild + } else if explicitRelationship == .spawned { + .explicitSpawnedChild + } else if hasAgentAncestor { + .verifiedAncestorChild + } else if isSpawnedChild { + .provisionalAmbiguousChild + } else { + nil + } + return AgentHookSessionAuthority( + relationship: relationship, + restoreAuthority: !isSpawnedChild, + evidence: evidence + ) + } +} + +/// Resolves an agent hook's run identity and nearest agent-process ancestor. +/// +/// The resolver walks at most ``maximumAncestorDepth`` parents and reads only +/// those process records. It never scans the full process table. The resulting +/// lineage is session metadata; restore publication still requires the caller +/// to enforce `restoreAuthority` independently of notification preferences. +struct AgentHookSessionLineageResolver: Sendable { + private let maximumAncestorDepth = 64 + private let launchModeClassifier: AgentLaunchModeClassifier + + init(launchModeClassifier: AgentLaunchModeClassifier = AgentLaunchModeClassifier()) { + self.launchModeClassifier = launchModeClassifier + } + + func resolve( + agentName: String, + sessionId: String, + pid: Int?, + environment: [String: String] + ) -> AgentHookSessionLineage { + let managedChild = Self.bool(environment["CMUX_AGENT_MANAGED_SUBAGENT"]) == true + let explicitRelationship = environment["CMUX_AGENT_RELATIONSHIP"] + .flatMap(Self.relationship) + let parentSessionId = Self.normalized(environment["CMUX_AGENT_PARENT_SESSION_ID"]) + let isCodex = Self.normalized(agentName)?.lowercased() == "codex" + let explicitRunId = isCodex ? Self.normalized(environment["CMUX_CODEX_TEAMS_THREAD_ID"]) : nil + let explicitParentRunId = isCodex + ? Self.normalized(environment["CMUX_CODEX_TEAMS_PARENT_THREAD_ID"]) + : nil + + let identity = pid.flatMap(processIdentity) + let processDescribesAgent = identity.map { + AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: $0.executableName, + arguments: $0.arguments, + kind: agentName + ) + } ?? false + let pidProcessLaunchMode = identity.map { + launchModeClassifier.processMode( + processName: $0.executableName, + arguments: $0.arguments, + kind: agentName + ) + } ?? .unknown + let processLaunchMode: AgentProcessLaunchMode + if pidProcessLaunchMode == .unknown, + identity != nil, + let capturedMode = exactEnvironmentProcessLaunchMode( + agentName: agentName, + environment: environment + ) { + processLaunchMode = capturedMode + } else { + processLaunchMode = pidProcessLaunchMode + } + let hibernationResumeAttemptId = Self.normalized( + environment[AgentHibernationResumeEvidence.environmentKey] + ).flatMap { UUID(uuidString: $0) } + let cmuxRuntime = AgentCmuxRuntimeIdentity(environment: environment) + let ancestorResolution = identity.map { + agentAncestor(startingAt: $0.parentPID, descendant: $0, agentName: agentName) + } ?? .unknown + let ancestor = ancestorResolution.identity + let runId = explicitRunId + ?? identity.map(Self.runId) + ?? cmuxRuntime.map { "runtime:\($0.id):session:\(agentName):\(sessionId)" } + ?? "session:\(agentName):\(sessionId)" + let parentRunId = explicitParentRunId ?? ancestor.map(Self.runId) + let authority = AgentHookSessionAuthorityPolicy().classify( + managedChild: managedChild, + explicitRelationship: explicitRelationship, + processIdentityAvailable: identity != nil, + hasAgentAncestor: ancestor != nil, + ancestryProvenAbsent: ancestorResolution.provesNoAgentAncestor + ) + + return AgentHookSessionLineage( + runId: runId, + pid: pid, + processStartedAt: identity?.startedAt, + processDescribesAgent: processDescribesAgent, + processLaunchMode: processLaunchMode, + hibernationResumeAttemptId: hibernationResumeAttemptId, + cmuxRuntime: cmuxRuntime, + parentRunId: parentRunId, + parentSessionId: parentSessionId, + relationship: authority.relationship, + restoreAuthority: authority.restoreAuthority, + authorityEvidence: authority.evidence + ) + } + + func processState(pid: Int?, expectedStartedAt: TimeInterval?) -> AgentProcessState { + guard let pid, let expectedStartedAt else { return .unknown } + guard let process = kernelProcessInfo(pid) else { return .exited } + let start = process.kp_proc.p_un.__p_starttime + let actualStartedAt = TimeInterval(start.tv_sec) + TimeInterval(start.tv_usec) / 1_000_000 + return abs(actualStartedAt - expectedStartedAt) <= 0.001 ? .alive : .exited + } + + private enum AgentAncestorResolution { + case found(AgentProcessIdentity) + case none + case unknown + + var identity: AgentProcessIdentity? { + guard case let .found(identity) = self else { return nil } + return identity + } + + var provesNoAgentAncestor: Bool { + if case .none = self { return true } + return false + } + } + + private func agentAncestor( + startingAt parentPID: Int, + descendant initialDescendant: AgentProcessIdentity, + agentName: String + ) -> AgentAncestorResolution { + var candidate = parentPID + var descendant = initialDescendant + var visited: Set = [] + var remaining = maximumAncestorDepth + while candidate > 1, remaining > 0, visited.insert(candidate).inserted { + guard let identity = processIdentity(candidate) else { return .unknown } + if identity.isCmuxTerminalHost { + return .none + } + if AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: identity.executableName, + arguments: identity.arguments, + kind: agentName + ) || AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: identity.executableName, + arguments: identity.arguments + ) { + if identity.pid == descendant.parentPID, + AgentLaunchCaptureTrust.nativeProcessIsSameAgentLauncherRelay( + parentProcessName: identity.executableName, + parentArguments: identity.arguments, + childProcessName: descendant.executableName, + childArguments: descendant.arguments, + kind: agentName + ) { + descendant = identity + candidate = identity.parentPID + remaining -= 1 + continue + } + return .found(identity) + } + if AgentLaunchCaptureTrust.nativeProcessIsAmbiguousInterpreterHost( + processName: identity.executableName, + arguments: identity.arguments + ) { + return .unknown + } + candidate = identity.parentPID + remaining -= 1 + } + return candidate <= 1 ? .none : .unknown + } + + private func processIdentity(_ pid: Int) -> AgentProcessIdentity? { + guard let process = kernelProcessInfo(pid) else { return nil } + let start = process.kp_proc.p_un.__p_starttime + let startedAt = TimeInterval(start.tv_sec) + TimeInterval(start.tv_usec) / 1_000_000 + let arguments = processArguments(pid) + return AgentProcessIdentity( + pid: pid, + parentPID: Int(process.kp_eproc.e_ppid), + startedAt: startedAt, + executableName: executableName(pid, arguments: arguments), + arguments: arguments + ) + } + + /// Some interpreter-hosted CLIs overwrite the kernel-visible argv after + /// startup (`pi` via Node and `omp` via Bun). Recover their launch mode only + /// from an exact-kind capture after the live PID classifier failed. The + /// capture is inherited by descendants, so wrapper aliases and captures + /// belonging to another provider are intentionally rejected here. + private func exactEnvironmentProcessLaunchMode( + agentName: String, + environment: [String: String] + ) -> AgentProcessLaunchMode? { + guard let normalizedAgentName = Self.normalized(agentName)?.lowercased(), + Self.normalized(environment["CMUX_AGENT_LAUNCH_KIND"])?.lowercased() + == normalizedAgentName, + let arguments = Self.decodeNULSeparatedBase64( + environment["CMUX_AGENT_LAUNCH_ARGV_B64"] + ), + AgentLaunchCaptureTrust.capturedArgumentsDescribeKind( + launcher: normalizedAgentName, + executablePath: environment["CMUX_AGENT_LAUNCH_EXECUTABLE"], + arguments: arguments, + kind: normalizedAgentName + ) else { + return nil + } + let mode = launchModeClassifier.processMode( + processName: environment["CMUX_AGENT_LAUNCH_EXECUTABLE"] ?? arguments.first, + arguments: arguments, + kind: normalizedAgentName + ) + return mode == .unknown ? nil : mode + } + + private func kernelProcessInfo(_ pid: Int) -> kinfo_proc? { + guard pid > 1, pid <= Int(Int32.max) else { return nil } + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, Int32(pid)] + var process = kinfo_proc() + var length = MemoryLayout.stride + guard sysctl(&mib, u_int(mib.count), &process, &length, nil, 0) == 0, + length >= MemoryLayout.stride, + process.kp_proc.p_pid == pid_t(pid) else { + return nil + } + return process + } + + private func executableName(_ pid: Int, arguments: [String]) -> String? { + var buffer = [CChar](repeating: 0, count: 4096) + let length = buffer.withUnsafeMutableBufferPointer { pointer in + proc_pidpath(pid_t(pid), pointer.baseAddress, UInt32(pointer.count)) + } + if length > 0 { + return URL(fileURLWithPath: String(cString: buffer)).lastPathComponent + } + return arguments.first.map { URL(fileURLWithPath: $0).lastPathComponent } + } + + private func processArguments(_ pid: Int) -> [String] { + var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, Int32(pid)] + var size: size_t = 0 + guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, + size > MemoryLayout.size else { + return [] + } + var bytes = [UInt8](repeating: 0, count: size) + let success = bytes.withUnsafeMutableBytes { buffer in + sysctl(&mib, u_int(mib.count), buffer.baseAddress, &size, nil, 0) == 0 + } + guard success else { return [] } + bytes = Array(bytes.prefix(Int(size))) + + var argcRaw: Int32 = 0 + withUnsafeMutableBytes(of: &argcRaw) { destination in + destination.copyBytes(from: bytes.prefix(MemoryLayout.size)) + } + let argc = Int(Int32(littleEndian: argcRaw)) + guard argc > 0 else { return [] } + + var index = MemoryLayout.size + Self.skipString(bytes, index: &index) + Self.skipNulls(bytes, index: &index) + var arguments: [String] = [] + for _ in 0.. [String]? { + guard let value = normalized(value), + let data = Data(base64Encoded: value), + !data.isEmpty, + data.last == 0 else { + return nil + } + let fields = data.split(separator: 0, omittingEmptySubsequences: false).dropLast() + var arguments: [String] = [] + arguments.reserveCapacity(fields.count) + for field in fields { + guard let argument = String(data: field, encoding: .utf8) else { return nil } + arguments.append(argument) + } + return arguments.isEmpty ? nil : arguments + } + + private static func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } + return value + } + + private static func bool(_ value: String?) -> Bool? { + switch normalized(value)?.lowercased() { + case "1", "true", "yes", "on": true + case "0", "false", "no", "off": false + default: nil + } + } + + private static func relationship(_ value: String) -> AgentSessionRelationship? { + AgentSessionRelationship(rawValue: value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + + private static func runId(_ identity: AgentProcessIdentity) -> String { + "pid:\(identity.pid)@\(Int64(identity.startedAt * 1_000_000))" + } +} diff --git a/CLI/AgentHookSessionRegistryBridge.swift b/CLI/AgentHookSessionRegistryBridge.swift new file mode 100644 index 000000000000..7e757d7908f7 --- /dev/null +++ b/CLI/AgentHookSessionRegistryBridge.swift @@ -0,0 +1,1440 @@ +import CmuxFoundation +import Foundation + +struct AgentHookSessionStoreLoadWarning: Codable, Sendable, Equatable { + enum Code: String, Codable, Sendable { + case authoritativeSnapshotDecodeFailed = "authoritative_snapshot_decode_failed" + case legacySourceImportFailed = "legacy_source_import_failed" + case storageLimitExceeded = "storage_limit_exceeded" + } + + enum Fallback: String, Codable, Sendable { + case legacy + case registry + } + + var provider: String + var path: String + var code: Code + var fallback: Fallback +} + +struct AgentHookSessionStoreLoadResult { + var store: ClaudeHookSessionStoreFile + var warning: AgentHookSessionStoreLoadWarning? +} + +struct AgentHookSessionRegistrySnapshots { + var snapshots: [String: CmuxAgentSessionRegistry.Snapshot] + var warnings: [AgentHookSessionStoreLoadWarning] + var totalRecordCounts: [String: Int] = [:] + var boundedValidationFailures: Set = [] +} + +struct AgentHookSessionStoreLoadFailure: Error { + enum Scope: String, Sendable { + case registryRecord = "registry_record" + case registryProvider = "registry_provider" + case registryGraphNodes = "registry_graph_nodes" + case providerMaterialization = "provider_materialization" + case selectionMaterialization = "selection_materialization" + case legacyFile = "legacy_file" + case legacySessions = "legacy_sessions" + case legacyGraphNodes = "legacy_graph_nodes" + case legacyRecord = "legacy_record" + } + + var provider: String + var path: String + var code: AgentHookSessionStoreLoadWarning.Code + var scope: Scope? = nil + var sessionID: String? = nil + var observedBytes: Int64? = nil + var maximumBytes: Int64? = nil + var observedCount: Int64? = nil + var maximumCount: Int64? = nil + var canonicalPath: String? = nil +} + +/// Converts provider-specific hook models to the shared row-oriented registry. +/// The bridge keeps legacy JSON as a compatibility projection while making the +/// registry authoritative for any row written by this schema generation. +struct AgentHookSessionRegistryBridge { + enum MutationError: Error { + case newerWriterGeneration + } + + private static let maximumInspectionRecordBytes: Int64 = 4 * 1_024 * 1_024 + private static let maximumInspectionProviderBytes: Int64 = 64 * 1_024 * 1_024 + private static let maximumInspectionSelectionBytes: Int64 = 128 * 1_024 * 1_024 + private static let maximumLegacyFileBytes: Int64 = 64 * 1_024 * 1_024 + private static let maximumLegacySessions = 20_000 + private static let maximumLegacyGraphNodes = 20_000 + + struct InspectionStorageLimits { + var recordBytes: Int64 + var providerBytes: Int64 + var selectionBytes: Int64 + var legacyFileBytes: Int64 + + static let production = InspectionStorageLimits( + recordBytes: AgentHookSessionRegistryBridge.maximumInspectionRecordBytes, + providerBytes: AgentHookSessionRegistryBridge.maximumInspectionProviderBytes, + selectionBytes: AgentHookSessionRegistryBridge.maximumInspectionSelectionBytes, + legacyFileBytes: AgentHookSessionRegistryBridge.maximumLegacyFileBytes + ) + } + + typealias InspectionAdmissionLoader = ( + CmuxAgentSessionRegistry.LegacySource, + CmuxAgentSessionRegistry.LegacyStamp, + Int + ) throws -> CmuxAgentSessionRegistry.HookLegacySourceAdmission + + struct InspectionSourcePreflight { + var provider: String + var registryPath: String + var legacyPath: String + var metrics: CmuxAgentSessionRegistry.HookStorageMetrics + var legacyBytes: Int64 + var legacyMetrics: CmuxAgentSessionRegistry.HookLegacySourceMetrics? = nil + } + + struct InspectionPreflightResult { + var admissions: [CmuxAgentSessionRegistry.HookLegacySourceAdmission] + var warnings: [AgentHookSessionStoreLoadWarning] + } + + let provider: String + let statePath: String + let environment: [String: String] + let fileManager: FileManager + + private var registry: CmuxAgentSessionRegistry { + CmuxAgentSessionRegistry(url: registryURL) + } + + private var registryURL: URL { + if let explicit = normalized(environment["CMUX_AGENT_SESSION_REGISTRY_PATH"]) { + return URL(fileURLWithPath: NSString(string: explicit).expandingTildeInPath) + } + if environment["CMUX_CLAUDE_HOOK_STATE_PATH"] != nil + || environment["CMUX_AGENT_HOOK_STATE_DIR"] != nil { + return URL(fileURLWithPath: statePath).deletingLastPathComponent() + .appendingPathComponent(CmuxAgentSessionRegistry.filename, isDirectory: false) + } + return CmuxAgentSessionRegistry.defaultURL(environment: environment) + } + + static func snapshots( + specifications: [(provider: String, suffix: String)], + stateDirectory: String, + environment: [String: String], + fileManager: FileManager, + maximumLegacyGraphNodes: Int = AgentHookSessionRegistryBridge.maximumLegacyGraphNodes + ) throws -> AgentHookSessionRegistrySnapshots { + let registryURL: URL + if let explicit = environment["CMUX_AGENT_SESSION_REGISTRY_PATH"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !explicit.isEmpty { + registryURL = URL(fileURLWithPath: NSString(string: explicit).expandingTildeInPath) + } else { + registryURL = URL(fileURLWithPath: stateDirectory, isDirectory: true) + .appendingPathComponent(CmuxAgentSessionRegistry.filename, isDirectory: false) + } + let registry = CmuxAgentSessionRegistry(url: registryURL) + let sources = specifications.map { specification in + CmuxAgentSessionRegistry.LegacySource( + provider: specification.provider, + url: URL(fileURLWithPath: stateDirectory, isDirectory: true) + .appendingPathComponent("\(specification.suffix)-hook-sessions.json", isDirectory: false) + ) + }.sorted { $0.provider < $1.provider } + let preflight = try preflightInspectionSources( + sources, + registry: registry, + registryPath: registryURL.path, + fileManager: fileManager, + maximumLegacyGraphNodes: max(0, maximumLegacyGraphNodes) + ) + let admissions = preflight.admissions + do { + return AgentHookSessionRegistrySnapshots( + snapshots: try registry.snapshotsImportingAdmittedLegacy( + sources: sources, + admissions: admissions, + maximumGraphNodes: max(0, maximumLegacyGraphNodes) + ), + warnings: preflight.warnings + ) + } catch let error as CmuxAgentSessionRegistry.HookInspectionGraphUnionLimitError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookGraphNodeInspectionLimitError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookGraphNodeMalformedRecordError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookInspectionStorageLimitError { + throw inspectionStorageLoadFailure(error, registryPath: registryURL.path) + } catch { + var recovered: [String: CmuxAgentSessionRegistry.Snapshot] = [:] + var warnings = preflight.warnings + for source in sources { + do { + recovered[source.provider] = try registry.snapshotsImportingAdmittedLegacy( + sources: [source], + admissions: admissions.filter { + $0.source.provider == source.provider + }, + maximumGraphNodes: max(0, maximumLegacyGraphNodes) + )[source.provider] ?? .init(records: [], activeSlots: []) + } catch let error as CmuxAgentSessionRegistry.HookInspectionGraphUnionLimitError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookGraphNodeInspectionLimitError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookGraphNodeMalformedRecordError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookInspectionStorageLimitError { + throw inspectionStorageLoadFailure(error, registryPath: registryURL.path) + } catch { + guard let fallback = try? registry.snapshot(provider: source.provider), + !fallback.records.isEmpty else { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.url.path, + code: .legacySourceImportFailed + ) + } + let bridge = AgentHookSessionRegistryBridge( + provider: source.provider, + statePath: source.url.path, + environment: environment, + fileManager: fileManager + ) + guard let validation = try? bridge.loadForInspection(snapshot: fallback), + validation.warning == nil, + !validation.store.sessions.isEmpty else { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.url.path, + code: .legacySourceImportFailed + ) + } + recovered[source.provider] = fallback + warnings.append(AgentHookSessionStoreLoadWarning( + provider: source.provider, + path: source.url.path, + code: .legacySourceImportFailed, + fallback: .registry + )) + } + } + let consistent: [String: CmuxAgentSessionRegistry.Snapshot] + do { + consistent = try registry.snapshotsImportingAdmittedLegacy( + sources: sources, + admissions: [], + maximumGraphNodes: max(0, maximumLegacyGraphNodes) + ) + } catch let error as CmuxAgentSessionRegistry.HookInspectionGraphUnionLimitError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookGraphNodeInspectionLimitError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookGraphNodeMalformedRecordError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookInspectionStorageLimitError { + throw inspectionStorageLoadFailure(error, registryPath: registryURL.path) + } + return AgentHookSessionRegistrySnapshots( + snapshots: consistent, + warnings: warnings + ) + } + } + + /// Loads only the newest list candidates per provider while preserving the + /// same legacy refresh, storage admission, and registry fallback behavior as + /// the complete inspection path. + static func boundedRecentSnapshotsForList( + specifications: [(provider: String, suffix: String)], + stateDirectory: String, + environment: [String: String], + fileManager: FileManager, + maximumRecordsPerProvider: Int, + maximumLegacyGraphNodes: Int = AgentHookSessionRegistryBridge.maximumLegacyGraphNodes + ) throws -> AgentHookSessionRegistrySnapshots { + let registryURL: URL + if let explicit = environment["CMUX_AGENT_SESSION_REGISTRY_PATH"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !explicit.isEmpty { + registryURL = URL(fileURLWithPath: NSString(string: explicit).expandingTildeInPath) + } else { + registryURL = URL(fileURLWithPath: stateDirectory, isDirectory: true) + .appendingPathComponent(CmuxAgentSessionRegistry.filename, isDirectory: false) + } + let registry = CmuxAgentSessionRegistry(url: registryURL) + let sources = specifications.map { specification in + CmuxAgentSessionRegistry.LegacySource( + provider: specification.provider, + url: URL(fileURLWithPath: stateDirectory, isDirectory: true) + .appendingPathComponent("\(specification.suffix)-hook-sessions.json", isDirectory: false) + ) + }.sorted { $0.provider < $1.provider } + let preflight = try preflightInspectionSources( + sources, + registry: registry, + registryPath: registryURL.path, + fileManager: fileManager, + maximumLegacyGraphNodes: max(0, maximumLegacyGraphNodes) + ) + let admissions = preflight.admissions + let maximumRecordsPerProvider = max(0, maximumRecordsPerProvider) + let decoder = JSONDecoder() + let canonicalizer = AgentSessionRunCanonicalizer() + func projectRecord( + provider: String, + stored: CmuxAgentSessionRegistry.Record + ) throws -> CmuxAgentSessionRegistry.HookListOrderKey { + do { + let record = try decoder.decode(ClaudeHookSessionRecord.self, from: stored.json) + guard record.sessionId == stored.sessionID else { + throw ProjectionError.recordIdentityMismatch + } + let run = canonicalizer.projectedRun(record: record, provider: provider) + return CmuxAgentSessionRegistry.HookListOrderKey( + updatedAt: run.updatedAt, + sortValues: .init( + sessionID: record.sessionId, + agent: provider, + runID: run.runId, + workspaceID: record.workspaceId, + surfaceID: record.surfaceId, + identitySource: "hook_session", + pid: run.pid, + processStartedAt: run.processStartedAt + ) + ) + } catch { + throw CmuxAgentSessionRegistry.HookListProjectionValidationError( + provider: provider + ) + } + } + func validateActiveSlot( + provider: String, + stored: CmuxAgentSessionRegistry.ActiveSlot + ) throws { + do { + let slot = try decoder.decode( + ClaudeHookActiveSessionRecord.self, + from: stored.json + ) + guard slot.sessionId == stored.sessionID else { + throw ProjectionError.slotIdentityMismatch + } + } catch { + throw CmuxAgentSessionRegistry.HookListProjectionValidationError( + provider: provider + ) + } + } + do { + let bounded = try registry.globallyBoundedRecentSnapshotsImportingAdmittedLegacy( + sources: sources, + admissions: admissions, + maximumRecords: maximumRecordsPerProvider, + maximumGraphNodes: max(0, maximumLegacyGraphNodes), + projectRecord: projectRecord, + validateActiveSlot: validateActiveSlot + ) + return AgentHookSessionRegistrySnapshots( + snapshots: bounded.mapValues(\.snapshot), + warnings: preflight.warnings, + totalRecordCounts: bounded.mapValues(\.totalRecordCount) + ) + } catch let error as CmuxAgentSessionRegistry.HookInspectionGraphUnionLimitError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookGraphNodeInspectionLimitError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookGraphNodeMalformedRecordError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookInspectionStorageLimitError { + throw inspectionStorageLoadFailure(error, registryPath: registryURL.path) + } catch { + guard error is CmuxAgentSessionRegistry.HookListProjectionValidationError + || error is CmuxAgentSessionRegistry.HookLegacySourceImportError else { + throw error + } + var recovered: [String: CmuxAgentSessionRegistry.Snapshot] = [:] + var totalRecordCounts: [String: Int] = [:] + var validationFailures: Set = [] + var warnings = preflight.warnings + for source in sources { + do { + let bounded = try registry.globallyBoundedRecentSnapshotsImportingAdmittedLegacy( + sources: [source], + admissions: admissions.filter { + $0.source.provider == source.provider + }, + maximumRecords: maximumRecordsPerProvider, + maximumGraphNodes: max(0, maximumLegacyGraphNodes), + projectRecord: projectRecord, + validateActiveSlot: validateActiveSlot + )[source.provider] ?? CmuxAgentSessionRegistry.BoundedRecentSnapshot( + snapshot: .init(records: [], activeSlots: []), + totalRecordCount: 0 + ) + recovered[source.provider] = bounded.snapshot + totalRecordCounts[source.provider] = bounded.totalRecordCount + } catch let error as CmuxAgentSessionRegistry.HookInspectionGraphUnionLimitError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookGraphNodeInspectionLimitError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookGraphNodeMalformedRecordError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookInspectionStorageLimitError { + throw inspectionStorageLoadFailure(error, registryPath: registryURL.path) + } catch let failure as CmuxAgentSessionRegistry.HookListProjectionValidationError + where failure.provider == source.provider { + recovered[source.provider] = .init(records: [], activeSlots: []) + totalRecordCounts[source.provider] = 0 + validationFailures.insert(source.provider) + } catch let failure as CmuxAgentSessionRegistry.HookLegacySourceImportError + where failure.provider == source.provider { + do { + let fallback = try registry + .globallyBoundedRecentSnapshotsImportingAdmittedLegacy( + sources: [source], + admissions: [], + maximumRecords: maximumRecordsPerProvider, + maximumGraphNodes: max(0, maximumLegacyGraphNodes), + projectRecord: projectRecord, + validateActiveSlot: validateActiveSlot + )[source.provider] ?? CmuxAgentSessionRegistry.BoundedRecentSnapshot( + snapshot: .init(records: [], activeSlots: []), + totalRecordCount: 0 + ) + guard fallback.totalRecordCount > 0 else { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.url.path, + code: .legacySourceImportFailed + ) + } + let bridge = AgentHookSessionRegistryBridge( + provider: source.provider, + statePath: source.url.path, + environment: environment, + fileManager: fileManager + ) + let validation: AgentHookSessionStoreLoadResult + do { + validation = try bridge.loadBoundedForInspection( + snapshot: fallback.snapshot + ) + } catch is AgentHookSessionStoreLoadFailure { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.url.path, + code: .legacySourceImportFailed + ) + } + guard validation.warning == nil, + !validation.store.sessions.isEmpty else { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.url.path, + code: .legacySourceImportFailed + ) + } + recovered[source.provider] = fallback.snapshot + totalRecordCounts[source.provider] = fallback.totalRecordCount + warnings.append(AgentHookSessionStoreLoadWarning( + provider: source.provider, + path: source.url.path, + code: .legacySourceImportFailed, + fallback: .registry + )) + } catch let failure as CmuxAgentSessionRegistry.HookListProjectionValidationError + where failure.provider == source.provider { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.url.path, + code: .legacySourceImportFailed + ) + } catch let failure as AgentHookSessionStoreLoadFailure { + throw failure + } + } + } + let validSources = sources.filter { + !validationFailures.contains($0.provider) + } + let consistent: [String: CmuxAgentSessionRegistry.BoundedRecentSnapshot] + do { + consistent = try registry.globallyBoundedRecentSnapshotsImportingAdmittedLegacy( + sources: validSources, + admissions: [], + maximumRecords: maximumRecordsPerProvider, + maximumGraphNodes: max(0, maximumLegacyGraphNodes), + projectRecord: projectRecord, + validateActiveSlot: validateActiveSlot + ) + } catch let error as CmuxAgentSessionRegistry.HookInspectionGraphUnionLimitError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookGraphNodeInspectionLimitError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookGraphNodeMalformedRecordError { + throw inspectionGraphLoadFailure(error, registryPath: registryURL.path) + } catch let error as CmuxAgentSessionRegistry.HookInspectionStorageLimitError { + throw inspectionStorageLoadFailure(error, registryPath: registryURL.path) + } + recovered = consistent.mapValues(\.snapshot) + totalRecordCounts = consistent.mapValues(\.totalRecordCount) + for provider in validationFailures { + recovered[provider] = .init(records: [], activeSlots: []) + totalRecordCounts[provider] = 0 + } + return AgentHookSessionRegistrySnapshots( + snapshots: recovered, + warnings: warnings, + totalRecordCounts: totalRecordCounts, + boundedValidationFailures: validationFailures + ) + } + } + + func load(decoder: JSONDecoder = JSONDecoder()) -> ClaudeHookSessionStoreFile { + if legacyFileSizeExceedsLimit() { + if let snapshot = try? registry.snapshot(provider: provider), + let state = try? decode(snapshot, decoder: decoder) { + return state + } + return ClaudeHookSessionStoreFile() + } + do { + let snapshot = try registry.snapshotImportingLegacy( + provider: provider, + legacyURL: URL(fileURLWithPath: statePath), + fileManager: fileManager + ) + return try decode(snapshot, decoder: decoder) + } catch { + // Hook reads must remain available when the bounded SQLite busy + // timeout expires. A corrupt compatibility projection keeps the + // last complete SQLite snapshot visible. Writers still fail closed. + if let snapshot = try? registry.snapshot(provider: provider), + let state = try? decode(snapshot, decoder: decoder) { + return state + } + return readLegacy(decoder: decoder) + } + } + + func lookup( + sessionID: String, + decoder: JSONDecoder = JSONDecoder() + ) throws -> ClaudeHookSessionRecord? { + try refreshLegacySource() + guard let stored = try registry.hookRecord(provider: provider, sessionID: sessionID) else { + return nil + } + let record = try decoder.decode(ClaudeHookSessionRecord.self, from: stored.json) + guard record.sessionId == stored.sessionID else { + throw ProjectionError.recordIdentityMismatch + } + return record + } + + func activeContext( + workspaceID: String, + surfaceID: String?, + decoder: JSONDecoder = JSONDecoder() + ) throws -> ClaudeHookSessionStoreFile { + try refreshLegacySource() + return try decode( + registry.hookActiveContext( + provider: provider, + workspaceID: workspaceID, + surfaceID: surfaceID + ).snapshot, + decoder: decoder + ) + } + + func fallbackRecords( + workspaceID: String?, + surfaceID: String?, + decoder: JSONDecoder = JSONDecoder() + ) throws -> [ClaudeHookSessionRecord] { + try refreshLegacySource() + return try registry.hookFallbackRecords( + provider: provider, + workspaceID: workspaceID, + surfaceID: surfaceID + ).map { stored in + let record = try decoder.decode(ClaudeHookSessionRecord.self, from: stored.json) + guard record.sessionId == stored.sessionID else { + throw ProjectionError.recordIdentityMismatch + } + return record + } + } + + func runningRecords( + workspaceID: String, + surfaceID: String?, + decoder: JSONDecoder = JSONDecoder() + ) throws -> [ClaudeHookSessionRecord] { + try refreshLegacySource() + return try registry.hookRunningRecords( + provider: provider, + workspaceID: workspaceID, + surfaceID: surfaceID + ).map { stored in + let record = try decoder.decode(ClaudeHookSessionRecord.self, from: stored.json) + guard record.sessionId == stored.sessionID else { + throw ProjectionError.recordIdentityMismatch + } + return record + } + } + + func load( + snapshot: CmuxAgentSessionRegistry.Snapshot, + decoder: JSONDecoder = JSONDecoder() + ) -> ClaudeHookSessionStoreFile { + (try? loadForInspection(snapshot: snapshot, decoder: decoder).store) + ?? ClaudeHookSessionStoreFile() + } + + func loadForInspection( + snapshot: CmuxAgentSessionRegistry.Snapshot, + decoder: JSONDecoder = JSONDecoder() + ) throws -> AgentHookSessionStoreLoadResult { + do { + let store = try decode(snapshot, decoder: decoder) + guard inspectionProjectionIdentityIsConsistent(store) else { + throw ProjectionError.slotIdentityMismatch + } + return AgentHookSessionStoreLoadResult( + store: store, + warning: nil + ) + } catch { + guard let legacy = readLegacyIfPresent( + decoder: decoder, + requireInspectionProjectionIdentity: true + ) else { + throw AgentHookSessionStoreLoadFailure( + provider: provider, + path: registryURL.path, + code: .authoritativeSnapshotDecodeFailed + ) + } + return AgentHookSessionStoreLoadResult( + store: legacy, + warning: AgentHookSessionStoreLoadWarning( + provider: provider, + path: registryURL.path, + code: .authoritativeSnapshotDecodeFailed, + fallback: .legacy + ) + ) + } + } + + func loadBoundedForInspection( + snapshot: CmuxAgentSessionRegistry.Snapshot, + authoritativeValidationFailed: Bool = false, + decoder: JSONDecoder = JSONDecoder() + ) throws -> AgentHookSessionStoreLoadResult { + do { + guard !authoritativeValidationFailed else { + throw ProjectionError.recordIdentityMismatch + } + let store = try decode(snapshot, decoder: decoder) + guard inspectionProjectionIdentityIsConsistent(store) else { + throw ProjectionError.slotIdentityMismatch + } + return AgentHookSessionStoreLoadResult(store: store, warning: nil) + } catch { + guard let legacy = readLegacyIfPresent( + decoder: decoder, + requireInspectionProjectionIdentity: true + ) else { + throw AgentHookSessionStoreLoadFailure( + provider: provider, + path: registryURL.path, + code: .authoritativeSnapshotDecodeFailed + ) + } + return AgentHookSessionStoreLoadResult( + store: legacy, + warning: AgentHookSessionStoreLoadWarning( + provider: provider, + path: registryURL.path, + code: .authoritativeSnapshotDecodeFailed, + fallback: .legacy + ) + ) + } + } + + func mutate( + _ body: (inout ClaudeHookSessionStoreFile) throws -> T + ) throws -> (result: T, state: ClaudeHookSessionStoreFile) { + _ = try registry.snapshotImportingLegacy( + provider: provider, + legacyURL: URL(fileURLWithPath: statePath), + fileManager: fileManager + ) + let decoder = JSONDecoder() + let encoder = JSONEncoder() + return try registry.mutateSnapshot(provider: provider) { snapshot in + var state = try decode(snapshot, decoder: decoder) + let previous = state + let result = try body(&state) + + var recordsByID = Dictionary(uniqueKeysWithValues: snapshot.records.map { ($0.sessionID, $0) }) + for (sessionID, record) in state.sessions { + guard previous.sessions[sessionID]?.updatedAt != record.updatedAt + || previous.sessions[sessionID] == nil else { continue } + if let existing = recordsByID[sessionID], + existing.writerGeneration > CmuxAgentSessionRegistry.currentWriterGeneration { + throw MutationError.newerWriterGeneration + } + recordsByID[sessionID] = CmuxAgentSessionRegistry.Record( + provider: provider, + sessionID: sessionID, + updatedAt: record.updatedAt, + json: try encoder.encode(record) + ) + } + for sessionID in Set(previous.sessions.keys).subtracting(state.sessions.keys) { + guard recordsByID[sessionID]?.writerGeneration ?? 0 + <= CmuxAgentSessionRegistry.currentWriterGeneration else { + throw MutationError.newerWriterGeneration + } + recordsByID.removeValue(forKey: sessionID) + } + snapshot.records = Array(recordsByID.values) + + let previousSlots = slotMap(previous) + let currentSlots = slotMap(state) + var slotsByKey = Dictionary(uniqueKeysWithValues: snapshot.activeSlots.map { + (CmuxAgentSessionRegistry.slotKey(scope: $0.scope, scopeID: $0.scopeID), $0) + }) + for (key, slot) in currentSlots { + let old = previousSlots[key] + guard old?.record.updatedAt != slot.record.updatedAt + || old?.record.sessionId != slot.record.sessionId + || old?.record.turnId != slot.record.turnId else { continue } + if let existing = slotsByKey[key], + existing.writerGeneration > CmuxAgentSessionRegistry.currentWriterGeneration { + throw MutationError.newerWriterGeneration + } + slotsByKey[key] = CmuxAgentSessionRegistry.ActiveSlot( + provider: provider, + scope: slot.scope, + scopeID: slot.scopeID, + sessionID: slot.record.sessionId, + updatedAt: slot.record.updatedAt, + json: try encoder.encode(slot.record) + ) + } + for key in Set(previousSlots.keys).subtracting(currentSlots.keys) { + guard slotsByKey[key]?.writerGeneration ?? 0 + <= CmuxAgentSessionRegistry.currentWriterGeneration else { + throw MutationError.newerWriterGeneration + } + slotsByKey.removeValue(forKey: key) + } + snapshot.activeSlots = Array(slotsByKey.values) + return (result, state) + } + } + + func mutateSession( + sessionID: String, + workspaceID: String?, + surfaceID: String?, + includeOwnedSlots: Bool = true, + _ body: (inout ClaudeHookSessionStoreFile) throws -> T + ) throws -> ( + result: T, + state: ClaudeHookSessionStoreFile, + revision: Int64, + recordsRead: Int, + slotsRead: Int, + recordsWritten: Int, + slotsWritten: Int + ) { + try refreshLegacySource() + let decoder = JSONDecoder() + let encoder = JSONEncoder() + var explicitSlots = Set() + if let workspaceID = normalized(workspaceID) { + explicitSlots.insert(.init(scope: .workspace, scopeID: workspaceID)) + } + if let surfaceID = normalized(surfaceID) { + explicitSlots.insert(.init(scope: .surface, scopeID: surfaceID)) + } + let mutation = try registry.mutateHookSession( + provider: provider, + sessionID: sessionID, + activeSlots: explicitSlots, + includeOwnedSlots: includeOwnedSlots + ) { snapshot in + var state = try decode(snapshot, decoder: decoder) + let previous = state + let result = try body(&state) + + let previousRecord = snapshot.records.first { $0.sessionID == sessionID } + let previousValue = previous.sessions[sessionID] + let currentValue = state.sessions[sessionID] + if let currentValue { + let currentTypedJSON = try encoder.encode(currentValue) + let previousTypedJSON = try previousValue.map(encoder.encode) + if previousTypedJSON != currentTypedJSON || previousRecord == nil { + if let previousRecord, + previousRecord.writerGeneration > CmuxAgentSessionRegistry.currentWriterGeneration { + throw MutationError.newerWriterGeneration + } + snapshot.records = [CmuxAgentSessionRegistry.Record( + provider: provider, + sessionID: sessionID, + updatedAt: currentValue.updatedAt, + json: try mergedJSON( + original: previousRecord?.json, + previousTyped: previousTypedJSON, + currentTyped: currentTypedJSON + ) + )] + } + } else { + if let previousRecord, + previousRecord.writerGeneration > CmuxAgentSessionRegistry.currentWriterGeneration { + throw MutationError.newerWriterGeneration + } + snapshot.records = [] + } + + let previousSlots = slotMap(previous) + let currentSlots = slotMap(state) + let storedSlots = Dictionary(uniqueKeysWithValues: snapshot.activeSlots.map { + (CmuxAgentSessionRegistry.slotKey(scope: $0.scope, scopeID: $0.scopeID), $0) + }) + var projectedSlots: [CmuxAgentSessionRegistry.ActiveSlot] = [] + projectedSlots.reserveCapacity(currentSlots.count) + for (key, slot) in currentSlots { + let oldValue = previousSlots[key]?.record + let oldTypedJSON = try oldValue.map(encoder.encode) + let currentTypedJSON = try encoder.encode(slot.record) + if oldTypedJSON == currentTypedJSON, let stored = storedSlots[key] { + projectedSlots.append(stored) + continue + } + if let stored = storedSlots[key], + stored.writerGeneration > CmuxAgentSessionRegistry.currentWriterGeneration, + oldTypedJSON != currentTypedJSON { + throw MutationError.newerWriterGeneration + } + projectedSlots.append(CmuxAgentSessionRegistry.ActiveSlot( + provider: provider, + scope: slot.scope, + scopeID: slot.scopeID, + sessionID: slot.record.sessionId, + updatedAt: slot.record.updatedAt, + writerGeneration: max( + storedSlots[key]?.writerGeneration ?? 0, + CmuxAgentSessionRegistry.currentWriterGeneration + ), + json: try mergedJSON( + original: storedSlots[key]?.json, + previousTyped: oldTypedJSON, + currentTyped: currentTypedJSON + ) + )) + } + for (key, stored) in storedSlots where currentSlots[key] == nil { + guard stored.writerGeneration <= CmuxAgentSessionRegistry.currentWriterGeneration else { + throw MutationError.newerWriterGeneration + } + } + snapshot.activeSlots = projectedSlots + return (result, state) + } + do { + try projectLegacy(including: mutation.revision) + } catch let error as POSIXError + where error.code == .EWOULDBLOCK || error.code == .EAGAIN { + // The canonical mutation is durable. A later hook or app read will + // converge the compatibility projection after the lock is released. + } + return ( + mutation.result.0, + mutation.result.1, + mutation.revision, + mutation.recordsRead, + mutation.slotsRead, + mutation.recordsWritten, + mutation.slotsWritten + ) + } + + func projectLegacy(including requiredRevision: Int64) throws { + try registry.projectHookLegacyStore( + provider: provider, + to: URL(fileURLWithPath: statePath), + including: requiredRevision, + fileManager: fileManager + ) + } + + func markLegacySourceCurrent() { + guard let stamp = CmuxAgentSessionRegistry.LegacyStamp.read(path: statePath, fileManager: fileManager) else { + return + } + try? registry.markLegacySource(provider: provider, stamp: stamp) + } + + private func readLegacy(decoder: JSONDecoder) -> ClaudeHookSessionStoreFile { + readLegacyIfPresent(decoder: decoder) ?? ClaudeHookSessionStoreFile() + } + + private func refreshLegacySource() throws { + try validateLegacyFileSize() + let result = try registry.refreshLegacySources( + [CmuxAgentSessionRegistry.LegacySource( + provider: provider, + url: URL(fileURLWithPath: statePath) + )], + fileManager: fileManager + ) + guard !result.failedProviders.contains(provider) + || !fileManager.fileExists(atPath: statePath) else { + throw AgentHookSessionStoreLoadFailure( + provider: provider, + path: statePath, + code: .legacySourceImportFailed + ) + } + } + + private func mergedJSON( + original: Data?, + previousTyped: Data?, + currentTyped: Data + ) throws -> Data { + var object = original.flatMap { + try? JSONSerialization.jsonObject(with: $0) as? [String: Any] + } ?? [:] + let previous = previousTyped.flatMap { + try? JSONSerialization.jsonObject(with: $0) as? [String: Any] + } ?? [:] + guard let current = try JSONSerialization.jsonObject(with: currentTyped) as? [String: Any] else { + throw CocoaError(.propertyListReadCorrupt) + } + for key in Set(previous.keys).union(current.keys) { + object.removeValue(forKey: key) + } + object.merge(current) { _, new in new } + guard JSONSerialization.isValidJSONObject(object) else { + throw CocoaError(.propertyListWriteInvalid) + } + return try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + } + + private func readLegacyIfPresent( + decoder: JSONDecoder, + requireInspectionProjectionIdentity: Bool = false + ) -> ClaudeHookSessionStoreFile? { + guard !legacyFileSizeExceedsLimit(), + fileManager.fileExists(atPath: statePath), + let data = readLegacyDataIfWithinLimit(), + let store = try? decoder.decode(ClaudeHookSessionStoreFile.self, from: data), + !requireInspectionProjectionIdentity || inspectionProjectionIdentityIsConsistent(store) else { + return nil + } + return store + } + + static func preflightInspectionSources( + _ sources: [CmuxAgentSessionRegistry.LegacySource], + registry: CmuxAgentSessionRegistry, + registryPath: String, + fileManager: FileManager, + maximumLegacyGraphNodes: Int, + limits: InspectionStorageLimits = .production, + admissionLoader: InspectionAdmissionLoader? = nil + ) throws -> InspectionPreflightResult { + var preflights: [InspectionSourcePreflight] = [] + var admissions: [CmuxAgentSessionRegistry.HookLegacySourceAdmission] = [] + var warnings: [AgentHookSessionStoreLoadWarning] = [] + var selectedLegacyGraphNodes = 0 + let loadAdmission: InspectionAdmissionLoader = admissionLoader ?? { + source, stamp, remainingGraphNodes in + try registry.hookLegacySourceAdmission( + source: source, + expectedStamp: stamp, + fileManager: fileManager, + maximumBytes: max(0, limits.legacyFileBytes), + maximumSessions: maximumLegacySessions, + maximumGraphNodes: remainingGraphNodes, + maximumRecordBytes: max(0, limits.recordBytes) + ) + } + let storageMetricsByProvider = try registry.hookStorageMetrics( + providers: sources.map(\.provider) + ) + for source in sources { + guard let storageMetrics = storageMetricsByProvider[source.provider] else { + throw CocoaError(.fileReadCorruptFile) + } + let initialStamp = CmuxAgentSessionRegistry.LegacyStamp.read( + path: source.url.path, + fileManager: fileManager + ) + let sourceChanged = if let initialStamp { + try !registry.legacySourceIsCurrent( + provider: source.provider, + stamp: initialStamp + ) + } else { + false + } + let changedLegacyBytes = sourceChanged ? max(0, initialStamp?.size ?? 0) : 0 + preflights.append(InspectionSourcePreflight( + provider: source.provider, + registryPath: registryPath, + legacyPath: source.url.path, + metrics: storageMetrics, + legacyBytes: changedLegacyBytes + )) + // The aggregate cap applies to retained source bytes. Check it + // incrementally before reading this compatibility revision so a + // set of individually valid files cannot allocate past the cap. + try validateInspectionStorage(preflights, limits: limits) + + guard sourceChanged, var expectedStamp = initialStamp else { continue } + var admissionAttempts = 0 + admissionLoop: while admissionAttempts < 2 { + if try registry.legacySourceIsCurrent( + provider: source.provider, + stamp: expectedStamp + ) { + preflights[preflights.index(before: preflights.endIndex)].legacyBytes = 0 + break admissionLoop + } + preflights[preflights.index(before: preflights.endIndex)].legacyBytes = + max(0, expectedStamp.size) + try validateInspectionStorage(preflights, limits: limits) + do { + let admission = try loadAdmission( + source, + expectedStamp, + max(0, maximumLegacyGraphNodes - selectedLegacyGraphNodes) + ) + admissions.append(admission) + preflights[preflights.index(before: preflights.endIndex)].legacyMetrics = + admission.metrics + selectedLegacyGraphNodes += admission.metrics.graphNodeCount + break admissionLoop + } catch { + let latestStamp = CmuxAgentSessionRegistry.LegacyStamp.read( + path: source.url.path, + fileManager: fileManager + ) + let pathRevisionChanged = if let latestStamp { + latestStamp != expectedStamp + } else { + true + } + if error is CmuxAgentSessionRegistry.HookLegacySourceRevisionChangedError + || pathRevisionChanged { + admissionAttempts += 1 + if admissionAttempts < 2, let latestStamp { + expectedStamp = latestStamp + continue admissionLoop + } + guard canonicalInspectionFallbackIsValid( + registry: registry, + provider: source.provider, + limits: limits + ) else { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.url.path, + code: .legacySourceImportFailed + ) + } + preflights[preflights.index(before: preflights.endIndex)].legacyBytes = 0 + warnings.append(AgentHookSessionStoreLoadWarning( + provider: source.provider, + path: source.url.path, + code: .legacySourceImportFailed, + fallback: .registry + )) + break admissionLoop + } + if let error = error as? CmuxAgentSessionRegistry.HookLegacySourceInspectionLimitError { + throw legacyInspectionFailure( + provider: source.provider, + error: error, + aggregateMaximumGraphNodes: maximumLegacyGraphNodes, + registryPath: registryPath + ) + } + if let error = error as? CmuxAgentSessionRegistry.HookLegacySourceSizeError { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.url.path, + code: .storageLimitExceeded, + scope: .legacyFile, + observedBytes: error.observedBytes, + maximumBytes: error.maximumBytes, + canonicalPath: registryPath + ) + } + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.url.path, + code: .legacySourceImportFailed + ) + } + } + } + try validateInspectionStorage(preflights, limits: limits) + return InspectionPreflightResult(admissions: admissions, warnings: warnings) + } + + private static func canonicalInspectionFallbackIsValid( + registry: CmuxAgentSessionRegistry, + provider: String, + limits: InspectionStorageLimits + ) -> Bool { + guard let snapshot = try? registry.hookBoundedSnapshot( + provider: provider, + maximumRecords: maximumLegacySessions, + maximumProviderBytes: max(0, limits.providerBytes), + maximumRecordBytes: max(0, limits.recordBytes) + ), !snapshot.records.isEmpty else { + return false + } + let decoder = JSONDecoder() + do { + var recordsByID: [String: ClaudeHookSessionRecord] = [:] + recordsByID.reserveCapacity(snapshot.records.count) + for stored in snapshot.records { + let record = try decoder.decode(ClaudeHookSessionRecord.self, from: stored.json) + guard record.sessionId == stored.sessionID else { return false } + recordsByID[stored.sessionID] = record + } + for stored in snapshot.activeSlots { + let slot = try decoder.decode(ClaudeHookActiveSessionRecord.self, from: stored.json) + guard slot.sessionId == stored.sessionID else { return false } + let owner = recordsByID[stored.sessionID] + switch stored.scope { + case .workspace: + guard owner?.workspaceId == stored.scopeID else { return false } + case .surface: + guard owner?.surfaceId == stored.scopeID else { return false } + } + } + return true + } catch { + return false + } + } + + private static func inspectionGraphLoadFailure( + _ error: CmuxAgentSessionRegistry.HookInspectionGraphUnionLimitError, + registryPath: String + ) -> AgentHookSessionStoreLoadFailure { + AgentHookSessionStoreLoadFailure( + provider: error.provider, + path: error.path, + code: .storageLimitExceeded, + scope: .legacyGraphNodes, + observedCount: error.observed, + maximumCount: error.maximum, + canonicalPath: registryPath + ) + } + + private static func inspectionGraphLoadFailure( + _ error: CmuxAgentSessionRegistry.HookGraphNodeInspectionLimitError, + registryPath: String + ) -> AgentHookSessionStoreLoadFailure { + AgentHookSessionStoreLoadFailure( + provider: error.provider, + path: registryPath, + code: .storageLimitExceeded, + scope: .registryGraphNodes, + observedCount: error.observed, + maximumCount: error.maximum, + canonicalPath: registryPath + ) + } + + private static func inspectionGraphLoadFailure( + _ error: CmuxAgentSessionRegistry.HookGraphNodeMalformedRecordError, + registryPath: String + ) -> AgentHookSessionStoreLoadFailure { + AgentHookSessionStoreLoadFailure( + provider: error.provider, + path: registryPath, + code: .authoritativeSnapshotDecodeFailed, + scope: .registryRecord, + sessionID: error.sessionID, + canonicalPath: registryPath + ) + } + + private static func inspectionStorageLoadFailure( + _ error: CmuxAgentSessionRegistry.HookInspectionStorageLimitError, + registryPath: String + ) -> AgentHookSessionStoreLoadFailure { + let scope: AgentHookSessionStoreLoadFailure.Scope = switch error.scope { + case .record: .registryRecord + case .provider: .registryProvider + case .selection: .selectionMaterialization + } + return AgentHookSessionStoreLoadFailure( + provider: error.provider, + path: registryPath, + code: .storageLimitExceeded, + scope: scope, + sessionID: error.sessionID, + observedBytes: error.observed, + maximumBytes: error.maximum, + canonicalPath: registryPath + ) + } + + private static func legacyInspectionFailure( + provider: String, + error: CmuxAgentSessionRegistry.HookLegacySourceInspectionLimitError, + aggregateMaximumGraphNodes: Int, + registryPath: String + ) -> AgentHookSessionStoreLoadFailure { + switch error.scope { + case .sessions: + AgentHookSessionStoreLoadFailure( + provider: provider, + path: error.path, + code: .storageLimitExceeded, + scope: .legacySessions, + observedCount: error.observed, + maximumCount: error.maximum, + canonicalPath: registryPath + ) + case .graphNodes: + AgentHookSessionStoreLoadFailure( + provider: provider, + path: error.path, + code: .storageLimitExceeded, + scope: .legacyGraphNodes, + sessionID: error.sessionID, + observedCount: Int64(aggregateMaximumGraphNodes) + 1, + maximumCount: Int64(aggregateMaximumGraphNodes), + canonicalPath: registryPath + ) + case .recordBytes, .identifierBytes: + AgentHookSessionStoreLoadFailure( + provider: provider, + path: error.path, + code: .storageLimitExceeded, + scope: .legacyRecord, + sessionID: error.sessionID, + observedBytes: error.observed, + maximumBytes: error.maximum, + canonicalPath: registryPath + ) + } + } + + static func validateInspectionStorage( + _ sources: [InspectionSourcePreflight], + limits: InspectionStorageLimits = .production + ) throws { + let recordBytesLimit = max(0, limits.recordBytes) + let providerBytesLimit = max(0, limits.providerBytes) + let selectionBytesLimit = max(0, limits.selectionBytes) + let legacyFileBytesLimit = max(0, limits.legacyFileBytes) + var selectedBytes: Int64 = 0 + for source in sources { + let metrics = source.metrics + if metrics.largestRecordBytes > recordBytesLimit { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.registryPath, + code: .storageLimitExceeded, + scope: .registryRecord, + sessionID: metrics.largestRecordSessionID, + observedBytes: metrics.largestRecordBytes, + maximumBytes: recordBytesLimit + ) + } + if metrics.totalBytes > providerBytesLimit { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.registryPath, + code: .storageLimitExceeded, + scope: .registryProvider, + observedBytes: metrics.totalBytes, + maximumBytes: providerBytesLimit + ) + } + if source.legacyBytes > legacyFileBytesLimit { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.legacyPath, + code: .storageLimitExceeded, + scope: .legacyFile, + observedBytes: source.legacyBytes, + maximumBytes: legacyFileBytesLimit, + canonicalPath: source.registryPath + ) + } + let (providerBytes, providerOverflow) = metrics.totalBytes.addingReportingOverflow( + source.legacyBytes + ) + let boundedProviderBytes: Int64 = providerOverflow ? .max : providerBytes + if boundedProviderBytes > providerBytesLimit { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.legacyBytes > 0 ? source.legacyPath : source.registryPath, + code: .storageLimitExceeded, + scope: .providerMaterialization, + observedBytes: boundedProviderBytes, + maximumBytes: providerBytesLimit + ) + } + let (sum, overflow) = selectedBytes.addingReportingOverflow(boundedProviderBytes) + selectedBytes = overflow ? .max : sum + if selectedBytes > selectionBytesLimit { + throw AgentHookSessionStoreLoadFailure( + provider: source.provider, + path: source.registryPath, + code: .storageLimitExceeded, + scope: .selectionMaterialization, + observedBytes: selectedBytes, + maximumBytes: selectionBytesLimit + ) + } + } + } + + private func validateLegacyFileSize() throws { + guard let stamp = CmuxAgentSessionRegistry.LegacyStamp.read( + path: statePath, + fileManager: fileManager + ), stamp.size > Self.maximumLegacyFileBytes else { + return + } + throw AgentHookSessionStoreLoadFailure( + provider: provider, + path: statePath, + code: .storageLimitExceeded, + scope: .legacyFile, + observedBytes: stamp.size, + maximumBytes: Self.maximumLegacyFileBytes + ) + } + + private func legacyFileSizeExceedsLimit() -> Bool { + guard let stamp = CmuxAgentSessionRegistry.LegacyStamp.read( + path: statePath, + fileManager: fileManager + ) else { + return false + } + return stamp.size > Self.maximumLegacyFileBytes + } + + private func readLegacyDataIfWithinLimit() -> Data? { + try? registry.readHookLegacySourceData( + at: URL(fileURLWithPath: statePath), + maximumBytes: Self.maximumLegacyFileBytes + ) + } + + private func inspectionProjectionIdentityIsConsistent( + _ store: ClaudeHookSessionStoreFile + ) -> Bool { + guard store.sessions.allSatisfy({ sessionID, record in + sessionID == record.sessionId + }) else { return false } + guard store.activeSessionsByWorkspace.allSatisfy({ workspaceID, slot in + store.sessions[slot.sessionId]?.workspaceId == workspaceID + }) else { return false } + return store.activeSessionsBySurface.allSatisfy({ surfaceID, slot in + store.sessions[slot.sessionId]?.surfaceId == surfaceID + }) + } + + private func decode( + _ snapshot: CmuxAgentSessionRegistry.Snapshot, + decoder: JSONDecoder + ) throws -> ClaudeHookSessionStoreFile { + var state = ClaudeHookSessionStoreFile() + for stored in snapshot.records { + let record = try decoder.decode(ClaudeHookSessionRecord.self, from: stored.json) + guard record.sessionId == stored.sessionID else { + throw ProjectionError.recordIdentityMismatch + } + state.sessions[stored.sessionID] = record + } + for slot in snapshot.activeSlots { + let record = try decoder.decode(ClaudeHookActiveSessionRecord.self, from: slot.json) + guard record.sessionId == slot.sessionID else { + throw ProjectionError.slotIdentityMismatch + } + switch slot.scope { + case .workspace: state.activeSessionsByWorkspace[slot.scopeID] = record + case .surface: state.activeSessionsBySurface[slot.scopeID] = record + } + } + return state + } + + private enum ProjectionError: Error { + case recordIdentityMismatch + case slotIdentityMismatch + } + + private struct SlotValue { + var scope: CmuxAgentSessionRegistry.Scope + var scopeID: String + var record: ClaudeHookActiveSessionRecord + } + + private func slotMap(_ state: ClaudeHookSessionStoreFile) -> [String: SlotValue] { + var result: [String: SlotValue] = [:] + for (scopeID, record) in state.activeSessionsByWorkspace { + let scope = CmuxAgentSessionRegistry.Scope.workspace + result[CmuxAgentSessionRegistry.slotKey(scope: scope, scopeID: scopeID)] = SlotValue( + scope: scope, + scopeID: scopeID, + record: record + ) + } + for (scopeID, record) in state.activeSessionsBySurface { + let scope = CmuxAgentSessionRegistry.Scope.surface + result[CmuxAgentSessionRegistry.slotKey(scope: scope, scopeID: scopeID)] = SlotValue( + scope: scope, + scopeID: scopeID, + record: record + ) + } + return result + } + + private func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } +} diff --git a/CLI/AgentHookSessionStoreCompletion.swift b/CLI/AgentHookSessionStoreCompletion.swift new file mode 100644 index 000000000000..ea18731a464a --- /dev/null +++ b/CLI/AgentHookSessionStoreCompletion.swift @@ -0,0 +1,31 @@ +import Foundation + +extension ClaudeHookSessionStore { + func completeSessionRecord(_ record: ClaudeHookSessionRecord) -> ClaudeHookSessionRecord { + var completed = record + let now = Date().timeIntervalSince1970 + completed.completedAt = now + completed.sessionState = .ended + completed.restoreAuthority = false + completed.runtimeStatus = .idle + completed.agentLifecycle = .idle + completed.foregroundState = completed.foregroundState == .interrupted ? .interrupted : .completed + completed.attentionState = AgentAttentionState.none + completed.workloads = AgentSessionWorkloadReconciler().cancellingActiveWorkloads( + completed.workloads ?? [], + reason: "root_exited", + now: now + ) + completed.updatedAt = now + if var runs = completed.runs { + for index in runs.indices where runs[index].endedAt == nil { + runs[index].endedAt = now + runs[index].updatedAt = now + runs[index].restoreAuthority = false + } + completed.runs = runs + } + completed.activeRunId = nil + return completed + } +} diff --git a/CLI/AgentHookSessionStoreModels.swift b/CLI/AgentHookSessionStoreModels.swift new file mode 100644 index 000000000000..c215132fbc25 --- /dev/null +++ b/CLI/AgentHookSessionStoreModels.swift @@ -0,0 +1,483 @@ +import Foundation + +struct ClaudeHookParsedInput { + let rawObject: [String: Any]? + let object: [String: Any]? + let rawFallback: String? + let sessionId: String? + let turnId: String? + let cwd: String? + let transcriptPath: String? +} + +enum AgentHookRuntimeStatus: String, Codable { + case running + case idle + case needsInput + case error +} + +struct ClaudeHookSessionRecord: Codable { + var sessionId: String + var workspaceId: String + var surfaceId: String + var cwd: String? + var transcriptPath: String? + var pid: Int? + var launchCommand: AgentHookLaunchCommandRecord? + /// Last hook-observed `permission_mode`, re-applied as `--permission-mode` + /// on user-owned session restore. + var lastPermissionMode: String? + var isRestorable: Bool? + var agentLifecycle: AgentHibernationLifecycleState? + var lastSubtitle: String? + var lastBody: String? + var lastNotificationStatus: AgentHookNotificationStatus? + var lastEmittedNotificationFingerprint: String? + var lastEmittedNotificationAt: TimeInterval? + var recentEmittedNotificationFingerprints: [String: TimeInterval]? + var runtimeStatus: AgentHookRuntimeStatus? + var activePromptDepth: Int? + var activePromptTurnId: String? + var activePromptTurnIds: [String]? + var lastPromptTurnId: String? + var terminalPromptTurnIds: [String]? + var startedAt: TimeInterval + var updatedAt: TimeInterval + // Auto-naming engine state (all optional so stores written before the + // feature decode unchanged). The durable baseline advances only after a + // confirmed title apply; the in-flight marker dedupes concurrent Stops. + var autoNameLastTitle: String? + var autoNameLastLineCount: Int? + var autoNameLastNamedAt: TimeInterval? + var autoNameInFlightAt: TimeInterval? + /// Wall-clock of the last summarization attempt (success OR failure), so a + /// persistently failing summarizer (rate-limited, signed out, timing out) + /// gets the same minInterval cooldown instead of respawning every turn. + var autoNameLastAttemptAt: TimeInterval? + var autoNameRecentMessages: [AutoNamingTranscriptMessage]? + var autoNameMessageSequence: Int? + /// Whether the most recent Stop reported unfinished background work + /// (a running `background_tasks` entry or a pending `session_crons`). + /// Cached here because the ~60s-later `idle_prompt` Notification payload + /// does not carry `background_tasks`, so the idle-reminder gate reads this. + /// Optional so stores written before this field decode unchanged. + var hadPendingBackgroundWorkAtStop: Bool? + /// Orthogonal semantic state used by `cmux agents`. Provider adapters update + /// these fields without storing commands, prompts, output, or environment. + var foregroundState: AgentForegroundState? = nil + var attentionState: AgentAttentionState? = nil + var workloads: [AgentWorkloadRecord]? = nil + var sessionState: AgentSessionLifecycleState? = nil + /// Process generations observed for this logical session. Optional for + /// compatibility with stores written before session graphs existed. + var runs: [AgentSessionRunRecord]? = nil + var activeRunId: String? = nil + var runId: String? = nil + var parentRunId: String? = nil + var restoreAuthority: Bool? = nil + var parentSessionId: String? = nil + var relationship: AgentSessionRelationship? = nil + var authorityEvidence: AgentSessionAuthorityEvidence? = nil + var completedAt: TimeInterval? = nil + /// The cmux app process that most recently owned the active run. + var cmuxRuntime: AgentCmuxRuntimeIdentity? = nil + /// App-owned recovery tokens. Making these fields part of the typed model + /// lets an accepted provider activation remove a completed hibernation + /// handoff while the registry bridge continues preserving unrelated + /// fields written by newer schema generations. + var cmuxRestoreAdoptionId: String? = nil + var cmuxHibernationAttemptId: String? = nil + var cmuxHibernatedAt: TimeInterval? = nil + var cmuxHibernationDetached: Bool? = nil + var cmuxHibernationResumeAttemptId: String? = nil + var cmuxHibernationResumeStartedAt: TimeInterval? = nil + var cmuxHibernationResumeFromAttemptId: String? = nil +} + +struct ClaudeHookActiveSessionRecord: Codable { + var sessionId: String + var turnId: String? + var allowsNewSessionReplacement: Bool? + var updatedAt: TimeInterval +} + +struct AgentPromptSubmitResult: Sendable, Equatable { + var accepted: Bool + var staleTerminalTurn: Bool + var nested: Bool +} + +struct AgentPromptStopResult: Sendable, Equatable { + var accepted: Bool + var nested: Bool + var completedGeneration: Bool = false + var completionReason: AgentPromptStopCompletionReason? = nil + var clearedActiveBoundary: Bool = false + + var shouldClearVisibleState: Bool { + guard completedGeneration, clearedActiveBoundary else { return false } + return completionReason == .terminalLaunch || completionReason == .processExited + } +} + +enum AgentPromptStopCompletionReason: Sendable, Equatable { + case terminalLaunch + case processExited + case processIdentityChanged + case inconsistentRecord +} + +enum AgentPromptStopLineageDecision: Sendable, Equatable { + case apply + case completeRecordedGeneration(AgentPromptStopCompletionReason) + case rejectStaleGeneration +} + +/// A Stop is a turn boundary for an already-observed process generation. It +/// may update that exact generation, or retire it once the kernel proves it is +/// gone. It must never create a replacement run from a dead or reused PID. +struct AgentPromptStopLineagePolicy: Sendable { + func decision( + record: ClaudeHookSessionRecord?, + lineage: AgentHookSessionLineage, + incomingPID: Int? + ) -> AgentPromptStopLineageDecision { + // Stores written before process generations existed have no evidence + // that can satisfy the fencing rules below. Accept their first Stop so + // the normal update path migrates them to a run-backed record. Once any + // lifecycle field exists, the strict generation checks apply. + if let record, + record.runs == nil, + record.activeRunId == nil, + record.runId == nil, + record.sessionState == nil, + record.completedAt == nil { + return .apply + } + // A PID-less Stop carries no process-generation evidence. Applying it + // can resurrect an ended record or mutate a newer generation that + // reused the logical session id, so it fails closed. + guard let incomingPID else { return .rejectStaleGeneration } + guard let record else { + guard lineage.processStartedAt != nil, lineage.processDescribesAgent else { + return .rejectStaleGeneration + } + return liveGenerationDecision(lineage) + } + guard record.completedAt == nil, record.sessionState != .ended else { + return .rejectStaleGeneration + } + + if let activeRunId = record.activeRunId { + let matchingRuns = (record.runs ?? []).filter { $0.runId == activeRunId } + guard matchingRuns.count <= 1 else { + return .completeRecordedGeneration(.inconsistentRecord) + } + if let activeRun = matchingRuns.first { + guard activeRun.identityConflict != true, activeRun.endedAt == nil else { + return .rejectStaleGeneration + } + if let activePID = activeRun.pid, activePID != incomingPID { + return .rejectStaleGeneration + } + guard let observedStartedAt = lineage.processStartedAt else { + return .completeRecordedGeneration(.processExited) + } + guard lineage.processDescribesAgent else { + return .completeRecordedGeneration(.processIdentityChanged) + } + if let expectedStartedAt = activeRun.processStartedAt { + guard abs(expectedStartedAt - observedStartedAt) <= 0.001 else { + return .completeRecordedGeneration(.processIdentityChanged) + } + return liveGenerationDecision(lineage) + } + // Legacy/runtime-fallback runs lack a start time. The live + // process is the recorded generation only when it predates the + // run's first hook observation. A later process proves reuse. + guard observedStartedAt <= activeRun.startedAt + 0.001 else { + return .completeRecordedGeneration(.processIdentityChanged) + } + return liveGenerationDecision(lineage) + } + } + + if let recordedPID = record.pid { + guard recordedPID == incomingPID else { return .rejectStaleGeneration } + guard let observedStartedAt = lineage.processStartedAt else { + return .completeRecordedGeneration(.processExited) + } + guard lineage.processDescribesAgent else { + return .completeRecordedGeneration(.processIdentityChanged) + } + // Pre-run stores can migrate safely when the process predates the + // immutable session creation boundary. A process born afterward is + // a reuse of the saved numeric PID, not this session generation. + guard observedStartedAt <= record.startedAt + 0.001 else { + return .completeRecordedGeneration(.processIdentityChanged) + } + return liveGenerationDecision(lineage) + } + guard lineage.processStartedAt != nil, lineage.processDescribesAgent else { + return .rejectStaleGeneration + } + return liveGenerationDecision(lineage) + } + + private func liveGenerationDecision( + _ lineage: AgentHookSessionLineage + ) -> AgentPromptStopLineageDecision { + switch lineage.processLaunchMode { + case .oneShot, .nonSession: + return .completeRecordedGeneration(.terminalLaunch) + case .interactive, .unknown: + return .apply + } + } +} + +struct AgentHookLaunchCommandRecord: Codable { + var launcher: String? + var executablePath: String? + var arguments: [String] + var workingDirectory: String? + var environment: [String: String]? + var capturedAt: TimeInterval? + var source: String? +} + +struct ClaudeHookSessionStoreFile: Codable { + var version: Int = 2 + var sessions: [String: ClaudeHookSessionRecord] = [:] + var activeSessionsByWorkspace: [String: ClaudeHookActiveSessionRecord] = [:] + // The pane-scoped active boundary. The workspace slot only remembers ONE + // active session, so once another pane promotes (e.g. a forked conversation + // in a split), it can no longer prove that a late hook from a superseded + // session in this pane is stale. Keyed by surface id. + // https://github.com/manaflow-ai/cmux/issues/5908 + var activeSessionsBySurface: [String: ClaudeHookActiveSessionRecord] = [:] + + enum CodingKeys: String, CodingKey { + case version + case sessions + case activeSessionsByWorkspace + case activeSessionsBySurface + } + + init() {} + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = max(try container.decodeIfPresent(Int.self, forKey: .version) ?? 1, 2) + sessions = try container.decodeIfPresent([String: ClaudeHookSessionRecord].self, forKey: .sessions) ?? [:] + guard sessions.allSatisfy({ sessionID, record in + sessionID == record.sessionId + }) else { + throw DecodingError.dataCorruptedError( + forKey: .sessions, + in: container, + debugDescription: "Session dictionary keys must match embedded session identifiers." + ) + } + activeSessionsByWorkspace = try container.decodeIfPresent( + [String: ClaudeHookActiveSessionRecord].self, + forKey: .activeSessionsByWorkspace + ) ?? [:] + activeSessionsBySurface = try container.decodeIfPresent( + [String: ClaudeHookActiveSessionRecord].self, + forKey: .activeSessionsBySurface + ) ?? [:] + } +} + +enum AgentHookSessionActivationProof: Sendable, Equatable { + case ordinary + case exactHibernationResumeAttempt(UUID) + case existingVerifiedResumeGeneration( + attemptId: UUID, + runId: String, + pid: Int, + processStartedAt: TimeInterval + ) +} + +enum AgentHookSessionActivationDecision: Sendable, Equatable { + case reject + case activate(AgentHookSessionActivationProof) +} + +struct AgentHookSessionActivationPolicy: Sendable { + func canActivate( + record: ClaudeHookSessionRecord, + lineage: AgentHookSessionLineage, + hasIncomingPID: Bool + ) -> Bool { + if case .activate = decision( + record: record, + lineage: lineage, + hasIncomingPID: hasIncomingPID + ) { + return true + } + return false + } + + func decision( + record: ClaudeHookSessionRecord, + lineage: AgentHookSessionLineage, + hasIncomingPID: Bool + ) -> AgentHookSessionActivationDecision { + if let activeRunId = record.activeRunId, + let activeRun = record.runs?.first(where: { $0.runId == activeRunId }) { + guard activeRun.identityConflict != true, activeRun.endedAt == nil else { + return .reject + } + if record.sessionState != .hibernated, + record.sessionState != .restoring, + lineage.processLaunchMode == .unknown, + lineage.hibernationResumeAttemptId != nil, + activeRunId == lineage.runId, + existingGenerationProof(record: record, lineage: lineage) == nil { + return .reject + } + } + if !hasIncomingPID, + let activeRunId = record.activeRunId, + record.runs?.contains(where: { + $0.runId == activeRunId + && $0.endedAt == nil + && $0.pid != nil + && $0.processStartedAt != nil + }) == true { + return .reject + } + if let activeRunId = record.activeRunId, + activeRunId != lineage.runId, + let activeRun = (record.runs ?? []).first(where: { + $0.runId == activeRunId && $0.endedAt == nil + }), + let activeStartedAt = activeRun.processStartedAt, + let incomingStartedAt = lineage.processStartedAt, + incomingStartedAt + 0.001 < activeStartedAt { + return .reject + } + switch record.sessionState { + case .hibernated, .restoring: + return protectedLifecycleDecision( + record: record, + lineage: lineage, + hasIncomingPID: hasIncomingPID + ) + case .active, .ended, nil: + break + } + guard record.completedAt != nil else { + return .activate(existingGenerationProof(record: record, lineage: lineage) ?? .ordinary) + } + // A completed record is a durable root-exit boundary. Only a hook that + // supplies a verified, different process generation can reopen it. + guard hasIncomingPID, let incomingStartedAt = lineage.processStartedAt else { return .reject } + let matchingRuns = (record.runs ?? []).filter { $0.runId == lineage.runId } + guard !matchingRuns.isEmpty else { + guard let completedAt = record.completedAt, + incomingStartedAt > completedAt + 0.001 else { + return .reject + } + return .activate(.ordinary) + } + let isNewGeneration = matchingRuns.allSatisfy { run in + if let previousStartedAt = run.processStartedAt { + return abs(previousStartedAt - incomingStartedAt) > 0.001 + } + guard let completedAt = record.completedAt else { return false } + return incomingStartedAt > completedAt + 0.001 + } + return isNewGeneration ? .activate(.ordinary) : .reject + } + + private func protectedLifecycleDecision( + record: ClaudeHookSessionRecord, + lineage: AgentHookSessionLineage, + hasIncomingPID: Bool + ) -> AgentHookSessionActivationDecision { + guard hasIncomingPID, + lineage.pid != nil, + lineage.restoreAuthority, + lineage.processDescribesAgent, + let incomingStartedAt = lineage.processStartedAt else { return .reject } + let proof: AgentHookSessionActivationProof + switch lineage.processLaunchMode { + case .interactive: + proof = .ordinary + case .unknown: + guard let incomingAttemptId = lineage.hibernationResumeAttemptId, + let recordedAttemptValue = record.cmuxHibernationResumeAttemptId, + let recordedAttemptId = UUID(uuidString: recordedAttemptValue), + incomingAttemptId == recordedAttemptId else { + return .reject + } + proof = .exactHibernationResumeAttempt(incomingAttemptId) + case .oneShot, .nonSession: + return .reject + } + let relevantRuns = (record.runs ?? []).filter { run in + run.runId == lineage.runId || run.runId == record.activeRunId + } + let generationBoundary = relevantRuns.compactMap(\.processStartedAt).max() + .map { max($0, record.updatedAt) } + ?? record.updatedAt + guard incomingStartedAt > generationBoundary + 0.001 else { return .reject } + return .activate(proof) + } + + private func existingGenerationProof( + record: ClaudeHookSessionRecord, + lineage: AgentHookSessionLineage + ) -> AgentHookSessionActivationProof? { + guard lineage.processLaunchMode == .unknown, + lineage.processDescribesAgent, + lineage.restoreAuthority, + let lineagePID = lineage.pid, + let lineageStartedAt = lineage.processStartedAt, + let incomingAttemptId = lineage.hibernationResumeAttemptId, + record.activeRunId == lineage.runId, + let activeRun = record.runs?.first(where: { + $0.runId == lineage.runId + && $0.endedAt == nil + && $0.restoreAuthority + && $0.cmuxHibernationResumeAttemptId.flatMap { UUID(uuidString: $0) } + == incomingAttemptId + && $0.pid == lineagePID + && $0.processStartedAt.map { + abs($0 - lineageStartedAt) <= 0.001 + } == true + }) else { + return nil + } + return .existingVerifiedResumeGeneration( + attemptId: incomingAttemptId, + runId: activeRun.runId, + pid: lineagePID, + processStartedAt: lineageStartedAt + ) + } +} + +struct AgentSessionTeardownConsumptionPolicy: Sendable { + func canConsume(record: ClaudeHookSessionRecord) -> Bool { + guard record.completedAt == nil else { return false } + switch record.sessionState { + case .ended, .hibernated, .restoring: + return false + case .active, nil: + return true + } + } +} + +struct AgentSessionSemanticUpdatePolicy: Sendable { + func canUpdate(record: ClaudeHookSessionRecord) -> Bool { + record.completedAt == nil && record.sessionState != .ended + } +} diff --git a/CLI/AgentProcessIdentity.swift b/CLI/AgentProcessIdentity.swift new file mode 100644 index 000000000000..7a44abcf7e8c --- /dev/null +++ b/CLI/AgentProcessIdentity.swift @@ -0,0 +1,30 @@ +import Foundation + +/// The minimal immutable process identity needed for agent ancestry checks. +struct AgentProcessIdentity: Sendable, Equatable { + var pid: Int + var parentPID: Int + var startedAt: TimeInterval + var executableName: String? + var arguments: [String] +} + +extension AgentProcessIdentity { + /// The cmux app is the ownership boundary for a terminal's process tree. + /// + /// A root agent's ancestors are its login shell and the cmux app. The app + /// itself may have been launched by another coding agent, Xcode, or a test + /// harness. Walking above it would misclassify that unrelated launcher as + /// the terminal session's parent agent. Real subagents remain below this + /// boundary, so their nearest agent ancestor is still discovered first. + var isCmuxTerminalHost: Bool { + ([executableName] + Array(arguments.prefix(1))).compactMap { $0 }.contains { candidate in + let normalized = candidate + .replacingOccurrences(of: "\\", with: "/") + .lowercased() + guard normalized.contains(".app/contents/macos/") else { return false } + let basename = URL(fileURLWithPath: normalized).lastPathComponent + return basename == "cmux" || basename.hasPrefix("cmux ") + } + } +} diff --git a/CLI/AgentSessionAuthorityEvidence.swift b/CLI/AgentSessionAuthorityEvidence.swift new file mode 100644 index 000000000000..5e9f9d58f1b9 --- /dev/null +++ b/CLI/AgentSessionAuthorityEvidence.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Why a process generation owns, or cannot own, session restoration. +/// +/// Child authority is monotonic only when its evidence is durable. An ancestry +/// walk that stopped inconclusively is provisional: it fails closed, but an +/// explicitly forked session may recover if a later complete walk proves that +/// it has no agent ancestor. Managed, explicit-spawn, verified-ancestor, and +/// legacy child records never recover authority. +enum AgentSessionAuthorityEvidence: String, Codable, Sendable, Equatable { + case verifiedForkRoot = "verified_fork_root" + case managedChild = "managed_child" + case explicitSpawnedChild = "explicit_spawned_child" + case verifiedAncestorChild = "verified_ancestor_child" + case provisionalAmbiguousChild = "provisional_ambiguous_child" + case legacyChild = "legacy_child" + + var isDurableChild: Bool { + switch self { + case .managedChild, .explicitSpawnedChild, .verifiedAncestorChild, .legacyChild: + true + case .verifiedForkRoot, .provisionalAmbiguousChild: + false + } + } + + var prohibitsRestore: Bool { + self != .verifiedForkRoot + } +} + +struct AgentSessionAuthorityTransition: Sendable { + func persistedEvidence(for run: AgentSessionRunRecord) -> AgentSessionAuthorityEvidence? { + guard run.relationship == .spawned else { return run.authorityEvidence } + if run.authorityEvidence == .provisionalAmbiguousChild { return run.authorityEvidence } + if run.authorityEvidence?.isDurableChild == true { return run.authorityEvidence } + return .legacyChild + } + + func canRecoverProvisionalFork( + previous: AgentSessionAuthorityEvidence?, + incoming: AgentHookSessionLineage + ) -> Bool { + previous == .provisionalAmbiguousChild + && incoming.authorityEvidence == .verifiedForkRoot + && incoming.relationship == .forked + && incoming.restoreAuthority + } +} diff --git a/CLI/AgentSessionGraphEdge.swift b/CLI/AgentSessionGraphEdge.swift new file mode 100644 index 000000000000..ea4b56d0e777 --- /dev/null +++ b/CLI/AgentSessionGraphEdge.swift @@ -0,0 +1,20 @@ +import Foundation + +/// A typed relationship between two session graph nodes. +struct AgentSessionGraphEdge: Codable, Sendable, Equatable { + var fromNodeId: String? = nil + var fromRunId: String? + var fromSessionId: String? + var toNodeId: String + var toRunId: String + var relationship: AgentSessionRelationship + + enum CodingKeys: String, CodingKey { + case fromNodeId = "from_node_id" + case fromRunId = "from_run_id" + case fromSessionId = "from_session_id" + case toNodeId = "to_node_id" + case toRunId = "to_run_id" + case relationship + } +} diff --git a/CLI/AgentSessionGraphEdgeResolver.swift b/CLI/AgentSessionGraphEdgeResolver.swift new file mode 100644 index 000000000000..b12c5b9bc536 --- /dev/null +++ b/CLI/AgentSessionGraphEdgeResolver.swift @@ -0,0 +1,352 @@ +import Foundation + +/// Resolves graph edges that retain a durable parent session ID after the +/// parent process generation is no longer available to the child hook. +struct AgentSessionGraphEdgeResolver: Sendable { + private struct ProviderRunKey: Hashable, Sendable { + var provider: String + var runID: String + } + + private struct ProviderRunSessionKey: Hashable, Sendable { + var provider: String + var runID: String + var sessionID: String + } + + private struct ProviderSessionKey: Hashable, Sendable { + var provider: String + var sessionID: String + } + + private struct RunSessionKey: Hashable, Sendable { + var runID: String + var sessionID: String + } + + private struct NodeIdentity: Sendable { + var provider: String + var sessionID: String? + var runID: String + } + + /// Stores only the two preferred node IDs needed by child exclusion. Input + /// is already preference-ordered, avoiding arrays of copied graph nodes. + private struct CandidateSummary: Sendable { + private(set) var count = 0 + private var firstNodeID: String? + private var secondNodeID: String? + + mutating func insertInPreferenceOrder(_ nodeID: String) { + count += 1 + if firstNodeID == nil { + firstNodeID = nodeID + } else if secondNodeID == nil { + secondNodeID = nodeID + } + } + + func firstNodeID(excluding nodeID: String) -> String? { + firstNodeID == nodeID ? secondNodeID : firstNodeID + } + + func uniqueNodeID(excluding nodeID: String, containsExcludedNode: Bool) -> String? { + guard count - (containsExcludedNode ? 1 : 0) == 1 else { return nil } + return firstNodeID(excluding: nodeID) + } + } + + private let candidatesByProviderRun: [ProviderRunKey: CandidateSummary] + private let candidatesByProviderRunSession: [ProviderRunSessionKey: CandidateSummary] + private let parentCandidatesByProviderSession: [ProviderSessionKey: CandidateSummary] + private let candidatesByRun: [String: CandidateSummary] + private let candidatesByRunSession: [RunSessionKey: CandidateSummary] + private let parentCandidatesBySession: [String: CandidateSummary] + private let nodesByNodeID: [String: NodeIdentity] + private let nodeIndex: AgentSessionGraphNodeIndex + private let graphOrdering: AgentSessionGraphOrdering + + init( + nodes: [AgentSessionGraphNode], + nodeIndex: AgentSessionGraphNodeIndex = AgentSessionGraphNodeIndex(), + graphOrdering: AgentSessionGraphOrdering = AgentSessionGraphOrdering() + ) { + self.nodeIndex = nodeIndex + self.graphOrdering = graphOrdering + let nodeIDs = nodes.map(\.nodeId) + var canonicalIndexByNodeID: [String: Int] = [:] + canonicalIndexByNodeID.reserveCapacity(nodes.count) + for index in nodes.indices { + let nodeID = nodeIDs[index] + guard let existing = canonicalIndexByNodeID[nodeID] else { + canonicalIndexByNodeID[nodeID] = index + continue + } + if nodeIndex.prefers(nodes[index], over: nodes[existing]) { + canonicalIndexByNodeID[nodeID] = index + } + } + let canonicalIndices = canonicalIndexByNodeID.values.sorted() + var mutableNodesByNodeID: [String: NodeIdentity] = [:] + mutableNodesByNodeID.reserveCapacity(canonicalIndices.count) + for index in canonicalIndices { + let node = nodes[index] + mutableNodesByNodeID[nodeIDs[index]] = NodeIdentity( + provider: node.provider, + sessionID: node.sessionId, + runID: node.runId + ) + } + nodesByNodeID = mutableNodesByNodeID + + let runOrderedIndices = canonicalIndices.sorted { lhsIndex, rhsIndex in + let lhs = nodes[lhsIndex] + let rhs = nodes[rhsIndex] + if nodeIndex.prefers(lhs, over: rhs) { return true } + if nodeIndex.prefers(rhs, over: lhs) { return false } + return nodeIDs[lhsIndex] < nodeIDs[rhsIndex] + } + var byProviderRun: [ProviderRunKey: CandidateSummary] = [:] + var byProviderRunSession: [ProviderRunSessionKey: CandidateSummary] = [:] + var byRun: [String: CandidateSummary] = [:] + var byRunSession: [RunSessionKey: CandidateSummary] = [:] + byProviderRun.reserveCapacity(runOrderedIndices.count) + byProviderRunSession.reserveCapacity(runOrderedIndices.count) + byRun.reserveCapacity(runOrderedIndices.count) + byRunSession.reserveCapacity(runOrderedIndices.count) + for index in runOrderedIndices { + let node = nodes[index] + let nodeID = nodeIDs[index] + byProviderRun[ProviderRunKey(provider: node.provider, runID: node.runId), default: CandidateSummary()] + .insertInPreferenceOrder(nodeID) + byRun[node.runId, default: CandidateSummary()].insertInPreferenceOrder(nodeID) + if let sessionID = node.sessionId { + byProviderRunSession[ProviderRunSessionKey( + provider: node.provider, + runID: node.runId, + sessionID: sessionID + ), default: CandidateSummary()] + .insertInPreferenceOrder(nodeID) + byRunSession[RunSessionKey( + runID: node.runId, + sessionID: sessionID + ), default: CandidateSummary()] + .insertInPreferenceOrder(nodeID) + } + } + candidatesByProviderRun = byProviderRun + candidatesByProviderRunSession = byProviderRunSession + candidatesByRun = byRun + candidatesByRunSession = byRunSession + + let sessionOrderedIndices = canonicalIndices.sorted { lhsIndex, rhsIndex in + Self.sessionParentPrecedes(nodes[lhsIndex], nodes[rhsIndex]) + } + var byProviderSession: [ProviderSessionKey: CandidateSummary] = [:] + var bySession: [String: CandidateSummary] = [:] + byProviderSession.reserveCapacity(sessionOrderedIndices.count) + bySession.reserveCapacity(sessionOrderedIndices.count) + for index in sessionOrderedIndices { + let node = nodes[index] + guard let sessionID = node.sessionId else { continue } + byProviderSession[ProviderSessionKey( + provider: node.provider, + sessionID: sessionID + ), default: CandidateSummary()] + .insertInPreferenceOrder(nodeIDs[index]) + bySession[sessionID, default: CandidateSummary()] + .insertInPreferenceOrder(nodeIDs[index]) + } + parentCandidatesByProviderSession = byProviderSession + parentCandidatesBySession = bySession + } + + func parentNodeId(for edge: AgentSessionGraphEdge) -> String? { + guard let child = nodesByNodeID[edge.toNodeId] else { return nil } + if let fromNodeID = edge.fromNodeId { + guard fromNodeID != edge.toNodeId, nodesByNodeID[fromNodeID] != nil else { return nil } + return fromNodeID + } + if let fromRunID = edge.fromRunId { + let runMatchesChild = child.runID == fromRunID + if let fromSessionID = edge.fromSessionId { + let exactCandidates = candidatesByProviderRunSession[ProviderRunSessionKey( + provider: child.provider, + runID: fromRunID, + sessionID: fromSessionID + )] + if let parentNodeID = exactCandidates?.firstNodeID(excluding: edge.toNodeId) { + return parentNodeID + } + // The durable session identity is stronger than a reused run + // ID. Fall through to session-only recovery below. + let sameProviderSession = parentCandidatesByProviderSession[ProviderSessionKey( + provider: child.provider, + sessionID: fromSessionID + )] + if let parentNodeID = sameProviderSession?.firstNodeID(excluding: edge.toNodeId) { + return parentNodeID + } + let sessionMatchesChild = child.sessionID == fromSessionID + if let parentNodeID = candidatesByRunSession[RunSessionKey( + runID: fromRunID, + sessionID: fromSessionID + )]?.uniqueNodeID( + excluding: edge.toNodeId, + containsExcludedNode: runMatchesChild && sessionMatchesChild + ) { + return parentNodeID + } + return parentCandidatesBySession[fromSessionID]?.uniqueNodeID( + excluding: edge.toNodeId, + containsExcludedNode: sessionMatchesChild + ) + } else { + let sameProvider = candidatesByProviderRun[ProviderRunKey( + provider: child.provider, + runID: fromRunID + )] + if let parentNodeID = sameProvider?.uniqueNodeID( + excluding: edge.toNodeId, + containsExcludedNode: runMatchesChild + ) { + return parentNodeID + } + return candidatesByRun[fromRunID]?.uniqueNodeID( + excluding: edge.toNodeId, + containsExcludedNode: runMatchesChild + ) + } + } + guard let fromSessionID = edge.fromSessionId else { return nil } + let sameProvider = parentCandidatesByProviderSession[ProviderSessionKey( + provider: child.provider, + sessionID: fromSessionID + )] + if let parentNodeID = sameProvider?.firstNodeID(excluding: edge.toNodeId) { + return parentNodeID + } + return parentCandidatesBySession[fromSessionID]?.uniqueNodeID( + excluding: edge.toNodeId, + containsExcludedNode: child.sessionID == fromSessionID + ) + } + + private static func sessionParentPrecedes( + _ lhs: AgentSessionGraphNode, + _ rhs: AgentSessionGraphNode + ) -> Bool { + if (lhs.endedAt == nil) != (rhs.endedAt == nil) { return lhs.endedAt == nil } + if lhs.restoreAuthority != rhs.restoreAuthority { return lhs.restoreAuthority } + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + if lhs.startedAt != rhs.startedAt { return lhs.startedAt > rhs.startedAt } + if lhs.runId != rhs.runId { return lhs.runId < rhs.runId } + return lhs.nodeId < rhs.nodeId + } +} + +/// Converts persisted parent claims into the single-parent, acyclic graph that +/// the CLI contract exposes. A process generation has one durable parent; when +/// corrupt history supplies several, the first edge in canonical output order +/// wins. Missing parents and self-parents are ignored. For every remaining +/// cycle, removing the edge owned by its greatest node ID makes the same node a +/// root regardless of input or dictionary iteration order. +struct AgentSessionGraphEdgeSanitizer: Sendable { + private struct ResolvedEdge: Sendable { + var edge: AgentSessionGraphEdge + var parentNodeIndex: Int + var childNodeIndex: Int + } + + private let nodeIndex: AgentSessionGraphNodeIndex + private let graphOrdering: AgentSessionGraphOrdering + + init( + nodeIndex: AgentSessionGraphNodeIndex = AgentSessionGraphNodeIndex(), + graphOrdering: AgentSessionGraphOrdering = AgentSessionGraphOrdering() + ) { + self.nodeIndex = nodeIndex + self.graphOrdering = graphOrdering + } + + func acyclicEdges( + nodes: [AgentSessionGraphNode], + edges: [AgentSessionGraphEdge] + ) -> [AgentSessionGraphEdge] { + guard !nodes.isEmpty, !edges.isEmpty else { return [] } + + let indexByNodeID = nodeIndex.indices(nodes) + let resolver = AgentSessionGraphEdgeResolver( + nodes: nodes, + nodeIndex: nodeIndex, + graphOrdering: graphOrdering + ) + var resolvedEdges: [ResolvedEdge] = [] + resolvedEdges.reserveCapacity(edges.count) + for edge in edges { + guard let parentNodeID = resolver.parentNodeId(for: edge), + let parentNodeIndex = indexByNodeID[parentNodeID], + let childNodeIndex = indexByNodeID[edge.toNodeId], + parentNodeIndex != childNodeIndex else { + continue + } + var resolvedEdge = edge + resolvedEdge.fromNodeId = parentNodeID + resolvedEdges.append(ResolvedEdge( + edge: resolvedEdge, + parentNodeIndex: parentNodeIndex, + childNodeIndex: childNodeIndex + )) + } + resolvedEdges.sort { lhs, rhs in + if graphOrdering.edgePrecedes(lhs.edge, rhs.edge) { return true } + if graphOrdering.edgePrecedes(rhs.edge, lhs.edge) { return false } + let lhsParentNodeID = nodes[lhs.parentNodeIndex].nodeId + let rhsParentNodeID = nodes[rhs.parentNodeIndex].nodeId + if lhsParentNodeID != rhsParentNodeID { return lhsParentNodeID < rhsParentNodeID } + return lhs.childNodeIndex < rhs.childNodeIndex + } + + var edgeByChildNodeIndex: [Int: ResolvedEdge] = [:] + edgeByChildNodeIndex.reserveCapacity(resolvedEdges.count) + for resolvedEdge in resolvedEdges where edgeByChildNodeIndex[resolvedEdge.childNodeIndex] == nil { + edgeByChildNodeIndex[resolvedEdge.childNodeIndex] = resolvedEdge + } + + let parentByChildNodeIndex = edgeByChildNodeIndex.mapValues(\.parentNodeIndex) + var visitState = Array(repeating: UInt8(0), count: nodes.count) + var positionInPath = Array(repeating: -1, count: nodes.count) + var removedChildNodeIndices: Set = [] + let traversalOrder = nodes.indices.sorted { nodes[$0].nodeId < nodes[$1].nodeId } + for startNodeIndex in traversalOrder where visitState[startNodeIndex] == 0 { + var path: [Int] = [] + var currentNodeIndex: Int? = startNodeIndex + while let nodeIndex = currentNodeIndex, visitState[nodeIndex] == 0 { + visitState[nodeIndex] = 1 + positionInPath[nodeIndex] = path.count + path.append(nodeIndex) + currentNodeIndex = parentByChildNodeIndex[nodeIndex] + } + if let nodeIndex = currentNodeIndex, + visitState[nodeIndex] == 1, + positionInPath[nodeIndex] >= 0 { + let cycleStart = positionInPath[nodeIndex] + let childNodeIndexToRemove = path[cycleStart...].max { + nodes[$0].nodeId < nodes[$1].nodeId + } + if let childNodeIndexToRemove { + removedChildNodeIndices.insert(childNodeIndexToRemove) + } + } + for nodeIndex in path { + visitState[nodeIndex] = 2 + positionInPath[nodeIndex] = -1 + } + } + + return edgeByChildNodeIndex.values + .filter { !removedChildNodeIndices.contains($0.childNodeIndex) } + .map(\.edge) + .sorted(by: graphOrdering.edgePrecedes) + } +} diff --git a/CLI/AgentSessionGraphNode.swift b/CLI/AgentSessionGraphNode.swift new file mode 100644 index 000000000000..22672edd5ec1 --- /dev/null +++ b/CLI/AgentSessionGraphNode.swift @@ -0,0 +1,240 @@ +import CmuxFoundation +import Foundation + +/// A sanitized CLI snapshot of one agent process generation. +struct AgentSessionGraphNode: Codable, Sendable, Equatable { + var provider: String + var sessionId: String? + var runId: String + var identitySource: String + var pid: Int? + var processStartedAt: TimeInterval? + var cmuxRuntime: AgentCmuxRuntimeIdentity? + var workspaceId: String + var surfaceId: String + var cwd: String? + var processState: AgentProcessState + var sessionState: AgentSessionLifecycleState + var foregroundState: AgentForegroundState + var attentionState: AgentAttentionState + var activity: AgentActivitySnapshot + var effectiveState: AgentEffectiveState + var workloads: [AgentWorkloadSnapshot] + var subtreeActivity = AgentSubtreeActivitySnapshot() + var restoreAuthority: Bool + var startedAt: TimeInterval + var updatedAt: TimeInterval + var endedAt: TimeInterval? + var terminalObservation: CmuxAgentTerminalObservation? + var terminalStateApplied: Bool + + /// A process generation can host more than one logical session and some + /// providers emit hooks from the same launcher process. Graph identity must + /// therefore include provider and session instead of treating `runId` as a + /// globally unique node key. Each untrusted component is byte-length + /// prefixed so embedded separators cannot alias another node. + var nodeId: String { + if let sessionId { + return "session:" + + Self.nodeIDComponent(provider) + + Self.nodeIDComponent(sessionId) + + Self.nodeIDComponent(runId) + } + let runtime = cmuxRuntime?.id ?? terminalObservation?.runtimeID ?? "unknown" + return "terminal:" + + Self.nodeIDComponent(runtime) + + Self.nodeIDComponent(surfaceId) + + Self.nodeIDComponent(runId) + } + + private static func nodeIDComponent(_ value: String) -> String { + "\(value.utf8.count):\(value)" + } + + init( + provider: String, + sessionId: String?, + runId: String, + identitySource: String = "hook_session", + pid: Int?, + processStartedAt: TimeInterval?, + cmuxRuntime: AgentCmuxRuntimeIdentity?, + workspaceId: String, + surfaceId: String, + cwd: String? = nil, + processState: AgentProcessState, + sessionState: AgentSessionLifecycleState, + foregroundState: AgentForegroundState, + attentionState: AgentAttentionState, + activity: AgentActivitySnapshot, + effectiveState: AgentEffectiveState, + workloads: [AgentWorkloadSnapshot], + subtreeActivity: AgentSubtreeActivitySnapshot = AgentSubtreeActivitySnapshot(), + restoreAuthority: Bool, + startedAt: TimeInterval, + updatedAt: TimeInterval, + endedAt: TimeInterval?, + terminalObservation: CmuxAgentTerminalObservation? = nil, + terminalStateApplied: Bool = false + ) { + self.provider = provider + self.sessionId = sessionId + self.runId = runId + self.identitySource = identitySource + self.pid = pid + self.processStartedAt = processStartedAt + self.cmuxRuntime = cmuxRuntime + self.workspaceId = workspaceId + self.surfaceId = surfaceId + self.cwd = cwd + self.processState = processState + self.sessionState = sessionState + self.foregroundState = foregroundState + self.attentionState = attentionState + self.activity = activity + self.effectiveState = effectiveState + self.workloads = workloads + self.subtreeActivity = subtreeActivity + self.restoreAuthority = restoreAuthority + self.startedAt = startedAt + self.updatedAt = updatedAt + self.endedAt = endedAt + self.terminalObservation = terminalObservation + self.terminalStateApplied = terminalStateApplied + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(nodeId, forKey: .nodeId) + try container.encode(provider, forKey: .provider) + try container.encodeIfPresent(sessionId, forKey: .sessionId) + try container.encode(runId, forKey: .runId) + try container.encode(identitySource, forKey: .identitySource) + try container.encodeIfPresent(pid, forKey: .pid) + try container.encodeIfPresent(processStartedAt, forKey: .processStartedAt) + try container.encodeIfPresent(cmuxRuntime, forKey: .cmuxRuntime) + try container.encode(workspaceId, forKey: .workspaceId) + try container.encode(surfaceId, forKey: .surfaceId) + try container.encodeIfPresent(cwd, forKey: .cwd) + try container.encode(processState, forKey: .processState) + try container.encode(sessionState, forKey: .sessionState) + try container.encode(foregroundState, forKey: .foregroundState) + try container.encode(attentionState, forKey: .attentionState) + try container.encode(activity, forKey: .activity) + try container.encode(effectiveState, forKey: .effectiveState) + try container.encode(workloads, forKey: .workloads) + try container.encode(subtreeActivity, forKey: .subtreeActivity) + try container.encode(restoreAuthority, forKey: .restoreAuthority) + try container.encode(startedAt, forKey: .startedAt) + try container.encode(updatedAt, forKey: .updatedAt) + try container.encodeIfPresent(endedAt, forKey: .endedAt) + try container.encodeIfPresent(terminalObservation, forKey: .terminalObservation) + try container.encode(terminalStateApplied ? "terminal" : "lifecycle", forKey: .stateSource) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // node_id is derived from the identity fields below. Decode the source + // fields so older persisted payloads without node_id remain compatible. + self.init( + provider: try container.decode(String.self, forKey: .provider), + sessionId: try container.decodeIfPresent(String.self, forKey: .sessionId), + runId: try container.decode(String.self, forKey: .runId), + identitySource: try container.decodeIfPresent(String.self, forKey: .identitySource) ?? "hook_session", + pid: try container.decodeIfPresent(Int.self, forKey: .pid), + processStartedAt: try container.decodeIfPresent(TimeInterval.self, forKey: .processStartedAt), + cmuxRuntime: try container.decodeIfPresent(AgentCmuxRuntimeIdentity.self, forKey: .cmuxRuntime), + workspaceId: try container.decode(String.self, forKey: .workspaceId), + surfaceId: try container.decode(String.self, forKey: .surfaceId), + cwd: try container.decodeIfPresent(String.self, forKey: .cwd), + processState: try container.decode(AgentProcessState.self, forKey: .processState), + sessionState: try container.decode(AgentSessionLifecycleState.self, forKey: .sessionState), + foregroundState: try container.decode(AgentForegroundState.self, forKey: .foregroundState), + attentionState: try container.decode(AgentAttentionState.self, forKey: .attentionState), + activity: try container.decode(AgentActivitySnapshot.self, forKey: .activity), + effectiveState: try container.decode(AgentEffectiveState.self, forKey: .effectiveState), + workloads: try container.decode([AgentWorkloadSnapshot].self, forKey: .workloads), + subtreeActivity: try container.decodeIfPresent( + AgentSubtreeActivitySnapshot.self, + forKey: .subtreeActivity + ) ?? AgentSubtreeActivitySnapshot(), + restoreAuthority: try container.decode(Bool.self, forKey: .restoreAuthority), + startedAt: try container.decode(TimeInterval.self, forKey: .startedAt), + updatedAt: try container.decode(TimeInterval.self, forKey: .updatedAt), + endedAt: try container.decodeIfPresent(TimeInterval.self, forKey: .endedAt), + terminalObservation: try container.decodeIfPresent( + CmuxAgentTerminalObservation.self, + forKey: .terminalObservation + ), + terminalStateApplied: (try container.decodeIfPresent(String.self, forKey: .stateSource)) == "terminal" + ) + } + + enum CodingKeys: String, CodingKey { + case nodeId = "node_id" + case provider + case sessionId = "session_id" + case runId = "run_id" + case identitySource = "identity_source" + case pid + case processStartedAt = "process_started_at" + case cmuxRuntime = "cmux_runtime" + case workspaceId = "workspace_id" + case surfaceId = "surface_id" + case cwd + case processState = "process_state" + case sessionState = "session_state" + case foregroundState = "foreground_state" + case attentionState = "attention_state" + case activity + case effectiveState = "effective_state" + case workloads + case subtreeActivity = "subtree_activity" + case restoreAuthority = "restore_authority" + case startedAt = "started_at" + case updatedAt = "updated_at" + case endedAt = "ended_at" + case terminalObservation = "terminal_observation" + case stateSource = "state_source" + } +} + +/// Duplicate-safe node lookup for corrupted or hand-edited session stores. +/// The newest copy of the same provider/session/run wins. Distinct logical +/// sessions sharing one process generation remain separate graph nodes. +struct AgentSessionGraphNodeIndex: Sendable { + func indices(_ nodes: [AgentSessionGraphNode]) -> [String: Int] { + nodes.indices.reduce(into: [:]) { result, candidateIndex in + let nodeId = nodes[candidateIndex].nodeId + guard let existingIndex = result[nodeId] else { + result[nodeId] = candidateIndex + return + } + if prefers(nodes[candidateIndex], over: nodes[existingIndex]) { + result[nodeId] = candidateIndex + } + } + } + + func nodes(_ nodes: [AgentSessionGraphNode]) -> [String: AgentSessionGraphNode] { + indices(nodes).mapValues { nodes[$0] } + } + + func canonicalNodes(_ nodes: [AgentSessionGraphNode]) -> [AgentSessionGraphNode] { + indices(nodes).values.sorted().map { nodes[$0] } + } + + func candidatesByRunId(_ nodes: [AgentSessionGraphNode]) -> [String: [AgentSessionGraphNode]] { + Dictionary(grouping: canonicalNodes(nodes), by: \.runId).mapValues { candidates in + candidates.sorted { prefers($0, over: $1) } + } + } + + func prefers(_ candidate: AgentSessionGraphNode, over existing: AgentSessionGraphNode) -> Bool { + if candidate.updatedAt != existing.updatedAt { return candidate.updatedAt > existing.updatedAt } + if candidate.startedAt != existing.startedAt { return candidate.startedAt > existing.startedAt } + let candidateKey = "\(candidate.provider):\(candidate.sessionId ?? ""):\(candidate.surfaceId)" + let existingKey = "\(existing.provider):\(existing.sessionId ?? ""):\(existing.surfaceId)" + return candidateKey < existingKey + } +} diff --git a/CLI/AgentSessionGraphSnapshot.swift b/CLI/AgentSessionGraphSnapshot.swift new file mode 100644 index 000000000000..9dd6d76201f6 --- /dev/null +++ b/CLI/AgentSessionGraphSnapshot.swift @@ -0,0 +1,380 @@ +import Foundation + +/// A versioned, flat agent-session graph suitable for CLI automation. +struct AgentSessionGraphSnapshot: Codable, Sendable, Equatable { + var schemaVersion: Int = 2 + var nodes: [AgentSessionGraphNode] + var edges: [AgentSessionGraphEdge] + var storeWarnings: [AgentHookSessionStoreLoadWarning]? = nil + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case nodes + case edges + case storeWarnings = "store_warnings" + } +} + +struct AgentSessionGraphOrdering: Sendable { + func nodePrecedes(_ lhs: AgentSessionGraphNode, _ rhs: AgentSessionGraphNode) -> Bool { + if lhs.startedAt != rhs.startedAt { return lhs.startedAt < rhs.startedAt } + if lhs.runId != rhs.runId { return lhs.runId < rhs.runId } + return lhs.nodeId < rhs.nodeId + } + + func edgePrecedes(_ lhs: AgentSessionGraphEdge, _ rhs: AgentSessionGraphEdge) -> Bool { + if lhs.toNodeId != rhs.toNodeId { return lhs.toNodeId < rhs.toNodeId } + if lhs.relationship != rhs.relationship { + return lhs.relationship.rawValue < rhs.relationship.rawValue + } + if lhs.toRunId != rhs.toRunId { return lhs.toRunId < rhs.toRunId } + if let result = optionalStringPrecedes(lhs.fromNodeId, rhs.fromNodeId) { return result } + if let result = optionalStringPrecedes(lhs.fromRunId, rhs.fromRunId) { return result } + if let result = optionalStringPrecedes(lhs.fromSessionId, rhs.fromSessionId) { return result } + return false + } + + private func optionalStringPrecedes(_ lhs: String?, _ rhs: String?) -> Bool? { + if lhs == rhs { return nil } + guard let lhs else { return true } + guard let rhs else { return false } + return lhs < rhs + } +} + +struct AgentSessionRunCanonicalizer: Sendable { + func runs( + record: ClaudeHookSessionRecord, + provider: String + ) -> [AgentSessionRunRecord] { + let rawRuns = if let runs = record.runs, !runs.isEmpty { + runs + } else { + [AgentSessionRunRecord( + runId: record.runId ?? "session:\(provider):\(record.sessionId)", + pid: record.pid, + processStartedAt: nil, + cmuxRuntime: record.cmuxRuntime, + parentRunId: record.parentRunId, + parentSessionId: record.parentSessionId, + relationship: record.relationship, + restoreAuthority: record.restoreAuthority ?? (record.relationship != .spawned), + authorityEvidence: record.authorityEvidence, + startedAt: record.startedAt, + updatedAt: record.updatedAt, + endedAt: record.completedAt + )] + } + var newestByRunID: [String: AgentSessionRunRecord] = [:] + newestByRunID.reserveCapacity(rawRuns.count) + for run in rawRuns { + if let current = newestByRunID[run.runId] { + newestByRunID[run.runId] = normalizedAuthority( + canonicalDuplicate(run, current) + ) + } else { + newestByRunID[run.runId] = normalizedAuthority(run) + } + } + return newestByRunID.values.sorted { $0.runId < $1.runId } + } + + func projectedRun( + record: ClaudeHookSessionRecord, + provider: String + ) -> AgentSessionRunRecord { + let canonicalRuns = runs(record: record, provider: provider) + return projectedRun(canonicalRuns: canonicalRuns, activeRunID: record.activeRunId) + } + + func projectedRun( + canonicalRuns: [AgentSessionRunRecord], + activeRunID: String? + ) -> AgentSessionRunRecord { + precondition(!canonicalRuns.isEmpty) + if let activeRunID, + let active = canonicalRuns.first(where: { $0.runId == activeRunID }) { + return active + } + return canonicalRuns.dropFirst().reduce(canonicalRuns[0]) { newest, candidate in + isNewer(candidate, than: newest) ? candidate : newest + } + } + + private func isNewer( + _ candidate: AgentSessionRunRecord, + than current: AgentSessionRunRecord + ) -> Bool { + if candidate.updatedAt != current.updatedAt { return candidate.updatedAt > current.updatedAt } + if candidate.startedAt != current.startedAt { return candidate.startedAt > current.startedAt } + if (candidate.endedAt == nil) != (current.endedAt == nil) { return candidate.endedAt == nil } + if candidate.endedAt != current.endedAt { + return (candidate.endedAt ?? -.infinity) > (current.endedAt ?? -.infinity) + } + if candidate.processStartedAt != current.processStartedAt { + return (candidate.processStartedAt ?? -.infinity) > (current.processStartedAt ?? -.infinity) + } + if candidate.pid != current.pid { return (candidate.pid ?? -1) > (current.pid ?? -1) } + if let result = optionalStringPrecedes(candidate.cmuxRuntime?.id, current.cmuxRuntime?.id) { + return result + } + if let result = optionalStringPrecedes( + candidate.cmuxRuntime?.socketPath, + current.cmuxRuntime?.socketPath + ) { + return result + } + if let result = optionalStringPrecedes( + candidate.cmuxRuntime?.bundleIdentifier, + current.cmuxRuntime?.bundleIdentifier + ) { + return result + } + if let result = optionalStringPrecedes(candidate.parentRunId, current.parentRunId) { + return result + } + if let result = optionalStringPrecedes(candidate.parentSessionId, current.parentSessionId) { + return result + } + if let result = optionalStringPrecedes( + candidate.relationship?.rawValue, + current.relationship?.rawValue + ) { + return result + } + if let result = optionalStringPrecedes( + candidate.authorityEvidence?.rawValue, + current.authorityEvidence?.rawValue + ) { + return result + } + if let result = optionalStringPrecedes( + candidate.cmuxHibernationResumeAttemptId, + current.cmuxHibernationResumeAttemptId + ) { + return result + } + if candidate.restoreAuthority != current.restoreAuthority { return !candidate.restoreAuthority } + return false + } + + private func canonicalDuplicate( + _ candidate: AgentSessionRunRecord, + _ current: AgentSessionRunRecord + ) -> AgentSessionRunRecord { + guard candidate.updatedAt == current.updatedAt, + candidate.startedAt == current.startedAt else { + return canonicalNonEqualDuplicate(candidate, current) + } + + let preferred = isNewer(candidate, than: current) ? candidate : current + let alternate = preferred == candidate ? current : candidate + var merged = preferred + // Equal-time duplicate writes describe one logical generation. Preserve + // the strongest identity evidence while making restore ownership + // monotonic, so corrupted ordering cannot promote a child into an owner. + let processIdentityConflict = conflictingProcessIdentity(candidate, current) + let runtimeIdentityConflict = conflictingRuntimeIdentity(candidate.cmuxRuntime, current.cmuxRuntime) + let resumeProofConflict = if let candidateAttempt = candidate.cmuxHibernationResumeAttemptId, + let currentAttempt = current.cmuxHibernationResumeAttemptId { + candidateAttempt != currentAttempt + } else { + false + } + let identityConflict = candidate.identityConflict == true + || current.identityConflict == true + || processIdentityConflict + || runtimeIdentityConflict + merged.identityConflict = identityConflict ? true : nil + merged.restoreAuthority = candidate.restoreAuthority + && current.restoreAuthority + && !identityConflict + if identityConflict { + merged.pid = nil + merged.processStartedAt = nil + merged.cmuxRuntime = nil + } else { + // Keep PID/start metadata from one row as a coherent pair. Combining + // complementary partial rows could invent a process generation that + // neither writer actually observed. + if preferred.pid != nil || preferred.processStartedAt != nil { + merged.pid = preferred.pid + merged.processStartedAt = preferred.processStartedAt + } else { + merged.pid = alternate.pid + merged.processStartedAt = alternate.processStartedAt + } + merged.cmuxRuntime = preferredRuntime(candidate.cmuxRuntime, current.cmuxRuntime) + } + merged.parentRunId = preferred.parentRunId ?? alternate.parentRunId + merged.parentSessionId = preferred.parentSessionId ?? alternate.parentSessionId + if candidate.relationship == .spawned || current.relationship == .spawned { + merged.relationship = .spawned + } else { + merged.relationship = preferred.relationship ?? alternate.relationship + } + merged.authorityEvidence = preferredAuthorityEvidence( + candidate.authorityEvidence, + current.authorityEvidence + ) + if resumeProofConflict { + merged.cmuxHibernationResumeAttemptId = nil + merged.restoreAuthority = false + } else { + merged.cmuxHibernationResumeAttemptId = candidate.cmuxHibernationResumeAttemptId + ?? current.cmuxHibernationResumeAttemptId + } + if let candidateEndedAt = candidate.endedAt, let currentEndedAt = current.endedAt { + merged.endedAt = max(candidateEndedAt, currentEndedAt) + } else { + merged.endedAt = candidate.endedAt ?? current.endedAt + } + return normalizedAuthority(merged) + } + + private func normalizedAuthority(_ source: AgentSessionRunRecord) -> AgentSessionRunRecord { + var run = source + run.authorityEvidence = AgentSessionAuthorityTransition().persistedEvidence(for: run) + if run.relationship == .spawned + || run.authorityEvidence?.prohibitsRestore == true + || run.endedAt != nil + || run.identityConflict == true { + run.restoreAuthority = false + } + return run + } + + private func canonicalNonEqualDuplicate( + _ candidate: AgentSessionRunRecord, + _ current: AgentSessionRunRecord + ) -> AgentSessionRunRecord { + let newer = isNewer(candidate, than: current) ? candidate : current + let older = newer == candidate ? current : candidate + let transition = AgentSessionAuthorityTransition() + let newerEvidence = transition.persistedEvidence(for: newer) + let olderEvidence = transition.persistedEvidence(for: older) + if let durableEvidence = preferredAuthorityEvidence( + newerEvidence?.isDurableChild == true ? newerEvidence : nil, + olderEvidence?.isDurableChild == true ? olderEvidence : nil + ) { + var merged = newer + merged.parentRunId = newer.parentRunId ?? older.parentRunId + merged.parentSessionId = newer.parentSessionId ?? older.parentSessionId + merged.relationship = .spawned + merged.restoreAuthority = false + merged.authorityEvidence = durableEvidence + return normalizedAuthority(merged) + } + if olderEvidence == .provisionalAmbiguousChild { + if newerEvidence == .verifiedForkRoot, + newer.relationship == .forked, + newer.restoreAuthority { + return normalizedAuthority(newer) + } + return provisionalChildProjection(newer: newer, older: older) + } + if newerEvidence == .provisionalAmbiguousChild { + return provisionalChildProjection(newer: newer, older: older) + } + return normalizedAuthority(newer) + } + + private func provisionalChildProjection( + newer: AgentSessionRunRecord, + older: AgentSessionRunRecord + ) -> AgentSessionRunRecord { + var merged = newer + merged.parentRunId = newer.parentRunId ?? older.parentRunId + merged.parentSessionId = newer.parentSessionId ?? older.parentSessionId + merged.relationship = .spawned + merged.restoreAuthority = false + merged.authorityEvidence = .provisionalAmbiguousChild + return normalizedAuthority(merged) + } + + private func conflictingProcessIdentity( + _ lhs: AgentSessionRunRecord, + _ rhs: AgentSessionRunRecord + ) -> Bool { + if let lhsPID = lhs.pid, let rhsPID = rhs.pid, lhsPID != rhsPID { return true } + if let lhsStartedAt = lhs.processStartedAt, + let rhsStartedAt = rhs.processStartedAt, + abs(lhsStartedAt - rhsStartedAt) > 0.001 { + return true + } + return false + } + + private func conflictingRuntimeIdentity( + _ lhs: AgentCmuxRuntimeIdentity?, + _ rhs: AgentCmuxRuntimeIdentity? + ) -> Bool { + guard let lhs, let rhs else { return false } + return lhs.id != rhs.id + || optionalValuesConflict(lhs.socketPath, rhs.socketPath) + || optionalValuesConflict(lhs.bundleIdentifier, rhs.bundleIdentifier) + || optionalValuesConflict(lhs.processId, rhs.processId) + || optionalValuesConflict(lhs.processStartSeconds, rhs.processStartSeconds) + || optionalValuesConflict(lhs.processStartMicroseconds, rhs.processStartMicroseconds) + } + + private func optionalValuesConflict(_ lhs: T?, _ rhs: T?) -> Bool { + guard let lhs, let rhs else { return false } + return lhs != rhs + } + + private func preferredRuntime( + _ lhs: AgentCmuxRuntimeIdentity?, + _ rhs: AgentCmuxRuntimeIdentity? + ) -> AgentCmuxRuntimeIdentity? { + guard let lhs else { return rhs } + guard let rhs else { return lhs } + guard lhs.id == rhs.id else { return nil } + return AgentCmuxRuntimeIdentity( + id: lhs.id, + socketPath: mergedRuntimeField(lhs.socketPath, rhs.socketPath), + bundleIdentifier: mergedRuntimeField(lhs.bundleIdentifier, rhs.bundleIdentifier), + processId: mergedRuntimeField(lhs.processId, rhs.processId), + processStartSeconds: mergedRuntimeField( + lhs.processStartSeconds, + rhs.processStartSeconds + ), + processStartMicroseconds: mergedRuntimeField( + lhs.processStartMicroseconds, + rhs.processStartMicroseconds + ) + ) + } + + private func mergedRuntimeField(_ lhs: String?, _ rhs: String?) -> String? { + guard let lhs else { return rhs } + guard let rhs else { return lhs } + return lhs == rhs ? lhs : nil + } + + private func mergedRuntimeField(_ lhs: T?, _ rhs: T?) -> T? { + guard let lhs else { return rhs } + guard let rhs else { return lhs } + return lhs == rhs ? lhs : nil + } + + private func preferredAuthorityEvidence( + _ lhs: AgentSessionAuthorityEvidence?, + _ rhs: AgentSessionAuthorityEvidence? + ) -> AgentSessionAuthorityEvidence? { + let candidates = [lhs, rhs].compactMap { $0 } + return candidates.sorted { first, second in + if first.isDurableChild != second.isDurableChild { return first.isDurableChild } + if first == .provisionalAmbiguousChild && second != .provisionalAmbiguousChild { return true } + if second == .provisionalAmbiguousChild && first != .provisionalAmbiguousChild { return false } + return first.rawValue < second.rawValue + }.first + } + + private func optionalStringPrecedes(_ lhs: String?, _ rhs: String?) -> Bool? { + if lhs == rhs { return nil } + guard let lhs else { return false } + guard let rhs else { return true } + return lhs < rhs + } +} diff --git a/CLI/AgentSessionRelationship.swift b/CLI/AgentSessionRelationship.swift new file mode 100644 index 000000000000..e50ad5a388e4 --- /dev/null +++ b/CLI/AgentSessionRelationship.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Describes why one agent session run is related to another. +enum AgentSessionRelationship: String, Codable, Sendable { + case spawned + case forked + case resumed +} diff --git a/CLI/AgentSessionRunRecord.swift b/CLI/AgentSessionRunRecord.swift new file mode 100644 index 000000000000..13d0505774e8 --- /dev/null +++ b/CLI/AgentSessionRunRecord.swift @@ -0,0 +1,235 @@ +import Foundation + +struct AgentStableProcessIdentity: Sendable, Equatable { + let executablePath: String? + let arguments: [String] + let startTime: TimeInterval +} + +struct AgentStableProcessIdentityValidator: Sendable { + func identity( + for pid: Int, + probedKernelStartTime: TimeInterval, + processStartTimeLookup: (Int) -> TimeInterval?, + executablePathLookup: (Int) -> String?, + argumentsLookup: (Int) -> [String]? + ) -> AgentStableProcessIdentity? { + let executablePath = executablePathLookup(pid) + let arguments = argumentsLookup(pid) ?? [] + guard let verifiedKernelStartTime = processStartTimeLookup(pid), + abs(verifiedKernelStartTime - probedKernelStartTime) <= 0.001 else { + return nil + } + return AgentStableProcessIdentity( + executablePath: executablePath, + arguments: arguments, + startTime: verifiedKernelStartTime + ) + } +} + +/// One process generation of a logical agent session. +struct AgentSessionRunRecord: Codable, Sendable, Equatable { + var runId: String + var pid: Int? + var processStartedAt: TimeInterval? + var cmuxRuntime: AgentCmuxRuntimeIdentity? = nil + var parentRunId: String? + var parentSessionId: String? + var relationship: AgentSessionRelationship? + var restoreAuthority: Bool + var authorityEvidence: AgentSessionAuthorityEvidence? = nil + /// Exact app-issued proof for an unknown custom CLI generation resumed by + /// cmux. Optional so older readers ignore it and older rows fail closed. + var cmuxHibernationResumeAttemptId: String? = nil + var startedAt: TimeInterval + var updatedAt: TimeInterval + var endedAt: TimeInterval? + /// Set only when equal-time duplicate rows disagree about the process or + /// cmux runtime generation. Consumers must not fall back to record-level + /// identity for a conflicted run. + var identityConflict: Bool? = nil + + func cmuxRuntime(fallingBackTo recordRuntime: AgentCmuxRuntimeIdentity?) -> AgentCmuxRuntimeIdentity? { + guard identityConflict != true else { return nil } + return cmuxRuntime ?? recordRuntime + } +} + +struct AgentSessionRunReconciler: Sendable { + var maximumRecords: Int + private let authorityTransition: AgentSessionAuthorityTransition + + init( + maximumRecords: Int, + authorityTransition: AgentSessionAuthorityTransition = AgentSessionAuthorityTransition() + ) { + self.maximumRecords = maximumRecords + self.authorityTransition = authorityTransition + } + + func reconciling( + _ existing: [AgentSessionRunRecord], + activeRunId: String?, + lineage: AgentHookSessionLineage, + now: TimeInterval + ) -> [AgentSessionRunRecord] { + var runs = existing + var effectiveLineage = lineage + if let activeRunId, + activeRunId != lineage.runId, + let index = runs.firstIndex(where: { $0.runId == activeRunId && $0.endedAt == nil }) { + runs[index].endedAt = now + runs[index].updatedAt = now + runs[index].restoreAuthority = false + if effectiveLineage.parentRunId == nil { + effectiveLineage.parentRunId = activeRunId + if effectiveLineage.relationship == nil { + effectiveLineage.relationship = .resumed + } + } + } + if let index = runs.firstIndex(where: { $0.runId == effectiveLineage.runId }) { + reconcileExisting(&runs[index], lineage: effectiveLineage, now: now) + } else { + runs.append(Self.newRun(lineage: effectiveLineage, now: now)) + } + guard runs.count > maximumRecords else { return runs } + let active = runs.filter { $0.endedAt == nil }.sorted { $0.updatedAt > $1.updatedAt } + if active.count >= maximumRecords { return Array(active.prefix(maximumRecords)) } + let ended = runs.filter { $0.endedAt != nil }.sorted { $0.updatedAt > $1.updatedAt } + return active + Array(ended.prefix(maximumRecords - active.count)) + } + + private func reconcileExisting( + _ run: inout AgentSessionRunRecord, + lineage: AgentHookSessionLineage, + now: TimeInterval + ) { + let previousEvidence = authorityTransition.persistedEvidence(for: run) + let recoversProvisionalFork = authorityTransition.canRecoverProvisionalFork( + previous: previousEvidence, + incoming: lineage + ) + let incomingDurableEvidence = lineage.authorityEvidence.flatMap { + $0.isDurableChild ? $0 : nil + } + let replacesKnownProcessGeneration = run.processStartedAt.flatMap { previousStartedAt in + lineage.processStartedAt.map { abs(previousStartedAt - $0) > 0.001 } + } == true + // Older rows may have recorded a PID without its kernel start time. A + // different PID plus a verified incoming start is still definitive + // replacement evidence. Same-PID enrichment stays within the existing + // generation so a late hook cannot revive revoked authority. + let replacesLegacyProcessGeneration = run.processStartedAt == nil + && lineage.processStartedAt != nil + && run.pid != nil + && lineage.pid != nil + && run.pid != lineage.pid + let replacesProcessGeneration = replacesKnownProcessGeneration + || replacesLegacyProcessGeneration + if replacesProcessGeneration { + let previous = run + run = Self.newRun(lineage: lineage, now: now) + if recoversProvisionalFork { + run.parentRunId = lineage.parentRunId ?? previous.parentRunId + run.parentSessionId = lineage.parentSessionId ?? previous.parentSessionId + return + } + // A stable logical run can span multiple process generations. Once + // durable evidence proves it is a child, loss of process ancestry + // after the parent exits must not turn it into a root. + run.parentRunId = lineage.parentRunId ?? previous.parentRunId + run.parentSessionId = lineage.parentSessionId ?? previous.parentSessionId + if let previousEvidence, previousEvidence.isDurableChild { + run.relationship = .spawned + run.restoreAuthority = false + run.authorityEvidence = previousEvidence + } else if let incomingDurableEvidence { + run.relationship = .spawned + run.restoreAuthority = false + run.authorityEvidence = incomingDurableEvidence + } else if previousEvidence == .provisionalAmbiguousChild { + run.relationship = .spawned + run.restoreAuthority = false + run.authorityEvidence = .provisionalAmbiguousChild + } else { + // Root authority is generation-scoped. Completion demotes the + // exited generation, but a verified replacement root starts + // with the new lineage's authority. + run.restoreAuthority = lineage.restoreAuthority + } + return + } + run.pid = lineage.pid ?? run.pid + run.processStartedAt = lineage.processStartedAt ?? run.processStartedAt + run.cmuxRuntime = lineage.cmuxRuntime ?? run.cmuxRuntime + if recoversProvisionalFork { + run.parentRunId = lineage.parentRunId ?? run.parentRunId + run.parentSessionId = lineage.parentSessionId ?? run.parentSessionId + run.relationship = .forked + run.restoreAuthority = true + run.authorityEvidence = .verifiedForkRoot + run.endedAt = nil + run.updatedAt = now + return + } + run.parentRunId = lineage.parentRunId ?? run.parentRunId + run.parentSessionId = lineage.parentSessionId ?? run.parentSessionId + run.cmuxHibernationResumeAttemptId = lineage.hibernationResumeAttemptId?.uuidString + ?? run.cmuxHibernationResumeAttemptId + if let previousEvidence, previousEvidence.isDurableChild { + run.relationship = .spawned + run.restoreAuthority = false + run.authorityEvidence = previousEvidence + run.endedAt = nil + run.updatedAt = now + return + } + if let incomingDurableEvidence { + run.relationship = .spawned + run.restoreAuthority = false + run.authorityEvidence = incomingDurableEvidence + run.endedAt = nil + run.updatedAt = now + return + } + if previousEvidence == .provisionalAmbiguousChild { + run.relationship = .spawned + run.restoreAuthority = false + run.authorityEvidence = .provisionalAmbiguousChild + run.endedAt = nil + run.updatedAt = now + return + } + run.relationship = lineage.relationship == .spawned ? .spawned : (run.relationship ?? lineage.relationship) + run.authorityEvidence = lineage.authorityEvidence ?? run.authorityEvidence + // Authority is monotonic within one process generation. New child + // evidence can demote a run, but missing ancestry later cannot promote + // that child into a restore owner. + run.restoreAuthority = run.restoreAuthority && lineage.restoreAuthority + run.endedAt = nil + run.updatedAt = now + } + + private static func newRun( + lineage: AgentHookSessionLineage, + now: TimeInterval + ) -> AgentSessionRunRecord { + AgentSessionRunRecord( + runId: lineage.runId, + pid: lineage.pid, + processStartedAt: lineage.processStartedAt, + cmuxRuntime: lineage.cmuxRuntime, + parentRunId: lineage.parentRunId, + parentSessionId: lineage.parentSessionId, + relationship: lineage.relationship, + restoreAuthority: lineage.restoreAuthority, + authorityEvidence: lineage.authorityEvidence, + cmuxHibernationResumeAttemptId: lineage.hibernationResumeAttemptId?.uuidString, + startedAt: now, + updatedAt: now, + endedAt: nil + ) + } +} diff --git a/CLI/AgentSessionStateProjection.swift b/CLI/AgentSessionStateProjection.swift new file mode 100644 index 000000000000..3284dfc256fa --- /dev/null +++ b/CLI/AgentSessionStateProjection.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Derives display and automation state from orthogonal stored observations. +struct AgentSessionStateProjection: Sendable, Equatable { + var process: AgentProcessState + var session: AgentSessionLifecycleState + var foreground: AgentForegroundState + var attention: AgentAttentionState + var workloads: [AgentWorkloadRecord] + var activity: AgentActivitySnapshot + var effective: AgentEffectiveState + + init( + record: ClaudeHookSessionRecord, + run: AgentSessionRunRecord, + probedProcessState: AgentProcessState? = nil + ) { + let ended = run.endedAt != nil || record.completedAt != nil + process = ended + ? .exited + : (probedProcessState ?? AgentHookSessionLineageResolver().processState( + pid: run.pid, + expectedStartedAt: run.processStartedAt + )) + session = ended ? .ended : (record.sessionState ?? .active) + foreground = ended + ? .completed + : (record.foregroundState ?? Self.foreground(from: record.runtimeStatus)) + attention = ended + ? .none + : (record.attentionState ?? Self.attention(from: record.runtimeStatus)) + workloads = ended ? [] : (record.workloads ?? []) + activity = Self.activity(foreground: foreground, workloads: workloads) + effective = Self.effective( + process: process, + session: session, + foreground: foreground, + attention: attention, + activity: activity + ) + } + + private static func foreground(from status: AgentHookRuntimeStatus?) -> AgentForegroundState { + switch status { + case .running?: .working + case .idle?, .needsInput?: .completed + case .error?: .failed + case nil: .unknown + } + } + + private static func attention(from status: AgentHookRuntimeStatus?) -> AgentAttentionState { + switch status { + case .needsInput?: .needsInput + case .error?: .error + case .running?, .idle?: .none + case nil: .unknown + } + } + + private static func activity( + foreground: AgentForegroundState, + workloads: [AgentWorkloadRecord] + ) -> AgentActivitySnapshot { + var counts = AgentActivitySnapshot.Counts() + if foreground == .working { counts.foreground = 1 } + for workload in workloads where workload.keepsSessionBusy && workload.phase.isActive { + switch workload.kind { + case .foreground: counts.foreground += 1 + case .backgroundTerminal: counts.backgroundTerminal += 1 + case .monitor: counts.monitor += 1 + case .scheduled: counts.scheduled += 1 + case .subagent: counts.subagent += 1 + case .tool: counts.tool += 1 + case .other: counts.other += 1 + } + } + var modes: [AgentActivityMode] = [] + if counts.foreground > 0 { modes.append(.foreground) } + if counts.backgroundTerminal + counts.other > 0 { modes.append(.background) } + if counts.monitor > 0 { modes.append(.monitoring) } + if counts.scheduled > 0 { modes.append(.scheduled) } + if counts.subagent > 0 { modes.append(.subagents) } + if counts.tool > 0 { modes.append(.tools) } + let state: AgentActivityState = if counts.total > 0 { + .busy + } else if foreground != .unknown || !workloads.isEmpty { + .idle + } else { + .unknown + } + return AgentActivitySnapshot( + state: state, + busy: counts.total > 0, + modes: modes, + counts: counts + ) + } + + private static func effective( + process: AgentProcessState, + session: AgentSessionLifecycleState, + foreground: AgentForegroundState, + attention: AgentAttentionState, + activity: AgentActivitySnapshot + ) -> AgentEffectiveState { + switch session { + case .ended: return .ended + case .hibernated: return .hibernated + case .restoring: return .restoring + case .active: break + } + if process == .exited { return .ended } + if attention == .needsInput { return .needsInput } + if attention == .error || foreground == .failed { return .error } + if activity.counts.foreground > 0 + || activity.counts.backgroundTerminal > 0 + || activity.counts.subagent > 0 + || activity.counts.tool > 0 + || activity.counts.other > 0 { + return .working + } + if activity.counts.monitor > 0 { return .monitoring } + if activity.counts.scheduled > 0 { return .scheduled } + if foreground == .interrupted { return .interrupted } + if foreground == .unknown && attention == .unknown { return .unknown } + return .idle + } +} diff --git a/CLI/AgentSessionWorkloadReconciler.swift b/CLI/AgentSessionWorkloadReconciler.swift new file mode 100644 index 000000000000..7eb501840ce2 --- /dev/null +++ b/CLI/AgentSessionWorkloadReconciler.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Merges provider snapshots while retaining bounded completed workload history. +struct AgentSessionWorkloadReconciler: Sendable { + private let maximumRecords = 256 + + func replacingActiveWorkloads( + _ existing: [AgentWorkloadRecord], + with incoming: [AgentWorkloadRecord], + now: TimeInterval + ) -> [AgentWorkloadRecord] { + let incomingIDs = Set(incoming.map(\.id)) + var merged = existing.map { workload -> AgentWorkloadRecord in + guard workload.phase.isActive, !incomingIDs.contains(workload.id) else { return workload } + var completed = workload + completed.phase = .completed + completed.updatedAt = now + completed.endedAt = now + completed.endReason = "provider_completed" + return completed + } + for workload in incoming { + if let index = merged.firstIndex(where: { $0.id == workload.id }) { + let originalStartedAt = merged[index].startedAt + merged[index] = workload + merged[index].startedAt = min(originalStartedAt, workload.startedAt) + } else { + merged.append(workload) + } + } + return bounded(merged) + } + + func cancellingActiveWorkloads( + _ existing: [AgentWorkloadRecord], + reason: String, + now: TimeInterval + ) -> [AgentWorkloadRecord] { + bounded(existing.map { workload in + guard workload.phase.isActive else { return workload } + var cancelled = workload + cancelled.phase = .cancelled + cancelled.updatedAt = now + cancelled.endedAt = now + cancelled.endReason = reason + return cancelled + }) + } + + private func bounded(_ records: [AgentWorkloadRecord]) -> [AgentWorkloadRecord] { + guard records.count > maximumRecords else { return records } + let active = records.filter { $0.phase.isActive }.sorted { $0.updatedAt > $1.updatedAt } + if active.count >= maximumRecords { + return Array(active.prefix(maximumRecords)) + } + let inactive = records.filter { !$0.phase.isActive }.sorted { $0.updatedAt > $1.updatedAt } + return active + Array(inactive.prefix(maximumRecords - active.count)) + } +} diff --git a/CLI/AgentStagedOutput.swift b/CLI/AgentStagedOutput.swift new file mode 100644 index 000000000000..db95dd955cdf --- /dev/null +++ b/CLI/AgentStagedOutput.swift @@ -0,0 +1,58 @@ +import Darwin +import Foundation + +struct AgentStagedOutput { + private let readChunkBytes: Int + + init(readChunkBytes: Int = 64 * 1_024) { + precondition(readChunkBytes > 0) + self.readChunkBytes = readChunkBytes + } + + func publish( + build: (FileHandle) throws -> Void, + publishChunk: (Data) -> Void + ) throws { + let templatePath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-output.XXXXXX", isDirectory: false) + .path + var template = templatePath.utf8CString + let descriptor = template.withUnsafeMutableBufferPointer { buffer in + mkstemp(buffer.baseAddress) + } + guard descriptor >= 0 else { throw posixError() } + let path = String( + decoding: template.dropLast().map { UInt8(bitPattern: $0) }, + as: UTF8.self + ) + guard fchmod(descriptor, S_IRUSR | S_IWUSR) == 0 else { + let error = posixError() + Darwin.close(descriptor) + path.withCString { _ = Darwin.unlink($0) } + throw error + } + guard path.withCString({ Darwin.unlink($0) }) == 0 else { + let error = posixError() + Darwin.close(descriptor) + throw error + } + + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + defer { + try? handle.close() + } + + // Build the complete document before publishing its first byte. Store + // validation or row-encoding failures therefore cannot leave a partial + // JSON object on stdout. + try build(handle) + try handle.seek(toOffset: 0) + while let chunk = try handle.read(upToCount: readChunkBytes), !chunk.isEmpty { + publishChunk(chunk) + } + } + + private func posixError() -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } +} diff --git a/CLI/AgentStopStateAdapter.swift b/CLI/AgentStopStateAdapter.swift new file mode 100644 index 000000000000..39fd18d5a232 --- /dev/null +++ b/CLI/AgentStopStateAdapter.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Maps provider-specific stop payloads into the shared foreground state. +/// Reads at most the tail of a Codex transcript and ignores free-form messages +/// to avoid treating prose about an interruption as an actual interrupted turn. +struct AgentStopStateAdapter: Sendable { + private let maximumTranscriptBytes: UInt64 = 512 * 1024 + + func isInterrupted( + provider: String, + input: ClaudeHookParsedInput, + transcriptPath: String? = nil + ) -> Bool { + if structuralSignals(input).contains(where: Self.isInterruptionSignal) { + return true + } + guard provider.lowercased() == "codex", + let path = normalized(transcriptPath ?? input.transcriptPath) else { + return false + } + return codexTurnWasAborted(path: path, turnId: normalized(input.turnId)) + } + + private func structuralSignals(_ input: ClaudeHookParsedInput) -> [String] { + let keys = [ + "hook_event_name", "hookEventName", "event", "event_name", "type", + "kind", "reason", "stop_reason", "stopReason", "terminationReason", "status", + ] + let objects = [ + input.rawObject, + input.object, + input.rawObject?["data"] as? [String: Any], + input.object?["data"] as? [String: Any], + ] + return objects.compactMap { $0 }.flatMap { object in + keys.compactMap { object[$0] as? String } + } + } + + private func codexTurnWasAborted(path: String, turnId: String?) -> Bool { + guard let data = readTail(path: path), + let text = String(data: data, encoding: .utf8) else { return false } + var lastTerminalEvent: String? + var currentTurnId: String? + for line in text.split(separator: "\n") { + guard let data = String(line).data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let objectType = object["type"] as? String else { continue } + if objectType == "turn_context", + let payload = object["payload"] as? [String: Any] { + currentTurnId = string(payload, keys: ["turn_id", "turnId"]) + continue + } + guard objectType == "event_msg", + let payload = object["payload"] as? [String: Any], + let event = payload["type"] as? String else { continue } + if event == "task_started" { + currentTurnId = string(payload, keys: ["turn_id", "turnId"]) + continue + } + guard ["task_complete", "turn_complete", "turn_aborted"].contains(event) else { continue } + let eventTurnId = string(payload, keys: ["turn_id", "turnId"]) ?? currentTurnId + if turnId == nil || eventTurnId == turnId { lastTerminalEvent = event } + } + return lastTerminalEvent == "turn_aborted" + } + + private func readTail(path: String) -> Data? { + guard let handle = FileHandle(forReadingAtPath: NSString(string: path).expandingTildeInPath) else { + return nil + } + defer { try? handle.close() } + guard let size = try? handle.seekToEnd() else { return nil } + let offset = size > maximumTranscriptBytes ? size - maximumTranscriptBytes : 0 + guard (try? handle.seek(toOffset: offset)) != nil, + let data = try? handle.readToEnd() else { return nil } + if offset == 0 { return data } + guard let firstNewline = data.firstIndex(of: 0x0A) else { return Data() } + return data.suffix(from: data.index(after: firstNewline)) + } + + private func string(_ object: [String: Any], keys: [String]) -> String? { + keys.compactMap { normalized(object[$0] as? String) }.first + } + + private func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } + return value + } + + private static func isInterruptionSignal(_ value: String) -> Bool { + let normalized = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "-", with: "_") + .replacingOccurrences(of: " ", with: "_") + return normalized == "interrupt" + || normalized == "interrupted" + || normalized == "abort" + || normalized == "aborted" + || normalized == "turn_aborted" + || normalized == "cancelled" + || normalized == "canceled" + || normalized == "user_cancelled" + || normalized == "user_canceled" + } +} diff --git a/CLI/AgentSubtreeActivitySnapshot.swift b/CLI/AgentSubtreeActivitySnapshot.swift new file mode 100644 index 000000000000..a9399147917f --- /dev/null +++ b/CLI/AgentSubtreeActivitySnapshot.swift @@ -0,0 +1,137 @@ +import Foundation + +/// Descendant-only activity. A parent's own state remains in `activity` and +/// `effective_state`; this rollup answers whether any nested agents are active. +struct AgentSubtreeActivitySnapshot: Codable, Sendable, Equatable { + var totalDescendants = 0 + var busyDescendants = 0 + var restoreOwners = 0 + var needsInput = 0 + var errors = 0 + var working = 0 + var monitoring = 0 + var scheduled = 0 + var interrupted = 0 + var hibernated = 0 + var ended = 0 + var workloadCounts = AgentActivitySnapshot.Counts() + + enum CodingKeys: String, CodingKey { + case totalDescendants = "total_descendants" + case busyDescendants = "busy_descendants" + case restoreOwners = "restore_owners" + case needsInput = "needs_input" + case errors + case working + case monitoring + case scheduled + case interrupted + case hibernated + case ended + case workloadCounts = "workload_counts" + } + + mutating func add(node: AgentSessionGraphNode) { + totalDescendants += 1 + if node.activity.busy { busyDescendants += 1 } + if node.restoreAuthority { restoreOwners += 1 } + switch node.effectiveState { + case .needsInput: needsInput += 1 + case .error: errors += 1 + case .working: working += 1 + case .monitoring: monitoring += 1 + case .scheduled: scheduled += 1 + case .interrupted: interrupted += 1 + case .hibernated: hibernated += 1 + case .ended: ended += 1 + case .idle, .restoring, .unknown: break + } + workloadCounts.add(node.activity.counts) + } + + mutating func add(_ other: AgentSubtreeActivitySnapshot) { + totalDescendants += other.totalDescendants + busyDescendants += other.busyDescendants + restoreOwners += other.restoreOwners + needsInput += other.needsInput + errors += other.errors + working += other.working + monitoring += other.monitoring + scheduled += other.scheduled + interrupted += other.interrupted + hibernated += other.hibernated + ended += other.ended + workloadCounts.add(other.workloadCounts) + } +} + +extension AgentActivitySnapshot.Counts { + mutating func add(_ other: Self) { + foreground += other.foreground + backgroundTerminal += other.backgroundTerminal + monitor += other.monitor + scheduled += other.scheduled + subagent += other.subagent + tool += other.tool + self.other += other.other + } +} + +/// Computes descendant rollups from leaves to roots in O(nodes + edges). Cycles +/// are ignored instead of recursing or blocking the CLI on corrupt history. +struct AgentSubtreeActivityProjector: Sendable { + private struct EdgeKey: Hashable, Sendable { + var parent: Int + var child: Int + } + + private let nodeIndex: AgentSessionGraphNodeIndex + private let graphOrdering: AgentSessionGraphOrdering + + init( + nodeIndex: AgentSessionGraphNodeIndex = AgentSessionGraphNodeIndex(), + graphOrdering: AgentSessionGraphOrdering = AgentSessionGraphOrdering() + ) { + self.nodeIndex = nodeIndex + self.graphOrdering = graphOrdering + } + + func project(nodes: inout [AgentSessionGraphNode], edges: [AgentSessionGraphEdge]) { + var indexByNode = nodeIndex.indices(nodes) + if indexByNode.count != nodes.count { + nodes = indexByNode.values.sorted().map { nodes[$0] } + indexByNode = nodeIndex.indices(nodes) + } + let edgeResolver = AgentSessionGraphEdgeResolver( + nodes: nodes, + nodeIndex: nodeIndex, + graphOrdering: graphOrdering + ) + var parentsByChild: [Int: [Int]] = [:] + var remainingChildren = Array(repeating: 0, count: nodes.count) + var seenEdges: Set = [] + for edge in edges { + guard let parentNode = edgeResolver.parentNodeId(for: edge), + let parent = indexByNode[parentNode], + let child = indexByNode[edge.toNodeId], + parent != child else { continue } + let edgeKey = EdgeKey(parent: parent, child: child) + guard seenEdges.insert(edgeKey).inserted else { continue } + parentsByChild[child, default: []].append(parent) + remainingChildren[parent] += 1 + } + + var queue = nodes.indices.filter { remainingChildren[$0] == 0 } + var cursor = 0 + while cursor < queue.count { + let child = queue[cursor] + cursor += 1 + for parent in parentsByChild[child] ?? [] { + nodes[parent].subtreeActivity.add(node: nodes[child]) + nodes[parent].subtreeActivity.add(nodes[child].subtreeActivity) + remainingChildren[parent] -= 1 + if remainingChildren[parent] == 0 { queue.append(parent) } + } + } + } +} diff --git a/CLI/AgentTerminalObservationJoiner.swift b/CLI/AgentTerminalObservationJoiner.swift new file mode 100644 index 000000000000..a5d7b7423b44 --- /dev/null +++ b/CLI/AgentTerminalObservationJoiner.swift @@ -0,0 +1,496 @@ +import CmuxFoundation +import Foundation + +struct AgentSessionProviderSelection: Sendable, Equatable { + var providerID: String? + /// Set only when the requested spelling is an exact catalog identifier. + /// Exact owners must not inherit live observations through a built-in + /// executable/family alias with the same spelling. + var exactObservationProviderID: String? + /// Set when a request uniquely matches a catalog identifier after case + /// folding. Matching remains provider-only, and accepted live observations + /// are rewritten to this catalog spelling before exact grouping and joins. + var caseFoldedObservationProviderID: String? = nil + + func ownedProviderMatch( + for observation: CmuxAgentTerminalObservation + ) -> Bool? { + if let exactObservationProviderID { + return observation.sessionProviderID == exactObservationProviderID + } + if let caseFoldedObservationProviderID { + return observation.sessionProviderID.caseInsensitiveCompare( + caseFoldedObservationProviderID + ) == .orderedSame + } + return nil + } + + func canonicalizedObservation( + _ observation: CmuxAgentTerminalObservation + ) -> CmuxAgentTerminalObservation { + guard let providerID = caseFoldedObservationProviderID, + observation.sessionProviderID.caseInsensitiveCompare(providerID) == .orderedSame, + observation.sessionProviderID != providerID else { + return observation + } + return CmuxAgentTerminalObservation( + runtimeID: observation.runtimeID, + workspaceID: observation.workspaceID, + surfaceID: observation.surfaceID, + surfaceGeneration: observation.surfaceGeneration, + revision: observation.revision, + familyID: observation.familyID, + sessionProviderID: providerID, + lifecycleAuthoritative: observation.lifecycleAuthoritative, + state: observation.state, + pid: observation.pid, + processStartSeconds: observation.processStartSeconds, + processStartMicroseconds: observation.processStartMicroseconds, + cwd: observation.cwd, + publishedAt: observation.publishedAt + ) + } +} + +/// Retains same-process siblings for exact-session reconciliation without +/// admitting a different kernel process generation that reused the numeric PID. +/// Missing start metadata remains a wildcard for compatibility with legacy rows. +struct AgentSessionProcessCohortMatcher: Sendable { + private struct Base: Hashable, Sendable { + var provider: String + var runtimeID: String + var surfaceID: String + var pid: Int + } + + private var allBases: Set = [] + private var basesWithUnknownStart: Set = [] + private var startMillisecondsByBase: [Base: Set] = [:] + + mutating func insert( + provider: String, + record: ClaudeHookSessionRecord, + run: AgentSessionRunRecord + ) { + guard let base = base(provider: provider, record: record, run: run) else { return } + allBases.insert(base) + if let startedAt = run.processStartedAt { + startMillisecondsByBase[base, default: []].insert(Self.milliseconds(startedAt)) + } else { + basesWithUnknownStart.insert(base) + } + } + + func matches( + provider: String, + record: ClaudeHookSessionRecord, + run: AgentSessionRunRecord + ) -> Bool { + guard let base = base(provider: provider, record: record, run: run), + allBases.contains(base) else { return false } + guard let startedAt = run.processStartedAt else { return true } + if basesWithUnknownStart.contains(base) { return true } + let milliseconds = Self.milliseconds(startedAt) + let knownStarts = startMillisecondsByBase[base] ?? [] + return knownStarts.contains(milliseconds - 1) + || knownStarts.contains(milliseconds) + || knownStarts.contains(milliseconds + 1) + } + + private func base( + provider: String, + record: ClaudeHookSessionRecord, + run: AgentSessionRunRecord + ) -> Base? { + guard run.identityConflict != true, + let runtimeID = run.cmuxRuntime(fallingBackTo: record.cmuxRuntime)?.id, + let pid = run.pid else { return nil } + return Base( + provider: provider, + runtimeID: runtimeID, + surfaceID: record.surfaceId.lowercased(), + pid: pid + ) + } + + private static func milliseconds(_ value: TimeInterval) -> Int64 { + Int64((value * 1_000).rounded()) + } +} + +/// Retains only the session candidates needed to decide whether a terminal +/// observation has one match, an active-slot match, or an ambiguous match. +/// The full lifecycle rows are emitted by the caller's normal store pass. +struct AgentTerminalObservationCandidateAccumulator { + private struct Bucket { + var observation: CmuxAgentTerminalObservation + var activeSessionID: String? + var activeCandidateNodeID: String? + var fallbackCandidateNodeIDs: [String] = [] + } + + private var bucketsByIdentity: [String: Bucket] = [:] + private var identitiesByProcessKey: [String: [String]] = [:] + private var nodesByID: [String: AgentSessionGraphNode] = [:] + private let joiner = AgentTerminalObservationJoiner() + + init( + observations: [CmuxAgentTerminalObservation], + activeSessionBySurface: [String: String] + ) { + let observations = AgentTerminalObservationJoiner.canonicalObservations(observations) + bucketsByIdentity.reserveCapacity(observations.count) + identitiesByProcessKey.reserveCapacity(observations.count) + nodesByID.reserveCapacity(min(observations.count, 341) * 3) + for observation in observations { + let identity = Self.observationIdentity(observation) + let surfaceKey = AgentTerminalObservationJoiner.surfaceKey( + provider: observation.sessionProviderID, + runtimeID: observation.runtimeID, + surfaceID: observation.surfaceID.uuidString + ) + bucketsByIdentity[identity] = Bucket( + observation: observation, + activeSessionID: activeSessionBySurface[surfaceKey] + ) + identitiesByProcessKey[ + AgentTerminalObservationJoiner.processKey(observation: observation), + default: [] + ].append(identity) + } + } + + var retainedCount: Int { retainedCandidates.count } + + func contains(nodeID: String) -> Bool { nodesByID[nodeID] != nil } + + var retainedCandidates: [AgentSessionGraphNode] { + var retainedNodeIDs: Set = [] + retainedNodeIDs.reserveCapacity(nodesByID.count) + for bucket in bucketsByIdentity.values { + if let activeCandidateNodeID = bucket.activeCandidateNodeID { + retainedNodeIDs.insert(activeCandidateNodeID) + } + retainedNodeIDs.formUnion(bucket.fallbackCandidateNodeIDs) + } + return retainedNodeIDs.compactMap { nodesByID[$0] }.sorted { + $0.nodeId < $1.nodeId + } + } + + mutating func insert(_ node: AgentSessionGraphNode) { + let processKey = AgentTerminalObservationJoiner.processKey(node: node) + guard !processKey.isEmpty, + let identities = identitiesByProcessKey[processKey] else { + return + } + for identity in identities { + guard var bucket = bucketsByIdentity[identity], + joiner.matches(node, observation: bucket.observation) else { + continue + } + let nodeID = node.nodeId + if let activeSessionID = bucket.activeSessionID, + node.sessionId == activeSessionID { + if bucket.activeCandidateNodeID == nodeID { + nodesByID[nodeID] = node + } else if bucket.activeCandidateNodeID == nil { + bucket.activeCandidateNodeID = nodeID + nodesByID[nodeID] = node + } + } else if bucket.fallbackCandidateNodeIDs.contains(nodeID) { + nodesByID[nodeID] = node + } else if bucket.fallbackCandidateNodeIDs.count < 2 { + bucket.fallbackCandidateNodeIDs.append(nodeID) + nodesByID[nodeID] = node + } + bucketsByIdentity[identity] = bucket + } + } + + private static func observationIdentity(_ observation: CmuxAgentTerminalObservation) -> String { + "\(AgentTerminalObservationJoiner.processKey(observation: observation))\u{1F}" + + "\(observation.surfaceGeneration)\u{1F}" + + "\(observation.processStartSeconds)\u{1F}" + + "\(observation.processStartMicroseconds)" + } +} + +/// Reconciles cached terminal observations with durable hook-session nodes. +/// +/// Matching requires cmux runtime, surface, provider, PID, and kernel process +/// start time. Ambiguous observations remain independent process nodes instead +/// of being attached to the wrong logical session. +struct AgentTerminalObservationJoiner: Sendable { + /// Detector family, provider, state, and workspace are mutable metadata. + /// The runtime-owned terminal generation plus kernel PID lifetime is the + /// stable identity used to collapse repeated publications. + private struct ProcessIdentity: Hashable, Sendable { + let runtimeID: String + let surfaceID: UUID + let surfaceGeneration: UInt64 + let pid: Int32 + let processStartSeconds: Int64 + let processStartMicroseconds: Int64 + + init(_ observation: CmuxAgentTerminalObservation) { + runtimeID = observation.runtimeID + surfaceID = observation.surfaceID + surfaceGeneration = observation.surfaceGeneration + pid = observation.pid + processStartSeconds = observation.processStartSeconds + processStartMicroseconds = observation.processStartMicroseconds + } + } + + static func canonicalObservations( + _ observations: [CmuxAgentTerminalObservation] + ) -> [CmuxAgentTerminalObservation] { + var result: [CmuxAgentTerminalObservation] = [] + var indexByIdentity: [ProcessIdentity: Int] = [:] + result.reserveCapacity(observations.count) + indexByIdentity.reserveCapacity(observations.count) + + for observation in observations { + let identity = ProcessIdentity(observation) + if let index = indexByIdentity[identity] { + if prefers(observation, over: result[index]) { + result[index] = observation + } + } else { + indexByIdentity[identity] = result.count + result.append(observation) + } + } + return result + } + + func merge( + nodes: [AgentSessionGraphNode], + observations: [CmuxAgentTerminalObservation], + activeSessionBySurface: [String: String] + ) -> [AgentSessionGraphNode] { + var result = nodes + _ = merge( + nodes: &result, + observations: observations, + activeSessionBySurface: activeSessionBySurface + ) + return result + } + + @discardableResult + func merge( + nodes: inout [AgentSessionGraphNode], + observations: [CmuxAgentTerminalObservation], + activeSessionBySurface: [String: String], + maximumNodeCount: Int? = nil, + includeUnmatchedNode: (AgentSessionGraphNode) -> Bool = { _ in true } + ) -> Bool { + let candidateIndices = Dictionary(grouping: nodes.indices) { index in + Self.processKey(node: nodes[index]) + } + for observation in Self.canonicalObservations(observations) { + let matchingIndices = (candidateIndices[Self.processKey(observation: observation)] ?? []).filter { index in + matches(nodes[index], observation: observation) + } + let activeSessionID = activeSessionBySurface[Self.surfaceKey( + provider: observation.sessionProviderID, + runtimeID: observation.runtimeID, + surfaceID: observation.surfaceID.uuidString + )] + let selectedIndex: Int? = if let activeSessionID { + matchingIndices.first { nodes[$0].sessionId == activeSessionID } + ?? (matchingIndices.count == 1 ? matchingIndices[0] : nil) + } else { + matchingIndices.count == 1 ? matchingIndices[0] : nil + } + if let selectedIndex { + nodes[selectedIndex] = applying(observation, to: nodes[selectedIndex]) + } else { + let node = processNode(observation) + guard includeUnmatchedNode(node) else { continue } + if let maximumNodeCount, nodes.count >= maximumNodeCount { return false } + nodes.append(node) + } + } + return true + } + + static func surfaceKey(provider: String, runtimeID: String, surfaceID: String) -> String { + "\(provider)\u{1F}\(runtimeID)\u{1F}\(surfaceID.lowercased())" + } + + static func processKey(node: AgentSessionGraphNode) -> String { + guard node.identitySource == "hook_session", + let runtimeID = node.cmuxRuntime?.id, + let pid = node.pid else { return "" } + return "\(surfaceKey(provider: node.provider, runtimeID: runtimeID, surfaceID: node.surfaceId))\u{1F}\(pid)" + } + + static func processKey(observation: CmuxAgentTerminalObservation) -> String { + let surface = surfaceKey( + provider: observation.sessionProviderID, + runtimeID: observation.runtimeID, + surfaceID: observation.surfaceID.uuidString + ) + return "\(surface)\u{1F}\(observation.pid)" + } + + private static func prefers( + _ candidate: CmuxAgentTerminalObservation, + over existing: CmuxAgentTerminalObservation + ) -> Bool { + if candidate.publishedAt != existing.publishedAt { + return candidate.publishedAt > existing.publishedAt + } + if candidate.revision != existing.revision { return candidate.revision > existing.revision } + if candidate.runtimeID != existing.runtimeID { return candidate.runtimeID > existing.runtimeID } + let candidateWorkspaceID = candidate.workspaceID.uuidString.lowercased() + let existingWorkspaceID = existing.workspaceID.uuidString.lowercased() + if candidateWorkspaceID != existingWorkspaceID { return candidateWorkspaceID > existingWorkspaceID } + let candidateSurfaceID = candidate.surfaceID.uuidString.lowercased() + let existingSurfaceID = existing.surfaceID.uuidString.lowercased() + if candidateSurfaceID != existingSurfaceID { return candidateSurfaceID > existingSurfaceID } + if candidate.surfaceGeneration != existing.surfaceGeneration { + return candidate.surfaceGeneration > existing.surfaceGeneration + } + if candidate.familyID != existing.familyID { return candidate.familyID > existing.familyID } + if candidate.sessionProviderID != existing.sessionProviderID { + return candidate.sessionProviderID > existing.sessionProviderID + } + if candidate.lifecycleAuthoritative != existing.lifecycleAuthoritative { + return candidate.lifecycleAuthoritative + } + if candidate.state.rawValue != existing.state.rawValue { + return candidate.state.rawValue > existing.state.rawValue + } + if candidate.pid != existing.pid { return candidate.pid > existing.pid } + if candidate.processStartSeconds != existing.processStartSeconds { + return candidate.processStartSeconds > existing.processStartSeconds + } + if candidate.processStartMicroseconds != existing.processStartMicroseconds { + return candidate.processStartMicroseconds > existing.processStartMicroseconds + } + switch (candidate.cwd, existing.cwd) { + case let (candidate?, existing?) where candidate != existing: + return candidate > existing + case (_?, nil): + return true + default: + return false + } + } + + func matches( + _ node: AgentSessionGraphNode, + observation: CmuxAgentTerminalObservation + ) -> Bool { + guard node.identitySource == "hook_session", + node.provider == observation.sessionProviderID, + node.cmuxRuntime?.id == observation.runtimeID, + node.surfaceId.lowercased() == observation.surfaceID.uuidString.lowercased(), + node.pid == Int(observation.pid), + let processStartedAt = node.processStartedAt else { return false } + return abs(processStartedAt - observation.processStartedAt) <= 0.001 + } + + private func applying( + _ observation: CmuxAgentTerminalObservation, + to node: AgentSessionGraphNode + ) -> AgentSessionGraphNode { + var result = node + result.terminalObservation = observation + result.workspaceId = observation.workspaceID.uuidString + if result.cwd == nil { result.cwd = observation.cwd } + guard result.sessionState == .active, + (!observation.lifecycleAuthoritative || result.effectiveState == .unknown) else { + return result + } + result.terminalStateApplied = true + apply(observation.state, to: &result, preserveBackgroundActivity: true) + return result + } + + private func processNode(_ observation: CmuxAgentTerminalObservation) -> AgentSessionGraphNode { + var node = AgentSessionGraphNode( + provider: observation.sessionProviderID, + sessionId: nil, + runId: "pid:\(observation.pid)@\(observation.processStartMicrosecondsSinceEpoch):surface:\(observation.surfaceGeneration)", + identitySource: "terminal_process", + pid: Int(observation.pid), + processStartedAt: observation.processStartedAt, + cmuxRuntime: AgentCmuxRuntimeIdentity( + id: observation.runtimeID, + socketPath: nil, + bundleIdentifier: nil + ), + workspaceId: observation.workspaceID.uuidString, + surfaceId: observation.surfaceID.uuidString, + cwd: observation.cwd, + processState: .alive, + sessionState: .active, + foregroundState: .unknown, + attentionState: .none, + activity: AgentActivitySnapshot( + state: .unknown, + busy: false, + modes: [], + counts: AgentActivitySnapshot.Counts() + ), + effectiveState: .unknown, + workloads: [], + restoreAuthority: false, + startedAt: observation.processStartedAt, + updatedAt: observation.publishedAt, + endedAt: nil, + terminalObservation: observation, + terminalStateApplied: true + ) + apply(observation.state, to: &node, preserveBackgroundActivity: false) + return node + } + + private func apply( + _ state: CmuxAgentObservedState, + to node: inout AgentSessionGraphNode, + preserveBackgroundActivity: Bool + ) { + var counts = preserveBackgroundActivity + ? node.activity.counts + : AgentActivitySnapshot.Counts() + counts.foreground = state == .working ? 1 : 0 + node.foregroundState = state == .working ? .working : .idle + node.attentionState = state == .blocked ? .needsInput : .none + var modes = node.activity.modes.filter { $0 != .foreground } + if counts.foreground > 0 { modes.insert(.foreground, at: 0) } + node.activity = AgentActivitySnapshot( + state: counts.total > 0 ? .busy : .idle, + busy: counts.total > 0, + modes: modes, + counts: counts + ) + if state == .blocked { + node.effectiveState = .needsInput + } else if counts.foreground + counts.backgroundTerminal + counts.subagent + counts.tool + counts.other > 0 { + node.effectiveState = .working + } else if counts.monitor > 0 { + node.effectiveState = .monitoring + } else if counts.scheduled > 0 { + node.effectiveState = .scheduled + } else { + node.effectiveState = .idle + } + } +} + +private extension CmuxAgentTerminalObservation { + var processStartedAt: TimeInterval { + TimeInterval(processStartSeconds) + TimeInterval(processStartMicroseconds) / 1_000_000 + } + + var processStartMicrosecondsSinceEpoch: Int64 { + processStartSeconds * 1_000_000 + processStartMicroseconds + } +} diff --git a/CLI/AgentTreeTextLineSequence.swift b/CLI/AgentTreeTextLineSequence.swift new file mode 100644 index 000000000000..eed744ae070f --- /dev/null +++ b/CLI/AgentTreeTextLineSequence.swift @@ -0,0 +1,176 @@ +import Foundation + +/// Streams the text tree one line at a time so deep or large histories do not +/// require a second full rendered copy in memory. +struct AgentTreeTextLineSequence: Sequence { + let snapshot: AgentSessionGraphSnapshot + let maximumDepth: Int + let nodeIndex = AgentSessionGraphNodeIndex() + + func makeIterator() -> Iterator { + Iterator(snapshot: snapshot, maximumDepth: maximumDepth, nodeIndex: nodeIndex) + } + + struct Iterator: IteratorProtocol { + private struct ResolvedEdgeKey: Hashable { + let parentNodeIndex: Int + let childNodeIndex: Int + } + + private struct RenderFrame { + var nodeIndex: Int + var relationship: AgentSessionRelationship? + var prefix: String + var connector: String + var depth: Int + } + + private struct Child { + var nodeIndex: Int + var relationship: AgentSessionRelationship + } + + private let maximumDepth: Int + private let childrenByNodeIndex: [Int: [Child]] + private let roots: [Int] + private let nodes: [AgentSessionGraphNode] + private var nextRootIndex = 0 + private var nextFallbackIndex = 0 + private var stack: [RenderFrame] = [] + private var visited: Set = [] + private var covered: Set = [] + + init( + snapshot: AgentSessionGraphSnapshot, + maximumDepth: Int, + nodeIndex: AgentSessionGraphNodeIndex + ) { + self.maximumDepth = maximumDepth + nodes = snapshot.nodes + guard !snapshot.edges.isEmpty else { + childrenByNodeIndex = [:] + roots = Array(snapshot.nodes.indices) + return + } + let indexByNodeID = nodeIndex.indices(snapshot.nodes) + let edgeResolver = AgentSessionGraphEdgeResolver( + nodes: snapshot.nodes, + nodeIndex: nodeIndex + ) + var seenEdgeKeys: Set = [] + var mutableChildrenByNodeIndex: [Int: [Child]] = [:] + var childNodeIndices: Set = [] + for edge in snapshot.edges { + guard let parentNodeID = edgeResolver.parentNodeId(for: edge), + let parentNodeIndex = indexByNodeID[parentNodeID], + let childNodeIndex = indexByNodeID[edge.toNodeId], + seenEdgeKeys.insert(ResolvedEdgeKey( + parentNodeIndex: parentNodeIndex, + childNodeIndex: childNodeIndex + )).inserted else { continue } + mutableChildrenByNodeIndex[parentNodeIndex, default: []].append(Child( + nodeIndex: childNodeIndex, + relationship: edge.relationship + )) + childNodeIndices.insert(childNodeIndex) + } + childrenByNodeIndex = mutableChildrenByNodeIndex + roots = snapshot.nodes.indices.filter { !childNodeIndices.contains($0) } + } + + mutating func next() -> String? { + while true { + if stack.isEmpty, !seedNextTraversal() { return nil } + guard let frame = stack.popLast() else { continue } + let node = nodes[frame.nodeIndex] + guard frame.depth <= maximumDepth, + visited.insert(frame.nodeIndex).inserted else { + continue + } + + let children = childrenByNodeIndex[frame.nodeIndex] ?? [] + let childPrefix = frame.prefix + + (frame.connector == "├── " ? "│ " : frame.connector == "└── " ? " " : "") + for index in children.indices.reversed() { + stack.append(RenderFrame( + nodeIndex: children[index].nodeIndex, + relationship: children[index].relationship, + prefix: childPrefix, + connector: index == children.count - 1 ? "└── " : "├── ", + depth: frame.depth + 1 + )) + } + return Self.line( + for: node, + relationship: frame.relationship, + prefix: frame.prefix, + connector: frame.connector + ) + } + } + + private mutating func seedNextTraversal() -> Bool { + while nextRootIndex < roots.count { + let rootIndex = roots[nextRootIndex] + nextRootIndex += 1 + guard !covered.contains(rootIndex) else { continue } + markReachable(from: rootIndex) + stack.append(RenderFrame( + nodeIndex: rootIndex, + relationship: nil, + prefix: "", + connector: "", + depth: 0 + )) + return true + } + while nextFallbackIndex < nodes.count { + let nodeIndex = nextFallbackIndex + nextFallbackIndex += 1 + guard !covered.contains(nodeIndex) else { continue } + // Components made entirely of cycles have no root. Mark the + // whole component before rendering its fallback seed so a + // depth-truncated descendant cannot later reappear as a root. + markReachable(from: nodeIndex) + stack.append(RenderFrame( + nodeIndex: nodeIndex, + relationship: nil, + prefix: "", + connector: "", + depth: 0 + )) + return true + } + return false + } + + private mutating func markReachable(from root: Int) { + var pending = [root] + while let nodeIndex = pending.popLast() { + guard covered.insert(nodeIndex).inserted else { continue } + pending.append(contentsOf: (childrenByNodeIndex[nodeIndex] ?? []).map(\.nodeIndex)) + } + } + + private static func line( + for node: AgentSessionGraphNode, + relationship: AgentSessionRelationship?, + prefix: String, + connector: String + ) -> String { + let authority: String + if node.identitySource == "terminal_process" { + authority = " process" + } else { + authority = node.restoreAuthority ? " restore-owner" : " child" + } + let modes = node.activity.modes.map(\.rawValue).joined(separator: ",") + let activity = modes.isEmpty ? "" : " [\(modes)]" + let identity = node.sessionId ?? "pid \(node.pid.map(String.init) ?? "unknown")" + let location = "workspace:\(node.workspaceId) surface:\(node.surfaceId)" + let workingDirectory = node.cwd.map { " cwd:\($0)" } ?? "" + let relationshipLabel = relationship.map { "\($0.rawValue) " } ?? "" + return "\(prefix)\(connector)\(relationshipLabel)\(node.provider) \(identity) \(node.effectiveState.rawValue.uppercased())\(activity)\(authority) \(location)\(workingDirectory)" + } + } +} diff --git a/CLI/AgentVisibleMutationOwnershipAgentName.swift b/CLI/AgentVisibleMutationOwnershipAgentName.swift new file mode 100644 index 000000000000..027a92454cd2 --- /dev/null +++ b/CLI/AgentVisibleMutationOwnershipAgentName.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Resolves the agent kind that owns user-visible session mutations. +struct AgentVisibleMutationOwnershipAgentName: Sendable { + func resolve( + explicitAgentName: String?, + environment: [String: String] + ) -> String { + normalized(explicitAgentName) + ?? normalized(environment["CMUX_AGENT_LAUNCH_KIND"]) + ?? "agent" + } + + private func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { + return nil + } + return value + } +} diff --git a/CLI/AgentWorkloadSnapshot.swift b/CLI/AgentWorkloadSnapshot.swift new file mode 100644 index 000000000000..1a030ee887cb --- /dev/null +++ b/CLI/AgentWorkloadSnapshot.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Stable public JSON shape for a sanitized workload record. +struct AgentWorkloadSnapshot: Codable, Sendable, Equatable { + var id: String + var kind: AgentWorkloadKind + var phase: AgentWorkloadPhase + var keepsSessionBusy: Bool + var startedAt: TimeInterval + var updatedAt: TimeInterval + var endedAt: TimeInterval? + var endReason: String? + + init(_ record: AgentWorkloadRecord) { + id = record.id + kind = record.kind + phase = record.phase + keepsSessionBusy = record.keepsSessionBusy + startedAt = record.startedAt + updatedAt = record.updatedAt + endedAt = record.endedAt + endReason = record.endReason + } + + enum CodingKeys: String, CodingKey { + case id + case kind + case phase + case keepsSessionBusy = "keeps_session_busy" + case startedAt = "started_at" + case updatedAt = "updated_at" + case endedAt = "ended_at" + case endReason = "end_reason" + } +} diff --git a/CLI/AutoNamingTranscriptMessage.swift b/CLI/AutoNamingTranscriptMessage.swift new file mode 100644 index 000000000000..64124c7983e9 --- /dev/null +++ b/CLI/AutoNamingTranscriptMessage.swift @@ -0,0 +1,7 @@ +import Foundation + +/// One user/assistant text message extracted from a transcript. +struct AutoNamingTranscriptMessage: Codable, Equatable, Sendable { + var role: String + var text: String +} diff --git a/CLI/CMUXCLI+AgentHookCatalog.swift b/CLI/CMUXCLI+AgentHookCatalog.swift index 62024808e9f6..d42a9928f6eb 100644 --- a/CLI/CMUXCLI+AgentHookCatalog.swift +++ b/CLI/CMUXCLI+AgentHookCatalog.swift @@ -94,6 +94,7 @@ extension CMUXCLI { .init(agentEvent: "beforeShellExecution", cmuxSubcommand: "shell-exec"), .init(agentEvent: "afterShellExecution", cmuxSubcommand: "shell-done"), ], + aliases: ["cursor-agent"], feedHookEvents: ["beforeShellExecution"] ), AgentHookDef( @@ -121,6 +122,7 @@ extension CMUXCLI { .init(agentEvent: "userPromptSubmit", cmuxSubcommand: "prompt-submit"), .init(agentEvent: "stop", cmuxSubcommand: "stop"), ], + aliases: ["kiro-cli"], feedHookEvents: ["preToolUse", "postToolUse"], postInstallNote: String( localized: "cli.hooks.kiro.postInstallNote", @@ -173,21 +175,24 @@ extension CMUXCLI { .init(agentEvent: "on_session_finalize", cmuxSubcommand: "session-finalize"), .init(agentEvent: "on_session_reset", cmuxSubcommand: "session-start"), ], + aliases: ["hermes"], sessionEndIsTurnBoundary: true, feedHookEvents: ["pre_tool_call", "post_tool_call", "pre_approval_request", "post_approval_response"] ), AgentHookDef( name: "copilot", displayName: "Copilot", statusKey: "copilot", - configDir: ".copilot", configFile: "config.json", configDirEnvOverride: "COPILOT_HOME", + configDir: ".copilot/hooks", configFile: "cmux.json", + configDirEnvOverride: "COPILOT_HOME", configDirEnvOverrideSubpath: "hooks", + createConfigDirIfMissing: true, sessionStoreSuffix: "copilot", disableEnvVar: "CMUX_COPILOT_HOOKS_DISABLED", - hookMarker: "cmux hooks copilot", format: .nested(timeoutMs: 5000), + hookMarker: "cmux hooks copilot", format: .flat, events: [ - .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"), - .init(agentEvent: "Stop", cmuxSubcommand: "stop"), - .init(agentEvent: "Notification", cmuxSubcommand: "stop"), - .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), + .init(agentEvent: "sessionStart", cmuxSubcommand: "session-start"), + .init(agentEvent: "agentStop", cmuxSubcommand: "stop"), + .init(agentEvent: "notification", cmuxSubcommand: "stop"), + .init(agentEvent: "sessionEnd", cmuxSubcommand: "session-end"), ], - feedHookEvents: ["PreToolUse"] + feedHookEvents: ["preToolUse"] ), AgentHookDef( name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy", @@ -213,6 +218,7 @@ extension CMUXCLI { .init(agentEvent: "Notification", cmuxSubcommand: "stop"), .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), ], + aliases: ["droid"], feedHookEvents: ["PreToolUse"] ), AgentHookDef( @@ -225,6 +231,7 @@ extension CMUXCLI { .init(agentEvent: "Stop", cmuxSubcommand: "stop"), .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), ], + aliases: ["qodercli"], feedHookEvents: ["PreToolUse"] ), AgentHookDef( @@ -241,6 +248,7 @@ extension CMUXCLI { .init(agentEvent: "StopFailure", cmuxSubcommand: "notification"), .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), ], + aliases: ["kimi-cli", "kimi-code"], feedHookEvents: ["PreToolUse", "PostToolUse"] ), ] diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 48b23d5dec1b..ac41ada3dd80 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -156,7 +156,12 @@ extension CMUXCLI { let command = "cmux hooks \(def.name) \(event.cmuxSubcommand)" let inline: String if def.name == "codex", codexHookCanRunFireAndForget(event.cmuxSubcommand) { - inline = codexFireAndForgetAgentHookShellCommand(command, for: def) + inline = codexFireAndForgetAgentHookShellCommand( + command, + for: def, + ownership: .persistent, + target: .wrapperEnvironment + ) } else { inline = agentHookShellCommand(command, for: def) } @@ -429,10 +434,21 @@ extension CMUXCLI { // Codex also had older top-level codex-hook/feed-hook commands. // Other generic agents can have stale `cmux hooks ...` files from // earlier integration attempts, and setup should be able to prune them. + if def.name == "codex" { + if isLegacyCodexProjectHookCommand(command) + || isLegacyCodexBundledDispatcher(command) + || isCmuxManagedCodexHookScript(command) { + return true + } + } return legacyCmuxCommandTokenLists(from: command, for: def).contains { tokens in isLegacyCmuxOwnedHookTokens(tokens, for: def) } } + private static func isLegacyCodexProjectHookCommand(_ command: String) -> Bool { + let pattern = #"^\s*['\"]?[^'\"]*/\.codex/hooks/cmux-codex-fire-and-forget\.sh['\"]?\s+(session-start|prompt-submit|stop)\s*$"# + return command.range(of: pattern, options: .regularExpression) != nil + } private static func isLegacyCmuxOwnedHookTokens(_ tokens: [String], for def: AgentHookDef) -> Bool { guard !tokens.isEmpty, diff --git a/CLI/CMUXCLI+AgentHookRestoreEvidence.swift b/CLI/CMUXCLI+AgentHookRestoreEvidence.swift index d194175283b0..0fe342aff2c0 100644 --- a/CLI/CMUXCLI+AgentHookRestoreEvidence.swift +++ b/CLI/CMUXCLI+AgentHookRestoreEvidence.swift @@ -52,9 +52,14 @@ extension CMUXCLI { } func agentHookSessionHasDurableResumeEvidence( kind: String, - launchCommand: AgentHookLaunchCommandRecord? + launchCommand: AgentHookLaunchCommandRecord?, + transcriptPath: String? = nil ) -> Bool { guard normalizedHookValue(launchCommand?.source)?.lowercased() != "rejected" else { return false } + if kind == "gemini" { + guard let transcriptPath = normalizedHookValue(transcriptPath) else { return false } + return regularNonEmptyAgentTranscriptExists(atPath: transcriptPath) + } guard kind == "codex" else { return true } guard let launchCommand else { return true } if normalizedHookValue(launchCommand.environment?["CODEX_HOME"]) != nil { @@ -95,11 +100,19 @@ extension CMUXCLI { let currentSource = normalizedHookValue(current?.source)?.lowercased() if let current, currentSource != "default", - agentHookSessionHasDurableResumeEvidence(kind: kind, launchCommand: current) { + agentHookSessionHasDurableResumeEvidence( + kind: kind, + launchCommand: current, + transcriptPath: transcriptPath + ) { return current } if let mappedLaunchCommand = mapped?.launchCommand, - agentHookSessionHasDurableResumeEvidence(kind: kind, launchCommand: mappedLaunchCommand) { + agentHookSessionHasDurableResumeEvidence( + kind: kind, + launchCommand: mappedLaunchCommand, + transcriptPath: transcriptPath ?? mapped?.transcriptPath + ) { return mappedLaunchCommand } if let current = replaySafeCodexLaunchCommand(kind: kind, launchCommand: current) { @@ -111,7 +124,11 @@ extension CMUXCLI { } if let current, currentSource == "default", - agentHookSessionHasDurableResumeEvidence(kind: kind, launchCommand: current) { + agentHookSessionHasDurableResumeEvidence( + kind: kind, + launchCommand: current, + transcriptPath: transcriptPath + ) { return current } if agentHookMappedSessionHasDurableTargetEvidence(kind: kind, mapped: mapped) { @@ -155,6 +172,10 @@ extension CMUXCLI { ) -> Bool { guard let mapped else { return false } guard normalizedHookValue(mapped.launchCommand?.source)?.lowercased() != "rejected" else { return false } + if kind == "gemini" { + guard let transcriptPath = normalizedHookValue(mapped.transcriptPath) else { return false } + return regularNonEmptyAgentTranscriptExists(atPath: transcriptPath) + } guard kind == "codex" else { return true } if mapped.isRestorable == true { return true } if let transcriptPath = normalizedHookValue(mapped.transcriptPath), @@ -183,6 +204,18 @@ extension CMUXCLI { || normalizedHookValue(environment?["CLAUDE_CONFIG_DIR"]) != nil) } + private func regularNonEmptyAgentTranscriptExists(atPath path: String) -> Bool { + let expandedPath = (path as NSString).expandingTildeInPath + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: expandedPath, isDirectory: &isDirectory), + !isDirectory.boolValue, + let attributes = try? FileManager.default.attributesOfItem(atPath: expandedPath), + let size = attributes[.size] as? NSNumber else { + return false + } + return size.intValue > 0 + } + /// A same-kind launch capture can inherit Claude account-selection environment from the /// terminal without making its sanitized Codex flags unsafe to replay. Keep those flags while /// dropping every identity-bearing part of the weak capture, so restore uses the current Codex diff --git a/CLI/CMUXCLI+AgentHookRuntimeIdentity.swift b/CLI/CMUXCLI+AgentHookRuntimeIdentity.swift new file mode 100644 index 000000000000..af316cb0b7e3 --- /dev/null +++ b/CLI/CMUXCLI+AgentHookRuntimeIdentity.swift @@ -0,0 +1,96 @@ +import Foundation + +extension CMUXCLI { + /// Adds the connected app's runtime identity to direct store queries. A CLI + /// launched from a normal shell has no inherited `CMUX_RUNTIME_ID`, while a + /// CLI launched inside a different cmux can inherit the wrong one. An + /// explicit `--socket` names the authority, so connected evidence wins. + func agentSessionQueryEnvironment( + environment: [String: String], + socketCapabilities: [String: Any] + ) -> [String: String] { + guard let identity = AgentCmuxRuntimeIdentity.resolve( + environment: environment, + socketCapabilities: socketCapabilities + ) else { + return environment + } + return identity.applying(to: environment) + } + + /// Resolves hook-store ownership from the connected cmux process without + /// touching the UI thread. `system.capabilities` is a socket-worker pure + /// probe; its one-second bound and environment fallback keep hooks safe for + /// older or unavailable servers. + func agentHookStoreEnvironment( + environment: [String: String], + client: SocketClient + ) -> [String: String] { + let capabilities = (try? client.sendV2( + method: "system.capabilities", + responseTimeout: 1 + )) ?? [:] + return agentSessionQueryEnvironment( + environment: environment, + socketCapabilities: capabilities + ) + } +} + +#if DEBUG +extension CMUXCLI { + func agentHookDebugLog( + _ message: @autoclosure () -> String, + socketPath: String? = nil, + env: [String: String] = ProcessInfo.processInfo.environment + ) { + let logPath = agentHookDebugLogPath(socketPath: socketPath, env: env) + let timestamp = String(format: "%.3f", Date().timeIntervalSince1970) + let line = "\(timestamp) \(message())\n" + guard let data = line.data(using: .utf8) else { return } + + if let handle = FileHandle(forWritingAtPath: logPath) { + defer { try? handle.close() } + guard (try? handle.seekToEnd()) != nil else { return } + try? handle.write(contentsOf: data) + } else { + FileManager.default.createFile(atPath: logPath, contents: data) + } + } + + private func agentHookDebugLogPath(socketPath: String?, env: [String: String]) -> String { + if let explicit = agentHookDebugNonEmpty(env["CMUX_DEBUG_LOG"]) { + return NSString(string: explicit).expandingTildeInPath + } + if let socketPath { + let socketName = URL(fileURLWithPath: socketPath).lastPathComponent + if socketName.hasPrefix("cmux-debug-"), socketName.hasSuffix(".sock") { + let logName = String(socketName.dropLast(".sock".count)) + ".log" + return URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent(logName, isDirectory: false).path + } + } + if let lastPath = try? String(contentsOfFile: "/tmp/cmux-last-debug-log-path", encoding: .utf8), + let normalized = agentHookDebugNonEmpty(lastPath) { + return NSString(string: normalized).expandingTildeInPath + } + return "/tmp/cmux-debug.log" + } + + private func agentHookDebugNonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { return nil } + return trimmed + } + + func agentHookDebugShort(_ value: String?) -> String { + guard let value = agentHookDebugNonEmpty(value) else { return "nil" } + return String(value.prefix(12)) + } + + func agentHookDebugSocketName(_ socketPath: String?) -> String { + guard let socketPath = agentHookDebugNonEmpty(socketPath) else { return "nil" } + return URL(fileURLWithPath: socketPath).lastPathComponent + } +} +#endif diff --git a/CLI/CMUXCLI+AgentNotificationOwnership.swift b/CLI/CMUXCLI+AgentNotificationOwnership.swift new file mode 100644 index 000000000000..9c87cdfc033d --- /dev/null +++ b/CLI/CMUXCLI+AgentNotificationOwnership.swift @@ -0,0 +1,91 @@ +import Foundation + +private let suppressSubagentNotificationsDefaultsKey = "suppressSubagentNotifications" +private let suppressSubagentNotificationsEnvironmentKey = "CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS" +private let managedSubagentEnvironmentKey = "CMUX_AGENT_MANAGED_SUBAGENT" + +extension CMUXCLI { + func shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: Int?, + agentName: String, + nestedPromptEvent: Bool = false, + transcriptSubagentSession: Bool = false, + env: [String: String] + ) -> Bool { + if let override = normalizedHookValue(env["CMUX_AGENT_HOOK_SUPPRESS_VISIBLE_MUTATIONS"])?.lowercased(), + Self.parseHookBoolean(override) == true { + return true + } + if nestedPromptEvent || managedSubagentVisibleMutationSuppressionRequested(env: env) { + return true + } + if transcriptSubagentSession { + return true + } + guard let currentAgentPID, currentAgentPID > 1 else { + return false + } + let kind = AgentVisibleMutationOwnershipAgentName().resolve( + explicitAgentName: agentName, + environment: env + ) + return !AgentHookSessionLineageResolver().resolve( + agentName: kind, + sessionId: "unknown", + pid: currentAgentPID, + environment: env + ).restoreAuthority + } + + /// Child sessions never own the root surface's status, resume binding, or + /// lifecycle. Notification delivery is a separate user policy: the default + /// suppresses child alerts, while an explicit opt-in allows the alert only. + func shouldSuppressNestedAgentNotification( + visibleMutationsSuppressed: Bool, + env: [String: String] + ) -> Bool { + visibleMutationsSuppressed && subagentNotificationSuppressionEnabled(env: env) + } + + func subagentNotificationSuppressionEnabled(env: [String: String]) -> Bool { + if let raw = normalizedHookValue(env[suppressSubagentNotificationsEnvironmentKey]), + let parsed = Self.parseHookBoolean(raw) { + return parsed + } + for defaults in appDefaultsCandidates(env: env) { + if defaults.object(forKey: suppressSubagentNotificationsDefaultsKey) != nil { + return defaults.bool(forKey: suppressSubagentNotificationsDefaultsKey) + } + } + return true + } + + private func appDefaultsCandidates(env: [String: String]) -> [UserDefaults] { + var candidates: [UserDefaults] = [] + if let bundleId = normalizedHookValue(env["CMUX_BUNDLE_ID"]), + let defaults = UserDefaults(suiteName: bundleId) { + candidates.append(defaults) + } + candidates.append(.standard) + return candidates + } + + private func managedSubagentVisibleMutationSuppressionRequested(env: [String: String]) -> Bool { + guard let raw = normalizedHookValue(env[managedSubagentEnvironmentKey]), + let parsed = Self.parseHookBoolean(raw) else { + return false + } + return parsed + } + + static func parseHookBoolean(_ rawValue: String) -> Bool? { + switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "on", "enabled": + return true + case "0", "false", "no", "off", "disabled": + return false + default: + return nil + } + } +} diff --git a/CLI/CMUXCLI+Agents.swift b/CLI/CMUXCLI+Agents.swift new file mode 100644 index 000000000000..3086985d3499 --- /dev/null +++ b/CLI/CMUXCLI+Agents.swift @@ -0,0 +1,1138 @@ +import CmuxFoundation +import Darwin +import Foundation + +struct AgentSessionProviderSpecification: Sendable, Equatable { + var name: String + var displayName: String + var sessionStoreSuffix: String + var configDirEnvOverride: String? +} + +private struct AgentSessionConfiguredProvider: Sendable { + var id: String + var name: String +} + +struct AgentSessionProviderCatalogLimitError: Error { + var maximumCount: Int + var observedAtLeast: Int +} + +struct AgentSessionProviderCollisionError: Error { + var firstProvider: String + var secondProvider: String +} + +struct AgentPrettyJSONStreamWriter { + private static let flushThresholdBytes = 64 * 1_024 + + private let handle: FileHandle + private var buffer: Data + private var fieldCount = 0 + private var arrayElementCount: Int? + + init(handle: FileHandle) throws { + self.handle = handle + self.buffer = Data() + buffer.reserveCapacity(Self.flushThresholdBytes) + buffer.append(contentsOf: "{".utf8) + } + + mutating func writeValueField(name: String, value: Any) throws { + try writeValueField(name: name, encodedValue: Self.encodeJSONObject(value)) + } + + mutating func writeValueField( + name: String, + value: T, + encoder: JSONEncoder + ) throws { + try writeValueField(name: name, encodedValue: encoder.encode(value)) + } + + mutating func beginArrayField(name: String) throws { + precondition(arrayElementCount == nil) + try beginField(name: name) + try write(Data("[".utf8)) + arrayElementCount = 0 + } + + mutating func writeArrayElement(_ value: Any) throws { + let encodedValue = try autoreleasepool { + try Self.encodeJSONObject(value) + } + try writeArrayElement(encodedValue: encodedValue) + } + + mutating func writeArrayElement( + _ value: T, + encoder: JSONEncoder + ) throws { + let encodedValue = try autoreleasepool { + try encoder.encode(value) + } + try writeArrayElement(encodedValue: encodedValue) + } + + mutating func writeArrayElements(_ values: [[String: Any]]) throws { + guard !values.isEmpty else { return } + let encodedValues = try autoreleasepool { + try JSONSerialization.data( + withJSONObject: values, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + } + try writeEncodedArrayElements(encodedValues, count: values.count) + } + + mutating func writeArrayElements( + _ values: [T], + encoder: JSONEncoder + ) throws { + guard !values.isEmpty else { return } + let encodedValues = try autoreleasepool { + try encoder.encode(values) + } + try writeEncodedArrayElements(encodedValues, count: values.count) + } + + mutating func endArray() throws { + guard let arrayElementCount else { preconditionFailure("No JSON array is open") } + if arrayElementCount == 0 { + try write(Data("\n\n ]".utf8)) + } else { + try write(Data("\n ]".utf8)) + } + self.arrayElementCount = nil + } + + mutating func finish() throws { + precondition(arrayElementCount == nil) + try write(Data("\n}\n".utf8)) + try flush() + } + + private mutating func writeValueField(name: String, encodedValue: Data) throws { + precondition(arrayElementCount == nil) + try beginField(name: name) + try writeIndented(encodedValue, continuationIndent: " ") + } + + private mutating func writeArrayElement(encodedValue: Data) throws { + guard let count = arrayElementCount else { preconditionFailure("No JSON array is open") } + try write(Data((count == 0 ? "\n " : ",\n ").utf8)) + try writeIndented(encodedValue, continuationIndent: " ") + arrayElementCount = count + 1 + } + + private mutating func writeEncodedArrayElements(_ encodedValues: Data, count: Int) throws { + guard let currentCount = arrayElementCount else { + preconditionFailure("No JSON array is open") + } + guard encodedValues.first == 91, encodedValues.last == 93 else { + throw CocoaError(.fileWriteUnknown) + } + let elements = encodedValues.dropFirst().dropLast() + guard !elements.isEmpty else { return } + try write(Data((currentCount == 0 ? "\n " : ",\n ").utf8)) + try write(Data(elements)) + arrayElementCount = currentCount + count + } + + private mutating func beginField(name: String) throws { + try write(Data((fieldCount == 0 ? "\n " : ",\n ").utf8)) + try write(Self.encodeJSONString(name)) + try write(Data(" : ".utf8)) + fieldCount += 1 + } + + private mutating func writeIndented(_ data: Data, continuationIndent: String) throws { + let lines = data.split(separator: 10, omittingEmptySubsequences: false) + for (index, line) in lines.enumerated() { + if index > 0 { try write(Data(("\n" + continuationIndent).utf8)) } + if !line.isEmpty { try write(Data(line)) } + } + } + + private mutating func write(_ data: Data) throws { + buffer.append(data) + if buffer.count >= Self.flushThresholdBytes { + try flush() + } + } + + private mutating func flush() throws { + guard !buffer.isEmpty else { return } + try handle.write(contentsOf: buffer) + buffer.removeAll(keepingCapacity: true) + } + + private static func encodeJSONObject(_ value: Any) throws -> Data { + guard JSONSerialization.isValidJSONObject(value) || value is NSNull + || value is String || value is NSNumber else { + throw CocoaError(.propertyListWriteInvalid) + } + return try JSONSerialization.data( + withJSONObject: value, + options: [.fragmentsAllowed, .prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + } + + private static func encodeJSONString(_ value: String) throws -> Data { + try JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed]) + } +} + +extension CMUXCLI { + enum AgentsValueOptionContext { + case agentsList + case sessionsList + case tree + } + + enum AgentsCommandInvocation: String { + case agents + case sessions + } + + private enum AgentsCommandOutputShape: String { + case list + case tree + } + + func runAgentsCommand( + commandArgs: [String], + jsonOutput: Bool, + processEnv: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + terminalObservations: [CmuxAgentTerminalObservation] = [], + invocation: AgentsCommandInvocation = .agents, + runtimeInspectionError: Error? = nil, + runtimeSocketPath: String? = nil + ) throws { + let subcommand = commandArgs.first?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let outputShape: AgentsCommandOutputShape = subcommand == "tree" ? .tree : .list + let structuredOutputRequested = jsonOutput || commandArgs.contains("--json") + do { + if let runtimeInspectionError { + throw agentsRuntimeUnavailableCLIError( + runtimeInspectionError, + socketPath: runtimeSocketPath + ) + } + if outputShape == .tree { + try runAgentsTreeCommand( + commandArgs: Array(commandArgs.dropFirst()), + jsonOutput: jsonOutput, + processEnv: processEnv, + fileManager: fileManager, + terminalObservations: terminalObservations + ) + return + } + try runSessionsCommand( + commandArgs: commandArgs, + jsonOutput: jsonOutput, + processEnv: processEnv, + fileManager: fileManager, + terminalObservations: terminalObservations, + invocation: invocation + ) + } catch { + let commandError = agentsCommandError( + error, + invocation: invocation, + outputShape: outputShape + ) + if structuredOutputRequested { + agentsWriteStructuredError(commandError, outputShape: outputShape) + } + throw commandError + } + } + + private func agentsRuntimeUnavailableCLIError( + _ error: Error, + socketPath: String? + ) -> CLIError { + let source = error as? CLIError ?? CLIError(message: String(describing: error)) + return CLIError( + message: source.message, + exitCode: source.exitCode, + v2Code: "agent_runtime_unavailable", + structuredFields: CLIErrorStructuredFields(path: socketPath) + ) + } + + private func agentsCommandError( + _ error: Error, + invocation: AgentsCommandInvocation, + outputShape: AgentsCommandOutputShape + ) -> CLIError { + let source = error as? CLIError ?? CLIError( + message: String(describing: error), + v2Code: "internal_error" + ) + let targetPrefix = "\(invocation.rawValue) \(outputShape.rawValue):" + let knownPrefixes = [ + "agents list:", + "sessions list:", + "agents tree:", + "sessions tree:", + "agents:", + "sessions:", + ] + let message: String + if let prefix = knownPrefixes.first(where: { source.message.hasPrefix($0) }) { + message = targetPrefix + String(source.message.dropFirst(prefix.count)) + } else if source.message.hasPrefix(targetPrefix) { + message = source.message + } else { + message = "\(targetPrefix) \(source.message)" + } + return CLIError( + message: message, + exitCode: source.exitCode, + v2Code: source.v2Code, + structuredFields: source.structuredFields + ) + } + + private func agentsWriteStructuredError( + _ error: CLIError, + outputShape: AgentsCommandOutputShape + ) { + var structuredError = error.structuredFields?.jsonObject ?? [:] + structuredError["code"] = error.v2Code ?? "invalid_arguments" + structuredError["message"] = error.message + var payload: [String: Any] = [ + "schema_version": 2, + "error": structuredError, + ] + switch outputShape { + case .list: + payload["sessions"] = [] + case .tree: + payload["nodes"] = [] + payload["edges"] = [] + } + cliWriteStdout(jsonString(payload) + "\n") + } + + func agentsUsage() -> String { + String(localized: "cli.sessions.usage", defaultValue: """ + Usage: cmux agents list [options] + cmux agents tree [options] + cmux agents [options] + + Print saved session lifecycle plus cached live terminal state. + With a cmux socket, this queries runtime identity and cached observations once. + Without a socket, it reads saved hook state from ~/.cmuxterm. + Inside cmux, default output is scoped to that running app process. + Pass --all to inspect cross-runtime history. + + `agents tree` renders process-spawn and conversation-fork relationships. + Add --json for a flat, versioned nodes-and-edges graph. + + Options: + --agent Filter to one agent, for example codex or claude + --session Filter to one agent session id + --workspace Filter to one saved workspace id + --surface Filter to one saved surface id + --state-dir Override hook state directory + --state Filter by effective state + --activity Filter by busy, idle, or unknown activity + --work-kind Filter by an active workload kind + --all Print all matches + --json Print structured JSON + + List options: + --cwd Filter by saved cwd or launch working directory + --codex-home Override the default Codex home used for transcript checks + --limit Limit rows (default: 100; with --all: unlimited) + + Tree options: + --relation Filter edges to spawned, forked, or resumed + --depth Limit rendered tree depth (default: 64; maximum: 4096) + --max-nodes Cap graph nodes (default: 10000; maximum: 20000) + + Codex rows include whether the saved id exists in CODEX_HOME/session_index.jsonl + and whether a matching transcript file exists under CODEX_HOME/sessions or + CODEX_HOME/archived_sessions. + + Compatibility aliases: + cmux sessions [list|tree] [options] + cmux sessions debug [options] + cmux session-debug [options] + """) + } + + func sessionsUsage() -> String { agentsUsage() } + + func agentsWriteStoreWarnings(_ warnings: [AgentHookSessionStoreLoadWarning]) { + for warning in warnings { + let format = switch warning.fallback { + case .legacy: + String( + localized: "cli.agents.warning.authoritativeSnapshotDecodeFailed.legacy", + defaultValue: "Warning [%@]: saved %@ agent state at %@ is damaged; using the last complete fallback, so newer sessions may be missing." + ) + case .registry: + String( + localized: "cli.agents.warning.legacySourceImportFailed.registry", + defaultValue: "Warning [%@]: saved %@ agent state at %@ could not be imported; using the last complete registry snapshot, so newer sessions may be missing." + ) + } + cliWriteStderr(String( + format: format, + warning.code.rawValue, + warning.provider, + warning.path + ) + "\n") + } + } + + func agentsStoreLoadCLIError( + _ failure: AgentHookSessionStoreLoadFailure, + context: AgentsValueOptionContext + ) -> CLIError { + let isTreeContext = switch context { + case .tree: true + case .agentsList, .sessionsList: false + } + let isGraphBudget = isTreeContext + && (failure.scope == .legacyGraphNodes || failure.scope == .registryGraphNodes) + let externalCode = isGraphBudget + ? "agent_graph_node_budget_exceeded" + : failure.code.rawValue + let failureHasLegacyScope = switch failure.scope { + case .legacyFile?, .legacySessions?, .legacyGraphNodes?, .legacyRecord?: true + default: false + } + let isLegacyStorageLimit = !isGraphBudget && failureHasLegacyScope + let canonicalPath = isLegacyStorageLimit + ? (failure.canonicalPath ?? URL(fileURLWithPath: failure.path).deletingLastPathComponent() + .appendingPathComponent(CmuxAgentSessionRegistry.filename, isDirectory: false) + .path) + : nil + let storageGuidance: String? = if failure.code == .storageLimitExceeded { + if isGraphBudget { + String( + format: String( + localized: "cli.agents.error.storageLimitGuidance.graph", + defaultValue: "Narrow the selection with --agent %@ or raise --max-nodes, up to %lld." + ), + failure.provider, + Self.agentsTreeHardMaximumNodes + ) + } else if let canonicalPath { + String( + format: String( + localized: "cli.agents.error.storageLimitGuidance.legacy", + defaultValue: "Move %@ aside without deleting it, then rerun so cmux can rebuild it from %@. If that database has no %@ rows, keep the moved file and restore it before proceeding." + ), + failure.path, + canonicalPath, + failure.provider + ) + } else { + String( + format: String( + localized: "cli.agents.error.storageLimitGuidance.canonical", + defaultValue: "Retry with --agent %@ to narrow the selection; inspect the canonical store at %@ before changing it." + ), + failure.provider, + failure.path + ) + } + } else { + nil + } + let message: String + if failure.code == .storageLimitExceeded, + let scope = failure.scope, + let storageGuidance { + if let observedBytes = failure.observedBytes, + let maximumBytes = failure.maximumBytes, + let sessionID = failure.sessionID { + message = String( + format: String( + localized: "cli.agents.error.storageLimitExceeded.session", + defaultValue: "agents: [%@] saved %@ agent state at %@ exceeds the %@ inspection limit for session %@ (%lld bytes observed, %lld maximum); %@" + ), + externalCode, + failure.provider, + failure.path, + scope.rawValue, + sessionID, + observedBytes, + maximumBytes, + storageGuidance + ) + } else if let observedBytes = failure.observedBytes, + let maximumBytes = failure.maximumBytes { + message = String( + format: String( + localized: "cli.agents.error.storageLimitExceeded", + defaultValue: "agents: [%@] saved %@ agent state at %@ exceeds the %@ inspection limit (%lld bytes observed, %lld maximum); %@" + ), + externalCode, + failure.provider, + failure.path, + scope.rawValue, + observedBytes, + maximumBytes, + storageGuidance + ) + } else if let observedCount = failure.observedCount, + let maximumCount = failure.maximumCount, + let sessionID = failure.sessionID { + message = String( + format: String( + localized: "cli.agents.error.storageLimitExceededCount.session", + defaultValue: "agents: [%@] saved %@ agent state at %@ exceeds the %@ inspection limit for session %@ (%lld entries observed, %lld maximum); %@" + ), + externalCode, + failure.provider, + failure.path, + scope.rawValue, + sessionID, + observedCount, + maximumCount, + storageGuidance + ) + } else if let observedCount = failure.observedCount, + let maximumCount = failure.maximumCount { + message = String( + format: String( + localized: "cli.agents.error.storageLimitExceededCount", + defaultValue: "agents: [%@] saved %@ agent state at %@ exceeds the %@ inspection limit (%lld entries observed, %lld maximum); %@" + ), + externalCode, + failure.provider, + failure.path, + scope.rawValue, + observedCount, + maximumCount, + storageGuidance + ) + } else { + message = String( + format: String( + localized: "cli.agents.error.storeLoadFailed", + defaultValue: "agents: [%@] saved %@ agent state at %@ could not be read and no complete fallback is available" + ), + externalCode, + failure.provider, + failure.path + ) + } + } else { + message = String( + format: String( + localized: "cli.agents.error.storeLoadFailed", + defaultValue: "agents: [%@] saved %@ agent state at %@ could not be read and no complete fallback is available" + ), + externalCode, + failure.provider, + failure.path + ) + } + + let recoveryAction: String? = if storageGuidance != nil { + if isGraphBudget { + "narrow_graph_selection" + } else if isLegacyStorageLimit { + "move_legacy_file_aside" + } else { + "narrow_agent_selection" + } + } else { + nil + } + return CLIError( + message: message, + v2Code: externalCode, + structuredFields: CLIErrorStructuredFields( + provider: failure.provider, + path: failure.path, + scope: failure.scope?.rawValue, + sessionID: failure.sessionID, + observedBytes: failure.observedBytes, + maximumBytes: failure.maximumBytes, + observedCount: failure.observedCount, + maximumCount: failure.maximumCount, + guidance: storageGuidance, + recoveryAction: recoveryAction, + canonicalPath: canonicalPath, + limit: isGraphBudget ? failure.maximumCount.flatMap(Int.init(exactly:)) : nil, + observedAtLeast: isGraphBudget ? failure.observedCount.flatMap(Int.init(exactly:)) : nil + ) + ) + } + + func agentsStateUnavailableCLIError( + stateDirectory: String, + context: AgentsValueOptionContext + ) -> CLIError { + let commandName = switch context { + case .agentsList: "agents list" + case .sessionsList: "sessions list" + case .tree: "agents tree" + } + let message = String( + format: String( + localized: "cli.agents.error.stateUnavailable", + defaultValue: "%@: saved agent state at %@ is unavailable" + ), + commandName, + stateDirectory + ) + return CLIError( + message: message, + v2Code: "agent_state_unavailable", + structuredFields: CLIErrorStructuredFields(path: stateDirectory) + ) + } + + func sessionsListEncodableJSONObject(_ value: T) -> Any { + guard let data = try? JSONEncoder().encode(value), + let object = try? JSONSerialization.jsonObject(with: data) else { + return NSNull() + } + return object + } + + func decodeAgentTerminalObservations( + _ response: [String: Any], + expectedRuntimeID: String? + ) throws -> [CmuxAgentTerminalObservation] { + guard let runtimeID = response["runtime_id"] as? String, + expectedRuntimeID == nil || runtimeID == expectedRuntimeID else { + throw CLIError(message: String( + localized: "cli.agents.error.runtimeMismatch", + defaultValue: "agents: live observation runtime does not match the connected cmux instance" + )) + } + guard let observations = response["observations"] as? [Any], + JSONSerialization.isValidJSONObject(observations) else { return [] } + let data = try JSONSerialization.data(withJSONObject: observations) + return try JSONDecoder().decode([CmuxAgentTerminalObservation].self, from: data) + } + + func agentSessionProviderSelection( + for requestedAgent: String, + availableProviderIDs: [String] = [], + terminalObservations: [CmuxAgentTerminalObservation] + ) -> AgentSessionProviderSelection { + let trimmed = requestedAgent.trimmingCharacters(in: .whitespacesAndNewlines) + // Exact configured/registry ids own their spelling. This must happen + // before static alias expansion so a custom provider literally named + // `cursor-agent` remains queryable instead of becoming `cursor`. + if let exact = availableProviderIDs.first(where: { $0 == trimmed }) { + return AgentSessionProviderSelection( + providerID: exact, + exactObservationProviderID: exact + ) + } + let normalized = agentsNormalizedAgentID(requestedAgent) + if normalized == "claude" || normalized == "claude-code" { + return AgentSessionProviderSelection( + providerID: "claude", + exactObservationProviderID: nil + ) + } + // Ollama has live terminal observations and native restore support, but + // no hook sidecar. Accept the canonical filter even when no app socket + // is available so empty offline list/tree queries remain well-formed. + if normalized == "ollama" { + return AgentSessionProviderSelection( + providerID: "ollama", + exactObservationProviderID: nil + ) + } + if let definition = Self.agentDef(named: normalized) { + return AgentSessionProviderSelection( + providerID: definition.name, + exactObservationProviderID: nil + ) + } + let configuredMatches = Set(availableProviderIDs.filter { + agentsNormalizedAgentID($0) == normalized + }) + if configuredMatches.count == 1 { + let providerID = configuredMatches.first + return AgentSessionProviderSelection( + providerID: providerID, + exactObservationProviderID: nil, + caseFoldedObservationProviderID: providerID + ) + } + let observedProviders = Set(terminalObservations.compactMap { observation -> String? in + guard agentTerminalObservation(observation, matchesAnyAgentID: [normalized]) else { + return nil + } + return observation.sessionProviderID + }) + guard observedProviders.count == 1 else { + return AgentSessionProviderSelection( + providerID: nil, + exactObservationProviderID: nil + ) + } + return AgentSessionProviderSelection( + providerID: observedProviders.first, + exactObservationProviderID: nil + ) + } + + /// Builds the complete offline inspection catalog without enumerating the + /// state directory. Static providers win over config, the nearest project + /// config wins over the global config, and configured names win over the + /// registry's identifier fallback. + func agentSessionProviderSpecifications( + stateDirectory: String, + homeDirectory: String, + requestedAgent: String? = nil, + processEnv: [String: String], + fileManager: FileManager + ) throws -> [AgentSessionProviderSpecification] { + var specifications = [ + AgentSessionProviderSpecification( + name: "claude", + displayName: "Claude Code", + sessionStoreSuffix: "claude", + configDirEnvOverride: "CLAUDE_CONFIG_DIR" + ), + AgentSessionProviderSpecification( + name: "ollama", + displayName: "Ollama", + sessionStoreSuffix: "ollama", + configDirEnvOverride: nil + ), + ] + specifications.append(contentsOf: Self.agentDefs.map { + AgentSessionProviderSpecification( + name: $0.name, + displayName: $0.displayName, + sessionStoreSuffix: $0.sessionStoreSuffix, + configDirEnvOverride: $0.configDirEnvOverride + ) + }) + let staticProviderIDs = Set(specifications.map(\.name)) + // These IDs are built-in hook providers and also own app-side Vault + // registrations. Config may rename them for display, but must retain + // the built-in sidecar and hook metadata. + let configurableStaticProviderIDs: Set = [ + "pi", "grok", "antigravity", "ollama", "omp", "campfire", + ] + let exactRequestedProviderID = requestedAgent? + .trimmingCharacters(in: .whitespacesAndNewlines) + var staticProviderIDBySidecarKey: [String: String] = [:] + for specification in specifications { + staticProviderIDBySidecarKey[specification.name.lowercased()] = specification.name + staticProviderIDBySidecarKey[specification.sessionStoreSuffix.lowercased()] = specification.name + } + var indexByProviderID = Dictionary( + uniqueKeysWithValues: specifications.enumerated().map { ($0.element.name, $0.offset) } + ) + var providerIDBySidecarKey = Dictionary( + uniqueKeysWithValues: specifications.map { + ($0.sessionStoreSuffix.lowercased(), $0.name) + } + ) + var dynamicProviderCount = 0 + + func include(_ provider: AgentSessionConfiguredProvider, replacesConfigured: Bool) throws { + if staticProviderIDs.contains(provider.id) { + guard replacesConfigured, + configurableStaticProviderIDs.contains(provider.id), + let index = indexByProviderID[provider.id] else { return } + specifications[index].displayName = provider.name + return + } + let sidecarKey = provider.id.lowercased() + if let staticProviderID = staticProviderIDBySidecarKey[sidecarKey] { + throw AgentSessionProviderCollisionError( + firstProvider: staticProviderID, + secondProvider: provider.id + ) + } + if let existingProviderID = providerIDBySidecarKey[sidecarKey], + existingProviderID != provider.id { + throw AgentSessionProviderCollisionError( + firstProvider: existingProviderID, + secondProvider: provider.id + ) + } + let specification = AgentSessionProviderSpecification( + name: provider.id, + displayName: provider.name, + sessionStoreSuffix: provider.id, + configDirEnvOverride: nil + ) + if let index = indexByProviderID[provider.id] { + if replacesConfigured { specifications[index] = specification } + return + } + guard dynamicProviderCount < CmuxAgentSessionRegistry.maximumProviderEnumerationCount else { + throw AgentSessionProviderCatalogLimitError( + maximumCount: CmuxAgentSessionRegistry.maximumProviderEnumerationCount, + observedAtLeast: dynamicProviderCount + 1 + ) + } + indexByProviderID[provider.id] = specifications.count + providerIDBySidecarKey[sidecarKey] = provider.id + specifications.append(specification) + dynamicProviderCount += 1 + } + + // A narrowed query can adopt one exact sidecar-only provider without + // enumerating the state directory. Probe this spelling before static + // executable aliases so `cursor-agent` can own its literal sidecar. + if let exactRequestedProviderID, + CmuxAgentSessionRegistry.isSafeProviderIdentifier(exactRequestedProviderID) { + let exactSidecarURL = URL(fileURLWithPath: stateDirectory, isDirectory: true) + .appendingPathComponent( + "\(exactRequestedProviderID)-hook-sessions.json", + isDirectory: false + ) + if fileManager.fileExists(atPath: exactSidecarURL.path) { + try include( + AgentSessionConfiguredProvider( + id: exactRequestedProviderID, + name: exactRequestedProviderID + ), + replacesConfigured: false + ) + } + } + + let configURLs = agentSessionProviderConfigURLs( + homeDirectory: homeDirectory, + workingDirectory: processEnv["PWD"], + fileManager: fileManager + ) + for configURL in configURLs { + for provider in try agentSessionConfiguredProviders( + at: configURL, + matchingProviderID: exactRequestedProviderID, + fileManager: fileManager + ) { + try include(provider, replacesConfigured: true) + } + } + + let registryURL: URL + if let explicit = processEnv["CMUX_AGENT_SESSION_REGISTRY_PATH"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !explicit.isEmpty { + registryURL = URL(fileURLWithPath: NSString(string: explicit).expandingTildeInPath) + } else { + registryURL = URL(fileURLWithPath: stateDirectory, isDirectory: true) + .appendingPathComponent(CmuxAgentSessionRegistry.filename, isDirectory: false) + } + let registry = CmuxAgentSessionRegistry(url: registryURL) + let registryProviderIDs: [String] + if let exactRequestedProviderID, + CmuxAgentSessionRegistry.isSafeProviderIdentifier(exactRequestedProviderID) { + registryProviderIDs = try registry.providerIdentifiers( + caseInsensitiveTo: exactRequestedProviderID + ) + } else if exactRequestedProviderID == nil { + registryProviderIDs = try registry.providerIdentifiers() + } else { + registryProviderIDs = [] + } + for providerID in registryProviderIDs { + try include( + AgentSessionConfiguredProvider(id: providerID, name: providerID), + replacesConfigured: false + ) + } + return specifications + } + + func agentsProviderCatalogCLIError( + _ error: any Error, + stateDirectory: String, + context: AgentsValueOptionContext + ) -> CLIError { + let fallback = agentsStateUnavailableCLIError( + stateDirectory: stateDirectory, + context: context + ) + switch error { + case let error as CmuxAgentSessionRegistry.ProviderEnumerationLimitError: + return CLIError( + message: fallback.message, + v2Code: "agent_provider_catalog_limit_exceeded", + structuredFields: CLIErrorStructuredFields( + path: stateDirectory, + maximumCount: Int64(error.maximumCount), + recoveryAction: "narrow_agent_selection", + observedAtLeast: error.observedAtLeast + ) + ) + case let error as AgentSessionProviderCatalogLimitError: + return CLIError( + message: fallback.message, + v2Code: "agent_provider_catalog_limit_exceeded", + structuredFields: CLIErrorStructuredFields( + path: stateDirectory, + maximumCount: Int64(error.maximumCount), + recoveryAction: "narrow_agent_selection", + observedAtLeast: error.observedAtLeast + ) + ) + case let error as CmuxAgentSessionRegistry.UnsafeProviderIdentifierError: + return CLIError( + message: fallback.message, + v2Code: "agent_provider_identifier_unsafe", + structuredFields: CLIErrorStructuredFields( + provider: error.provider, + path: stateDirectory, + recoveryAction: "repair_agent_registry" + ) + ) + case let error as AgentSessionProviderCollisionError: + return CLIError( + message: fallback.message, + v2Code: "agent_provider_identifier_collision", + structuredFields: CLIErrorStructuredFields( + provider: error.secondProvider, + conflictingProvider: error.firstProvider, + path: stateDirectory, + scope: "case_insensitive_sidecar_suffix", + recoveryAction: "rename_agent_provider" + ) + ) + default: + return fallback + } + } + + private func agentSessionProviderConfigURLs( + homeDirectory: String, + workingDirectory: String?, + fileManager: FileManager + ) -> [URL] { + let home = (homeDirectory as NSString).standardizingPath + var urls = [ + URL(fileURLWithPath: home, isDirectory: true) + .appendingPathComponent(".config/cmux/cmux.json", isDirectory: false), + ] + if let workingDirectory = workingDirectory? + .trimmingCharacters(in: .whitespacesAndNewlines), + !workingDirectory.isEmpty, + let localURL = agentSessionNearestProviderConfigURL( + startingAt: workingDirectory, + fileManager: fileManager + ) { + urls.append(localURL) + } + var seenPaths: Set = [] + return urls.filter { seenPaths.insert(($0.path as NSString).standardizingPath).inserted } + } + + private func agentSessionNearestProviderConfigURL( + startingAt path: String, + fileManager: FileManager + ) -> URL? { + var isDirectory: ObjCBool = false + let start = fileManager.fileExists(atPath: path, isDirectory: &isDirectory) + && isDirectory.boolValue + ? path + : (path as NSString).deletingLastPathComponent + var current = (start as NSString).standardizingPath + // Two exact probes per ancestor are predictable and avoid directory + // enumeration. Real paths reach the filesystem root well before 64. + for _ in 0..<64 { + let candidates = [ + URL(fileURLWithPath: current, isDirectory: true) + .appendingPathComponent(".cmux/cmux.json", isDirectory: false), + URL(fileURLWithPath: current, isDirectory: true) + .appendingPathComponent("cmux.json", isDirectory: false), + ] + if let candidate = candidates.first(where: { fileManager.fileExists(atPath: $0.path) }) { + return candidate + } + let parent = (current as NSString).deletingLastPathComponent + guard parent != current else { return nil } + current = parent + } + return nil + } + + private func agentSessionConfiguredProviders( + at url: URL, + matchingProviderID: String?, + fileManager: FileManager + ) throws -> [AgentSessionConfiguredProvider] { + guard fileManager.fileExists(atPath: url.path), + let handle = try? FileHandle(forReadingFrom: url) else { + return [] + } + defer { try? handle.close() } + let maximumConfigBytes = 1_024 * 1_024 + guard let data = try? handle.read(upToCount: maximumConfigBytes + 1), + !data.isEmpty, + data.count <= maximumConfigBytes, + let sanitized = try? JSONCParser.preprocess(data: data), + let root = try? JSONSerialization.jsonObject(with: sanitized) as? [String: Any], + let vault = root["vault"] as? [String: Any], + let registrations = vault["agents"] as? [[String: Any]] else { + return [] + } + var providers: [AgentSessionConfiguredProvider] = [] + providers.reserveCapacity( + min(registrations.count, CmuxAgentSessionRegistry.maximumProviderEnumerationCount) + ) + var indexByProviderID: [String: Int] = [:] + for registration in registrations { + guard let rawID = registration["id"] as? String, + let rawName = registration["name"] as? String, + let resumeCommand = registration["resumeCommand"] as? String, + registration["sessionIdSource"] != nil else { + continue + } + let id = rawID.trimmingCharacters(in: .whitespacesAndNewlines) + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard CmuxAgentSessionRegistry.isSafeProviderIdentifier(id), + !name.isEmpty, + name.utf8.count <= 256, + resumeCommand.contains("{{sessionId}}") + || resumeCommand.contains("{{sessionPath}}") else { + continue + } + if let matchingProviderID, + id != matchingProviderID, + agentsNormalizedAgentID(id) != agentsNormalizedAgentID(matchingProviderID) { + continue + } + let provider = AgentSessionConfiguredProvider(id: id, name: name) + if let index = indexByProviderID[id] { + providers[index] = provider + } else { + indexByProviderID[id] = providers.count + providers.append(provider) + } + } + return providers + } + + func agentTerminalObservation( + _ observation: CmuxAgentTerminalObservation, + matchesAnyAgentID agentIDs: Set + ) -> Bool { + let normalizedIDs = Set(agentIDs.map(agentsNormalizedAgentID)) + let provider = agentsNormalizedAgentID(observation.sessionProviderID) + let family = agentsNormalizedAgentID(observation.familyID) + return normalizedIDs.contains(provider) || normalizedIDs.contains(family) + } + + func agentTerminalObservation( + _ observation: CmuxAgentTerminalObservation, + matches selection: AgentSessionProviderSelection, + requestedNormalizedID: String + ) -> Bool { + if let ownedProviderMatch = selection.ownedProviderMatch(for: observation) { + return ownedProviderMatch + } + return agentTerminalObservation( + observation, + matchesAnyAgentID: Set([ + requestedNormalizedID, + selection.providerID, + ].compactMap { $0 }) + ) + } + + func agentTerminalObservation( + _ observation: CmuxAgentTerminalObservation, + canonicalizedFor selection: AgentSessionProviderSelection + ) -> CmuxAgentTerminalObservation { + selection.canonicalizedObservation(observation) + } + + func agentsNormalizedAgentID(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "_", with: "-") + } + + func parseAgentsValueOption( + _ arguments: [String], + name: String, + context: AgentsValueOptionContext + ) throws -> (String?, [String]) { + var remaining: [String] = [] + var value: String? + var skipNext = false + var pastTerminator = false + + for (index, argument) in arguments.enumerated() { + if skipNext { + skipNext = false + continue + } + if argument == "--" { + pastTerminator = true + remaining.append(argument) + continue + } + if !pastTerminator, argument.hasPrefix("\(name)=") { + let candidate = String(argument.dropFirst(name.count + 1)) + guard !candidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CLIError(message: agentsOptionRequiresValueMessage(name: name, context: context)) + } + value = candidate + continue + } + if !pastTerminator, argument == name { + guard index + 1 < arguments.count else { + throw CLIError(message: agentsOptionRequiresValueMessage(name: name, context: context)) + } + let candidate = arguments[index + 1] + guard !candidate.hasPrefix("-"), + !candidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CLIError(message: agentsOptionRequiresValueMessage(name: name, context: context)) + } + value = candidate + skipNext = true + continue + } + remaining.append(argument) + } + + return (value, remaining) + } + + private func agentsOptionRequiresValueMessage( + name: String, + context: AgentsValueOptionContext + ) -> String { + let agentMessage = switch context { + case .agentsList, .sessionsList: + String( + localized: "cli.sessions.error.agentRequiresValue", + defaultValue: "%@ list: --agent requires a value" + ) + case .tree: + String( + localized: "cli.agents.tree.error.agentRequiresValue", + defaultValue: "agents tree: --agent requires a value" + ) + } + let optionMessage = agentMessage.replacingOccurrences(of: "--agent", with: name) + switch context { + case .agentsList: + return String(format: optionMessage, AgentsCommandInvocation.agents.rawValue) + case .sessionsList: + return String(format: optionMessage, AgentsCommandInvocation.sessions.rawValue) + case .tree: + return optionMessage + } + } +} diff --git a/CLI/CMUXCLI+AgentsTree.swift b/CLI/CMUXCLI+AgentsTree.swift new file mode 100644 index 000000000000..c171e710e0a8 --- /dev/null +++ b/CLI/CMUXCLI+AgentsTree.swift @@ -0,0 +1,963 @@ +import CmuxFoundation +import Foundation + +extension CMUXCLI { + private static let agentsTreeDefaultMaximumNodes = 10_000 + static let agentsTreeHardMaximumNodes = 20_000 + private static let agentsTreeHardMaximumDepth = 4_096 + private static let agentsTreeNodeBudgetErrorCode = "agent_graph_node_budget_exceeded" + private static let agentsTreeRecordSizeErrorCode = "agent_graph_record_too_large" + private static let agentsTreeHardMaximumRecordBytes = 4 * 1_024 * 1_024 + + func runAgentsTreeCommand( + commandArgs: [String], + jsonOutput: Bool, + processEnv: [String: String], + fileManager: FileManager, + terminalObservations: [CmuxAgentTerminalObservation] + ) throws { + let (agentFilter, remainder0) = try parseAgentsValueOption(commandArgs, name: "--agent", context: .tree) + let (sessionFilter, remainder1) = try parseAgentsValueOption(remainder0, name: "--session", context: .tree) + let (workspaceFilter, remainder2) = try parseAgentsValueOption(remainder1, name: "--workspace", context: .tree) + let (surfaceFilter, remainder3) = try parseAgentsValueOption(remainder2, name: "--surface", context: .tree) + let (stateDirOverride, remainder4) = try parseAgentsValueOption(remainder3, name: "--state-dir", context: .tree) + let (relationshipFilter, remainder5) = try parseAgentsValueOption(remainder4, name: "--relation", context: .tree) + let (stateFilter, remainder6) = try parseAgentsValueOption(remainder5, name: "--state", context: .tree) + let (activityFilter, remainder7) = try parseAgentsValueOption(remainder6, name: "--activity", context: .tree) + let (workKindFilter, remainder8) = try parseAgentsValueOption(remainder7, name: "--work-kind", context: .tree) + let (depthRaw, remainder9) = try parseAgentsValueOption(remainder8, name: "--depth", context: .tree) + let (maximumNodesRaw, remainder10) = try parseAgentsValueOption( + remainder9, + name: "--max-nodes", + context: .tree + ) + + var localJSONOutput = jsonOutput + var includeAll = false + for argument in remainder10 { + switch argument { + case "--json": localJSONOutput = true + case "--all", "--history": includeAll = true + default: + throw CLIError(message: String( + format: String(localized: "cli.agents.tree.error.unexpectedArgument", defaultValue: "agents tree: unexpected argument '%@'"), + argument + )) + } + } + let maximumDepth: Int + if let depthRaw { + guard let parsed = Int(depthRaw), parsed > 0 else { + throw CLIError(message: String(localized: "cli.agents.tree.error.invalidDepth", defaultValue: "agents tree: --depth must be a positive integer")) + } + guard parsed <= Self.agentsTreeHardMaximumDepth else { + throw CLIError(message: String( + format: String( + localized: "cli.agents.tree.error.depthTooLarge", + defaultValue: "agents tree: --depth must not exceed %lld" + ), + Self.agentsTreeHardMaximumDepth + )) + } + maximumDepth = parsed + } else { + maximumDepth = 64 + } + let maximumNodes: Int + if let maximumNodesRaw { + guard let parsed = Int(maximumNodesRaw), + parsed > 0, + parsed <= Self.agentsTreeHardMaximumNodes else { + throw CLIError(message: String( + format: String( + localized: "cli.agents.tree.error.invalidMaximumNodes", + defaultValue: "agents tree: --max-nodes must be an integer from 1 through %lld" + ), + Self.agentsTreeHardMaximumNodes + )) + } + maximumNodes = parsed + } else { + maximumNodes = Self.agentsTreeDefaultMaximumNodes + } + + let stateDirectory = agentsTreeExpandedPath( + stateDirOverride + ?? processEnv["CMUX_AGENT_HOOK_STATE_DIR"] + ?? URL(fileURLWithPath: processEnv["HOME"] ?? NSHomeDirectory(), isDirectory: true) + .appendingPathComponent(".cmuxterm", isDirectory: true) + .path + ) + let normalizedAgent: String? + if let agentFilter { + let value = agentsNormalizedAgentID(agentFilter) + guard !value.isEmpty else { + throw CLIError(message: String( + localized: "cli.agents.tree.error.agentRequiresValue", + defaultValue: "agents tree: --agent requires a value" + )) + } + normalizedAgent = value + } else { + normalizedAgent = nil + } + let normalizedSession = agentsTreeNormalized(sessionFilter)?.lowercased() + let normalizedWorkspace = agentsTreeNormalizedID(workspaceFilter)?.lowercased() + let normalizedSurface = agentsTreeNormalizedID(surfaceFilter)?.lowercased() + let normalizedRelationship = agentsTreeNormalized(relationshipFilter)?.lowercased() + let normalizedState = agentsTreeNormalized(stateFilter)?.lowercased() + let normalizedActivity = agentsTreeNormalized(activityFilter)?.lowercased() + let normalizedWorkKind = agentsTreeNormalized(workKindFilter)?.lowercased() + let queryScope = AgentSessionQueryScope(includeHistory: includeAll, environment: processEnv) + let includesEndedRecords = includeAll + || normalizedSession != nil + || normalizedWorkspace != nil + || normalizedSurface != nil + || normalizedState == AgentEffectiveState.ended.rawValue + if let normalizedRelationship, + normalizedRelationship != "all", + AgentSessionRelationship(rawValue: normalizedRelationship) == nil { + throw CLIError(message: String( + format: String(localized: "cli.agents.tree.error.unknownRelationship", defaultValue: "agents tree: unknown relationship '%@'"), + normalizedRelationship + )) + } + if let normalizedState, AgentEffectiveState(rawValue: normalizedState) == nil { + throw CLIError(message: String( + format: String(localized: "cli.agents.tree.error.unknownState", defaultValue: "agents tree: unknown state '%@'"), + normalizedState + )) + } + if let normalizedActivity, AgentActivityState(rawValue: normalizedActivity) == nil { + throw CLIError(message: String( + format: String(localized: "cli.agents.tree.error.unknownActivity", defaultValue: "agents tree: unknown activity '%@'"), + normalizedActivity + )) + } + if let normalizedWorkKind, AgentWorkloadKind(rawValue: normalizedWorkKind) == nil { + throw CLIError(message: String( + format: String(localized: "cli.agents.tree.error.unknownWorkKind", defaultValue: "agents tree: unknown workload kind '%@'"), + normalizedWorkKind + )) + } + let canonicalTerminalObservations = AgentTerminalObservationJoiner.canonicalObservations( + terminalObservations + ) + + let homeDirectory = agentsTreeExpandedPath(processEnv["HOME"] ?? NSHomeDirectory()) + let specifications: [AgentSessionProviderSpecification] + do { + specifications = try agentSessionProviderSpecifications( + stateDirectory: stateDirectory, + homeDirectory: homeDirectory, + requestedAgent: agentFilter, + processEnv: processEnv, + fileManager: fileManager + ) + } catch { + throw agentsProviderCatalogCLIError( + error, + stateDirectory: stateDirectory, + context: .tree + ) + } + let providerSelection = normalizedAgent.map { + agentSessionProviderSelection( + for: agentFilter ?? $0, + availableProviderIDs: specifications.map(\.name), + terminalObservations: canonicalTerminalObservations + ) + } + if let normalizedAgent { + let hasMatchingObservation = canonicalTerminalObservations.contains { + guard let providerSelection else { return false } + return agentTerminalObservation( + $0, + matches: providerSelection, + requestedNormalizedID: normalizedAgent + ) + } + guard providerSelection?.providerID != nil || hasMatchingObservation else { + throw CLIError(message: String( + format: String( + localized: "cli.agents.tree.error.unknownAgent", + defaultValue: "agents tree: unknown agent '%@'" + ), + agentFilter ?? normalizedAgent + )) + } + } + let selectedSpecifications = if let providerID = providerSelection?.providerID { + specifications.filter { $0.name == providerID } + } else { + normalizedAgent == nil ? specifications : [] + } + let claudeTranscriptLookup = SessionsListClaudeTranscriptLookupCache( + homeDirectory: homeDirectory, + fileManager: fileManager + ) + var processStartTimeByPID: [Int: TimeInterval] = [:] + var missingProcessStartTimePIDs: Set = [] + var processStateByIdentity: [String: AgentProcessState] = [:] + let processStateLookup: (Int?, TimeInterval?) -> AgentProcessState = { pid, expectedStartedAt in + guard let pid, let expectedStartedAt else { return .unknown } + let identity = "\(pid)\u{1F}\(expectedStartedAt.bitPattern)" + if let cached = processStateByIdentity[identity] { return cached } + let actualStartedAt: TimeInterval? + if let cached = processStartTimeByPID[pid] { + actualStartedAt = cached + } else if missingProcessStartTimePIDs.contains(pid) { + actualStartedAt = nil + } else if let probed = sessionsListProcessStartTime(for: pid) { + processStartTimeByPID[pid] = probed + actualStartedAt = probed + } else { + missingProcessStartTimePIDs.insert(pid) + actualStartedAt = nil + } + let state: AgentProcessState = actualStartedAt.map { + abs($0 - expectedStartedAt) <= 0.001 ? .alive : .exited + } ?? .exited + processStateByIdentity[identity] = state + return state + } + let matchingObservations: [CmuxAgentTerminalObservation] = canonicalTerminalObservations + .compactMap { observation -> CmuxAgentTerminalObservation? in + if let providerSelection, + let normalizedAgent, + !agentTerminalObservation( + observation, + matches: providerSelection, + requestedNormalizedID: normalizedAgent + ) { + return nil + } + if let normalizedSurface, + observation.surfaceID.uuidString.lowercased() != normalizedSurface { return nil } + switch queryScope { + case .history, .legacyUnscoped: + break + case let .currentRuntime(runtimeID): + guard observation.runtimeID == runtimeID else { return nil } + } + if let providerSelection { + return agentTerminalObservation( + observation, + canonicalizedFor: providerSelection + ) + } + return observation + } + let observationJoiner = AgentTerminalObservationJoiner() + let observationsByProcessKey = Dictionary( + grouping: matchingObservations, + by: { AgentTerminalObservationJoiner.processKey(observation: $0) } + ) + let observationsByProvider = Dictionary( + grouping: matchingObservations, + by: \.sessionProviderID + ) + let snapshotLoad: AgentHookSessionRegistrySnapshots + do { + // Storage admission has its own hard ceiling. The user-facing node + // budget is enforced below after provider/session/workspace filters, + // so narrowing a large registry can actually make the query fit. + snapshotLoad = try AgentHookSessionRegistryBridge.snapshots( + specifications: selectedSpecifications.map { + (provider: $0.name, suffix: $0.sessionStoreSuffix) + }, + stateDirectory: stateDirectory, + environment: processEnv, + fileManager: fileManager + ) + } catch let failure as AgentHookSessionStoreLoadFailure { + throw agentsStoreLoadCLIError(failure, context: .tree) + } catch { + throw agentsStateUnavailableCLIError( + stateDirectory: stateDirectory, + context: .tree + ) + } + // Finish the cheap, record-at-a-time pass across every selected + // provider before allowing any provider-wide compatibility decode. + // Counts are conservatively additive because node IDs include provider. + var remainingPreflightNodes = maximumNodes + for specification in selectedSpecifications { + guard let snapshot = snapshotLoad.snapshots[specification.name], + let visibleCount = try agentsTreeSnapshotVisibleNodeCount( + snapshot, + provider: specification.name, + queryScope: queryScope, + includesEndedRecords: includesEndedRecords, + normalizedSession: normalizedSession, + normalizedWorkspace: normalizedWorkspace, + normalizedSurface: normalizedSurface, + normalizedState: normalizedState, + normalizedActivity: normalizedActivity, + normalizedWorkKind: normalizedWorkKind, + observationsByProcessKey: observationsByProcessKey, + terminalObservations: observationsByProvider[specification.name] ?? [], + claudeTranscriptLookup: claudeTranscriptLookup, + processStateLookup: processStateLookup, + maximumNodes: remainingPreflightNodes + ) else { + continue + } + guard visibleCount <= remainingPreflightNodes else { + throw agentsTreeNodeBudgetExceededError( + maximumNodes: maximumNodes, + observedAtLeast: maximumNodes + 1 + ) + } + remainingPreflightNodes -= visibleCount + } + var nodes: [AgentSessionGraphNode] = [] + var edges: [AgentSessionGraphEdge] = [] + var definitelyVisibleNodeIDs: Set = [] + definitelyVisibleNodeIDs.reserveCapacity(min(maximumNodes, 8_192)) + var nodeIndexByID: [String: Int] = [:] + nodeIndexByID.reserveCapacity(min(maximumNodes, 8_192)) + let (provisionalMaximumNodes, provisionalMaximumOverflow) = maximumNodes + .addingReportingOverflow(matchingObservations.count) + let provisionalNodeLimit = provisionalMaximumOverflow ? Int.max : provisionalMaximumNodes + var activeSessionBySurface: [String: String] = [:] + var processedObservationProviders: Set = [] + var storeWarnings = snapshotLoad.warnings + for specification in selectedSpecifications { + let url = URL(fileURLWithPath: stateDirectory, isDirectory: true) + .appendingPathComponent( + "\(specification.sessionStoreSuffix)-hook-sessions.json", + isDirectory: false + ) + var storeEnvironment = processEnv + storeEnvironment["CMUX_AGENT_HOOK_STATE_DIR"] = stateDirectory + storeEnvironment["CMUX_CLAUDE_HOOK_STATE_PATH"] = url.path + let bridge = AgentHookSessionRegistryBridge( + provider: specification.name, + statePath: url.path, + environment: storeEnvironment, + fileManager: fileManager + ) + let store: ClaudeHookSessionStoreFile + if let snapshot = snapshotLoad.snapshots[specification.name] { + let load: AgentHookSessionStoreLoadResult + do { + load = try bridge.loadForInspection(snapshot: snapshot) + } catch let failure as AgentHookSessionStoreLoadFailure { + throw agentsStoreLoadCLIError(failure, context: .tree) + } catch { + throw agentsStateUnavailableCLIError( + stateDirectory: stateDirectory, + context: .tree + ) + } + store = load.store + if let warning = load.warning { storeWarnings.append(warning) } + } else { + store = ClaudeHookSessionStore( + processEnv: storeEnvironment, + fileManager: fileManager, + agentName: specification.name + ).snapshot() + } + guard !store.sessions.isEmpty else { continue } + let activeSessionIds = Set(store.activeSessionsBySurface.values.map(\.sessionId)) + .union(store.activeSessionsByWorkspace.values.map(\.sessionId)) + for (surfaceID, active) in store.activeSessionsBySurface { + guard let record = store.sessions[active.sessionId] else { continue } + for run in agentsTreeRuns(record: record, provider: specification.name) { + guard let runtimeID = run.cmuxRuntime(fallingBackTo: record.cmuxRuntime)?.id else { + continue + } + activeSessionBySurface[AgentTerminalObservationJoiner.surfaceKey( + provider: specification.name, + runtimeID: runtimeID, + surfaceID: surfaceID + )] = record.sessionId + } + } + let providerObservations = observationsByProvider[specification.name] ?? [] + processedObservationProviders.insert(specification.name) + var observationCandidates = AgentTerminalObservationCandidateAccumulator( + observations: providerObservations, + activeSessionBySurface: activeSessionBySurface + ) + var candidateEdgesByNodeID: [String: AgentSessionGraphEdge] = [:] + let sessionProcessCohort = normalizedSession.map { normalizedSession in + var matcher = AgentSessionProcessCohortMatcher() + for record in store.sessions.values + where record.sessionId.lowercased() == normalizedSession { + for run in agentsTreeRuns(record: record, provider: specification.name) { + matcher.insert(provider: specification.name, record: record, run: run) + } + } + return matcher + } + for record in store.sessions.values { + let runs = agentsTreeRuns(record: record, provider: specification.name) + if let normalizedSession, record.sessionId.lowercased() != normalizedSession { + guard runs.contains(where: { run in + sessionProcessCohort?.matches( + provider: specification.name, + record: record, + run: run + ) == true + }) else { continue } + } + if let normalizedSurface, record.surfaceId.lowercased() != normalizedSurface { continue } + for run in runs { + let legacyRunVisible = queryScope == .legacyUnscoped + && (activeSessionIds.contains(record.sessionId) + || agentHookRunIsRestorable( + agent: specification.name, + record: record, + run: run, + claudeTranscriptLookup: claudeTranscriptLookup + )) + guard queryScope.includes( + recordRuntime: run.identityConflict == true ? nil : record.cmuxRuntime, + runRuntime: run.cmuxRuntime, + legacyVisible: run.identityConflict != true && legacyRunVisible + ) else { continue } + let projection = AgentSessionStateProjection( + record: record, + run: run, + probedProcessState: processStateLookup(run.pid, run.processStartedAt) + ) + guard includesEndedRecords || queryScope.includes(projection: projection) else { continue } + let runtime = run.cmuxRuntime(fallingBackTo: record.cmuxRuntime) + let node = AgentSessionGraphNode( + provider: specification.name, + sessionId: record.sessionId, + runId: run.runId, + pid: run.pid, + processStartedAt: run.processStartedAt, + cmuxRuntime: runtime, + workspaceId: record.workspaceId, + surfaceId: record.surfaceId, + cwd: record.cwd, + processState: projection.process, + sessionState: projection.session, + foregroundState: projection.foreground, + attentionState: projection.attention, + activity: projection.activity, + effectiveState: projection.effective, + workloads: projection.workloads.map(AgentWorkloadSnapshot.init), + restoreAuthority: run.restoreAuthority, + startedAt: run.startedAt, + updatedAt: run.updatedAt, + endedAt: run.endedAt + ) + let matchingProcessObservations = observationsByProcessKey[ + AgentTerminalObservationJoiner.processKey(node: node) + ] ?? [] + let canChangeThroughTerminalObservation = matchingProcessObservations.contains { + observationJoiner.matches(node, observation: $0) + } + let matchesLifecycleFilters = (normalizedSession == nil + || node.sessionId?.lowercased() == normalizedSession) + && agentsTreeNodeMatchesFilters( + node, + normalizedWorkspace: normalizedWorkspace, + normalizedState: normalizedState, + normalizedActivity: normalizedActivity, + normalizedWorkKind: normalizedWorkKind + ) + let edge = agentsTreeEdge( + node: node, + run: run, + normalizedRelationship: normalizedRelationship + ) + if canChangeThroughTerminalObservation { + observationCandidates.insert(node) + if !matchesLifecycleFilters, + observationCandidates.contains(nodeID: node.nodeId), + let edge { + candidateEdgesByNodeID[node.nodeId] = edge + } + } + guard matchesLifecycleFilters else { continue } + guard try agentsTreeReserveVisibleNode( + nodeID: node.nodeId, + visibleNodeIDs: &definitelyVisibleNodeIDs, + maximumNodes: maximumNodes, + provisionalMaximumNodes: provisionalNodeLimit + ) else { + continue + } + nodeIndexByID[node.nodeId] = nodes.count + nodes.append(node) + if let edge { edges.append(edge) } + } + } + + var projectedCandidates = observationCandidates.retainedCandidates + _ = observationJoiner.merge( + nodes: &projectedCandidates, + observations: providerObservations, + activeSessionBySurface: activeSessionBySurface + ) + for node in projectedCandidates { + if let existingIndex = nodeIndexByID[node.nodeId] { + nodes[existingIndex] = node + continue + } + guard normalizedSession == nil || node.sessionId?.lowercased() == normalizedSession, + agentsTreeNodeMatchesFilters( + node, + normalizedWorkspace: normalizedWorkspace, + normalizedState: normalizedState, + normalizedActivity: normalizedActivity, + normalizedWorkKind: normalizedWorkKind + ), + try agentsTreeReserveVisibleNode( + nodeID: node.nodeId, + visibleNodeIDs: &definitelyVisibleNodeIDs, + maximumNodes: maximumNodes, + provisionalMaximumNodes: provisionalNodeLimit + ) else { + continue + } + nodeIndexByID[node.nodeId] = nodes.count + nodes.append(node) + if let edge = candidateEdgesByNodeID[node.nodeId] { edges.append(edge) } + } + } + + var unhandledObservationNodes: [AgentSessionGraphNode] = [] + _ = observationJoiner.merge( + nodes: &unhandledObservationNodes, + observations: matchingObservations.filter { + !processedObservationProviders.contains($0.sessionProviderID) + }, + activeSessionBySurface: activeSessionBySurface + ) + for node in unhandledObservationNodes where normalizedSession == nil + && agentsTreeNodeMatchesFilters( + node, + normalizedWorkspace: normalizedWorkspace, + normalizedState: normalizedState, + normalizedActivity: normalizedActivity, + normalizedWorkKind: normalizedWorkKind + ) { + guard try agentsTreeReserveVisibleNode( + nodeID: node.nodeId, + visibleNodeIDs: &definitelyVisibleNodeIDs, + maximumNodes: maximumNodes, + provisionalMaximumNodes: provisionalNodeLimit + ) else { continue } + nodes.append(node) + } + nodes.removeAll { node in + if let normalizedSession, node.sessionId?.lowercased() != normalizedSession { return true } + return !agentsTreeNodeMatchesFilters( + node, + normalizedWorkspace: normalizedWorkspace, + normalizedState: normalizedState, + normalizedActivity: normalizedActivity, + normalizedWorkKind: normalizedWorkKind + ) + } + if nodes.count > maximumNodes { + throw agentsTreeNodeBudgetExceededError( + maximumNodes: maximumNodes, + observedAtLeast: maximumNodes + 1 + ) + } + + if !edges.isEmpty { + edges = AgentSessionGraphEdgeSanitizer( + graphOrdering: agentSessionGraphOrdering + ).acyclicEdges(nodes: nodes, edges: edges) + if !edges.isEmpty { + AgentSubtreeActivityProjector().project(nodes: &nodes, edges: edges) + } + } + + nodes.sort(by: agentSessionGraphOrdering.nodePrecedes) + edges.sort(by: agentSessionGraphOrdering.edgePrecedes) + let snapshot = AgentSessionGraphSnapshot( + nodes: nodes, + edges: edges, + storeWarnings: storeWarnings.isEmpty ? nil : storeWarnings + ) + if localJSONOutput { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + try AgentStagedOutput().publish(build: { handle in + var writer = try AgentPrettyJSONStreamWriter(handle: handle) + try writer.beginArrayField(name: "edges") + for start in stride(from: 0, to: snapshot.edges.count, by: 512) { + let end = min(start + 512, snapshot.edges.count) + try writer.writeArrayElements( + Array(snapshot.edges[start.. [AgentSessionRunRecord] { + agentSessionRunCanonicalizer.runs(record: record, provider: provider) + } + + /// Counts raw registry rows one at a time before the compatibility bridge + /// builds a provider-wide object graph. A malformed authoritative row skips + /// this optimization so the existing complete-fallback path stays intact. + private func agentsTreeSnapshotVisibleNodeCount( + _ snapshot: CmuxAgentSessionRegistry.Snapshot, + provider: String, + queryScope: AgentSessionQueryScope, + includesEndedRecords: Bool, + normalizedSession: String?, + normalizedWorkspace: String?, + normalizedSurface: String?, + normalizedState: String?, + normalizedActivity: String?, + normalizedWorkKind: String?, + observationsByProcessKey: [String: [CmuxAgentTerminalObservation]], + terminalObservations: [CmuxAgentTerminalObservation], + claudeTranscriptLookup: SessionsListClaudeTranscriptLookupCache, + processStateLookup: (Int?, TimeInterval?) -> AgentProcessState, + maximumNodes: Int + ) throws -> Int? { + let decoder = JSONDecoder() + var activeSessionIDs: Set = [] + var activeSessionIDBySurface: [String: String] = [:] + activeSessionIDs.reserveCapacity(snapshot.activeSlots.count) + for slot in snapshot.activeSlots { + guard let active = try? decoder.decode(ClaudeHookActiveSessionRecord.self, from: slot.json), + active.sessionId == slot.sessionID else { + return nil + } + activeSessionIDs.insert(active.sessionId) + if slot.scope == .surface { + activeSessionIDBySurface[slot.scopeID.lowercased()] = active.sessionId + } + } + + var visibleNodeIDs: Set = [] + visibleNodeIDs.reserveCapacity(min(maximumNodes + 1, 8_192)) + let observationJoiner = AgentTerminalObservationJoiner() + var activeSessionBySurface: [String: String] = [:] + activeSessionBySurface.reserveCapacity(terminalObservations.count) + for observation in terminalObservations { + guard let activeSessionID = activeSessionIDBySurface[ + observation.surfaceID.uuidString.lowercased() + ] else { continue } + activeSessionBySurface[AgentTerminalObservationJoiner.surfaceKey( + provider: observation.sessionProviderID, + runtimeID: observation.runtimeID, + surfaceID: observation.surfaceID.uuidString + )] = activeSessionID + } + var observationCandidates = AgentTerminalObservationCandidateAccumulator( + observations: terminalObservations, + activeSessionBySurface: activeSessionBySurface + ) + let (provisionalMaximum, provisionalOverflow) = maximumNodes.addingReportingOverflow( + terminalObservations.count + ) + let provisionalNodeLimit = provisionalOverflow ? Int.max : provisionalMaximum + let sessionProcessCohort: AgentSessionProcessCohortMatcher? = if let normalizedSession { + try agentsTreeSnapshotProcessCohort( + snapshot, + provider: provider, + normalizedSession: normalizedSession + ) + } else { + nil + } + for stored in snapshot.records { + if stored.json.count > Self.agentsTreeHardMaximumRecordBytes { + throw agentsTreeRecordSizeExceededError( + provider: provider, + sessionID: stored.sessionID, + observedBytes: stored.json.count + ) + } + guard let record = try? decoder.decode(ClaudeHookSessionRecord.self, from: stored.json), + record.sessionId == stored.sessionID else { + return nil + } + if let normalizedSurface, record.surfaceId.lowercased() != normalizedSurface { continue } + let runs = agentsTreeRuns(record: record, provider: provider) + if let normalizedSession, record.sessionId.lowercased() != normalizedSession { + guard runs.contains(where: { run in + sessionProcessCohort?.matches(provider: provider, record: record, run: run) == true + }) else { + continue + } + } + for run in runs { + let legacyVisible = queryScope == .legacyUnscoped + && (activeSessionIDs.contains(record.sessionId) + || agentHookRunIsRestorable( + agent: provider, + record: record, + run: run, + claudeTranscriptLookup: claudeTranscriptLookup + )) + guard queryScope.includes( + recordRuntime: run.identityConflict == true ? nil : record.cmuxRuntime, + runRuntime: run.cmuxRuntime, + legacyVisible: run.identityConflict != true && legacyVisible + ) else { continue } + let projection = AgentSessionStateProjection( + record: record, + run: run, + probedProcessState: processStateLookup(run.pid, run.processStartedAt) + ) + guard includesEndedRecords || queryScope.includes(projection: projection) else { continue } + let node = AgentSessionGraphNode( + provider: provider, + sessionId: record.sessionId, + runId: run.runId, + pid: run.pid, + processStartedAt: run.processStartedAt, + cmuxRuntime: run.cmuxRuntime(fallingBackTo: record.cmuxRuntime), + workspaceId: record.workspaceId, + surfaceId: record.surfaceId, + cwd: record.cwd, + processState: projection.process, + sessionState: projection.session, + foregroundState: projection.foreground, + attentionState: projection.attention, + activity: projection.activity, + effectiveState: projection.effective, + workloads: projection.workloads.map(AgentWorkloadSnapshot.init), + restoreAuthority: run.restoreAuthority, + startedAt: run.startedAt, + updatedAt: run.updatedAt, + endedAt: run.endedAt + ) + let observations = observationsByProcessKey[ + AgentTerminalObservationJoiner.processKey(node: node) + ] ?? [] + let isUncertainObservationCandidate = observations.contains { + observationJoiner.matches(node, observation: $0) + } + if isUncertainObservationCandidate { observationCandidates.insert(node) } + // Same-process cohort rows participate in exact-session + // disambiguation and therefore count toward the inspection + // budget even though only the requested session is emitted. + let matchesLifecycleFilters = agentsTreeNodeMatchesFilters( + node, + normalizedWorkspace: normalizedWorkspace, + normalizedState: normalizedState, + normalizedActivity: normalizedActivity, + normalizedWorkKind: normalizedWorkKind + ) + guard matchesLifecycleFilters || isUncertainObservationCandidate else { + continue + } + if matchesLifecycleFilters { + visibleNodeIDs.insert(node.nodeId) + } + if visibleNodeIDs.count > provisionalNodeLimit { return maximumNodes + 1 } + } + } + var projectedCandidates = observationCandidates.retainedCandidates + _ = observationJoiner.merge( + nodes: &projectedCandidates, + observations: terminalObservations, + activeSessionBySurface: activeSessionBySurface + ) + for node in projectedCandidates { + if node.identitySource == "hook_session" { + visibleNodeIDs.remove(node.nodeId) + } + guard normalizedSession == nil || node.sessionId?.lowercased() == normalizedSession, + agentsTreeNodeMatchesFilters( + node, + normalizedWorkspace: normalizedWorkspace, + normalizedState: normalizedState, + normalizedActivity: normalizedActivity, + normalizedWorkKind: normalizedWorkKind + ) else { continue } + visibleNodeIDs.insert(node.nodeId) + } + if visibleNodeIDs.count > maximumNodes { return maximumNodes + 1 } + return visibleNodeIDs.count + } + + private func agentsTreeSnapshotProcessCohort( + _ snapshot: CmuxAgentSessionRegistry.Snapshot, + provider: String, + normalizedSession: String + ) throws -> AgentSessionProcessCohortMatcher? { + let decoder = JSONDecoder() + var matcher = AgentSessionProcessCohortMatcher() + for stored in snapshot.records where stored.sessionID.lowercased() == normalizedSession { + if stored.json.count > Self.agentsTreeHardMaximumRecordBytes { + throw agentsTreeRecordSizeExceededError( + provider: provider, + sessionID: stored.sessionID, + observedBytes: stored.json.count + ) + } + guard let record = try? decoder.decode(ClaudeHookSessionRecord.self, from: stored.json), + record.sessionId == stored.sessionID else { + return nil + } + for run in agentsTreeRuns(record: record, provider: provider) { + matcher.insert(provider: provider, record: record, run: run) + } + } + return matcher + } + + private func agentsTreeNodeMatchesFilters( + _ node: AgentSessionGraphNode, + normalizedWorkspace: String?, + normalizedState: String?, + normalizedActivity: String?, + normalizedWorkKind: String? + ) -> Bool { + if let normalizedWorkspace, node.workspaceId.lowercased() != normalizedWorkspace { return false } + if let normalizedState, node.effectiveState.rawValue != normalizedState { return false } + if let normalizedActivity, node.activity.state.rawValue != normalizedActivity { return false } + if let normalizedWorkKind, + !node.workloads.contains(where: { + $0.kind.rawValue == normalizedWorkKind && $0.phase.isActive + }) { + return false + } + return true + } + + private func agentsTreeEdge( + node: AgentSessionGraphNode, + run: AgentSessionRunRecord, + normalizedRelationship: String? + ) -> AgentSessionGraphEdge? { + guard let relationship = run.relationship, + normalizedRelationship == nil + || normalizedRelationship == "all" + || relationship.rawValue == normalizedRelationship else { + return nil + } + return AgentSessionGraphEdge( + fromRunId: run.parentRunId, + fromSessionId: run.parentSessionId, + toNodeId: node.nodeId, + toRunId: run.runId, + relationship: relationship + ) + } + + private func agentsTreeReserveVisibleNode( + nodeID: String, + visibleNodeIDs: inout Set, + maximumNodes: Int, + provisionalMaximumNodes: Int + ) throws -> Bool { + guard !visibleNodeIDs.contains(nodeID) else { return false } + guard visibleNodeIDs.count < provisionalMaximumNodes else { + throw agentsTreeNodeBudgetExceededError( + maximumNodes: maximumNodes, + observedAtLeast: maximumNodes + 1 + ) + } + visibleNodeIDs.insert(nodeID) + return true + } + + private func agentsTreeNodeBudgetExceededError( + maximumNodes: Int, + observedAtLeast: Int + ) -> CLIError { + let message = String( + format: String( + localized: "cli.agents.tree.error.nodeBudgetExceeded", + defaultValue: "agents tree: [%@] graph exceeds --max-nodes %lld (observed at least %lld); narrow the filters or raise --max-nodes, up to %lld" + ), + Self.agentsTreeNodeBudgetErrorCode, + maximumNodes, + observedAtLeast, + Self.agentsTreeHardMaximumNodes + ) + return CLIError( + message: message, + v2Code: Self.agentsTreeNodeBudgetErrorCode, + structuredFields: CLIErrorStructuredFields( + limit: maximumNodes, + observedAtLeast: observedAtLeast + ) + ) + } + + private func agentsTreeRecordSizeExceededError( + provider: String, + sessionID: String, + observedBytes: Int + ) -> CLIError { + let message = String( + format: String( + localized: "cli.agents.tree.error.recordTooLarge", + defaultValue: "agents tree: [%@] saved %@ session %@ is %lld bytes; narrow --agent or repair the store (maximum record: %lld bytes)" + ), + Self.agentsTreeRecordSizeErrorCode, + provider, + sessionID, + observedBytes, + Self.agentsTreeHardMaximumRecordBytes + ) + return CLIError( + message: message, + v2Code: Self.agentsTreeRecordSizeErrorCode, + structuredFields: CLIErrorStructuredFields( + provider: provider, + sessionID: sessionID, + observedBytes: Int64(observedBytes), + maximumRecordBytes: Self.agentsTreeHardMaximumRecordBytes + ) + ) + } + + private func agentsTreeExpandedPath(_ value: String) -> String { + NSString(string: value).expandingTildeInPath + } + + private func agentsTreeNormalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } + return value + } + + private func agentsTreeNormalizedID(_ value: String?) -> String? { + guard let value = agentsTreeNormalized(value) else { return nil } + return value.split(separator: ":", maxSplits: 1).last.map(String.init) + } +} diff --git a/CLI/CMUXCLI+AmpExtension.swift b/CLI/CMUXCLI+AmpExtension.swift index aa012708fd3e..306295b8b1a8 100644 --- a/CLI/CMUXCLI+AmpExtension.swift +++ b/CLI/CMUXCLI+AmpExtension.swift @@ -124,7 +124,7 @@ function sendHook( event: eventName(subcommand), ...extra, }; - const cmux = process.env.CMUX_AMP_CMUX_BIN || "cmux"; + const cmux = process.env.CMUX_AMP_CMUX_BIN || process.env.CMUX_BUNDLED_CLI_PATH || "cmux"; try { const child = spawn(cmux, ["hooks", "amp", subcommand], { env: hookEnvironment(cwd), @@ -259,7 +259,7 @@ function statusEnvironment(): NodeJS.ProcessEnv { function runCmux(args: string[]): void { if (process.env.CMUX_AMP_HOOKS_DISABLED === "1") return; if (!process.env.CMUX_SURFACE_ID) return; - const cmux = process.env.CMUX_AMP_CMUX_BIN || "cmux"; + const cmux = process.env.CMUX_AMP_CMUX_BIN || process.env.CMUX_BUNDLED_CLI_PATH || "cmux"; try { const child = spawn(cmux, args, { env: statusEnvironment(), diff --git a/CLI/CMUXCLI+AutoNaming.swift b/CLI/CMUXCLI+AutoNaming.swift index 61eb85f97320..2bd16429826e 100644 --- a/CLI/CMUXCLI+AutoNaming.swift +++ b/CLI/CMUXCLI+AutoNaming.swift @@ -78,12 +78,6 @@ enum AutoNamingThrottleDecision: Equatable, Sendable { case skipInsufficientGrowth } -/// One user/assistant text message extracted from a transcript. -struct AutoNamingTranscriptMessage: Codable, Equatable, Sendable { - var role: String - var text: String -} - /// Environment policy for the summarizer subprocess: scrub the variables /// that would recurse into cmux hooks or the parent agent session while /// preserving backend selection (Vertex/Bedrock/Anthropic) so the call works diff --git a/CLI/CMUXCLI+AutoNamingHooks.swift b/CLI/CMUXCLI+AutoNamingHooks.swift index a201149ad5bc..ecb8519d3a15 100644 --- a/CLI/CMUXCLI+AutoNamingHooks.swift +++ b/CLI/CMUXCLI+AutoNamingHooks.swift @@ -27,7 +27,11 @@ extension CMUXCLI { } let claudePid = mappedSession?.pid ?? claudeAgentPID(from: env) - guard !shouldSuppressNestedAgentVisibleMutations(currentAgentPID: claudePid, env: env) else { + guard !shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: claudePid, + agentName: "claude", + env: env + ) else { telemetry.breadcrumb("claude-hook.auto-name.nested-suppressed") return } diff --git a/CLI/CMUXCLI+CampfireExtension.swift b/CLI/CMUXCLI+CampfireExtension.swift index 3ed76f162a13..836c4352482d 100644 --- a/CLI/CMUXCLI+CampfireExtension.swift +++ b/CLI/CMUXCLI+CampfireExtension.swift @@ -186,7 +186,7 @@ function hookInvocation(subcommand: string, ctx: ExtensionContext, extra: Record event: eventName(subcommand), ...extra, }; - const cmux = process.env.CMUX_CAMPFIRE_CMUX_BIN || "cmux"; + const cmux = process.env.CMUX_CAMPFIRE_CMUX_BIN || process.env.CMUX_BUNDLED_CLI_PATH || "cmux"; return { cmux, cwd, diff --git a/CLI/CMUXCLI+ClaudePushNotificationHook.swift b/CLI/CMUXCLI+ClaudePushNotificationHook.swift index 2032993f2dff..f15ed34f68e8 100644 --- a/CLI/CMUXCLI+ClaudePushNotificationHook.swift +++ b/CLI/CMUXCLI+ClaudePushNotificationHook.swift @@ -58,8 +58,13 @@ extension CMUXCLI { return } let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) - guard !shouldSuppressNestedAgentVisibleMutations( + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, + agentName: "claude", + env: ProcessInfo.processInfo.environment + ) + guard !shouldSuppressNestedAgentNotification( + visibleMutationsSuppressed: suppressVisibleMutations, env: ProcessInfo.processInfo.environment ) else { telemetry.breadcrumb("claude-hook.push-notification.nested-suppressed") diff --git a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift index f437edaa3d66..e907da79c49f 100644 --- a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift +++ b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift @@ -1,6 +1,18 @@ import Foundation +enum CodexHookWriterOwnership { + case persistent + case wrapperInjected +} + +enum CodexHookDispatchTarget { + case wrapperEnvironment + case unavailable +} + extension CMUXCLI { + static let codexWrapperHookOwnerEnvironmentKey = "CMUX_CODEX_WRAPPER_HOOK_OWNER" + /// The per-invocation Codex hook events the wrapper injects, paired with the /// cmux subcommand they call and the codex hook timeout (ms). Lifecycle /// events are short; feed events (`PreToolUse`/`PermissionRequest`) are long @@ -43,7 +55,10 @@ extension CMUXCLI { var args: [String] = ["--enable", "hooks", "--dangerously-bypass-hook-trust"] for event in Self.codexWrapperInjectionEvents { let ff = Self.codexFireAndForgetAgentHookShellCommand( - "cmux hooks codex \(event.cmuxSubcommand)", for: codexDef + "cmux hooks codex \(event.cmuxSubcommand)", + for: codexDef, + ownership: .wrapperInjected, + target: .wrapperEnvironment ) let command: String if let scriptPath = hooksDir.flatMap({ @@ -93,18 +108,22 @@ extension CMUXCLI { } } - /// Writes (idempotently) a `#!/bin/sh` hook script for one event into `dir` - /// and returns its absolute path, or nil on any failure. The body is the - /// same env-driven fire-and-forget snippet used inline; as a real executable - /// file it runs under any runtime, including ones that exec the hook command - /// directly rather than through a shell. Content is identical across - /// invocations, so the file is only rewritten when missing or changed. + /// Writes an immutable, content-addressed `#!/bin/sh` hook script for one + /// event and returns its absolute path, or nil on failure. Stable event-only + /// filenames let an older Nightly overwrite a newer tagged build's ownership + /// gate while both are running. Including the content hash gives every cmux + /// version its own executable and keeps already-launched Codex processes on + /// the exact script they were configured to call. static func writeCodexHookScript(subcommand: String, body: String, in dir: URL) -> String? { let safeName = subcommand.replacingOccurrences( of: "[^A-Za-z0-9_-]", with: "-", options: .regularExpression ) - let url = dir.appendingPathComponent("cmux-codex-hook-\(safeName).sh", isDirectory: false) let contents = "#!/bin/sh\n\(body)\n" + let contentHash = codexHookStableContentHash(contents) + let url = dir.appendingPathComponent( + "cmux-codex-hook-\(safeName)-\(contentHash).sh", + isDirectory: false + ) let fileManager = FileManager.default if let existing = try? String(contentsOf: url, encoding: .utf8), existing == contents { // Ensure it stays executable, then reuse. @@ -120,14 +139,95 @@ extension CMUXCLI { } } - static func codexFireAndForgetAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String { + private static func codexHookStableContentHash(_ contents: String) -> String { + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in contents.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(format: "%016llx", hash) + } + + static func codexFireAndForgetAgentHookShellCommand( + _ command: String, + for def: AgentHookDef, + ownership: CodexHookWriterOwnership, + target: CodexHookDispatchTarget + ) -> String { let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command let runner = "payload=\"$1\"; shift; \"$@\" <\"$payload\" >/dev/null 2>&1 & child=\"$!\"; ( sleep 30; kill \"$child\" 2>/dev/null || true ) & watchdog=\"$!\"; wait \"$child\" 2>/dev/null || true; kill \"$watchdog\" 2>/dev/null || true; rm -f \"$payload\"" + let targetSetup: String + let socketSetup: String + switch target { + case .wrapperEnvironment: + // Every wrapper exports its exact bundled CLI and socket before + // starting Codex. Keeping those values in the native process + // environment makes the persistent hook command identical across + // concurrent cmux instances, so shared hooks.json can never route + // one Codex launch through another instance's pinned socket. + // Fail closed if Codex strips the environment instead of selecting + // an arbitrary cmux from PATH. + targetSetup = "cmux_cli=\"${CMUX_CODEX_HOOK_CMUX_BIN:-${CMUX_BUNDLED_CLI_PATH:-}}\"" + socketSetup = "cmux_socket=\"${CMUX_SOCKET_PATH:-}\"" + case .unavailable: + // State-mutating persistent hooks must not fall back to an arbitrary + // PATH cmux. An older CLI can decode the shared store and erase fields + // introduced by a newer schema. + targetSetup = "cmux_cli=\"\"" + socketSetup = "cmux_socket=\"\"" + } + let ownershipGate: String + let agentPIDSetup: String + switch ownership { + case .persistent: + ownershipGate = "[ \"$\(def.disableEnvVar)\" != \"1\" ]" + // The wrapper exports the native Codex PID. Prefer it because some + // runtimes insert a short-lived hook relay as this shell's PPID. + agentPIDSetup = "agent_pid=\"${CMUX_CODEX_PID:-${PPID:-}}\"" + case .wrapperInjected: + ownershipGate = "[ \"${\(Self.codexWrapperHookOwnerEnvironmentKey):-}\" = \"1\" ]" + agentPIDSetup = "agent_pid=\"${CMUX_CODEX_PID:-${PPID:-}}\"" + } return [ - "cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"", - "if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi", - "agent_pid=\"${CMUX_CODEX_PID:-${PPID:-}}\"", - "if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then payload=\"$(mktemp \"${TMPDIR:-/tmp}/cmux-codex-hook.XXXXXX\" 2>/dev/null || mktemp -t cmux-codex-hook 2>/dev/null)\" || { echo '{}'; exit 0; }; cat >\"$payload\" || true; if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then CMUX_CODEX_PID=\"$agent_pid\" nohup sh -c '\(runner)' cmux-codex-hook \"$payload\" \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments) >/dev/null 2>&1 & else CMUX_CODEX_PID=\"$agent_pid\" nohup sh -c '\(runner)' cmux-codex-hook \"$payload\" \"$cmux_cli\" \(routedArguments) >/dev/null 2>&1 & fi; echo '{}'; else echo '{}'; fi", + targetSetup, + agentPIDSetup, + socketSetup, + "if [ -n \"$CMUX_SURFACE_ID\" ] && \(ownershipGate) && [ -n \"$cmux_cli\" ] && [ -x \"$cmux_cli\" ]; then payload=\"$(mktemp \"${TMPDIR:-/tmp}/cmux-codex-hook.XXXXXX\" 2>/dev/null || mktemp -t cmux-codex-hook 2>/dev/null)\" || { echo '{}'; exit 0; }; cat >\"$payload\" || true; if [ -n \"$cmux_socket\" ]; then CMUX_CODEX_PID=\"$agent_pid\" nohup sh -c '\(runner)' cmux-codex-hook \"$payload\" \"$cmux_cli\" --socket \"$cmux_socket\" \(routedArguments) >/dev/null 2>&1 & else CMUX_CODEX_PID=\"$agent_pid\" nohup sh -c '\(runner)' cmux-codex-hook \"$payload\" \"$cmux_cli\" \(routedArguments) >/dev/null 2>&1 & fi; echo '{}'; else echo '{}'; fi", ].joined(separator: "; ") } + + /// Content-addressed hook filenames change when the dispatcher generation + /// changes. Reinstall must replace older cmux generations while preserving + /// user hooks outside cmux's private hook directory. + static func isCmuxManagedCodexHookScript(_ command: String) -> Bool { + var path = command.trimmingCharacters(in: .whitespacesAndNewlines) + if path.count >= 2, + let first = path.first, + let last = path.last, + first == last, + (first == "\"" || first == "'") { + path.removeFirst() + path.removeLast() + } + guard path.hasPrefix("/") else { return false } + let url = URL(fileURLWithPath: path).standardizedFileURL + let parent = url.deletingLastPathComponent().path + .replacingOccurrences(of: "\\", with: "/") + .lowercased() + let filename = url.lastPathComponent.lowercased() + return parent.hasSuffix("/.cmux/hooks") + && filename.hasPrefix("cmux-codex-hook-") + && filename.hasSuffix(".sh") + } + + static func isLegacyCodexBundledDispatcher(_ command: String) -> Bool { + guard command.contains("CMUX_BUNDLED_CLI_PATH"), + command.contains("cmux_cli=") else { + return false + } + return command.contains("hooks codex session-start") + || command.contains("hooks codex prompt-submit") + || command.contains("hooks codex stop") + || command.contains("hooks feed --source codex") + } } diff --git a/CLI/CMUXCLI+HooksUsage.swift b/CLI/CMUXCLI+HooksUsage.swift new file mode 100644 index 000000000000..d2c6aa21c385 --- /dev/null +++ b/CLI/CMUXCLI+HooksUsage.swift @@ -0,0 +1,49 @@ +import Foundation + +extension CMUXCLI { + func hooksUsage() -> String { + String(localized: "cli.hooks.usage", defaultValue: """ + Usage: cmux hooks setup [agent] [--agent ] [--yes|-y] + cmux hooks uninstall [agent] [--agent ] [--yes|-y] + cmux hooks install [--yes|-y] (opencode supports --project) + cmux hooks uninstall [--yes|-y] (opencode supports --project) + cmux hooks [flags] + cmux hooks feed --source [--event ] + + Manage and run cmux agent hooks without adding one top-level command per + agent. Claude Code hooks are injected automatically by the cmux Claude wrapper. + + Agents: + codex, grok, opencode, pi, omp, campfire, amp, cursor, gemini, kiro, antigravity (alias: agy), rovodev (alias: rovo), hermes-agent, copilot, codebuddy, factory, qoder, kimi + + Hook targets: + setup Install hooks for all supported agents on PATH + uninstall Remove hooks for all supported agents + install Install one agent integration + uninstall Remove one agent integration + Internal hook entrypoint used by generated configs + feed Internal Feed decision bridge + + Generated files: + ~/.config/opencode/plugins/cmux-session.js + ~/.config/opencode/plugins/cmux-feed.js + ~/.pi/agent/extensions/cmux-session.ts + ~/.omp/agent/extensions/cmux-omp-session.ts + ~/.campfire/agent/extensions/cmux-campfire-session.ts + ~/.config/amp/plugins/cmux-session.ts + ~/.kiro/agents/cmux.json + ~/.kimi/config.toml + See docs/agent-hooks.md for the full integration matrix. + + Examples: + cmux hooks setup + cmux hooks setup --agent codex + cmux hooks setup rovo + cmux hooks setup omp + cmux hooks uninstall rovo + cmux hooks codex install + cmux hooks opencode install --project + cmux hooks uninstall + """) + } +} diff --git a/CLI/CMUXCLI+OmpExtension.swift b/CLI/CMUXCLI+OmpExtension.swift index e01583b747de..20891fc3dc83 100644 --- a/CLI/CMUXCLI+OmpExtension.swift +++ b/CLI/CMUXCLI+OmpExtension.swift @@ -143,7 +143,7 @@ function hookInvocation(subcommand: string, ctx: ExtensionContext, extra: Record event: eventName(subcommand), ...extra, }; - const cmux = process.env.CMUX_OMP_CMUX_BIN || "cmux"; + const cmux = process.env.CMUX_OMP_CMUX_BIN || process.env.CMUX_BUNDLED_CLI_PATH || "cmux"; return { cmux, cwd, diff --git a/CLI/CMUXCLI+PiExtensionSourcePart1.swift b/CLI/CMUXCLI+PiExtensionSourcePart1.swift index b169e3d8a468..eb16011f956b 100644 --- a/CLI/CMUXCLI+PiExtensionSourcePart1.swift +++ b/CLI/CMUXCLI+PiExtensionSourcePart1.swift @@ -266,7 +266,7 @@ function warn(ctx: ExtensionContext | null, message: string, details: Record, transcriptPathBySessionId: [String: String]) func runSessionsCommand( commandArgs rawArgs: [String], jsonOutput: Bool, processEnv: [String: String] = ProcessInfo.processInfo.environment, - fileManager: FileManager = .default + fileManager: FileManager = .default, + terminalObservations: [CmuxAgentTerminalObservation] = [], + invocation: AgentsCommandInvocation = .sessions ) throws { var args = rawArgs + let commandName = invocation.rawValue + let listContext: AgentsValueOptionContext = invocation == .agents + ? .agentsList + : .sessionsList let subcommand = args.first?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if subcommand == "debug" || subcommand == "list" { args.removeFirst() @@ -20,24 +25,29 @@ extension CMUXCLI { return } else if let subcommand, !subcommand.hasPrefix("-") { throw CLIError(message: String( - format: String(localized: "cli.sessions.error.unknownSubcommand", defaultValue: "Unknown sessions subcommand: %@. Usage: cmux sessions list [options]"), - subcommand + format: String(localized: "cli.sessions.error.unknownSubcommand", defaultValue: "Unknown %@ subcommand: %@. Usage: cmux %@ list [options]"), + commandName, + subcommand, + commandName )) } - let (agentRaw, rem0) = parseOption(args, name: "--agent") - let (sessionRaw, rem1) = parseOption(rem0, name: "--session") - let (workspaceRaw, rem2) = parseOption(rem1, name: "--workspace") - let (surfaceRaw, rem3) = parseOption(rem2, name: "--surface") - let (cwdRaw, rem4) = parseOption(rem3, name: "--cwd") - let (stateDirRaw, rem5) = parseOption(rem4, name: "--state-dir") - let (codexHomeRaw, rem6) = parseOption(rem5, name: "--codex-home") - let (limitRaw, rem7) = parseOption(rem6, name: "--limit") + let (agentRaw, rem0) = try parseAgentsValueOption(args, name: "--agent", context: listContext) + let (sessionRaw, rem1) = try parseAgentsValueOption(rem0, name: "--session", context: listContext) + let (workspaceRaw, rem2) = try parseAgentsValueOption(rem1, name: "--workspace", context: listContext) + let (surfaceRaw, rem3) = try parseAgentsValueOption(rem2, name: "--surface", context: listContext) + let (cwdRaw, rem4) = try parseAgentsValueOption(rem3, name: "--cwd", context: listContext) + let (stateDirRaw, rem5) = try parseAgentsValueOption(rem4, name: "--state-dir", context: listContext) + let (codexHomeRaw, rem6) = try parseAgentsValueOption(rem5, name: "--codex-home", context: listContext) + let (limitRaw, rem7) = try parseAgentsValueOption(rem6, name: "--limit", context: listContext) + let (stateRaw, rem8) = try parseAgentsValueOption(rem7, name: "--state", context: listContext) + let (activityRaw, rem9) = try parseAgentsValueOption(rem8, name: "--activity", context: listContext) + let (workKindRaw, rem10) = try parseAgentsValueOption(rem9, name: "--work-kind", context: listContext) var includeAll = false var localJSONOutput = jsonOutput var remaining: [String] = [] - for arg in rem7 { + for arg in rem10 { switch arg { case "--all": includeAll = true @@ -49,25 +59,30 @@ extension CMUXCLI { } if let unknown = remaining.first(where: { $0.hasPrefix("-") }) { throw CLIError(message: String( - format: String(localized: "cli.sessions.error.unknownFlag", defaultValue: "sessions list: unknown flag '%@'"), + format: String(localized: "cli.sessions.error.unknownFlag", defaultValue: "%@ list: unknown flag '%@'"), + commandName, unknown )) } if let extra = remaining.first { throw CLIError(message: String( - format: String(localized: "cli.sessions.error.unexpectedArgument", defaultValue: "sessions list: unexpected argument '%@'"), + format: String(localized: "cli.sessions.error.unexpectedArgument", defaultValue: "%@ list: unexpected argument '%@'"), + commandName, extra )) } let limit: Int - if includeAll { - limit = Int.max - } else if let limitRaw { + if let limitRaw { guard let parsed = Int(limitRaw), parsed > 0 else { - throw CLIError(message: String(localized: "cli.sessions.error.invalidLimit", defaultValue: "sessions list: --limit must be a positive integer")) + throw CLIError(message: String( + format: String(localized: "cli.sessions.error.invalidLimit", defaultValue: "%@ list: --limit must be a positive integer"), + commandName + )) } limit = parsed + } else if includeAll { + limit = Int.max } else { limit = 100 } @@ -87,24 +102,59 @@ extension CMUXCLI { .path ) let homeDirectory = sessionsListExpandedPath(processEnv["HOME"] ?? NSHomeDirectory()) + let canonicalTerminalObservations = AgentTerminalObservationJoiner.canonicalObservations( + terminalObservations + ) - let agentSpecs = sessionsListAgentSpecs() - let selectedSpecs: [SessionListAgentSpec] - if let agentRaw { - let normalized = agentRaw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let agentSpecs: [AgentSessionProviderSpecification] + do { + agentSpecs = try agentSessionProviderSpecifications( + stateDirectory: stateDir, + homeDirectory: homeDirectory, + requestedAgent: agentRaw, + processEnv: processEnv, + fileManager: fileManager + ) + } catch { + throw agentsProviderCatalogCLIError( + error, + stateDirectory: stateDir, + context: listContext + ) + } + let requestedAgent = agentRaw.map(agentsNormalizedAgentID) + var providerSelection: AgentSessionProviderSelection? + let selectedSpecs: [AgentSessionProviderSpecification] + if let agentRaw, let normalized = requestedAgent { guard !normalized.isEmpty else { - throw CLIError(message: String(localized: "cli.sessions.error.agentRequiresValue", defaultValue: "sessions list: --agent requires a value")) + throw CLIError(message: String( + format: String(localized: "cli.sessions.error.agentRequiresValue", defaultValue: "%@ list: --agent requires a value"), + commandName + )) } - if normalized == "claude" || normalized == "claude-code" || normalized == "claude_code" { - selectedSpecs = agentSpecs.filter { $0.name == "claude" } - } else if let def = Self.agentDef(named: normalized) { - selectedSpecs = agentSpecs.filter { $0.name == def.name } - } else { + let selection = agentSessionProviderSelection( + for: agentRaw, + availableProviderIDs: agentSpecs.map(\.name), + terminalObservations: canonicalTerminalObservations + ) + let hasMatchingObservation = canonicalTerminalObservations.contains { + agentTerminalObservation( + $0, + matches: selection, + requestedNormalizedID: normalized + ) + } + guard selection.providerID != nil || hasMatchingObservation else { throw CLIError(message: String( - format: String(localized: "cli.sessions.error.unknownAgent", defaultValue: "sessions list: unknown agent '%@'"), + format: String(localized: "cli.sessions.error.unknownAgent", defaultValue: "%@ list: unknown agent '%@'"), + commandName, agentRaw )) } + selectedSpecs = selection.providerID.map { providerID in + agentSpecs.filter { $0.name == providerID } + } ?? [] + providerSelection = selection } else { selectedSpecs = agentSpecs } @@ -113,245 +163,972 @@ extension CMUXCLI { let workspaceFilter = sessionsListNormalizedIDRef(workspaceRaw)?.lowercased() let surfaceFilter = sessionsListNormalizedIDRef(surfaceRaw)?.lowercased() let cwdFilter = sessionsListNormalized(cwdRaw)?.lowercased() - let hasRecordFilter = sessionFilter != nil || workspaceFilter != nil || surfaceFilter != nil || cwdFilter != nil + let stateFilter = sessionsListNormalized(stateRaw)?.lowercased() + let activityFilter = sessionsListNormalized(activityRaw)?.lowercased() + let workKindFilter = sessionsListNormalized(workKindRaw)?.lowercased() + if let stateFilter, AgentEffectiveState(rawValue: stateFilter) == nil { + throw CLIError(message: String( + format: String(localized: "cli.agents.list.error.unknownState", defaultValue: "agents list: unknown state '%@'"), + stateFilter + )) + } + if let activityFilter, AgentActivityState(rawValue: activityFilter) == nil { + throw CLIError(message: String( + format: String(localized: "cli.agents.list.error.unknownActivity", defaultValue: "agents list: unknown activity '%@'"), + activityFilter + )) + } + if let workKindFilter, AgentWorkloadKind(rawValue: workKindFilter) == nil { + throw CLIError(message: String( + format: String(localized: "cli.agents.list.error.unknownWorkKind", defaultValue: "agents list: unknown workload kind '%@'"), + workKindFilter + )) + } + let hasIdentityFilter = sessionFilter != nil || workspaceFilter != nil + || surfaceFilter != nil || cwdFilter != nil + let includesEndedRecords = includeAll || hasIdentityFilter || stateFilter == AgentEffectiveState.ended.rawValue + // History sorting and non-state filters do not depend on a live PID. + // Defer sysctl work until after top-K selection in that common path. + let defersProcessStateProbe = includeAll && stateFilter == nil + let queryScope = AgentSessionQueryScope(includeHistory: includeAll, environment: processEnv) + let matchingObservations: [CmuxAgentTerminalObservation] = canonicalTerminalObservations + .compactMap { observation -> CmuxAgentTerminalObservation? in + if let providerSelection, + let requestedAgent, + !agentTerminalObservation( + observation, + matches: providerSelection, + requestedNormalizedID: requestedAgent + ) { + return nil + } + if let surfaceFilter, + observation.surfaceID.uuidString.lowercased() != surfaceFilter { return nil } + switch queryScope { + case .history, .legacyUnscoped: + break + case let .currentRuntime(runtimeID): + guard observation.runtimeID == runtimeID else { return nil } + } + if let providerSelection { + return agentTerminalObservation( + observation, + canonicalizedFor: providerSelection + ) + } + return observation + } + // The history-only, unfiltered top-K query has no predicate that needs a + // decoded record. Read K candidates per provider and merge them globally; + // any provider row ranked below its own K cannot enter the global K. + let usesBoundedHistoryFastPath = includeAll + && limit != Int.max + && sessionFilter == nil + && workspaceFilter == nil + && surfaceFilter == nil + && cwdFilter == nil + && stateFilter == nil + && activityFilter == nil + && workKindFilter == nil + && matchingObservations.isEmpty + let observationJoiner = AgentTerminalObservationJoiner() + let observationsByProcessKey = Dictionary( + grouping: matchingObservations, + by: { AgentTerminalObservationJoiner.processKey(observation: $0) } + ) + let observationsByProvider = Dictionary( + grouping: matchingObservations, + by: \.sessionProviderID + ) var codexIndexes: [String: CodexSessionListIndex] = [:] let claudeTranscriptLookup = SessionsListClaudeTranscriptLookupCache(homeDirectory: homeDirectory) - var entries: [SessionListEntry] = [] + var processStartTimeByPID: [Int: TimeInterval] = [:] + var missingProcessStartTimePIDs: Set = [] + let processStartTimeLookup: (Int) -> TimeInterval? = { pid in + if let cached = processStartTimeByPID[pid] { return cached } + if missingProcessStartTimePIDs.contains(pid) { return nil } + if let startTime = sessionsListProcessStartTime(for: pid) { + processStartTimeByPID[pid] = startTime + return startTime + } + missingProcessStartTimePIDs.insert(pid) + return nil + } + var processIdentityByPID: [Int: SessionsListProcessIdentity] = [:] + var missingProcessIdentityPIDs: Set = [] + let processIdentityLookup: (Int) -> SessionsListProcessIdentity? = { pid in + if let cached = processIdentityByPID[pid] { return cached } + if missingProcessIdentityPIDs.contains(pid) { return nil } + guard let startTime = processStartTimeLookup(pid), + let identity = sessionsListProcessIdentity( + for: pid, + probedKernelStartTime: startTime + ) else { + missingProcessIdentityPIDs.insert(pid) + return nil + } + processIdentityByPID[pid] = identity + return identity + } + var processExistenceByPID: [Int: Bool] = [:] + var probedProcessExistencePIDs: Set = [] + let processExistenceLookup: (Int?) -> Bool? = { pid in + guard let pid, pid > 0 else { return nil } + if probedProcessExistencePIDs.contains(pid) { return processExistenceByPID[pid] } + probedProcessExistencePIDs.insert(pid) + let exists = sessionsListStoredPIDExists(pid) + if let exists { processExistenceByPID[pid] = exists } + return exists + } + var entries = SessionListEntryAccumulator(limit: limit) + var activeSessionBySurface: [String: String] = [:] + var processedObservationProviders: Set = [] var stores: [[String: Any]] = [] + var storeWarnings: [AgentHookSessionStoreLoadWarning] = [] - let decoder = JSONDecoder() + let timestampFormatter = ISO8601DateFormatter() + timestampFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let snapshotLoad: AgentHookSessionRegistrySnapshots + do { + if usesBoundedHistoryFastPath { + snapshotLoad = try AgentHookSessionRegistryBridge.boundedRecentSnapshotsForList( + specifications: selectedSpecs.map { + (provider: $0.name, suffix: $0.sessionStoreSuffix) + }, + stateDirectory: stateDir, + environment: processEnv, + fileManager: fileManager, + maximumRecordsPerProvider: limit + ) + } else { + snapshotLoad = try AgentHookSessionRegistryBridge.snapshots( + specifications: selectedSpecs.map { + (provider: $0.name, suffix: $0.sessionStoreSuffix) + }, + stateDirectory: stateDir, + environment: processEnv, + fileManager: fileManager + ) + } + } catch let failure as AgentHookSessionStoreLoadFailure { + throw agentsStoreLoadCLIError(failure, context: listContext) + } catch { + throw agentsStateUnavailableCLIError( + stateDirectory: stateDir, + context: listContext + ) + } + storeWarnings.append(contentsOf: snapshotLoad.warnings) for spec in selectedSpecs { let storePath = URL(fileURLWithPath: stateDir, isDirectory: true) .appendingPathComponent("\(spec.sessionStoreSuffix)-hook-sessions.json", isDirectory: false) .path + var storeEnvironment = processEnv + storeEnvironment["CMUX_AGENT_HOOK_STATE_DIR"] = stateDir + storeEnvironment["CMUX_CLAUDE_HOOK_STATE_PATH"] = storePath + let bridge = AgentHookSessionRegistryBridge( + provider: spec.name, + statePath: storePath, + environment: storeEnvironment, + fileManager: fileManager + ) + let store: ClaudeHookSessionStoreFile + var boundedLoadUsedLegacyFallback = false + if let snapshot = snapshotLoad.snapshots[spec.name] { + let load: AgentHookSessionStoreLoadResult + do { + if usesBoundedHistoryFastPath { + load = try bridge.loadBoundedForInspection( + snapshot: snapshot, + authoritativeValidationFailed: snapshotLoad + .boundedValidationFailures.contains(spec.name) + ) + } else { + load = try bridge.loadForInspection(snapshot: snapshot) + } + } catch let failure as AgentHookSessionStoreLoadFailure { + throw agentsStoreLoadCLIError(failure, context: listContext) + } catch { + throw agentsStateUnavailableCLIError( + stateDirectory: stateDir, + context: listContext + ) + } + store = load.store + boundedLoadUsedLegacyFallback = usesBoundedHistoryFastPath + && load.warning?.fallback == .legacy + if let warning = load.warning { storeWarnings.append(warning) } + } else { + store = ClaudeHookSessionStore( + processEnv: storeEnvironment, + fileManager: fileManager, + agentName: spec.name + ).snapshot() + } + let totalProviderSessionCount = boundedLoadUsedLegacyFallback + ? store.sessions.count + : snapshotLoad.totalRecordCounts[spec.name] ?? store.sessions.count var storePayload: [String: Any] = [ "agent": spec.name, "path": storePath, - "exists": fileManager.fileExists(atPath: storePath) + "exists": fileManager.fileExists(atPath: storePath) || totalProviderSessionCount > 0 ] - guard fileManager.fileExists(atPath: storePath) else { - storePayload["session_count"] = 0 - stores.append(storePayload) - continue - } - - let storeData = try Data(contentsOf: URL(fileURLWithPath: storePath)) - let store = try decoder.decode(ClaudeHookSessionStoreFile.self, from: storeData) - storePayload["session_count"] = store.sessions.count + storePayload["session_count"] = totalProviderSessionCount stores.append(storePayload) - for rawRecord in store.sessions.values { - let record = spec.name == "claude" - ? sessionsListResolvedClaudeWorkflowRecord(rawRecord, lookup: claudeTranscriptLookup) - : rawRecord - let rawSessionId = rawRecord.sessionId.lowercased() - let resolvedSessionId = record.sessionId.lowercased() - guard sessionFilter == nil || rawSessionId == sessionFilter || resolvedSessionId == sessionFilter else { - continue - } - guard workspaceFilter == nil || record.workspaceId.lowercased() == workspaceFilter else { continue } - guard surfaceFilter == nil || record.surfaceId.lowercased() == surfaceFilter else { continue } - if let cwdFilter { - let cwd = (record.cwd ?? "").lowercased() - let launchCwd = (record.launchCommand?.workingDirectory ?? "").lowercased() - guard cwd.contains(cwdFilter) || launchCwd.contains(cwdFilter) else { continue } + let sessionProcessCohort = sessionFilter.map { sessionFilter in + var matcher = AgentSessionProcessCohortMatcher() + for record in store.sessions.values + where record.sessionId.lowercased() == sessionFilter { + matcher.insert( + provider: spec.name, + record: record, + run: sessionsListProjectedRun(record: record, provider: spec.name) + ) } + return matcher + } - var payload: [String: Any] = [ - "agent": spec.name, - "agent_display_name": spec.displayName, - "session_id": record.sessionId, - "workspace_id": record.workspaceId, - "surface_id": record.surfaceId, - "store_path": storePath, - "started_at": sessionsListTimestamp(record.startedAt), - "updated_at": sessionsListTimestamp(record.updatedAt), - "updated_at_unix": record.updatedAt - ] - if rawRecord.sessionId != record.sessionId { - payload["hook_session_id"] = rawRecord.sessionId + for record in store.sessions.values { + let run = sessionsListProjectedRun(record: record, provider: spec.name) + guard store.activeSessionsBySurface[record.surfaceId]?.sessionId == record.sessionId, + let runtimeID = run.cmuxRuntime(fallingBackTo: record.cmuxRuntime)?.id else { + continue } - payload["cwd"] = record.cwd ?? NSNull() - payload["transcript_path"] = record.transcriptPath ?? NSNull() - payload["pid"] = record.pid ?? NSNull() - payload["runtime_status"] = record.runtimeStatus?.rawValue ?? NSNull() - payload["agent_lifecycle"] = record.agentLifecycle?.rawValue ?? NSNull() - payload["last_prompt_turn_id"] = record.lastPromptTurnId ?? NSNull() - payload["active_prompt_turn_id"] = record.activePromptTurnId ?? NSNull() - payload["launch_working_directory"] = record.launchCommand?.workingDirectory ?? NSNull() - payload["launch_arguments"] = record.launchCommand?.arguments ?? [] - payload.merge( - sessionsListForkDiagnostics( - agent: spec.name, + activeSessionBySurface[AgentTerminalObservationJoiner.surfaceKey( + provider: spec.name, + runtimeID: runtimeID, + surfaceID: record.surfaceId + )] = record.sessionId + } + + let providerObservations = observationsByProvider[spec.name] ?? [] + processedObservationProviders.insert(spec.name) + let observationProjection: ( + nodesByID: [String: AgentSessionGraphNode], + processNodes: [AgentSessionGraphNode] + ) = { + guard !providerObservations.isEmpty else { return ([:], []) } + var candidateAccumulator = AgentTerminalObservationCandidateAccumulator( + observations: providerObservations, + activeSessionBySurface: activeSessionBySurface + ) + for rawRecord in store.sessions.values { + let run = sessionsListProjectedRun(record: rawRecord, provider: spec.name) + guard queryScope.includes( + recordRuntime: run.identityConflict == true ? nil : rawRecord.cmuxRuntime, + runRuntime: run.cmuxRuntime, + legacyVisible: run.identityConflict != true + ) else { continue } + let record = rawRecord + if let sessionFilter, record.sessionId.lowercased() != sessionFilter { + guard sessionProcessCohort?.matches( + provider: spec.name, + record: record, + run: run + ) == true else { + continue + } + } + guard surfaceFilter == nil || record.surfaceId.lowercased() == surfaceFilter else { + continue + } + let runtime = run.cmuxRuntime(fallingBackTo: record.cmuxRuntime) + guard let runtimeID = runtime?.id, + let pid = run.pid, + let processStartedAt = run.processStartedAt else { + continue + } + let surfaceKey = AgentTerminalObservationJoiner.surfaceKey( + provider: spec.name, + runtimeID: runtimeID, + surfaceID: record.surfaceId + ) + let processKey = "\(surfaceKey)\u{1F}\(pid)" + let observations = observationsByProcessKey[processKey] ?? [] + guard observations.contains(where: { observation in + observation.sessionProviderID == spec.name + && observation.runtimeID == runtimeID + && observation.surfaceID.uuidString.lowercased() + == record.surfaceId.lowercased() + && Int(observation.pid) == pid + && abs( + TimeInterval(observation.processStartSeconds) + + TimeInterval(observation.processStartMicroseconds) / 1_000_000 + - processStartedAt + ) <= 0.001 + }) else { + continue + } + let probedProcessState: AgentProcessState? + if run.identityConflict == true { + probedProcessState = .unknown + } else if let pid = run.pid, let expectedStartedAt = run.processStartedAt { + probedProcessState = processStartTimeLookup(pid).map { + abs($0 - expectedStartedAt) <= 0.001 ? .alive : .exited + } ?? .exited + } else { + probedProcessState = nil + } + let projection = AgentSessionStateProjection( record: record, - claudeTranscriptLookup: claudeTranscriptLookup - ), - uniquingKeysWith: { _, new in new } + run: run, + probedProcessState: probedProcessState + ) + guard includesEndedRecords || queryScope.includes(projection: projection) else { + continue + } + let workspaceActive = store.activeSessionsByWorkspace[record.workspaceId] + let surfaceActive = store.activeSessionsBySurface[record.surfaceId] + let activeForWorkspace = workspaceActive?.sessionId == record.sessionId + let activeForSurface = surfaceActive?.sessionId == record.sessionId + let legacyRecordRestorable: Bool + if queryScope == .legacyUnscoped, + run.identityConflict != true, + !activeForWorkspace, + !activeForSurface { + legacyRecordRestorable = agentHookRunIsRestorable( + agent: spec.name, + record: record, + run: run, + claudeTranscriptLookup: claudeTranscriptLookup + ) + } else { + legacyRecordRestorable = false + } + let defaultVisible = queryScope.includes( + recordRuntime: run.identityConflict == true ? nil : record.cmuxRuntime, + runRuntime: run.cmuxRuntime, + legacyVisible: run.identityConflict != true + && (activeForWorkspace || activeForSurface || legacyRecordRestorable) + ) + guard includeAll || hasIdentityFilter + || stateFilter == AgentEffectiveState.ended.rawValue + || defaultVisible else { + continue + } + let node = AgentSessionGraphNode( + provider: spec.name, + sessionId: record.sessionId, + runId: run.runId, + pid: run.pid, + processStartedAt: run.processStartedAt, + cmuxRuntime: runtime, + workspaceId: record.workspaceId, + surfaceId: record.surfaceId, + cwd: record.cwd, + processState: projection.process, + sessionState: projection.session, + foregroundState: projection.foreground, + attentionState: projection.attention, + activity: projection.activity, + effectiveState: projection.effective, + workloads: projection.workloads.map(AgentWorkloadSnapshot.init), + restoreAuthority: run.restoreAuthority, + startedAt: run.startedAt, + updatedAt: run.updatedAt, + endedAt: run.endedAt + ) + candidateAccumulator.insert(node) + } + var candidates = candidateAccumulator.retainedCandidates + observationJoiner.merge( + nodes: &candidates, + observations: providerObservations, + activeSessionBySurface: activeSessionBySurface ) + var nodesByID: [String: AgentSessionGraphNode] = [:] + var processNodes: [AgentSessionGraphNode] = [] + nodesByID.reserveCapacity(providerObservations.count) + processNodes.reserveCapacity(providerObservations.count) + for node in candidates { + if node.identitySource == "terminal_process" { + processNodes.append(node) + } else if node.terminalObservation != nil { + nodesByID[node.nodeId] = node + } + } + return (nodesByID, processNodes) + }() + for rawRecord in store.sessions.values { + let projectedRun = sessionsListProjectedRun(record: rawRecord, provider: spec.name) + guard queryScope.includes( + recordRuntime: projectedRun.identityConflict == true ? nil : rawRecord.cmuxRuntime, + runRuntime: projectedRun.cmuxRuntime, + legacyVisible: projectedRun.identityConflict != true + ) else { continue } + let record = rawRecord + if let sessionFilter, record.sessionId.lowercased() != sessionFilter { + guard sessionProcessCohort?.matches( + provider: spec.name, + record: record, + run: projectedRun + ) == true else { + continue + } + } + guard surfaceFilter == nil || record.surfaceId.lowercased() == surfaceFilter else { continue } + let probedProcessState: AgentProcessState? + if projectedRun.identityConflict == true || defersProcessStateProbe { + // Supplying `.unknown` prevents the projection from probing + // through its compatibility fallback. + probedProcessState = .unknown + } else if let pid = projectedRun.pid, + let expectedStartedAt = projectedRun.processStartedAt { + probedProcessState = processStartTimeLookup(pid).map { + abs($0 - expectedStartedAt) <= 0.001 ? .alive : .exited + } ?? .exited + } else { + probedProcessState = nil + } + let projection = AgentSessionStateProjection( + record: record, + run: projectedRun, + probedProcessState: probedProcessState + ) + guard includesEndedRecords || queryScope.includes(projection: projection) else { continue } let workspaceActive = store.activeSessionsByWorkspace[record.workspaceId] let surfaceActive = store.activeSessionsBySurface[record.surfaceId] let activeForWorkspace = workspaceActive?.sessionId == record.sessionId || workspaceActive?.sessionId == rawRecord.sessionId let activeForSurface = surfaceActive?.sessionId == record.sessionId || surfaceActive?.sessionId == rawRecord.sessionId - payload["active_for_workspace"] = activeForWorkspace - payload["active_for_surface"] = activeForSurface - payload["active_workspace_session_id"] = workspaceActive?.sessionId ?? NSNull() - payload["active_surface_session_id"] = surfaceActive?.sessionId ?? NSNull() - payload["is_restorable"] = record.isRestorable ?? NSNull() - var transcriptBacked = false + let runtime = projectedRun.cmuxRuntime(fallingBackTo: record.cmuxRuntime) + if activeForSurface, let runtimeID = runtime?.id { + activeSessionBySurface[AgentTerminalObservationJoiner.surfaceKey( + provider: spec.name, + runtimeID: runtimeID, + surfaceID: record.surfaceId + )] = record.sessionId + } - if spec.name == "codex" { - let codexHome = sessionsListExpandedPath( - sessionsListNormalized(record.launchCommand?.environment?["CODEX_HOME"]) ?? defaultCodexHome - ) - let index = try codexIndexes[codexHome] ?? buildCodexDebugIndex( - codexHome: codexHome, - fileManager: fileManager + let legacyRecordRestorable: Bool + if queryScope == .legacyUnscoped, + projectedRun.identityConflict != true, + !activeForWorkspace, + !activeForSurface { + legacyRecordRestorable = agentHookRunIsRestorable( + agent: spec.name, + record: record, + run: projectedRun, + claudeTranscriptLookup: claudeTranscriptLookup ) - codexIndexes[codexHome] = index - let transcriptPath = index.transcriptPathBySessionId[record.sessionId] - let savedTranscriptPath = sessionsListNormalized(record.transcriptPath) - let expandedSavedTranscriptPath = savedTranscriptPath.map { sessionsListExpandedPath($0) } - payload["session_home"] = codexHome - payload["session_dir"] = URL(fileURLWithPath: codexHome, isDirectory: true) - .appendingPathComponent("sessions", isDirectory: true) - .path - payload["codex_indexed"] = index.indexedSessionIds.contains(record.sessionId) - payload["codex_transcript_found"] = transcriptPath != nil || expandedSavedTranscriptPath.map { fileManager.fileExists(atPath: $0) } == true - payload["codex_transcript_path"] = transcriptPath ?? expandedSavedTranscriptPath ?? NSNull() - transcriptBacked = payload["codex_transcript_found"] as? Bool == true - } else if let envKey = spec.configDirEnvOverride, - let value = sessionsListNormalized(record.launchCommand?.environment?[envKey]) { - payload["session_home"] = sessionsListExpandedPath(value) - payload["session_dir"] = sessionsListExpandedPath(value) - if let transcriptPath = sessionsListNormalized(record.transcriptPath) { - transcriptBacked = fileManager.fileExists(atPath: sessionsListExpandedPath(transcriptPath)) - } } else { - payload["session_home"] = NSNull() - payload["session_dir"] = NSNull() - if let transcriptPath = sessionsListNormalized(record.transcriptPath) { - transcriptBacked = fileManager.fileExists(atPath: sessionsListExpandedPath(transcriptPath)) - } + legacyRecordRestorable = false } - payload["transcript_backed"] = transcriptBacked - let launchBacked = record.launchCommand != nil && agentHookSessionHasDurableResumeEvidence( - kind: spec.name, - launchCommand: record.launchCommand - ) - payload["launch_backed"] = launchBacked - - let defaultVisible = activeForWorkspace + let legacyDefaultVisible = activeForWorkspace || activeForSurface - || record.isRestorable == true - || launchBacked - || transcriptBacked - payload["default_visible"] = defaultVisible - guard includeAll || hasRecordFilter || defaultVisible else { + || legacyRecordRestorable + let defaultVisible = queryScope.includes( + recordRuntime: projectedRun.identityConflict == true ? nil : record.cmuxRuntime, + runRuntime: projectedRun.cmuxRuntime, + legacyVisible: projectedRun.identityConflict != true && legacyDefaultVisible + ) + guard includeAll || hasIdentityFilter + || stateFilter == AgentEffectiveState.ended.rawValue + || defaultVisible else { continue } - entries.append((updatedAt: record.updatedAt, payload: payload)) + let enrichment: SessionListEntryAccumulator.Enrichment = { [self] payload in + if defersProcessStateProbe, + projectedRun.identityConflict != true, + payload["state_source"] as? String != "terminal" { + let retainedProcessState: AgentProcessState? + if let pid = projectedRun.pid, + let expectedStartedAt = projectedRun.processStartedAt { + retainedProcessState = processStartTimeLookup(pid).map { + abs($0 - expectedStartedAt) <= 0.001 ? .alive : .exited + } ?? .exited + } else { + retainedProcessState = nil + } + let retainedProjection = AgentSessionStateProjection( + record: record, + run: projectedRun, + probedProcessState: retainedProcessState + ) + self.sessionsListApply(projection: retainedProjection, to: &payload) + } + payload.merge( + sessionsListForkDiagnostics( + agent: spec.name, + record: record, + projectedRunRestoreAuthority: projectedRun.restoreAuthority, + claudeTranscriptLookup: claudeTranscriptLookup, + processIdentityLookup: processIdentityLookup, + processExistenceLookup: processExistenceLookup + ), + uniquingKeysWith: { _, new in new } + ) + + var transcriptBacked = false + if spec.name == "codex" { + let codexHome = sessionsListExpandedPath( + sessionsListNormalized(record.launchCommand?.environment?["CODEX_HOME"]) + ?? defaultCodexHome + ) + let index = codexIndexes[codexHome] ?? buildCodexDebugIndex( + codexHome: codexHome, + fileManager: fileManager + ) + codexIndexes[codexHome] = index + let transcriptPath = index.transcriptPathBySessionId[record.sessionId] + let savedTranscriptPath = sessionsListNormalized(record.transcriptPath) + let expandedSavedTranscriptPath = savedTranscriptPath.map { + self.sessionsListExpandedPath($0) + } + payload["session_home"] = codexHome + payload["session_dir"] = URL(fileURLWithPath: codexHome, isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + .path + payload["codex_indexed"] = index.indexedSessionIds.contains(record.sessionId) + payload["codex_transcript_found"] = transcriptPath != nil + || expandedSavedTranscriptPath.map { + fileManager.fileExists(atPath: $0) + } == true + payload["codex_transcript_path"] = transcriptPath + ?? expandedSavedTranscriptPath + ?? NSNull() + transcriptBacked = payload["codex_transcript_found"] as? Bool == true + } else if spec.name == "claude" { + if let envKey = spec.configDirEnvOverride, + let value = sessionsListNormalized(record.launchCommand?.environment?[envKey]) { + payload["session_home"] = sessionsListExpandedPath(value) + payload["session_dir"] = sessionsListExpandedPath(value) + } else { + payload["session_home"] = NSNull() + payload["session_dir"] = NSNull() + } + transcriptBacked = sessionsListClaudeHasExactTranscript( + record: record, + lookup: claudeTranscriptLookup + ) + } else if let envKey = spec.configDirEnvOverride, + let value = sessionsListNormalized(record.launchCommand?.environment?[envKey]) { + payload["session_home"] = sessionsListExpandedPath(value) + payload["session_dir"] = sessionsListExpandedPath(value) + if let transcriptPath = sessionsListNormalized(record.transcriptPath) { + transcriptBacked = fileManager.fileExists( + atPath: sessionsListExpandedPath(transcriptPath) + ) + } + } else { + payload["session_home"] = NSNull() + payload["session_dir"] = NSNull() + if let transcriptPath = sessionsListNormalized(record.transcriptPath) { + transcriptBacked = fileManager.fileExists( + atPath: sessionsListExpandedPath(transcriptPath) + ) + } + } + payload["transcript_backed"] = transcriptBacked + payload["launch_backed"] = record.launchCommand != nil + && agentHookSessionHasDurableResumeEvidence( + kind: spec.name, + launchCommand: record.launchCommand, + transcriptPath: record.transcriptPath + ) + } + + let node = AgentSessionGraphNode( + provider: spec.name, + sessionId: record.sessionId, + runId: projectedRun.runId, + pid: projectedRun.pid, + processStartedAt: projectedRun.processStartedAt, + cmuxRuntime: runtime, + workspaceId: record.workspaceId, + surfaceId: record.surfaceId, + cwd: record.cwd, + processState: projection.process, + sessionState: projection.session, + foregroundState: projection.foreground, + attentionState: projection.attention, + activity: projection.activity, + effectiveState: projection.effective, + workloads: projection.workloads.map(AgentWorkloadSnapshot.init), + restoreAuthority: projectedRun.restoreAuthority, + startedAt: projectedRun.startedAt, + updatedAt: projectedRun.updatedAt, + endedAt: projectedRun.endedAt + ) + let projectedNode = observationProjection.nodesByID[node.nodeId] ?? node + guard sessionsListNodeMatchesFilters( + node: projectedNode, + launchWorkingDirectory: record.launchCommand?.workingDirectory, + sessionFilter: sessionFilter, + workspaceFilter: workspaceFilter, + cwdFilter: cwdFilter, + stateFilter: stateFilter, + activityFilter: activityFilter, + workKindFilter: workKindFilter + ) else { continue } + let activeWorkspaceSessionID = workspaceActive?.sessionId + let activeSurfaceSessionID = surfaceActive?.sessionId + entries.insert( + updatedAt: node.updatedAt, + sortValues: sessionsListSortValues( + node: projectedNode, + sessionID: record.sessionId, + agent: spec.name, + surfaceID: record.surfaceId + ), + payloadFactory: { [self] in + var payload: [String: Any] = [ + "agent": spec.name, + "agent_display_name": spec.displayName, + "session_id": record.sessionId, + "workspace_id": record.workspaceId, + "surface_id": record.surfaceId, + "store_path": storePath, + "started_at": sessionsListTimestamp( + record.startedAt, + formatter: timestampFormatter + ), + "updated_at": sessionsListTimestamp( + record.updatedAt, + formatter: timestampFormatter + ), + "updated_at_unix": record.updatedAt, + ] + payload["cwd"] = record.cwd ?? NSNull() + payload["transcript_path"] = record.transcriptPath ?? NSNull() + payload["pid"] = record.pid ?? NSNull() + payload["runtime_status"] = record.runtimeStatus?.rawValue ?? NSNull() + payload["agent_lifecycle"] = record.agentLifecycle?.rawValue ?? NSNull() + payload["process_state"] = projection.process.rawValue + payload["session_state"] = projection.session.rawValue + payload["foreground_state"] = projection.foreground.rawValue + payload["attention_state"] = projection.attention.rawValue + payload["effective_state"] = projection.effective.rawValue + payload["activity"] = sessionsListEncodableJSONObject(projection.activity) + payload["workloads"] = sessionsListEncodableJSONObject( + projection.workloads.map(AgentWorkloadSnapshot.init) + ) + payload["restore_authority"] = projectedRun.restoreAuthority + payload["cmux_runtime"] = runtime.map { + sessionsListEncodableJSONObject($0) + } ?? NSNull() + payload["last_prompt_turn_id"] = record.lastPromptTurnId ?? NSNull() + payload["active_prompt_turn_id"] = record.activePromptTurnId ?? NSNull() + payload["launch_working_directory"] = record.launchCommand?.workingDirectory + ?? NSNull() + payload["launch_arguments"] = record.launchCommand?.arguments ?? [] + payload["active_for_workspace"] = activeForWorkspace + payload["active_for_surface"] = activeForSurface + payload["active_workspace_session_id"] = activeWorkspaceSessionID ?? NSNull() + payload["active_surface_session_id"] = activeSurfaceSessionID ?? NSNull() + payload["is_restorable"] = record.isRestorable ?? NSNull() + payload["default_visible"] = defaultVisible + sessionsListApply(node: projectedNode, to: &payload) + enrichment(&payload) + return payload + } + ) + } + for node in observationProjection.processNodes { + guard sessionsListNodeMatchesFilters( + node: node, + launchWorkingDirectory: nil, + sessionFilter: sessionFilter, + workspaceFilter: workspaceFilter, + cwdFilter: cwdFilter, + stateFilter: stateFilter, + activityFilter: activityFilter, + workKindFilter: workKindFilter + ) else { continue } + entries.insert( + updatedAt: node.updatedAt, + sortValues: sessionsListSortValues( + node: node, + sessionID: nil, + agent: node.provider, + surfaceID: node.surfaceId + ), + payloadFactory: { [self] in + sessionsListProcessPayload( + node: node, + displayName: spec.displayName, + timestampFormatter: timestampFormatter + ) + } + ) + } + if usesBoundedHistoryFastPath { + entries.addUnmaterializedMatches( + max(0, totalProviderSessionCount - store.sessions.count) + ) } } - - let sortedEntries = entries.sorted { - if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt } - let lhs = ($0.payload["session_id"] as? String) ?? "" - let rhs = ($1.payload["session_id"] as? String) ?? "" - return lhs < rhs + let displayNameByProvider = Dictionary( + selectedSpecs.map { ($0.name, $0.displayName) }, + uniquingKeysWith: { first, _ in first } + ) + var unhandledObservationNodes: [AgentSessionGraphNode] = [] + observationJoiner.merge( + nodes: &unhandledObservationNodes, + observations: matchingObservations.filter { + !processedObservationProviders.contains($0.sessionProviderID) + }, + activeSessionBySurface: activeSessionBySurface + ) + for node in unhandledObservationNodes { + guard sessionsListNodeMatchesFilters( + node: node, + launchWorkingDirectory: nil, + sessionFilter: sessionFilter, + workspaceFilter: workspaceFilter, + cwdFilter: cwdFilter, + stateFilter: stateFilter, + activityFilter: activityFilter, + workKindFilter: workKindFilter + ) else { continue } + let displayName = displayNameByProvider[node.provider] ?? node.provider + entries.insert( + updatedAt: node.updatedAt, + sortValues: sessionsListSortValues( + node: node, + sessionID: nil, + agent: node.provider, + surfaceID: node.surfaceId + ), + payloadFactory: { [self] in + sessionsListProcessPayload( + node: node, + displayName: displayName, + timestampFormatter: timestampFormatter + ) + } + ) } - let limitedEntries = Array(sortedEntries.prefix(limit)) if localJSONOutput { - print(jsonString([ - "state_dir": stateDir, - "default_codex_home": defaultCodexHome, - "total_matches": sortedEntries.count, - "limit": limit == Int.max ? NSNull() : limit, - "stores": stores, - "sessions": limitedEntries.map(\.payload) - ])) + try AgentStagedOutput().publish(build: { handle in + var writer = try AgentPrettyJSONStreamWriter(handle: handle) + try writer.writeValueField(name: "schema_version", value: 2) + try writer.writeValueField(name: "default_codex_home", value: defaultCodexHome) + try writer.writeValueField( + name: "limit", + value: limit == Int.max ? NSNull() : NSNumber(value: limit) + ) + try writer.beginArrayField(name: "sessions") + var payloadBatch: [[String: Any]] = [] + payloadBatch.reserveCapacity(512) + try entries.forEachSortedPayload { payload in + payloadBatch.append(payload) + if payloadBatch.count == 512 { + try writer.writeArrayElements(payloadBatch) + payloadBatch.removeAll(keepingCapacity: true) + } + } + if !payloadBatch.isEmpty { + try writer.writeArrayElements(payloadBatch) + } + try writer.endArray() + try writer.writeValueField(name: "state_dir", value: stateDir) + if !storeWarnings.isEmpty { + try writer.writeValueField( + name: "store_warnings", + value: storeWarnings.map(sessionsListEncodableJSONObject) + ) + } + try writer.writeValueField(name: "stores", value: stores) + try writer.writeValueField(name: "total_matches", value: entries.totalCount) + try writer.finish() + }, publishChunk: cliWriteStdout) return } - if limitedEntries.isEmpty { + agentsWriteStoreWarnings(storeWarnings) + if entries.retainedCount == 0 { print(String(localized: "cli.sessions.output.noMatches", defaultValue: "No saved agent sessions matched.")) print("state_dir=\(stateDir)") return } - for entry in limitedEntries { - print(renderSessionListLine(entry.payload)) + entries.forEachSortedPayload { payload in + print(renderSessionListLine(payload)) } - if sortedEntries.count > limitedEntries.count { + if entries.totalCount > entries.retainedCount { + let moreFormat = if includeAll { + String( + localized: "cli.sessions.output.moreLimitedAll", + defaultValue: "... %lld more. Raise --limit ." + ) + } else { + String( + localized: "cli.sessions.output.more", + defaultValue: "... %lld more. Pass --all or --limit ." + ) + } print(String( - format: String(localized: "cli.sessions.output.more", defaultValue: "... %lld more. Pass --all or --limit ."), - sortedEntries.count - limitedEntries.count + format: moreFormat, + entries.totalCount - entries.retainedCount )) } } - func sessionsUsage() -> String { - String(localized: "cli.sessions.usage", defaultValue: """ - Usage: cmux sessions list [options] - cmux sessions [options] - - Print saved agent session records from ~/.cmuxterm/*-hook-sessions.json. - This command does not require a running cmux socket. - By default, broad output shows active, restorable, or transcript-backed records. - Pass --all to inspect every saved hook record. - - Options: - --agent Filter to one agent, for example codex or claude - --session Filter to one agent session id - --workspace Filter to one saved workspace id - --surface Filter to one saved surface id - --cwd Filter by saved cwd or launch working directory - --state-dir Override hook state directory - --codex-home Override the default Codex home used for transcript checks - --limit Limit text output (default: 100) - --all Print all matches - --json Print structured JSON - - Codex rows include whether the saved id exists in CODEX_HOME/session_index.jsonl - and whether a matching transcript file exists under CODEX_HOME/sessions or - CODEX_HOME/archived_sessions. - - Compatibility aliases: - cmux sessions debug [options] - cmux session-debug [options] - """) + private func sessionsListProjectedRun( + record: ClaudeHookSessionRecord, + provider: String + ) -> AgentSessionRunRecord { + agentSessionRunCanonicalizer.projectedRun(record: record, provider: provider) } - private func sessionsListAgentSpecs() -> [SessionListAgentSpec] { - var specs: [SessionListAgentSpec] = [ - ( - name: "claude", - displayName: "Claude Code", - sessionStoreSuffix: "claude", - configDirEnvOverride: "CLAUDE_CONFIG_DIR" - ) + private func sessionsListNodeMatchesFilters( + node: AgentSessionGraphNode, + launchWorkingDirectory: String?, + sessionFilter: String?, + workspaceFilter: String?, + cwdFilter: String?, + stateFilter: String?, + activityFilter: String?, + workKindFilter: String? + ) -> Bool { + if let sessionFilter, node.sessionId?.lowercased() != sessionFilter { return false } + if let workspaceFilter, node.workspaceId.lowercased() != workspaceFilter { return false } + if let cwdFilter { + let cwd = (node.cwd ?? "").lowercased() + let launchCWD = (launchWorkingDirectory ?? "").lowercased() + if !cwd.contains(cwdFilter) && !launchCWD.contains(cwdFilter) { return false } + } + if let stateFilter, node.effectiveState.rawValue != stateFilter { return false } + if let activityFilter, node.activity.state.rawValue != activityFilter { return false } + if let workKindFilter, + !node.workloads.contains(where: { + $0.kind.rawValue == workKindFilter && $0.phase.isActive + }) { + return false + } + return true + } + + private func sessionsListSortValues( + node: AgentSessionGraphNode, + sessionID: String?, + agent: String, + surfaceID: String + ) -> SessionListEntryAccumulator.SortValues { + SessionListEntryAccumulator.SortValues( + sessionID: sessionID, + agent: agent, + runID: node.runId, + workspaceID: node.workspaceId, + surfaceID: surfaceID, + identitySource: node.identitySource, + pid: node.pid, + processStartedAt: node.processStartedAt + ) + } + + private func sessionsListApply( + node: AgentSessionGraphNode, + to payload: inout [String: Any] + ) { + payload["identity_source"] = node.identitySource + payload["run_id"] = node.runId + payload["pid"] = node.pid ?? NSNull() + payload["process_started_at"] = node.processStartedAt ?? NSNull() + payload["workspace_id"] = node.workspaceId + payload["cwd"] = node.cwd ?? NSNull() + payload["process_state"] = node.processState.rawValue + payload["session_state"] = node.sessionState.rawValue + payload["foreground_state"] = node.foregroundState.rawValue + payload["attention_state"] = node.attentionState.rawValue + payload["effective_state"] = node.effectiveState.rawValue + payload["activity"] = sessionsListEncodableJSONObject(node.activity) + payload["workloads"] = sessionsListEncodableJSONObject(node.workloads) + payload["restore_authority"] = node.restoreAuthority + payload["cmux_runtime"] = node.cmuxRuntime + .map { sessionsListEncodableJSONObject($0) } ?? NSNull() + payload["state_source"] = node.terminalStateApplied ? "terminal" : "lifecycle" + payload["terminal_observation"] = node.terminalObservation + .map { sessionsListEncodableJSONObject($0) } ?? NSNull() + } + + private func sessionsListApply( + projection: AgentSessionStateProjection, + to payload: inout [String: Any] + ) { + payload["process_state"] = projection.process.rawValue + payload["session_state"] = projection.session.rawValue + payload["foreground_state"] = projection.foreground.rawValue + payload["attention_state"] = projection.attention.rawValue + payload["effective_state"] = projection.effective.rawValue + payload["activity"] = sessionsListEncodableJSONObject(projection.activity) + payload["workloads"] = sessionsListEncodableJSONObject( + projection.workloads.map(AgentWorkloadSnapshot.init) + ) + } + + private func sessionsListProcessPayload( + node: AgentSessionGraphNode, + displayName: String, + timestampFormatter: ISO8601DateFormatter + ) -> [String: Any] { + var payload: [String: Any] = [ + "agent": node.provider, + "agent_display_name": displayName, + "identity_source": node.identitySource, + "session_id": NSNull(), + "run_id": node.runId, + "workspace_id": node.workspaceId, + "surface_id": node.surfaceId, + "store_path": NSNull(), + "started_at": sessionsListTimestamp(node.startedAt, formatter: timestampFormatter), + "updated_at": sessionsListTimestamp(node.updatedAt, formatter: timestampFormatter), + "updated_at_unix": node.updatedAt, + "cwd": node.cwd ?? NSNull(), + "transcript_path": NSNull(), + "pid": node.pid ?? NSNull(), + "process_started_at": node.processStartedAt ?? NSNull(), + "runtime_status": NSNull(), + "agent_lifecycle": NSNull(), + "process_state": node.processState.rawValue, + "session_state": node.sessionState.rawValue, + "foreground_state": node.foregroundState.rawValue, + "attention_state": node.attentionState.rawValue, + "effective_state": node.effectiveState.rawValue, + "activity": sessionsListEncodableJSONObject(node.activity), + "workloads": sessionsListEncodableJSONObject(node.workloads), + "restore_authority": false, + "cmux_runtime": node.cmuxRuntime.map { sessionsListEncodableJSONObject($0) } ?? NSNull(), + "state_source": "terminal", + "terminal_observation": node.terminalObservation + .map { sessionsListEncodableJSONObject($0) } ?? NSNull(), + "last_prompt_turn_id": NSNull(), + "active_prompt_turn_id": NSNull(), + "launch_working_directory": NSNull(), + "launch_arguments": [], + "fork_command_available": false, + "fork_supported": false, + "active_for_workspace": false, + "active_for_surface": false, + "active_workspace_session_id": NSNull(), + "active_surface_session_id": NSNull(), + "is_restorable": false, + "session_home": NSNull(), + "session_dir": NSNull(), + "transcript_backed": false, + "launch_backed": false, + "default_visible": true, ] - specs.append(contentsOf: Self.agentDefs.map { - ( - name: $0.name, - displayName: $0.displayName, - sessionStoreSuffix: $0.sessionStoreSuffix, - configDirEnvOverride: $0.configDirEnvOverride - ) - }) - return specs + if node.provider == "codex" { + payload["codex_indexed"] = false + payload["codex_transcript_found"] = false + payload["codex_transcript_path"] = NSNull() + } + return payload } private func buildCodexDebugIndex( codexHome: String, fileManager: FileManager - ) throws -> CodexSessionListIndex { + ) -> CodexSessionListIndex { let homeURL = URL(fileURLWithPath: codexHome, isDirectory: true) var indexedSessionIds = Set() let sessionIndexURL = homeURL.appendingPathComponent("session_index.jsonl", isDirectory: false) @@ -405,51 +1182,6 @@ extension CMUXCLI { } } - private func renderSessionListLine(_ payload: [String: Any]) -> String { - let agent = (payload["agent"] as? String) ?? "unknown" - let sessionId = (payload["session_id"] as? String) ?? "unknown" - let workspaceId = (payload["workspace_id"] as? String) ?? "-" - let surfaceId = (payload["surface_id"] as? String) ?? "-" - let cwd = (payload["cwd"] as? String) ?? "-" - let updatedAt = (payload["updated_at"] as? String) ?? "-" - let sessionHome = (payload["session_home"] as? String) ?? "-" - let sessionDir = (payload["session_dir"] as? String) ?? "-" - let activeWorkspace = ((payload["active_for_workspace"] as? Bool) == true) ? "yes" : "no" - let activeSurface = ((payload["active_for_surface"] as? Bool) == true) ? "yes" : "no" - var parts = [ - "\(agent) \(sessionId)", - "workspace=\(workspaceId)", - "surface=\(surfaceId)", - "cwd=\(cwd)", - "active_ws=\(activeWorkspace)", - "active_surface=\(activeSurface)", - "updated=\(updatedAt)" - ] - if agent == "codex" { - parts.append("session_home=\(sessionHome)") - let indexed = ((payload["codex_indexed"] as? Bool) == true) ? "yes" : "no" - let transcript = ((payload["codex_transcript_found"] as? Bool) == true) ? "yes" : "no" - parts.append("codex_indexed=\(indexed)") - parts.append("codex_transcript=\(transcript)") - } else { - parts.append("session_dir=\(sessionDir)") - } - let forkCommandAvailable = ((payload["fork_command_available"] as? Bool) == true) ? "yes" : "no" - parts.append("fork_command=\(forkCommandAvailable)") - let forkSupported = ((payload["fork_supported"] as? Bool) == true) ? "yes" : "no" - parts.append("fork=\(forkSupported)") - if let pidExists = payload["stored_pid_exists"] as? Bool { - parts.append("pid_exists=\(pidExists ? "yes" : "no")") - } - return parts.joined(separator: " ") - } - - private func sessionsListTimestamp(_ value: TimeInterval) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string(from: Date(timeIntervalSince1970: value)) - } - func sessionsListExpandedPath(_ value: String) -> String { NSString(string: value).expandingTildeInPath } diff --git a/CLI/CMUXCLI+SessionsListClaudeWorkflow.swift b/CLI/CMUXCLI+SessionsListClaudeWorkflow.swift deleted file mode 100644 index 90e9a5ff2a1b..000000000000 --- a/CLI/CMUXCLI+SessionsListClaudeWorkflow.swift +++ /dev/null @@ -1,124 +0,0 @@ -import Foundation - -extension CMUXCLI { - func sessionsListResolvedClaudeWorkflowRecord( - _ record: ClaudeHookSessionRecord, - lookup: SessionsListClaudeTranscriptLookupCache - ) -> ClaudeHookSessionRecord { - guard sessionsListClaudeSessionIdIsSafeFilename(record.sessionId) else { - return record - } - if let transcriptPath = sessionsListNormalized(record.transcriptPath), - sessionsListRegularNonEmptyFileExists(atPath: (transcriptPath as NSString).expandingTildeInPath) { - return record - } - - let roots = lookup.configRoots(record: record) - guard !roots.isEmpty else { return record } - let candidateProjectDirs = sessionsListClaudeWorkflowProjectDirs( - record: record, - roots: roots, - lookup: lookup - ) - guard let resolved = sessionsListSingleClaudeSiblingTranscript( - in: candidateProjectDirs, - excludingSessionId: record.sessionId - ) else { - return record - } - - var resolvedRecord = record - resolvedRecord.sessionId = resolved.sessionId - resolvedRecord.transcriptPath = resolved.path - return resolvedRecord - } - - private func sessionsListClaudeWorkflowProjectDirs( - record: ClaudeHookSessionRecord, - roots: [String], - lookup: SessionsListClaudeTranscriptLookupCache - ) -> [String] { - var projectDirs: [String] = [] - var seen: Set = [] - - func appendIfWorkflowContainer(projectRoot: String) { - let workflowContainer = (projectRoot as NSString).appendingPathComponent(record.sessionId) - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: workflowContainer, isDirectory: &isDirectory), - isDirectory.boolValue else { - return - } - let standardized = (projectRoot as NSString).standardizingPath - guard seen.insert(standardized).inserted else { return } - projectDirs.append(standardized) - } - - let cwdCandidates = [ - sessionsListNormalized(record.launchCommand?.workingDirectory), - sessionsListNormalized(record.cwd), - ].compactMap { $0 } - for root in roots { - let projectsRoot = (root as NSString).appendingPathComponent("projects") - for cwd in cwdCandidates { - appendIfWorkflowContainer( - projectRoot: (projectsRoot as NSString) - .appendingPathComponent(sessionsListEncodeClaudeProjectDir(cwd)) - ) - } - for projectDir in lookup.projectDirs(configRoot: root) { - appendIfWorkflowContainer( - projectRoot: (projectsRoot as NSString).appendingPathComponent(projectDir) - ) - } - } - return projectDirs - } - - private func sessionsListSingleClaudeSiblingTranscript( - in projectDirs: [String], - excludingSessionId excludedSessionId: String - ) -> (sessionId: String, path: String)? { - var matches: [(sessionId: String, path: String)] = [] - for projectDir in projectDirs { - sessionsListCollectClaudeTranscripts( - inDirectory: projectDir, - excludingSessionId: excludedSessionId, - remainingDirectoryDepth: 4, - matches: &matches - ) - } - guard matches.count == 1, let match = matches.first else { return nil } - return match - } - - private func sessionsListCollectClaudeTranscripts( - inDirectory directory: String, - excludingSessionId excludedSessionId: String, - remainingDirectoryDepth: Int, - matches: inout [(sessionId: String, path: String)] - ) { - guard sessionsListDirectoryExists(atPath: directory), - let children = try? FileManager.default.contentsOfDirectory(atPath: directory) else { - return - } - for child in children { - let childPath = (directory as NSString).appendingPathComponent(child) - if child.hasSuffix(".jsonl") { - let sessionId = String(child.dropLast(".jsonl".count)) - guard sessionId != excludedSessionId, - sessionsListClaudeSessionIdIsSafeFilename(sessionId), - sessionsListRegularNonEmptyFileExists(atPath: childPath) else { - continue - } - matches.append((sessionId, childPath)) - } else if remainingDirectoryDepth > 0 { - sessionsListCollectClaudeTranscripts( - inDirectory: childPath, - excludingSessionId: excludedSessionId, - remainingDirectoryDepth: remainingDirectoryDepth - 1, - matches: &matches - ) - } - } - } -} diff --git a/CLI/CMUXCLI+SessionsListForkDiagnostics.swift b/CLI/CMUXCLI+SessionsListForkDiagnostics.swift index 030c9e7ed81c..bbb9929d3c79 100644 --- a/CLI/CMUXCLI+SessionsListForkDiagnostics.swift +++ b/CLI/CMUXCLI+SessionsListForkDiagnostics.swift @@ -2,172 +2,31 @@ import Foundation import CMUXAgentLaunch import Darwin -final class SessionsListClaudeTranscriptLookupCache { - private let homeDirectory: String - private var defaultRoots: [String]? - private var projectDirsByConfigRoot: [String: [String]] = [:] - private var transcriptPathByProjectRootAndSession: [String: String] = [:] - private var missingTranscriptPathByProjectRootAndSession: Set = [] - private var transcriptPathByConfigRootAndSession: [String: String] = [:] - private var missingTranscriptPathByConfigRootAndSession: Set = [] - - init(homeDirectory: String) { - self.homeDirectory = homeDirectory - } - - func configRoots(record: ClaudeHookSessionRecord) -> [String] { - if let configured = normalized(record.launchCommand?.environment?["CLAUDE_CONFIG_DIR"]) { - return [ - ClaudeConfigDirectoryPath.preferredPath( - expandedPath(configured), - fileManager: .default, - homeDirectory: homeDirectory - ), - ] - } - - if let defaultRoots { return defaultRoots } - - var roots: [String] = [] - var seen: Set = [] - func appendRoot(_ path: String) { - let standardized = (path as NSString).standardizingPath - guard seen.insert(standardized).inserted else { return } - roots.append(standardized) - } - - let accountRoot = (homeDirectory as NSString).appendingPathComponent(".codex-accounts/claude") - if directoryExists(atPath: accountRoot), - let accountDirs = try? FileManager.default.contentsOfDirectory(atPath: accountRoot) { - for accountDir in accountDirs.sorted() { - appendRoot((accountRoot as NSString).appendingPathComponent(accountDir)) - } - } - appendRoot((homeDirectory as NSString).appendingPathComponent(".claude")) - appendRoot( - ClaudeConfigDirectoryPath.preferredPath( - (homeDirectory as NSString).appendingPathComponent(".subrouter/codex/claude"), - fileManager: .default, - homeDirectory: homeDirectory - ) - ) - - defaultRoots = roots - return roots - } - - func transcriptPath(configRoot: String, projectDirName: String, sessionId: String) -> String? { - let standardizedRoot = (configRoot as NSString).standardizingPath - let projectsRoot = (standardizedRoot as NSString).appendingPathComponent("projects") - let projectRoot = ((projectsRoot as NSString).appendingPathComponent(projectDirName) as NSString) - .standardizingPath - let key = cacheKey(projectRoot, sessionId) - if let cached = transcriptPathByProjectRootAndSession[key] { return cached } - if missingTranscriptPathByProjectRootAndSession.contains(key) { return nil } - - let path = transcriptPath(inProjectRoot: projectRoot, sessionId: sessionId) - if let path { - transcriptPathByProjectRootAndSession[key] = path - } else { - missingTranscriptPathByProjectRootAndSession.insert(key) - } - return path - } - - func transcriptPathInAnyProject(configRoot: String, sessionId: String) -> String? { - let standardizedRoot = (configRoot as NSString).standardizingPath - let key = cacheKey(standardizedRoot, sessionId) - if let cached = transcriptPathByConfigRootAndSession[key] { return cached } - if missingTranscriptPathByConfigRootAndSession.contains(key) { return nil } - - for projectDir in projectDirs(configRoot: standardizedRoot) { - if let path = transcriptPath( - configRoot: standardizedRoot, - projectDirName: projectDir, - sessionId: sessionId - ) { - transcriptPathByConfigRootAndSession[key] = path - return path - } - } - missingTranscriptPathByConfigRootAndSession.insert(key) - return nil - } - - func projectDirs(configRoot: String) -> [String] { - let standardizedRoot = (configRoot as NSString).standardizingPath - if let cached = projectDirsByConfigRoot[standardizedRoot] { return cached } - let projectsRoot = (standardizedRoot as NSString).appendingPathComponent("projects") - guard directoryExists(atPath: projectsRoot), - let projectDirs = try? FileManager.default.contentsOfDirectory(atPath: projectsRoot) else { - projectDirsByConfigRoot[standardizedRoot] = [] - return [] - } - projectDirsByConfigRoot[standardizedRoot] = projectDirs - return projectDirs - } - - private func transcriptPath(inProjectRoot projectRoot: String, sessionId: String) -> String? { - guard directoryExists(atPath: projectRoot) else { return nil } - let directPath = (projectRoot as NSString).appendingPathComponent("\(sessionId).jsonl") - if regularNonEmptyFileExists(atPath: directPath) { return directPath } - - let nestedMessagesPath = (((projectRoot as NSString) - .appendingPathComponent(sessionId) as NSString) - .appendingPathComponent("messages") as NSString) - .appendingPathComponent("\(sessionId).jsonl") - if regularNonEmptyFileExists(atPath: nestedMessagesPath) { return nestedMessagesPath } - return nil - } - - private func regularNonEmptyFileExists(atPath path: String) -> Bool { - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), - !isDirectory.boolValue, - let attrs = try? FileManager.default.attributesOfItem(atPath: path), - let size = attrs[.size] as? NSNumber else { - return false - } - return size.intValue > 0 - } - - private func directoryExists(atPath path: String) -> Bool { - var isDirectory: ObjCBool = false - return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue - } - - private func normalized(_ value: String?) -> String? { - guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), - !trimmed.isEmpty else { - return nil - } - return trimmed - } - - private func expandedPath(_ value: String) -> String { - (value as NSString).expandingTildeInPath - } - - private func cacheKey(_ prefix: String, _ sessionId: String) -> String { - prefix + "\u{0}" + sessionId - } -} extension CMUXCLI { func sessionsListForkDiagnostics( agent: String, record: ClaudeHookSessionRecord, - claudeTranscriptLookup: SessionsListClaudeTranscriptLookupCache + projectedRunRestoreAuthority: Bool, + claudeTranscriptLookup: SessionsListClaudeTranscriptLookupCache, + processIdentityLookup: (Int) -> SessionsListProcessIdentity?, + processExistenceLookup: (Int?) -> Bool? ) -> [String: Any] { - let diagnosticRecord = agent == "claude" - ? sessionsListResolvedClaudeWorkflowRecord(record, lookup: claudeTranscriptLookup) - : record - let storedPIDExists = sessionsListStoredPIDExists(diagnosticRecord.pid) - let hookRecordRestorable = sessionsListHookRecordRestorable( + // Keep diagnostics bound to the exact hook identity used by the list + // projection. Transcript lookup only verifies that identity. + let diagnosticRecord = record + let storedPIDExists = processExistenceLookup(diagnosticRecord.pid) + let recordHasDurableResumeEvidence = agentHookRecordHasDurableResumeEvidence( agent: agent, record: diagnosticRecord, claudeTranscriptLookup: claudeTranscriptLookup ) + // The compatibility fields on the logical record can lag or conflict + // with canonical run history. Forking must use the same projected run + // authority that the list row exposes, otherwise a stale root flag can + // promote a spawned or one-shot generation back into a restore owner. + let hookRecordRestorable = projectedRunRestoreAuthority + && recordHasDurableResumeEvidence let trustedLaunchCommand = sessionsListTrustedLaunchCommand(agent: agent, record: diagnosticRecord) let forkArguments = hookRecordRestorable ? sessionsListForkArguments( agent: agent, @@ -194,7 +53,9 @@ extension CMUXCLI { let unavailableReason: String if forkSupported { unavailableReason = "available" - } else if !hookRecordRestorable { + } else if !projectedRunRestoreAuthority { + unavailableReason = "run_marked_non_restorable" + } else if !recordHasDurableResumeEvidence { unavailableReason = "record_marked_non_restorable" } else if !forkCommandAvailable { unavailableReason = "agent_has_no_fork_command" @@ -209,13 +70,14 @@ extension CMUXCLI { "fork_startup_input_available": forkStartupInputAvailable, "hook_record_restorable": hookRecordRestorable, "stale_pid_blocks_restore_in_0_64_17": sessionsListStalePIDBlocksRestoreIn06417( - agent: agent, - record: diagnosticRecord, - hookRecordRestorable: hookRecordRestorable + agent: agent, + record: diagnosticRecord, + hookRecordRestorable: hookRecordRestorable, + processIdentityLookup: processIdentityLookup ), ] if let pid = diagnosticRecord.pid, - let process = sessionsListProcessIdentity(for: pid) { + let process = processIdentityLookup(pid) { diagnostics["stored_pid_arguments"] = process.arguments } diagnostics["stored_pid_exists"] = storedPIDExists ?? NSNull() @@ -225,18 +87,25 @@ extension CMUXCLI { private func sessionsListStalePIDBlocksRestoreIn06417( agent: String, record: ClaudeHookSessionRecord, - hookRecordRestorable: Bool + hookRecordRestorable: Bool, + processIdentityLookup: (Int) -> SessionsListProcessIdentity? ) -> Bool { guard hookRecordRestorable, let pid = record.pid else { return false } - return !sessionsListStoredPIDStillMatchesLaunch(agent: agent, record: record, pid: pid) + return !sessionsListStoredPIDStillMatchesLaunch( + agent: agent, + record: record, + pid: pid, + processIdentityLookup: processIdentityLookup + ) } private func sessionsListStoredPIDStillMatchesLaunch( agent: String, record: ClaudeHookSessionRecord, - pid: Int + pid: Int, + processIdentityLookup: (Int) -> SessionsListProcessIdentity? ) -> Bool { - guard let process = sessionsListProcessIdentity(for: pid), + guard let process = processIdentityLookup(pid), sessionsListProcessStartTimeMatchesRecord(process.startTime, record: record) else { return false } @@ -276,21 +145,52 @@ extension CMUXCLI { (value as NSString).lastPathComponent } - private func sessionsListHookRecordRestorable( + /// One restore-evidence predicate shared by list visibility, tree + /// visibility, and fork diagnostics. A rejected launch capture is an + /// explicit trust failure and cannot be rescued by a legacy nil flag. + func agentHookRunIsRestorable( agent: String, record: ClaudeHookSessionRecord, + run: AgentSessionRunRecord, claudeTranscriptLookup: SessionsListClaudeTranscriptLookupCache ) -> Bool { - guard agent == "claude" else { - return record.isRestorable != false + guard run.identityConflict != true, run.restoreAuthority else { return false } + return agentHookRecordHasDurableResumeEvidence( + agent: agent, + record: record, + claudeTranscriptLookup: claudeTranscriptLookup + ) + } + + private func agentHookRecordHasDurableResumeEvidence( + agent: String, + record: ClaudeHookSessionRecord, + claudeTranscriptLookup: SessionsListClaudeTranscriptLookupCache + ) -> Bool { + guard sessionsListNormalized(record.launchCommand?.source)?.lowercased() != "rejected" else { + return false } - if let transcriptPath = sessionsListNormalized(record.transcriptPath), - sessionsListRegularNonEmptyFileExists( - atPath: (transcriptPath as NSString).expandingTildeInPath - ) { - return true + if agent == "gemini" { + guard record.isRestorable != false, + let transcriptPath = sessionsListNormalized(record.transcriptPath) else { + return false + } + return sessionsListRegularNonEmptyFileExists( + atPath: (transcriptPath as NSString).expandingTildeInPath + ) + } + guard agent == "claude" else { + guard record.isRestorable != false else { return false } + return agentHookSessionHasDurableResumeEvidence( + kind: agent, + launchCommand: record.launchCommand, + transcriptPath: record.transcriptPath + ) } - return sessionsListClaudeTranscriptExists(record: record, lookup: claudeTranscriptLookup) + return sessionsListClaudeHasExactTranscript( + record: record, + lookup: claudeTranscriptLookup + ) } func sessionsListRegularNonEmptyFileExists(atPath path: String) -> Bool { @@ -304,6 +204,28 @@ extension CMUXCLI { return size.intValue > 0 } + func sessionsListClaudeHasExactTranscript( + record: ClaudeHookSessionRecord, + lookup: SessionsListClaudeTranscriptLookupCache + ) -> Bool { + guard sessionsListClaudeSessionIdIsSafeFilename(record.sessionId) else { + return false + } + if let transcriptPath = sessionsListNormalized(record.transcriptPath) { + let expandedTranscriptPath = (transcriptPath as NSString).expandingTildeInPath + guard sessionsListClaudeTranscriptPath( + expandedTranscriptPath, + matchesSessionId: record.sessionId + ) else { + return false + } + if sessionsListRegularNonEmptyFileExists(atPath: expandedTranscriptPath) { + return true + } + } + return sessionsListClaudeTranscriptExists(record: record, lookup: lookup) + } + private func sessionsListClaudeTranscriptExists( record: ClaudeHookSessionRecord, lookup: SessionsListClaudeTranscriptLookupCache @@ -314,18 +236,22 @@ extension CMUXCLI { let roots = lookup.configRoots(record: record) guard !roots.isEmpty else { return false } - let cwd = sessionsListNormalized(record.cwd) ?? sessionsListNormalized(record.launchCommand?.workingDirectory) - for root in roots { - if let cwd, - lookup.transcriptPath( - configRoot: root, - projectDirName: sessionsListEncodeClaudeProjectDir(cwd), - sessionId: record.sessionId - ) != nil { - return true - } - if lookup.transcriptPathInAnyProject(configRoot: root, sessionId: record.sessionId) != nil { - return true + var seenProjectDirectories: Set = [] + let candidates = [ + sessionsListNormalized(record.launchCommand?.workingDirectory), + sessionsListNormalized(record.cwd), + ].compactMap { $0 } + for cwd in candidates { + let projectDirectory = sessionsListEncodeClaudeProjectDir(cwd) + guard seenProjectDirectories.insert(projectDirectory).inserted else { continue } + for root in roots { + if lookup.transcriptPath( + configRoot: root, + projectDirName: projectDirectory, + sessionId: record.sessionId + ) != nil { + return true + } } } return false @@ -336,6 +262,12 @@ extension CMUXCLI { && !sessionId.isEmpty && sessionId != "." && sessionId != ".." + && sessionId.trimmingCharacters(in: .whitespacesAndNewlines) == sessionId + && sessionId.rangeOfCharacter(from: .controlCharacters) == nil + } + + private func sessionsListClaudeTranscriptPath(_ path: String, matchesSessionId sessionId: String) -> Bool { + (path as NSString).lastPathComponent == "\(sessionId).jsonl" } func sessionsListEncodeClaudeProjectDir(_ path: String) -> String { @@ -456,7 +388,7 @@ extension CMUXCLI { } } - private func sessionsListStoredPIDExists(_ pid: Int?) -> Bool? { + func sessionsListStoredPIDExists(_ pid: Int?) -> Bool? { guard let pid, pid > 0 else { return nil } guard let processID = pid_t(exactly: pid) else { return nil } errno = 0 diff --git a/CLI/CMUXCLI+SessionsListForkStartupInput.swift b/CLI/CMUXCLI+SessionsListForkStartupInput.swift index 662f8e18e3b6..f12f1ae435ff 100644 --- a/CLI/CMUXCLI+SessionsListForkStartupInput.swift +++ b/CLI/CMUXCLI+SessionsListForkStartupInput.swift @@ -32,7 +32,11 @@ extension CMUXCLI { commandParts.append("env") commandParts.append(contentsOf: environmentParts) } - commandParts.append(contentsOf: arguments) + if commandParts.first == "env", arguments.first == "env" { + commandParts.append(contentsOf: arguments.dropFirst()) + } else { + commandParts.append(contentsOf: arguments) + } let workingDirectory = sessionsListNormalized(launchCommand?.workingDirectory ?? record.cwd) let sanitizedCommandParts = AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( diff --git a/CLI/CMUXCLI+SessionsListProcessArguments.swift b/CLI/CMUXCLI+SessionsListProcessArguments.swift index cd4d853fb6c2..e708fb0aa575 100644 --- a/CLI/CMUXCLI+SessionsListProcessArguments.swift +++ b/CLI/CMUXCLI+SessionsListProcessArguments.swift @@ -2,19 +2,35 @@ import Darwin import Foundation extension CMUXCLI { - struct SessionsListProcessIdentity { - let executablePath: String? - let arguments: [String] - let startTime: TimeInterval - } + typealias SessionsListProcessIdentity = AgentStableProcessIdentity - func sessionsListProcessIdentity(for pid: Int) -> SessionsListProcessIdentity? { + func sessionsListProcessIdentity( + for pid: Int, + probedKernelStartTime: TimeInterval + ) -> SessionsListProcessIdentity? { guard pid > 0, pid <= Int(Int32.max) else { return nil } - guard let startTime = sessionsListProcessStartTime(for: pid) else { return nil } - return SessionsListProcessIdentity( - executablePath: sessionsListProcessExecutablePath(for: pid), - arguments: sessionsListProcessArguments(for: pid) ?? [], - startTime: startTime + return sessionsListStableProcessIdentity( + for: pid, + probedKernelStartTime: probedKernelStartTime, + processStartTimeLookup: sessionsListProcessStartTime, + executablePathLookup: sessionsListProcessExecutablePath, + argumentsLookup: sessionsListProcessArguments + ) + } + + func sessionsListStableProcessIdentity( + for pid: Int, + probedKernelStartTime: TimeInterval, + processStartTimeLookup: (Int) -> TimeInterval?, + executablePathLookup: (Int) -> String?, + argumentsLookup: (Int) -> [String]? + ) -> SessionsListProcessIdentity? { + agentStableProcessIdentityValidator.identity( + for: pid, + probedKernelStartTime: probedKernelStartTime, + processStartTimeLookup: processStartTimeLookup, + executablePathLookup: executablePathLookup, + argumentsLookup: argumentsLookup ) } @@ -28,7 +44,7 @@ extension CMUXCLI { processStartTime <= record.updatedAt + 5 } - private func sessionsListProcessStartTime(for pid: Int) -> TimeInterval? { + func sessionsListProcessStartTime(for pid: Int) -> TimeInterval? { var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, Int32(pid)] var process = kinfo_proc() var length = MemoryLayout.stride diff --git a/CLI/CMUXCLI+SessionsListRendering.swift b/CLI/CMUXCLI+SessionsListRendering.swift new file mode 100644 index 000000000000..6ee644b35c2b --- /dev/null +++ b/CLI/CMUXCLI+SessionsListRendering.swift @@ -0,0 +1,58 @@ +import Foundation + +extension CMUXCLI { + func renderSessionListLine(_ payload: [String: Any]) -> String { + let agent = (payload["agent"] as? String) ?? "unknown" + let sessionId = (payload["session_id"] as? String) + ?? (payload["pid"] as? Int).map { "pid \($0)" } + ?? "unknown" + let workspaceId = (payload["workspace_id"] as? String) ?? "-" + let surfaceId = (payload["surface_id"] as? String) ?? "-" + let cwd = (payload["cwd"] as? String) ?? "-" + let updatedAt = (payload["updated_at"] as? String) ?? "-" + let sessionHome = (payload["session_home"] as? String) ?? "-" + let sessionDir = (payload["session_dir"] as? String) ?? "-" + let activeWorkspace = ((payload["active_for_workspace"] as? Bool) == true) ? "yes" : "no" + let activeSurface = ((payload["active_for_surface"] as? Bool) == true) ? "yes" : "no" + let effectiveState = (payload["effective_state"] as? String) ?? "unknown" + let activityState = ((payload["activity"] as? [String: Any])?["state"] as? String) ?? "unknown" + let identitySource = (payload["identity_source"] as? String) ?? "unknown" + let stateSource = (payload["state_source"] as? String) ?? "unknown" + let restoreOwner = ((payload["restore_authority"] as? Bool) == true) ? "yes" : "no" + var parts = [ + "\(agent) \(sessionId)", + "state=\(effectiveState)", + "activity=\(activityState)", + "identity=\(identitySource)", + "state_source=\(stateSource)", + "restore_owner=\(restoreOwner)", + "workspace=\(workspaceId)", + "surface=\(surfaceId)", + "cwd=\(cwd)", + "active_ws=\(activeWorkspace)", + "active_surface=\(activeSurface)", + "updated=\(updatedAt)", + ] + if agent == "codex" { + parts.append("session_home=\(sessionHome)") + let indexed = ((payload["codex_indexed"] as? Bool) == true) ? "yes" : "no" + let transcript = ((payload["codex_transcript_found"] as? Bool) == true) ? "yes" : "no" + parts.append("codex_indexed=\(indexed)") + parts.append("codex_transcript=\(transcript)") + } else { + parts.append("session_dir=\(sessionDir)") + } + let forkCommandAvailable = ((payload["fork_command_available"] as? Bool) == true) ? "yes" : "no" + parts.append("fork_command=\(forkCommandAvailable)") + let forkSupported = ((payload["fork_supported"] as? Bool) == true) ? "yes" : "no" + parts.append("fork=\(forkSupported)") + if let pidExists = payload["stored_pid_exists"] as? Bool { + parts.append("pid_exists=\(pidExists ? "yes" : "no")") + } + return parts.joined(separator: " ") + } + + func sessionsListTimestamp(_ value: TimeInterval, formatter: ISO8601DateFormatter) -> String { + return formatter.string(from: Date(timeIntervalSince1970: value)) + } +} diff --git a/CLI/CMUXCLIModels.swift b/CLI/CMUXCLIModels.swift new file mode 100644 index 000000000000..ca1aa4eb6d58 --- /dev/null +++ b/CLI/CMUXCLIModels.swift @@ -0,0 +1,106 @@ +import Foundation + +struct CodexMonitorLeaseRecord: Codable { + var leaseId: String + var sessionId: String + var turnId: String? + var workspaceId: String + var surfaceId: String? + var createdAt: TimeInterval + var retiredAt: TimeInterval? +} + +enum CLIIDFormat: String { + case refs + case uuids + case both + + static func parse(_ raw: String?) throws -> CLIIDFormat? { + guard let raw else { return nil } + guard let parsed = CLIIDFormat(rawValue: raw.lowercased()) else { + throw CLIError(message: "--id-format must be one of: refs, uuids, both") + } + return parsed + } +} + +struct CLIError: Error, CustomStringConvertible { + let message: String + let exitCode: Int32 + /// Structured v2 protocol error code when the failure came from a v2 error response. + let v2Code: String? + /// Optional fields merged into a command-owned structured error envelope. + let structuredFields: CLIErrorStructuredFields? + + init( + message: String, + exitCode: Int32 = 1, + v2Code: String? = nil, + structuredFields: CLIErrorStructuredFields? = nil + ) { + self.message = message + self.exitCode = exitCode + self.v2Code = v2Code + self.structuredFields = structuredFields + } + + var description: String { message } +} + +struct CLIErrorStructuredFields: Sendable { + var provider: String? = nil + var conflictingProvider: String? = nil + var path: String? = nil + var scope: String? = nil + var sessionID: String? = nil + var observedBytes: Int64? = nil + var maximumBytes: Int64? = nil + var observedCount: Int64? = nil + var maximumCount: Int64? = nil + var guidance: String? = nil + var recoveryAction: String? = nil + var canonicalPath: String? = nil + var limit: Int? = nil + var observedAtLeast: Int? = nil + var maximumRecordBytes: Int? = nil + + var jsonObject: [String: Any] { + var object: [String: Any] = [:] + object["provider"] = (provider as Any?) ?? NSNull() + if let conflictingProvider { object["conflicting_provider"] = conflictingProvider } + object["path"] = (path as Any?) ?? NSNull() + object["scope"] = (scope as Any?) ?? NSNull() + object["session_id"] = (sessionID as Any?) ?? NSNull() + object["observed_bytes"] = (observedBytes as Any?) ?? NSNull() + object["maximum_bytes"] = (maximumBytes as Any?) ?? NSNull() + object["observed_count"] = (observedCount as Any?) ?? NSNull() + object["maximum_count"] = (maximumCount as Any?) ?? NSNull() + if let guidance { object["guidance"] = guidance } + if let recoveryAction { object["recovery_action"] = recoveryAction } + if let canonicalPath { object["canonical_path"] = canonicalPath } + if let limit { object["limit"] = limit } + if let observedAtLeast { object["observed_at_least"] = observedAtLeast } + if let maximumRecordBytes { object["maximum_record_bytes"] = maximumRecordBytes } + return object + } +} + +struct WindowInfo { + let index: Int + let id: String + let key: Bool + let selectedWorkspaceId: String? + let workspaceCount: Int +} + +struct NotificationInfo { + let id: String + let workspaceId: String + let surfaceId: String? + let isRead: Bool + let title: String + let subtitle: String + let body: String + let createdAt: String? + let tabTitle: String? +} diff --git a/CLI/ClaudeAgentWorkloadAdapter.swift b/CLI/ClaudeAgentWorkloadAdapter.swift new file mode 100644 index 000000000000..c4a49198c636 --- /dev/null +++ b/CLI/ClaudeAgentWorkloadAdapter.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Converts Claude's provider payload into sanitized shared workload records. +struct ClaudeAgentWorkloadAdapter: Sendable { + func workloads(from input: ClaudeHookParsedInput, now: TimeInterval) -> [AgentWorkloadRecord]? { + guard let object = input.rawObject, + object["background_tasks"] != nil || object["session_crons"] != nil else { + return nil + } + var workloads: [AgentWorkloadRecord] = [] + if let tasks = object["background_tasks"] as? [[String: Any]] { + workloads.append(contentsOf: tasks.enumerated().compactMap { index, task in + let id = normalized(task["id"] as? String) ?? "background:\(index)" + let status = normalized(task["status"] as? String)?.lowercased() ?? "unknown" + let phase: AgentWorkloadPhase = switch status { + case "queued", "pending": .queued + case "running", "active": .running + case "watching", "monitoring": .watching + case "waiting": .waiting + case "completed", "done", "success": .completed + case "failed", "error": .failed + case "cancelled", "canceled", "stopped": .cancelled + default: .unknown + } + let type = normalized(task["type"] as? String)?.lowercased() ?? "" + let kind: AgentWorkloadKind = switch type { + case "shell", "bash", "terminal": .backgroundTerminal + case "monitor", "watch": .monitor + case "agent", "subagent": .subagent + case "tool": .tool + default: .other + } + return AgentWorkloadRecord( + id: id, + kind: kind, + phase: phase, + keepsSessionBusy: phase.isActive, + startedAt: now, + updatedAt: now, + endedAt: phase.isActive ? nil : now, + endReason: phase.isActive ? nil : "provider_\(phase.rawValue)" + ) + }) + } + if let crons = object["session_crons"] as? [[String: Any]] { + workloads.append(contentsOf: crons.enumerated().map { index, cron in + AgentWorkloadRecord( + id: normalized(cron["id"] as? String) ?? "scheduled:\(index)", + kind: .scheduled, + phase: .queued, + keepsSessionBusy: true, + startedAt: now, + updatedAt: now + ) + }) + } + return workloads + } + + private func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } +} diff --git a/CLI/ClaudeChildSessionObserver.swift b/CLI/ClaudeChildSessionObserver.swift new file mode 100644 index 000000000000..6640643bd721 --- /dev/null +++ b/CLI/ClaudeChildSessionObserver.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Stores nested Claude sessions without promoting them to the surface's active +/// restore slot or mutating parent UI state. +struct ClaudeChildSessionObserver: Sendable { + func recordPrompt( + input: ClaudeHookParsedInput, + store: ClaudeHookSessionStore, + workspaceId: String, + surfaceId: String, + pid: Int?, + launchCommand: AgentHookLaunchCommandRecord?, + environment: [String: String] + ) { + guard let sessionId = input.sessionId, + shouldRecord(sessionId: sessionId, store: store, pid: pid, environment: environment) else { + return + } + _ = try? store.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: input.cwd, + transcriptPath: input.transcriptPath, + pid: pid, + launchCommand: launchCommand, + isRestorable: false, + agentLifecycle: .running, + runtimeStatus: .running, + updateRuntimeStatus: true + ) + _ = try? store.reconcileSemanticState( + sessionId: sessionId, + foregroundState: .working, + attentionState: AgentAttentionState.none + ) + } + + func recordStop( + input: ClaudeHookParsedInput, + store: ClaudeHookSessionStore, + workspaceId: String, + surfaceId: String, + pid: Int?, + launchCommand: AgentHookLaunchCommandRecord?, + environment: [String: String] + ) { + guard let sessionId = input.sessionId, + shouldRecord(sessionId: sessionId, store: store, pid: pid, environment: environment) else { + return + } + let workloads = ClaudeAgentWorkloadAdapter().workloads(from: input, now: Date().timeIntervalSince1970) + let busy = workloads?.contains { $0.keepsSessionBusy && $0.phase.isActive } == true + _ = try? store.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: input.cwd, + transcriptPath: input.transcriptPath, + pid: pid, + launchCommand: launchCommand, + isRestorable: false, + agentLifecycle: busy ? .running : .idle, + runtimeStatus: busy ? .running : .idle, + updateRuntimeStatus: true + ) + _ = try? store.reconcileSemanticState( + sessionId: sessionId, + foregroundState: AgentStopStateAdapter().isInterrupted( + provider: "claude", + input: input, + transcriptPath: input.transcriptPath + ) ? .interrupted : .completed, + attentionState: AgentAttentionState.none, + workloads: workloads + ) + } + + private func shouldRecord( + sessionId: String, + store: ClaudeHookSessionStore, + pid: Int?, + environment: [String: String] + ) -> Bool { + guard !AgentHookSessionLineageResolver().resolve( + agentName: "claude", + sessionId: sessionId, + pid: pid, + environment: environment + ).restoreAuthority else { return false } + return (try? store.projectedRestoreAuthority(sessionId: sessionId)) != true + } +} diff --git a/CLI/ClaudeHookSessionStore.swift b/CLI/ClaudeHookSessionStore.swift new file mode 100644 index 000000000000..cf733ac081a5 --- /dev/null +++ b/CLI/ClaudeHookSessionStore.swift @@ -0,0 +1,1786 @@ +import Darwin +import Foundation + +/// Durable provider hook-session state and its lifecycle transitions. +final class ClaudeHookSessionStore { + private enum PromptStopPreparation { + case apply(AgentHookSessionLineage) + case completed(AgentPromptStopCompletionReason, clearedActiveBoundary: Bool) + case rejected + } + + private static let defaultStatePath = "~/.cmuxterm/claude-hook-sessions.json" + private static let maxRunsPerSession = 128 + private static let maxRememberedTerminalPromptTurnIds = 32 + private static let maxAutoNameRecentMessages = 24 + private static let maxAutoNameMessageCharacters = 1_000 + + let statePath: String + let fileManager: FileManager + let processEnv: [String: String] + let agentName: String + private let lineageResolver: AgentHookSessionLineageResolver + private let decoder = JSONDecoder() + + private var registryBridge: AgentHookSessionRegistryBridge { + AgentHookSessionRegistryBridge( + provider: agentName, + statePath: statePath, + environment: processEnv, + fileManager: fileManager + ) + } + + init( + processEnv: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + agentName: String = "claude", + lineageResolver: AgentHookSessionLineageResolver = AgentHookSessionLineageResolver() + ) { + if let overridePath = processEnv["CMUX_CLAUDE_HOOK_STATE_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !overridePath.isEmpty { + self.statePath = NSString(string: overridePath).expandingTildeInPath + } else if let overrideDirectory = processEnv["CMUX_AGENT_HOOK_STATE_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !overrideDirectory.isEmpty { + self.statePath = URL(fileURLWithPath: NSString(string: overrideDirectory).expandingTildeInPath, isDirectory: true) + .appendingPathComponent("claude-hook-sessions.json", isDirectory: false) + .path + } else { + self.statePath = NSString(string: Self.defaultStatePath).expandingTildeInPath + } + self.fileManager = fileManager + self.processEnv = processEnv + self.agentName = agentName + self.lineageResolver = lineageResolver + } + + func lookup(sessionId: String) throws -> ClaudeHookSessionRecord? { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return nil } + return try registryBridge.lookup(sessionID: normalized, decoder: decoder) + } + + func projectedRestoreAuthority(sessionId: String) throws -> Bool? { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return nil } + return try withSessionSnapshot(sessionID: normalized) { record -> Bool? in + guard let record else { return nil } + return AgentSessionRunCanonicalizer().projectedRun( + record: record, + provider: agentName + ).restoreAuthority + } + } + + func snapshot() -> ClaudeHookSessionStoreFile { + withSnapshotState { $0 } + } + + func reconcileSemanticState( + sessionId: String, + foregroundState: AgentForegroundState? = nil, + attentionState: AgentAttentionState? = nil, + workloads: [AgentWorkloadRecord]? = nil, + now: TimeInterval = Date().timeIntervalSince1970 + ) throws { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return } + try withLockedSessionState(sessionID: normalized) { state in + guard var record = state.sessions[normalized], + AgentSessionSemanticUpdatePolicy().canUpdate(record: record) else { return } + if let foregroundState { record.foregroundState = foregroundState } + if let attentionState { record.attentionState = attentionState } + if let workloads { + record.workloads = AgentSessionWorkloadReconciler().replacingActiveWorkloads( + record.workloads ?? [], + with: workloads, + now: now + ) + } + record.updatedAt = max(record.updatedAt, now) + state.sessions[normalized] = record + } + } + + /// Records hook-observed runtime permission state without creating a + /// session record that has not passed the normal session-start path. + func updateLastPermissionMode( + sessionId: String, + permissionMode: String, + now: TimeInterval = Date().timeIntervalSince1970 + ) throws { + let normalized = normalizeSessionId(sessionId) + let mode = permissionMode.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, !mode.isEmpty else { return } + try withLockedSessionState(sessionID: normalized) { state in + guard var record = state.sessions[normalized], + record.lastPermissionMode != mode else { return } + record.lastPermissionMode = mode + record.updatedAt = max(record.updatedAt, now) + state.sessions[normalized] = record + } + } + + struct AutoNamingRecentMessagesSnapshot { + var messages: [AutoNamingTranscriptMessage] + var totalMessageCount: Int + } + + func autoNamingRecentMessages(sessionId: String) throws -> [AutoNamingTranscriptMessage] { + try autoNamingRecentMessagesSnapshot(sessionId: sessionId).messages + } + + func autoNamingRecentMessagesSnapshot(sessionId: String) throws -> AutoNamingRecentMessagesSnapshot { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { + return AutoNamingRecentMessagesSnapshot(messages: [], totalMessageCount: 0) + } + return try withSessionSnapshot(sessionID: normalized) { record in + let messages = record?.autoNameRecentMessages ?? [] + return AutoNamingRecentMessagesSnapshot( + messages: messages, + totalMessageCount: max(messages.count, record?.autoNameMessageSequence ?? 0) + ) + } + } + + struct AutoNamingBeginOutcome { + var decision: AutoNamingThrottleDecision + var lastTitle: String? + } + + /// Atomically evaluates the auto-naming throttle for a session and, when + /// the decision is to proceed, records the in-flight marker inside the + /// same locked transaction so a concurrent Stop hook sees it and skips. + /// When no session record exists yet (the auto-name hook can race the + /// sync Stop hook's upsert), a minimal record is synthesized so the + /// marker and baseline writes are never silently dropped. + func beginAutoNaming( + sessionId: String, + workspaceId: String, + surfaceId: String, + transcriptLineCount: Int, + now: Date, + engine: AutoNamingEngine + ) throws -> AutoNamingBeginOutcome { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { + return AutoNamingBeginOutcome(decision: .skipShortTranscript, lastTitle: nil) + } + return try withLockedSessionState( + sessionID: normalized, + workspaceID: workspaceId, + surfaceID: surfaceId + ) { state in + var record = state.sessions[normalized] ?? ClaudeHookSessionRecord( + sessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + startedAt: now.timeIntervalSince1970, + updatedAt: now.timeIntervalSince1970 + ) + let snapshot = AutoNamingSessionSnapshot( + lastTitle: record.autoNameLastTitle, + lastLineCount: record.autoNameLastLineCount, + lastNamedAt: record.autoNameLastNamedAt, + inFlightAt: record.autoNameInFlightAt, + lastAttemptAt: record.autoNameLastAttemptAt + ) + let decision = engine.throttleDecision( + snapshot: snapshot, + transcriptLineCount: transcriptLineCount, + now: now + ) + switch decision { + case .proceed: + record.autoNameInFlightAt = now.timeIntervalSince1970 + case .reseedBaseline(let to): + record.autoNameLastLineCount = to + case .skipShortTranscript, .skipInFlight, .skipTooSoon, .skipInsufficientGrowth: + break + } + record.updatedAt = Date().timeIntervalSince1970 + state.sessions[normalized] = record + return AutoNamingBeginOutcome(decision: decision, lastTitle: snapshot.lastTitle) + } + } + + /// Records a completed naming pass. On a confirmed apply, the durable + /// baseline (title, line count, timestamp) advances; on failure only the + /// in-flight marker clears, so the next qualifying Stop retries. + func finishAutoNaming( + sessionId: String, + appliedTitle: String?, + baselineLineCount: Int?, + now: Date + ) throws { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return } + try withLockedSessionState(sessionID: normalized) { state in + guard var record = state.sessions[normalized] else { return } + record.autoNameInFlightAt = nil + // Stamp every completed pass (success or failure) so the throttle + // enforces a cooldown before retrying a failing summarizer. + record.autoNameLastAttemptAt = now.timeIntervalSince1970 + if let appliedTitle, let baselineLineCount { + record.autoNameLastTitle = appliedTitle + record.autoNameLastLineCount = baselineLineCount + record.autoNameLastNamedAt = now.timeIntervalSince1970 + } + record.updatedAt = Date().timeIntervalSince1970 + state.sessions[normalized] = record + } + } + + func clearAgentLifecycleIfPresent( + sessionId: String, + workspaceId: String?, + surfaceId: String? + ) throws { + let normalizedSessionId = normalizeSessionId(sessionId) + guard !normalizedSessionId.isEmpty else { return } + try withLockedSessionState( + sessionID: normalizedSessionId, + workspaceID: workspaceId, + surfaceID: surfaceId + ) { state in + guard var record = state.sessions[normalizedSessionId] else { return } + record.agentLifecycle = .unknown + record.updatedAt = Date().timeIntervalSince1970 + state.sessions[normalizedSessionId] = record + } + } + + @discardableResult + func recordPromptSubmit( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + transcriptPath: String? = nil, + turnId: String? = nil, + previousActivePromptTurnIsTerminal: Bool = false, + terminalActivePromptTurnIds: Set = [], + pid: Int?, + launchCommand: AgentHookLaunchCommandRecord?, + agentLifecycle: AgentHibernationLifecycleState? = nil, + runtimeStatus: AgentHookRuntimeStatus? = nil, + updateRuntimeStatus: Bool = false, + autoNameMessages: [AutoNamingTranscriptMessage] = [], + rejectTerminalTurn: Bool = false + ) throws -> AgentPromptSubmitResult { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return AgentPromptSubmitResult(accepted: false, staleTerminalTurn: false, nested: false) } + return try withLockedSessionState( + sessionID: normalized, + workspaceID: workspaceId, + surfaceID: surfaceId + ) { state in + let now = Date().timeIntervalSince1970 + var record = makeSessionRecord( + state: state, + sessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + now: now + ) + let normalizedTurnId = normalizeOptional(turnId) + if rejectTerminalTurn, + let normalizedTurnId, + terminalPromptTurnSet(from: record).contains(normalizedTurnId) { + return AgentPromptSubmitResult(accepted: false, staleTerminalTurn: true, nested: false) + } + guard update( + &record, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: transcriptPath, + pid: pid, + launchCommand: launchCommand, + isRestorable: nil, + agentLifecycle: agentLifecycle, + lastSubtitle: nil, + lastBody: nil, + lastNotificationStatus: nil, + updateLastNotificationStatus: false, + runtimeStatus: runtimeStatus, + updateRuntimeStatus: updateRuntimeStatus, + now: now + ) else { return AgentPromptSubmitResult(accepted: false, staleTerminalTurn: false, nested: false) } + appendAutoNameMessages(autoNameMessages, to: &record) + if let normalizedTurnId { + markPromptTurnActive(normalizedTurnId, on: &record) + var turnStack = activePromptTurnStack(from: record) + let legacyDepth = max(0, record.activePromptDepth ?? 0) + if turnStack.isEmpty, legacyDepth > 0 { + record.activePromptDepth = legacyDepth + 1 + record.activePromptTurnId = nil + record.activePromptTurnIds = nil + record.lastPromptTurnId = normalizedTurnId + state.sessions[normalized] = record + return AgentPromptSubmitResult(accepted: true, staleTerminalTurn: false, nested: true) + } else if let activeTurnId = turnStack.last, + activeTurnId != normalizedTurnId { + var removedTurnCount = 0 + var removedTerminalTurnIds: [String] = [] + if previousActivePromptTurnIsTerminal { + removedTerminalTurnIds.append(turnStack.removeLast()) + removedTurnCount += 1 + while let activeTurnId = turnStack.last, + terminalActivePromptTurnIds.contains(activeTurnId) { + removedTerminalTurnIds.append(turnStack.removeLast()) + removedTurnCount += 1 + } + } + let totalDepth = max(0, max(legacyDepth, turnStack.count + removedTurnCount) - removedTurnCount) + 1 + turnStack.append(normalizedTurnId) + setActivePromptTurnStack(turnStack, totalDepth: totalDepth, on: &record) + markPromptTurnsTerminal(removedTerminalTurnIds, on: &record) + record.lastPromptTurnId = normalizedTurnId + state.sessions[normalized] = record + return AgentPromptSubmitResult(accepted: true, staleTerminalTurn: false, nested: totalDepth > 1) + } + if turnStack.last == normalizedTurnId { + let totalDepth = max(legacyDepth, turnStack.count) + setActivePromptTurnStack(turnStack, totalDepth: totalDepth, on: &record) + record.lastPromptTurnId = normalizedTurnId + state.sessions[normalized] = record + return AgentPromptSubmitResult(accepted: true, staleTerminalTurn: false, nested: totalDepth > 1) + } + let totalDepth = max(legacyDepth, turnStack.count) + 1 + turnStack.append(normalizedTurnId) + setActivePromptTurnStack(turnStack, totalDepth: totalDepth, on: &record) + record.lastPromptTurnId = normalizedTurnId + state.sessions[normalized] = record + return AgentPromptSubmitResult(accepted: true, staleTerminalTurn: false, nested: totalDepth > 1) + } + let existingTurnStackDepth = activePromptTurnStack(from: record).count + record.activePromptDepth = max(max(0, record.activePromptDepth ?? 0), existingTurnStackDepth) + 1 + state.sessions[normalized] = record + return AgentPromptSubmitResult(accepted: true, staleTerminalTurn: false, nested: (record.activePromptDepth ?? 0) > 1) + } + } + @discardableResult + func recordPromptStop( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + transcriptPath: String? = nil, + turnId: String? = nil, + terminalActivePromptTurnIds: Set = [], + pid: Int?, + launchCommand: AgentHookLaunchCommandRecord?, + agentLifecycle: AgentHibernationLifecycleState? = nil, + lastSubtitle: String?, + lastBody: String?, + lastNotificationStatus: AgentHookNotificationStatus? = nil, + updateLastNotificationStatus: Bool = false, + runtimeStatus: AgentHookRuntimeStatus? = nil, + updateRuntimeStatus: Bool = false, + hadPendingBackgroundWorkAtStop: Bool? = nil, + autoNameMessages: [AutoNamingTranscriptMessage] = [] + ) throws -> AgentPromptStopResult { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return AgentPromptStopResult(accepted: false, nested: false) } + return try withLockedSessionState( + sessionID: normalized, + workspaceID: workspaceId, + surfaceID: surfaceId + ) { state in + let now = Date().timeIntervalSince1970 + let existingRecord = state.sessions[normalized] + let allowsTerminalLaunchCompletion = promptStopIsRootBoundary(existingRecord) + && !recordHasLiveBackgroundAuthority( + existingRecord, + incomingPendingBackgroundWork: hadPendingBackgroundWorkAtStop + ) + let lineage: AgentHookSessionLineage + switch preparePromptStop( + in: &state, + sessionId: normalized, + pid: pid, + allowsTerminalLaunchCompletion: allowsTerminalLaunchCompletion + ) { + case .apply(let preparedLineage): + lineage = preparedLineage + case .completed(let reason, let clearedActiveBoundary): + return AgentPromptStopResult( + accepted: false, + nested: false, + completedGeneration: true, + completionReason: reason, + clearedActiveBoundary: clearedActiveBoundary + ) + case .rejected: + return AgentPromptStopResult(accepted: false, nested: false) + } + var record = makeSessionRecord( + state: state, + sessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + now: now + ) + let depthBeforeStop = max(0, record.activePromptDepth ?? 0) + let depthAfterStop = max(0, depthBeforeStop - 1) + guard update( + &record, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: transcriptPath, + pid: pid, + launchCommand: launchCommand, + isRestorable: nil, + agentLifecycle: depthAfterStop == 0 ? agentLifecycle : .running, + lastSubtitle: lastSubtitle, + lastBody: lastBody, + lastNotificationStatus: lastNotificationStatus, + updateLastNotificationStatus: updateLastNotificationStatus, + runtimeStatus: runtimeStatus, + updateRuntimeStatus: updateRuntimeStatus, + hadPendingBackgroundWorkAtStop: hadPendingBackgroundWorkAtStop, + now: now, + preResolvedLineage: lineage + ) else { return AgentPromptStopResult(accepted: false, nested: false) } + appendAutoNameMessages(autoNameMessages, to: &record) + let normalizedTurnId = normalizeOptional(turnId) + if let normalizedTurnId { + var turnStack = activePromptTurnStack(from: record) + var totalDepthBeforeStop = max(depthBeforeStop, turnStack.count) + let terminalTurnIdsToPrune = terminalActivePromptTurnIds.subtracting([normalizedTurnId]) + if !terminalTurnIdsToPrune.isEmpty { + var removedTerminalTurnIds: [String] = [] + turnStack.removeAll { activeTurnId in + if terminalTurnIdsToPrune.contains(activeTurnId) { + removedTerminalTurnIds.append(activeTurnId) + return true + } + return false + } + if !removedTerminalTurnIds.isEmpty { + totalDepthBeforeStop = max(0, totalDepthBeforeStop - removedTerminalTurnIds.count) + setActivePromptTurnStack(turnStack, totalDepth: totalDepthBeforeStop, on: &record) + markPromptTurnsTerminal(removedTerminalTurnIds, on: &record) + } + } + if let lastTurnId = turnStack.last { + if lastTurnId == normalizedTurnId { + let nested = totalDepthBeforeStop > 1 + turnStack.removeLast() + setActivePromptTurnStack( + turnStack, + totalDepth: max(0, totalDepthBeforeStop - 1), + on: &record + ) + markPromptTurnTerminal(normalizedTurnId, on: &record) + state.sessions[normalized] = record + return AgentPromptStopResult(accepted: true, nested: nested) + } + if let staleIndex = turnStack.lastIndex(of: normalizedTurnId) { + turnStack.remove(at: staleIndex) + setActivePromptTurnStack( + turnStack, + totalDepth: max(0, totalDepthBeforeStop - 1), + on: &record + ) + markPromptTurnTerminal(normalizedTurnId, on: &record) + } else if depthBeforeStop > turnStack.count { + setActivePromptTurnStack( + turnStack, + totalDepth: max(0, totalDepthBeforeStop - 1), + on: &record + ) + markPromptTurnTerminal(normalizedTurnId, on: &record) + } + state.sessions[normalized] = record + return AgentPromptStopResult(accepted: true, nested: true) + } + if totalDepthBeforeStop == 0, terminalPromptTurnSet(from: record).contains(normalizedTurnId) { + state.sessions[normalized] = record + return AgentPromptStopResult(accepted: true, nested: true) + } + markPromptTurnTerminal(normalizedTurnId, on: &record) + if totalDepthBeforeStop == 0 { + state.sessions[normalized] = record + return AgentPromptStopResult(accepted: true, nested: false) + } + let depthAfterTurnStop = max(0, totalDepthBeforeStop - 1) + if depthAfterTurnStop == 0 { + record.activePromptDepth = nil + } else { + record.activePromptDepth = depthAfterTurnStop + } + record.activePromptTurnId = nil + record.activePromptTurnIds = nil + state.sessions[normalized] = record + return AgentPromptStopResult(accepted: true, nested: totalDepthBeforeStop > 1) + } + if depthAfterStop == 0 { + record.activePromptDepth = nil + record.activePromptTurnId = nil + record.activePromptTurnIds = nil + } else { + let turnStack = activePromptTurnStack(from: record) + if !turnStack.isEmpty { + setActivePromptTurnStack( + Array(turnStack.prefix(depthAfterStop)), + totalDepth: depthAfterStop, + on: &record + ) + } else { + record.activePromptDepth = depthAfterStop + } + if let normalizedTurnId, turnStack.isEmpty { + record.activePromptTurnId = normalizedTurnId + record.activePromptTurnIds = Array(repeating: normalizedTurnId, count: depthAfterStop) + } + } + state.sessions[normalized] = record + return AgentPromptStopResult(accepted: true, nested: depthBeforeStop > 1) + } + } + + private func preparePromptStop( + in state: inout ClaudeHookSessionStoreFile, + sessionId: String, + pid: Int?, + allowsTerminalLaunchCompletion: Bool + ) -> PromptStopPreparation { + let existingRecord = state.sessions[sessionId] + let lineage = lineageResolver.resolve( + agentName: agentName, + sessionId: sessionId, + pid: pid, + environment: processEnv + ) + switch AgentPromptStopLineagePolicy().decision( + record: existingRecord, + lineage: lineage, + incomingPID: pid + ) { + case .apply: + return .apply(lineage) + case .completeRecordedGeneration(let reason): + if reason == .terminalLaunch, !allowsTerminalLaunchCompletion { + return .apply(lineage) + } + guard let existingRecord, + AgentSessionTeardownConsumptionPolicy().canConsume(record: existingRecord) else { + return .rejected + } + let completed = completeSessionRecord(existingRecord) + state.sessions[sessionId] = completed + let clearedActiveBoundary = clearActiveSessionIfMatching( + &state, + removed: completed, + turnId: nil + ) + return .completed(reason, clearedActiveBoundary: clearedActiveBoundary) + case .rejectStaleGeneration: + return .rejected + } + } + + private func promptStopIsRootBoundary(_ record: ClaudeHookSessionRecord?) -> Bool { + guard let record else { return true } + let depth = max( + max(0, record.activePromptDepth ?? 0), + activePromptTurnStack(from: record).count + ) + return depth <= 1 + } + + private func recordHasLiveBackgroundAuthority( + _ record: ClaudeHookSessionRecord?, + incomingPendingBackgroundWork: Bool? + ) -> Bool { + if let incomingPendingBackgroundWork { + return incomingPendingBackgroundWork + } + return record?.hadPendingBackgroundWorkAtStop == true + || record?.workloads?.contains(where: { + $0.keepsSessionBusy && $0.phase.isActive + }) == true + } + + func upsert( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + transcriptPath: String? = nil, + pid: Int? = nil, + launchCommand: AgentHookLaunchCommandRecord? = nil, + isRestorable: Bool? = nil, + agentLifecycle: AgentHibernationLifecycleState? = nil, + lastSubtitle: String? = nil, + lastBody: String? = nil, + lastNotificationStatus: AgentHookNotificationStatus? = nil, + updateLastNotificationStatus: Bool = false, + runtimeStatus: AgentHookRuntimeStatus? = nil, + updateRuntimeStatus: Bool = false, + hadPendingBackgroundWorkAtStop: Bool? = nil, + markActive: Bool = false, + turnId: String? = nil, + allowsNewSessionReplacement: Bool = false + ) throws -> Bool { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return false } + return try withLockedSessionState( + sessionID: normalized, + workspaceID: workspaceId, + surfaceID: surfaceId + ) { state in + applyUpsert( + in: &state, + normalizedSessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: transcriptPath, + pid: pid, + launchCommand: launchCommand, + isRestorable: isRestorable, + agentLifecycle: agentLifecycle, + lastSubtitle: lastSubtitle, + lastBody: lastBody, + lastNotificationStatus: lastNotificationStatus, + updateLastNotificationStatus: updateLastNotificationStatus, + runtimeStatus: runtimeStatus, + updateRuntimeStatus: updateRuntimeStatus, + hadPendingBackgroundWorkAtStop: hadPendingBackgroundWorkAtStop, + markActive: markActive, + turnId: turnId, + allowsNewSessionReplacement: allowsNewSessionReplacement, + preResolvedLineage: nil, + now: Date().timeIntervalSince1970 + ) + } + } + + func upsertPromptStop( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + transcriptPath: String? = nil, + pid: Int? = nil, + launchCommand: AgentHookLaunchCommandRecord? = nil, + isRestorable: Bool? = nil, + agentLifecycle: AgentHibernationLifecycleState? = nil, + lastSubtitle: String? = nil, + lastBody: String? = nil, + lastNotificationStatus: AgentHookNotificationStatus? = nil, + updateLastNotificationStatus: Bool = false, + runtimeStatus: AgentHookRuntimeStatus? = nil, + updateRuntimeStatus: Bool = false, + hadPendingBackgroundWorkAtStop: Bool? = nil, + markActive: Bool = false, + turnId: String? = nil, + allowsNewSessionReplacement: Bool = false + ) throws -> AgentPromptStopResult { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { + return AgentPromptStopResult(accepted: false, nested: false) + } + return try withLockedSessionState( + sessionID: normalized, + workspaceID: workspaceId, + surfaceID: surfaceId + ) { state in + let existingRecord = state.sessions[normalized] + let allowsTerminalLaunchCompletion = promptStopIsRootBoundary(existingRecord) + && !recordHasLiveBackgroundAuthority( + existingRecord, + incomingPendingBackgroundWork: hadPendingBackgroundWorkAtStop + ) + let lineage: AgentHookSessionLineage + switch preparePromptStop( + in: &state, + sessionId: normalized, + pid: pid, + allowsTerminalLaunchCompletion: allowsTerminalLaunchCompletion + ) { + case .apply(let preparedLineage): + lineage = preparedLineage + case .completed(let reason, let clearedActiveBoundary): + return AgentPromptStopResult( + accepted: false, + nested: false, + completedGeneration: true, + completionReason: reason, + clearedActiveBoundary: clearedActiveBoundary + ) + case .rejected: + return AgentPromptStopResult(accepted: false, nested: false) + } + let accepted = applyUpsert( + in: &state, + normalizedSessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: transcriptPath, + pid: pid, + launchCommand: launchCommand, + isRestorable: isRestorable, + agentLifecycle: agentLifecycle, + lastSubtitle: lastSubtitle, + lastBody: lastBody, + lastNotificationStatus: lastNotificationStatus, + updateLastNotificationStatus: updateLastNotificationStatus, + runtimeStatus: runtimeStatus, + updateRuntimeStatus: updateRuntimeStatus, + hadPendingBackgroundWorkAtStop: hadPendingBackgroundWorkAtStop, + markActive: markActive, + turnId: turnId, + allowsNewSessionReplacement: allowsNewSessionReplacement, + preResolvedLineage: lineage, + now: Date().timeIntervalSince1970 + ) + return AgentPromptStopResult(accepted: accepted, nested: false) + } + } + + private func applyUpsert( + in state: inout ClaudeHookSessionStoreFile, + normalizedSessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + transcriptPath: String?, + pid: Int?, + launchCommand: AgentHookLaunchCommandRecord?, + isRestorable: Bool?, + agentLifecycle: AgentHibernationLifecycleState?, + lastSubtitle: String?, + lastBody: String?, + lastNotificationStatus: AgentHookNotificationStatus?, + updateLastNotificationStatus: Bool, + runtimeStatus: AgentHookRuntimeStatus?, + updateRuntimeStatus: Bool, + hadPendingBackgroundWorkAtStop: Bool?, + markActive: Bool, + turnId: String?, + allowsNewSessionReplacement: Bool, + preResolvedLineage: AgentHookSessionLineage?, + now: TimeInterval + ) -> Bool { + var record = state.sessions[normalizedSessionId] ?? ClaudeHookSessionRecord( + sessionId: normalizedSessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: nil, + transcriptPath: nil, + pid: nil, + launchCommand: nil, + isRestorable: nil, + agentLifecycle: nil, + lastSubtitle: nil, + lastBody: nil, + lastNotificationStatus: nil, + lastEmittedNotificationFingerprint: nil, + lastEmittedNotificationAt: nil, + runtimeStatus: nil, + activePromptDepth: nil, + activePromptTurnId: nil, + activePromptTurnIds: nil, + lastPromptTurnId: nil, + terminalPromptTurnIds: nil, + startedAt: now, + updatedAt: now + ) + guard update( + &record, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: transcriptPath, + pid: pid, + launchCommand: launchCommand, + isRestorable: isRestorable, + agentLifecycle: agentLifecycle, + lastSubtitle: lastSubtitle, + lastBody: lastBody, + lastNotificationStatus: lastNotificationStatus, + updateLastNotificationStatus: updateLastNotificationStatus, + runtimeStatus: runtimeStatus, + updateRuntimeStatus: updateRuntimeStatus, + hadPendingBackgroundWorkAtStop: hadPendingBackgroundWorkAtStop, + now: now, + preResolvedLineage: preResolvedLineage + ) else { return false } + state.sessions[normalizedSessionId] = record + if markActive { + let activeRecord = ClaudeHookActiveSessionRecord( + sessionId: normalizedSessionId, + turnId: normalizeOptional(turnId), + allowsNewSessionReplacement: allowsNewSessionReplacement ? true : nil, + updatedAt: now + ) + if let normalizedWorkspace = normalizeOptional(workspaceId) { + state.activeSessionsByWorkspace[normalizedWorkspace] = activeRecord + } + if let normalizedSurface = normalizeOptional(surfaceId) { + state.activeSessionsBySurface[normalizedSurface] = activeRecord + } + } + return true + } + + @discardableResult + func upsertCodexSessionStartIfFresh( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + transcriptPath: String? = nil, + pid: Int? = nil, + launchCommand: AgentHookLaunchCommandRecord? = nil, + agentLifecycle: AgentHibernationLifecycleState? = nil, + runtimeStatus: AgentHookRuntimeStatus? = nil, + updateRuntimeStatus: Bool = false + ) throws -> Bool { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return false } + return try withLockedSessionState( + sessionID: normalized, + workspaceID: workspaceId, + surfaceID: surfaceId + ) { state in + let now = Date().timeIntervalSince1970 + var record = makeSessionRecord( + state: state, + sessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + now: now + ) + if codexSessionStartIsStale(record, incomingPID: pid) { + return false + } + clearCodexSessionStartTurnState(on: &record) + guard update( + &record, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: transcriptPath, + pid: pid, + launchCommand: launchCommand, + isRestorable: nil, + agentLifecycle: agentLifecycle, + lastSubtitle: nil, + lastBody: nil, + lastNotificationStatus: nil, + updateLastNotificationStatus: false, + runtimeStatus: runtimeStatus, + updateRuntimeStatus: updateRuntimeStatus, + now: now + ) else { return false } + state.sessions[normalized] = record + return true + } + } + + @discardableResult + func upsertCodexPromptRunningIfFresh( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + transcriptPath: String? = nil, + turnId: String? = nil, + pid: Int? = nil, + launchCommand: AgentHookLaunchCommandRecord? = nil + ) throws -> Bool { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return false } + return try withLockedSessionState( + sessionID: normalized, + workspaceID: workspaceId, + surfaceID: surfaceId + ) { state in + let now = Date().timeIntervalSince1970 + var record = makeSessionRecord( + state: state, + sessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + now: now + ) + if let normalizedTurnId = normalizeOptional(turnId), + terminalPromptTurnSet(from: record).contains(normalizedTurnId) { + return false + } + guard update( + &record, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: transcriptPath, + pid: pid, + launchCommand: launchCommand, + isRestorable: nil, + agentLifecycle: .running, + lastSubtitle: nil, + lastBody: nil, + lastNotificationStatus: nil, + updateLastNotificationStatus: false, + runtimeStatus: .running, + updateRuntimeStatus: true, + now: now + ) else { return false } + state.sessions[normalized] = record + return true + } + } + + func codexSessionStartIsStale( + sessionId: String, + incomingPID: Int?, + includeTerminalPromptTurnIds: Bool = true + ) throws -> Bool { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return false } + return try withSessionSnapshot(sessionID: normalized) { record in + guard let record else { return false } + return codexSessionStartIsStale( + record, + incomingPID: incomingPID, + includeTerminalPromptTurnIds: includeTerminalPromptTurnIds + ) + } + } + + func codexPromptTurnIsTerminal(sessionId: String, turnId: String?) throws -> Bool { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty, let normalizedTurnId = normalizeOptional(turnId) else { return false } + return try withSessionSnapshot(sessionID: normalized) { record in + guard let record else { return false } + return terminalPromptTurnSet(from: record).contains(normalizedTurnId) + } + } + + @discardableResult func markNotificationResolved( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + transcriptPath: String? = nil, + pid: Int? = nil, + launchCommand: AgentHookLaunchCommandRecord? = nil, + agentLifecycle: AgentHibernationLifecycleState? = nil, + runtimeStatus: AgentHookRuntimeStatus? = nil + ) throws -> Bool { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return false } + return try withLockedSessionState( + sessionID: normalized, + workspaceID: workspaceId, + surfaceID: surfaceId + ) { state in + let now = Date().timeIntervalSince1970 + var record = makeSessionRecord( + state: state, + sessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + now: now + ) + guard update( + &record, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: transcriptPath, + pid: pid, + launchCommand: launchCommand, + isRestorable: nil, + agentLifecycle: agentLifecycle, + lastSubtitle: nil, + lastBody: nil, + lastNotificationStatus: nil, + updateLastNotificationStatus: true, + runtimeStatus: runtimeStatus, + updateRuntimeStatus: runtimeStatus != nil, + now: now + ) else { return false } + record.lastSubtitle = nil; record.lastBody = nil; record.lastNotificationStatus = nil + state.sessions[normalized] = record; return true + } + } + + private func makeSessionRecord( + state: ClaudeHookSessionStoreFile, + sessionId: String, + workspaceId: String, + surfaceId: String, + now: TimeInterval + ) -> ClaudeHookSessionRecord { + state.sessions[sessionId] ?? ClaudeHookSessionRecord( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: nil, + transcriptPath: nil, + pid: nil, + launchCommand: nil, + isRestorable: nil, + agentLifecycle: nil, + lastSubtitle: nil, + lastBody: nil, + lastNotificationStatus: nil, + lastEmittedNotificationFingerprint: nil, + lastEmittedNotificationAt: nil, + runtimeStatus: nil, + activePromptDepth: nil, + activePromptTurnId: nil, + activePromptTurnIds: nil, + lastPromptTurnId: nil, + terminalPromptTurnIds: nil, + startedAt: now, + updatedAt: now + ) + } + + private func activePromptTurnStack(from record: ClaudeHookSessionRecord) -> [String] { + if let activePromptTurnIds = record.activePromptTurnIds { + let normalized = activePromptTurnIds.compactMap { normalizeOptional($0) } + if !normalized.isEmpty { + return normalized + } + } + if let activePromptTurnId = normalizeOptional(record.activePromptTurnId) { + return [activePromptTurnId] + } + return [] + } + + private func setActivePromptTurnStack(_ stack: [String], totalDepth: Int? = nil, on record: inout ClaudeHookSessionRecord) { + let normalizedStack = stack.compactMap { normalizeOptional($0) } + let resolvedDepth = max(max(0, totalDepth ?? normalizedStack.count), normalizedStack.count) + if resolvedDepth == 0 { + record.activePromptDepth = nil + record.activePromptTurnId = nil + record.activePromptTurnIds = nil + } else { + record.activePromptDepth = resolvedDepth + record.activePromptTurnId = normalizedStack.last + record.activePromptTurnIds = normalizedStack.isEmpty ? nil : normalizedStack + } + } + + private func terminalPromptTurnStack(from record: ClaudeHookSessionRecord) -> [String] { + record.terminalPromptTurnIds?.compactMap { normalizeOptional($0) } ?? [] + } + + private func terminalPromptTurnSet(from record: ClaudeHookSessionRecord) -> Set { + Set(terminalPromptTurnStack(from: record)) + } + + private func codexSessionStartIsStale( + _ record: ClaudeHookSessionRecord, + incomingPID: Int?, + includeTerminalPromptTurnIds: Bool = true + ) -> Bool { + if max(record.activePromptDepth ?? 0, record.activePromptTurnIds?.count ?? 0) > 0 { + // SessionStart is asynchronous. A late hook from the process that + // owns the active turn must not erase that turn, but a resumed or + // restored Codex process is a new generation and must be allowed to + // replace state left behind when the previous TUI exited mid-turn. + guard let incomingPID, let existingPID = record.pid else { return true } + return incomingPID == existingPID + } + let hasCompletedTurnState = normalizeOptional(record.lastPromptTurnId) != nil + || (includeTerminalPromptTurnIds && !terminalPromptTurnSet(from: record).isEmpty) + guard hasCompletedTurnState, + let incomingPID, + let existingPID = record.pid else { + return false + } + return incomingPID == existingPID + } + + private func clearCodexSessionStartTurnState(on record: inout ClaudeHookSessionRecord) { + record.activePromptDepth = nil + record.activePromptTurnId = nil + record.activePromptTurnIds = nil + record.lastPromptTurnId = nil + } + + private func markPromptTurnActive(_ turnId: String, on record: inout ClaudeHookSessionRecord) { + var terminalTurnIds = terminalPromptTurnStack(from: record) + terminalTurnIds.removeAll { $0 == turnId } + record.terminalPromptTurnIds = terminalTurnIds.isEmpty ? nil : terminalTurnIds + } + + private func markPromptTurnsTerminal(_ turnIds: [String], on record: inout ClaudeHookSessionRecord) { + for turnId in turnIds { + markPromptTurnTerminal(turnId, on: &record) + } + } + + private func markPromptTurnTerminal(_ turnId: String, on record: inout ClaudeHookSessionRecord) { + guard let normalizedTurnId = normalizeOptional(turnId) else { return } + var terminalTurnIds = terminalPromptTurnStack(from: record) + terminalTurnIds.removeAll { $0 == normalizedTurnId } + terminalTurnIds.append(normalizedTurnId) + if terminalTurnIds.count > Self.maxRememberedTerminalPromptTurnIds { + terminalTurnIds.removeFirst(terminalTurnIds.count - Self.maxRememberedTerminalPromptTurnIds) + } + record.lastPromptTurnId = normalizedTurnId + record.terminalPromptTurnIds = terminalTurnIds.isEmpty ? nil : terminalTurnIds + } + + private func appendAutoNameMessages( + _ messages: [AutoNamingTranscriptMessage], + to record: inout ClaudeHookSessionRecord + ) { + guard !messages.isEmpty else { return } + var recent = record.autoNameRecentMessages ?? [] + var appendedCount = 0 + for message in messages { + guard let normalized = normalizedAutoNameMessage(message) else { continue } + if recent.last == normalized { continue } + recent.append(normalized) + appendedCount += 1 + } + if recent.count > Self.maxAutoNameRecentMessages { + recent.removeFirst(recent.count - Self.maxAutoNameRecentMessages) + } + record.autoNameRecentMessages = recent.isEmpty ? nil : recent + if appendedCount > 0 { + record.autoNameMessageSequence = (record.autoNameMessageSequence ?? 0) + appendedCount + } + } + + private func normalizedAutoNameMessage(_ message: AutoNamingTranscriptMessage) -> AutoNamingTranscriptMessage? { + let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard role == "user" || role == "assistant" else { return nil } + let text = autoNameNormalizedSingleLine(message.text) + guard !text.isEmpty else { return nil } + return AutoNamingTranscriptMessage( + role: role, + text: autoNameTruncate(text, maxLength: Self.maxAutoNameMessageCharacters) + ) + } + + private func autoNameNormalizedSingleLine(_ value: String) -> String { + let collapsed = value.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + return collapsed.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func autoNameTruncate(_ value: String, maxLength: Int) -> String { + guard value.count > maxLength else { return value } + let index = value.index(value.startIndex, offsetBy: max(0, maxLength - 1)) + return String(value[.. Bool { + let lineage = preResolvedLineage ?? lineageResolver.resolve( + agentName: agentName, + sessionId: record.sessionId, + pid: pid, + environment: processEnv + ) + let activationDecision = AgentHookSessionActivationPolicy().decision( + record: record, + lineage: lineage, + hasIncomingPID: pid != nil + ) + guard case let .activate(activationProof) = activationDecision else { + return false + } + record.workspaceId = workspaceId + if !surfaceId.isEmpty { + record.surfaceId = surfaceId + } + if let cwd = normalizeOptional(cwd) { + record.cwd = cwd + } + if let transcriptPath = normalizeOptional(transcriptPath) { + record.transcriptPath = transcriptPath + } + if let pid { + record.pid = pid + } + if let launchCommand { + let existingHasArguments = !(record.launchCommand?.arguments.isEmpty ?? true) + let incomingHasArguments = !launchCommand.arguments.isEmpty + let incomingHasEnvironment = !(launchCommand.environment?.isEmpty ?? true) + // Persist an argv-bearing record always. Persist an argv-less, env-only record (the + // CODEX_HOME / CLAUDE_CONFIG_DIR fallback for a plain agent whose launch argv couldn't be + // captured) only when we don't already hold an argv-bearing one — so the durable store + // keeps the non-default home for the fork/resume path without ever downgrading a richer + // earlier capture to an env-only stub. + if incomingHasArguments || normalizeOptional(launchCommand.source)?.lowercased() == "rejected" || (normalizeOptional(launchCommand.source)?.lowercased() == "default" && !existingHasArguments && normalizeOptional(record.launchCommand?.environment?["CODEX_HOME"]) == nil) || (incomingHasEnvironment && !existingHasArguments) { + record.launchCommand = launchCommand + } + } + if let isRestorable { + // Preserve sticky true: a later isRestorable=false must not clear + // record.isRestorable=true from a transcript-backed event. + record.isRestorable = isRestorable || record.isRestorable == true + } + if let agentLifecycle { + record.agentLifecycle = agentLifecycle + } + if let subtitle = normalizeOptional(lastSubtitle) { + record.lastSubtitle = subtitle + } + if let body = normalizeOptional(lastBody) { + record.lastBody = body + } + if updateLastNotificationStatus { + record.lastNotificationStatus = lastNotificationStatus + } + if updateRuntimeStatus { + record.runtimeStatus = runtimeStatus + } + if let hadPendingBackgroundWorkAtStop { + record.hadPendingBackgroundWorkAtStop = hadPendingBackgroundWorkAtStop + } + record.completedAt = nil + record.cmuxRestoreAdoptionId = nil + record.cmuxHibernationAttemptId = nil + record.cmuxHibernatedAt = nil + record.cmuxHibernationDetached = nil + record.cmuxHibernationResumeAttemptId = nil + record.cmuxHibernationResumeStartedAt = nil + record.cmuxHibernationResumeFromAttemptId = nil + record.sessionState = .active + recordSessionRun( + &record, + lineage: lineage, + activationProof: activationProof, + now: now + ) + record.updatedAt = now + return true + } + + private func recordSessionRun( + _ record: inout ClaudeHookSessionRecord, + lineage: AgentHookSessionLineage, + activationProof: AgentHookSessionActivationProof, + now: TimeInterval + ) { + var effectiveLineage = lineage + effectiveLineage.hibernationResumeAttemptId = nil + switch lineage.processLaunchMode { + case .oneShot, .nonSession: + // Utility and print/exec processes may publish hooks while they are + // alive, but they never own an interactive conversation that cmux + // may replay after hibernation or app restore. + effectiveLineage.restoreAuthority = false + case .unknown where lineage.processStartedAt != nil: + // A live native process with an argv shape we do not understand is + // not safe to replay unless the protected lifecycle transition + // proved its exact cmux resume attempt, or a later duplicate hook + // describes that same already-authoritative process generation. + // This catches newly added one-shot provider flags without breaking + // legacy hook payloads that carry no PID. + switch activationProof { + case .exactHibernationResumeAttempt(let attemptId) + where lineage.hibernationResumeAttemptId == attemptId + && lineage.processDescribesAgent + && lineage.restoreAuthority: + effectiveLineage.hibernationResumeAttemptId = attemptId + break + case .existingVerifiedResumeGeneration(let attemptId, let runId, let pid, let processStartedAt) + where lineage.hibernationResumeAttemptId == attemptId + && lineage.runId == runId + && lineage.pid == pid + && lineage.processStartedAt.map { + abs($0 - processStartedAt) <= 0.001 + } == true + && lineage.processDescribesAgent + && lineage.restoreAuthority: + effectiveLineage.hibernationResumeAttemptId = attemptId + break + case .ordinary, + .exactHibernationResumeAttempt(_), + .existingVerifiedResumeGeneration(_, _, _, _): + effectiveLineage.restoreAuthority = false + } + case .interactive, .unknown: + break + } + let runs = AgentSessionRunReconciler(maximumRecords: Self.maxRunsPerSession).reconciling( + record.runs ?? [], + activeRunId: record.activeRunId, + lineage: effectiveLineage, + now: now + ) + record.runs = runs + record.activeRunId = effectiveLineage.runId + record.runId = effectiveLineage.runId + let activeRun = runs.first { $0.runId == effectiveLineage.runId } + record.cmuxRuntime = activeRun?.cmuxRuntime + record.parentRunId = activeRun?.parentRunId + record.restoreAuthority = activeRun?.restoreAuthority ?? false + record.parentSessionId = activeRun?.parentSessionId + record.relationship = activeRun?.relationship + } + + func clearNotificationEmission(sessionId: String) throws { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return } + try withLockedSessionState(sessionID: normalized) { state in + guard var record = state.sessions[normalized] else { return } + let now = Date().timeIntervalSince1970 + record.lastEmittedNotificationFingerprint = nil + record.lastEmittedNotificationAt = nil + record.recentEmittedNotificationFingerprints = nil + record.updatedAt = now + state.sessions[normalized] = record + } + } + + func recentlyEmittedNotification( + sessionId: String, + fingerprint: String, + within interval: TimeInterval = 60 * 60 + ) throws -> Bool { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return false } + let normalizedFingerprint = fingerprint.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedFingerprint.isEmpty else { return false } + return try withSessionSnapshot(sessionID: normalized) { record in + guard let record else { return false } + let now = Date().timeIntervalSince1970 + if let emittedAt = record.recentEmittedNotificationFingerprints?[normalizedFingerprint], + now - emittedAt <= interval { + return true + } + guard record.lastEmittedNotificationFingerprint == normalizedFingerprint, + let emittedAt = record.lastEmittedNotificationAt else { + return false + } + return now - emittedAt <= interval + } + } + + func markNotificationEmitted(sessionId: String, fingerprint: String) throws { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return } + let normalizedFingerprint = fingerprint.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedFingerprint.isEmpty else { return } + try withLockedSessionState(sessionID: normalized) { state in + guard var record = state.sessions[normalized] else { return } + let now = Date().timeIntervalSince1970 + record.lastEmittedNotificationFingerprint = normalizedFingerprint + record.lastEmittedNotificationAt = now + var recent = record.recentEmittedNotificationFingerprints ?? [:] + recent[normalizedFingerprint] = now + recent = recent.filter { now - $0.value <= 60 * 60 } + if recent.count > 16 { + let keep = recent.sorted { lhs, rhs in + if lhs.value == rhs.value { return lhs.key < rhs.key } + return lhs.value > rhs.value + }.prefix(16) + recent = Dictionary(uniqueKeysWithValues: keep.map { ($0.key, $0.value) }) + } + record.recentEmittedNotificationFingerprints = recent.isEmpty ? nil : recent + record.updatedAt = now + state.sessions[normalized] = record + } + } + + func hasRunningSession( + workspaceId: String, + surfaceId: String?, + excludingSessionId: String?, + onlyNewerThanExcludedSession: Bool = false, + requireLiveProcess: Bool = false + ) throws -> Bool { + guard let normalizedWorkspace = normalizeOptional(workspaceId) else { + return false + } + let normalizedSurface = normalizeOptional(surfaceId) + let excluded = normalizeOptional(excludingSessionId) + let excludedUpdatedAt = try excluded.flatMap { + try registryBridge.lookup(sessionID: $0, decoder: decoder)?.updatedAt + } + let candidates = try registryBridge.runningRecords( + workspaceID: normalizedWorkspace, + surfaceID: normalizedSurface, + decoder: decoder + ) + let canonicalizer = AgentSessionRunCanonicalizer() + for candidate in candidates { + guard candidate.sessionId != excluded else { continue } + if onlyNewerThanExcludedSession, let excludedUpdatedAt, + candidate.updatedAt <= excludedUpdatedAt { + continue + } + guard requireLiveProcess else { return true } + let observedRun = canonicalizer.projectedRun(record: candidate, provider: agentName) + let processIsLive = if observedRun.processStartedAt != nil { + lineageResolver.processState( + pid: candidate.pid, + expectedStartedAt: observedRun.processStartedAt + ) == .alive + } else { + Self.processExists(candidate.pid) + } + if observedRun.pid == candidate.pid, + processIsLive { + return true + } + + let observedPID = candidate.pid + let observedRunID = observedRun.runId + let observedProcessStartedAt = observedRun.processStartedAt + try withLockedSessionState( + sessionID: candidate.sessionId, + workspaceID: candidate.workspaceId, + surfaceID: candidate.surfaceId + ) { state in + guard var current = state.sessions[candidate.sessionId], + current.runtimeStatus == .running, + current.pid == observedPID else { return } + let currentRun = canonicalizer.projectedRun(record: current, provider: agentName) + guard currentRun.runId == observedRunID, + currentRun.pid == observedPID, + currentRun.processStartedAt == observedProcessStartedAt else { return } + current.runtimeStatus = nil + current.updatedAt = Date().timeIntervalSince1970 + state.sessions[candidate.sessionId] = current + } + } + return false + } + + private static func processExists(_ pid: Int?) -> Bool { + guard let pid, pid > 0 else { return false } + if kill(pid_t(pid), 0) == 0 { + return true + } + return errno == EPERM + } + + /// Returns true when an event belongs to the workspace's active Claude session. + /// It fails open when the event cannot identify a session/workspace, when no + /// active session is registered yet, or when either side lacks a turnId so + /// multi-turn continuations can proceed after Stop clears the active turn. + func isCurrent( + sessionId: String?, + workspaceId: String, + surfaceId: String? = nil, + turnId: String? = nil + ) throws -> Bool { + guard let normalizedSessionId = normalizeOptional(sessionId), + let normalizedWorkspace = normalizeOptional(workspaceId) else { + return true + } + var state = try registryBridge.activeContext( + workspaceID: normalizedWorkspace, + surfaceID: normalizeOptional(surfaceId), + decoder: decoder + ) + backfillSurfaceActiveSlots(&state) + return { + // The pane's own active boundary decides first: a hook is stale when a + // DIFFERENT session was promoted in the SAME surface (post-/clear or + // replaced-session races in one pane). This stays true even after a + // sibling pane — e.g. a forked conversation in a split — later takes + // the single workspace-active slot. + // https://github.com/manaflow-ai/cmux/issues/5908 + if let normalizedSurfaceId = normalizeOptional(surfaceId), + let surfaceActive = state.activeSessionsBySurface[normalizedSurfaceId] { + guard surfaceActive.sessionId == normalizedSessionId else { + return false + } + guard let activeTurnId = normalizeOptional(surfaceActive.turnId), + let normalizedTurnId = normalizeOptional(turnId) else { + return true + } + return activeTurnId == normalizedTurnId + } + guard let active = state.activeSessionsByWorkspace[normalizedWorkspace] else { + return true + } + guard active.sessionId == normalizedSessionId else { + // Legacy fallback for stores written before per-surface tracking: + // a different active session only makes this hook stale when that + // session lives in the SAME surface; concurrent sessions in + // sibling panes stay current for their own surface. + guard let normalizedSurfaceId = normalizeOptional(surfaceId), + let activeRecord = state.sessions[active.sessionId], + let activeSurfaceId = normalizeOptional(activeRecord.surfaceId) else { + // Cross-surface protection needs both surfaces; when the caller + // omits surfaceId or the active session's record is gone/surface- + // less, fall back to the stricter workspace-scoped staleness. + return false + } + return activeSurfaceId != normalizedSurfaceId + } + guard let activeTurnId = normalizeOptional(active.turnId), + let normalizedTurnId = normalizeOptional(turnId) else { + return true + } + return activeTurnId == normalizedTurnId + }() + } + + func canReplaceActiveSession( + sessionId: String?, + workspaceId: String, + surfaceId: String? = nil + ) throws -> Bool { + guard let normalizedSessionId = normalizeOptional(sessionId), + let normalizedWorkspace = normalizeOptional(workspaceId) else { + return false + } + var state = try registryBridge.activeContext( + workspaceID: normalizedWorkspace, + surfaceID: normalizeOptional(surfaceId), + decoder: decoder + ) + backfillSurfaceActiveSlots(&state) + return { + // Replacement is pane-scoped like staleness: a stopped session in + // THIS surface allows its own pane to start a new session even when + // another pane currently holds the workspace-active slot. + // https://github.com/manaflow-ai/cmux/issues/5908 + if let normalizedSurfaceId = normalizeOptional(surfaceId), + let surfaceActive = state.activeSessionsBySurface[normalizedSurfaceId] { + guard surfaceActive.sessionId != normalizedSessionId else { + return false + } + return surfaceActive.allowsNewSessionReplacement == true + } + guard let active = state.activeSessionsByWorkspace[normalizedWorkspace], + active.sessionId != normalizedSessionId else { + return false + } + return active.allowsNewSessionReplacement == true + }() + } + + func consume( + sessionId: String?, + workspaceId: String?, + surfaceId: String?, + turnId: String? = nil + ) throws -> ClaudeHookSessionRecord? { + let normalizedSessionId = normalizeOptional(sessionId) + let normalizedWorkspace = normalizeOptional(workspaceId) + let normalizedSurface = normalizeOptional(surfaceId) + let exact = try normalizedSessionId.flatMap { + try registryBridge.lookup(sessionID: $0, decoder: decoder) + } + let target: ClaudeHookSessionRecord + if let exact { + target = exact + } else { + let fallbacks = try registryBridge.fallbackRecords( + workspaceID: normalizedWorkspace, + surfaceID: normalizedSurface, + decoder: decoder + ) + if normalizedSurface != nil { + guard let fallback = fallbacks.first else { return nil } + target = fallback + } else { + guard fallbacks.count == 1, let fallback = fallbacks.first else { return nil } + target = fallback + } + } + return try withLockedSessionState( + sessionID: target.sessionId, + workspaceID: target.workspaceId, + surfaceID: target.surfaceId + ) { state in + guard let existing = state.sessions[target.sessionId], + AgentSessionTeardownConsumptionPolicy().canConsume(record: existing) else { + return nil + } + guard !hasActiveTurnMismatch(state, record: existing, turnId: turnId) else { + return nil + } + let completed = completeSessionRecord(existing) + state.sessions[target.sessionId] = completed + clearActiveSessionIfMatching(&state, removed: completed, turnId: turnId) + return completed + } + } + + + private func hasActiveTurnMismatch( + _ state: ClaudeHookSessionStoreFile, + record: ClaudeHookSessionRecord, + turnId: String? + ) -> Bool { + guard let incomingTurnId = normalizeOptional(turnId) else { + return false + } + // Consult the pane-scoped slot alongside the workspace slot: once a + // sibling pane takes the single workspace-active slot, only the + // surface slot still proves that this session is mid-turn in its own + // pane and a stale SessionEnd from an older turn must not consume it. + // https://github.com/manaflow-ai/cmux/issues/5908 + var activeRecords: [ClaudeHookActiveSessionRecord] = [] + if let workspaceId = normalizeOptional(record.workspaceId), + let active = state.activeSessionsByWorkspace[workspaceId] { + activeRecords.append(active) + } + if let surfaceId = normalizeOptional(record.surfaceId), + let active = state.activeSessionsBySurface[surfaceId] { + activeRecords.append(active) + } + return activeRecords.contains { active in + guard active.sessionId == record.sessionId, + let activeTurnId = normalizeOptional(active.turnId) else { + return false + } + return activeTurnId != incomingTurnId + } + } + + @discardableResult + private func clearActiveSessionIfMatching( + _ state: inout ClaudeHookSessionStoreFile, + removed: ClaudeHookSessionRecord, + turnId: String? + ) -> Bool { + var cleared = false + let incomingTurnId = normalizeOptional(turnId) + func matches(_ active: ClaudeHookActiveSessionRecord) -> Bool { + guard active.sessionId == removed.sessionId else { return false } + if let activeTurnId = normalizeOptional(active.turnId), + let incomingTurnId, + activeTurnId != incomingTurnId { + return false + } + return true + } + if let workspaceId = normalizeOptional(removed.workspaceId), + let active = state.activeSessionsByWorkspace[workspaceId], + matches(active) { + state.activeSessionsByWorkspace.removeValue(forKey: workspaceId) + cleared = true + } + for (surfaceId, active) in state.activeSessionsBySurface where matches(active) { + state.activeSessionsBySurface.removeValue(forKey: surfaceId) + cleared = true + } + return cleared + } + + private func withLockedSessionState( + sessionID: String, + workspaceID: String? = nil, + surfaceID: String? = nil, + _ body: (inout ClaudeHookSessionStoreFile) throws -> T + ) throws -> T { + try registryBridge.mutateSession( + sessionID: sessionID, + workspaceID: workspaceID, + surfaceID: surfaceID + ) { state in + backfillSurfaceActiveSlots(&state) + canonicalizeSessionRunsForMutation(&state, sessionID: sessionID) + return try body(&state) + }.result + } + + private func canonicalizeSessionRunsForMutation( + _ state: inout ClaudeHookSessionStoreFile, + sessionID: String + ) { + guard var record = state.sessions[sessionID], record.runs?.isEmpty == false else { return } + let canonicalizer = AgentSessionRunCanonicalizer() + let runs = canonicalizer.runs(record: record, provider: agentName) + let projectedRun = canonicalizer.projectedRun( + canonicalRuns: runs, + activeRunID: record.activeRunId + ) + record.runs = runs + record.runId = projectedRun.runId + record.parentRunId = projectedRun.parentRunId + record.parentSessionId = projectedRun.parentSessionId + record.relationship = projectedRun.relationship + record.restoreAuthority = projectedRun.restoreAuthority + state.sessions[sessionID] = record + } + + private func withSessionSnapshot( + sessionID: String, + _ body: (ClaudeHookSessionRecord?) -> T + ) throws -> T { + try body(registryBridge.lookup(sessionID: sessionID, decoder: decoder)) + } + + /// Read-only hook decisions use an immutable file snapshot. Writers publish + /// with an atomic rename, so readers observe either the complete previous + /// state or the complete next state without joining the global writer lock. + /// This keeps prompt hooks responsive when many cmux versions and agents + /// share the durable history file. Pruning remains a writer responsibility. + private func withSnapshotState(_ body: (ClaudeHookSessionStoreFile) -> T) -> T { + body(loadUnlocked()) + } + + private func loadUnlocked() -> ClaudeHookSessionStoreFile { + var decoded = AgentHookSessionRegistryBridge( + provider: agentName, + statePath: statePath, + environment: processEnv, + fileManager: fileManager + ).load(decoder: decoder) + backfillSurfaceActiveSlots(&decoded) + return decoded + } + + /// Stores written before per-surface tracking (or rewritten by an older + /// CLI, which drops the unknown key) carry only workspace-active slots. + /// Rebuild the pane boundary from each workspace-active session's recorded + /// surface so pre-upgrade panes keep suppressing stale hooks after a + /// sibling pane takes the workspace slot. + /// https://github.com/manaflow-ai/cmux/issues/5908 + private func backfillSurfaceActiveSlots(_ state: inout ClaudeHookSessionStoreFile) { + guard state.activeSessionsBySurface.isEmpty else { return } + for active in state.activeSessionsByWorkspace.values { + guard let surfaceId = normalizeOptional(state.sessions[active.sessionId]?.surfaceId) else { + continue + } + state.activeSessionsBySurface[surfaceId] = active + } + } + + private func normalizeSessionId(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func normalizeOptional(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } +} diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index 12b68a19b18e..93074cf28558 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -246,6 +246,18 @@ struct FeedEventClassifier { "agentSpawn": .sessionStart, "stop": .response, ], + // Copilot's native hook files use lower-camel event names. It has no + // dedicated approval event, so preToolUse remains the maybe-approval + // bridge while its lifecycle events stay non-blocking telemetry. + "copilot": [ + "preToolUse": .toolStartMaybeApproval, + "postToolUse": .toolEnd, + "userPromptSubmit": .promptSubmit, + "sessionStart": .sessionStart, + "sessionEnd": .sessionEnd, + "agentStop": .response, + "notification": .statusNotification, + ], ] /// Fallback table for agents without a dedicated entry in diff --git a/CLI/SessionListEntryAccumulator.swift b/CLI/SessionListEntryAccumulator.swift new file mode 100644 index 000000000000..18d620b69b28 --- /dev/null +++ b/CLI/SessionListEntryAccumulator.swift @@ -0,0 +1,149 @@ +import CmuxFoundation +import Foundation + +/// Retains the exact sorted prefix for a bounded `agents list` query without +/// keeping every matching session payload in memory. +struct SessionListEntryAccumulator { + typealias Enrichment = (inout [String: Any]) -> Void + typealias PayloadFactory = () -> [String: Any] + typealias SortValues = CmuxAgentSessionRegistry.HookListSortValues + + private struct Entry { + var updatedAt: TimeInterval + var sortValues: SortValues + var payloadFactory: PayloadFactory + } + + private let limit: Int + private var retained: [Entry] = [] + private(set) var totalCount = 0 + + init(limit: Int) { + precondition(limit > 0) + self.limit = limit + if limit != Int.max { retained.reserveCapacity(min(limit, 1_024)) } + } + + var retainedCount: Int { retained.count } + + /// Accounts for matches proven by the registry count query whose payloads + /// were intentionally not decoded because they cannot enter this top K. + mutating func addUnmaterializedMatches(_ count: Int) { + precondition(count >= 0) + let sum = totalCount.addingReportingOverflow(count) + totalCount = sum.overflow ? Int.max : sum.partialValue + } + + var sortedPayloads: [[String: Any]] { + var payloads: [[String: Any]] = [] + payloads.reserveCapacity(retained.count) + forEachSortedPayload { payloads.append($0) } + return payloads + } + + func forEachSortedPayload( + _ visit: ([String: Any]) throws -> Void + ) rethrows { + for entry in retained.sorted(by: Self.isOrderedBefore) { + try autoreleasepool { + let payload = entry.payloadFactory() + try visit(payload) + // Swift and Foundation enrichment objects are released when + // the pool exits. Unbounded `--all` output retains compact + // sources instead of materialized rows across iterations. + } + } + } + + mutating func insert( + updatedAt: TimeInterval, + payload: [String: Any], + enrichment: Enrichment? = nil + ) { + insert( + updatedAt: updatedAt, + sortValues: SortValues(payload: payload), + payloadFactory: { + var result = payload + enrichment?(&result) + return result + } + ) + } + + mutating func insert( + updatedAt: TimeInterval, + sortValues: SortValues, + payloadFactory: @escaping PayloadFactory + ) { + totalCount += 1 + let entry = Entry( + updatedAt: updatedAt, + sortValues: sortValues, + payloadFactory: payloadFactory + ) + guard limit != Int.max else { + retained.append(entry) + return + } + guard retained.count == limit else { + retained.append(entry) + siftUp(from: retained.count - 1) + return + } + guard let worst = retained.first, Self.isOrderedBefore(entry, worst) else { return } + retained[0] = entry + siftDown(from: 0) + } + + private mutating func siftUp(from start: Int) { + var child = start + while child > 0 { + let parent = (child - 1) / 2 + guard Self.isWorse(retained[child], than: retained[parent]) else { return } + retained.swapAt(child, parent) + child = parent + } + } + + private mutating func siftDown(from start: Int) { + var parent = start + while true { + let left = parent * 2 + 1 + guard left < retained.count else { return } + let right = left + 1 + let worseChild = right < retained.count && Self.isWorse(retained[right], than: retained[left]) + ? right + : left + guard Self.isWorse(retained[worseChild], than: retained[parent]) else { return } + retained.swapAt(parent, worseChild) + parent = worseChild + } + } + + private static func isOrderedBefore(_ lhs: Entry, _ rhs: Entry) -> Bool { + CmuxAgentSessionRegistry.HookListOrderKey.isOrderedBefore( + .init(updatedAt: lhs.updatedAt, sortValues: lhs.sortValues), + .init(updatedAt: rhs.updatedAt, sortValues: rhs.sortValues) + ) + } + + private static func isWorse(_ lhs: Entry, than rhs: Entry) -> Bool { + isOrderedBefore(rhs, lhs) + } +} + +private extension CmuxAgentSessionRegistry.HookListSortValues { + init(payload: [String: Any]) { + self.init( + sessionID: payload["session_id"] as? String, + agent: payload["agent"] as? String, + runID: payload["run_id"] as? String, + workspaceID: payload["workspace_id"] as? String, + surfaceID: payload["surface_id"] as? String, + identitySource: payload["identity_source"] as? String, + pid: payload["pid"] as? Int, + processStartedAt: payload["process_started_at"] as? TimeInterval + ) + } +} diff --git a/CLI/SessionsListClaudeTranscriptLookupCache.swift b/CLI/SessionsListClaudeTranscriptLookupCache.swift new file mode 100644 index 000000000000..2cc978b19bd1 --- /dev/null +++ b/CLI/SessionsListClaudeTranscriptLookupCache.swift @@ -0,0 +1,113 @@ +import CMUXAgentLaunch +import Foundation + +/// Reuses exact Claude transcript lookups across every row in one list or tree invocation. +final class SessionsListClaudeTranscriptLookupCache { + private let homeDirectory: String + private let fileManager: FileManager + private var defaultRoots: [String]? + private var transcriptPathByProjectRootAndSession: [String: String] = [:] + private var missingTranscriptPathByProjectRootAndSession: Set = [] + + init(homeDirectory: String, fileManager: FileManager = .default) { + self.homeDirectory = homeDirectory + self.fileManager = fileManager + } + + func configRoots(record: ClaudeHookSessionRecord) -> [String] { + if let configured = normalized(record.launchCommand?.environment?["CLAUDE_CONFIG_DIR"]) { + return [ + ClaudeConfigDirectoryPath.preferredPath( + expandedPath(configured), + fileManager: fileManager, + homeDirectory: homeDirectory + ), + ] + } + + if let defaultRoots { return defaultRoots } + + var roots: [String] = [] + var seen: Set = [] + func appendRoot(_ path: String) { + let standardized = (path as NSString).standardizingPath + guard seen.insert(standardized).inserted else { return } + roots.append(standardized) + } + + appendRoot((homeDirectory as NSString).appendingPathComponent(".claude")) + appendRoot( + ClaudeConfigDirectoryPath.preferredPath( + (homeDirectory as NSString).appendingPathComponent(".subrouter/codex/claude"), + fileManager: fileManager, + homeDirectory: homeDirectory + ) + ) + + defaultRoots = roots + return roots + } + + func transcriptPath(configRoot: String, projectDirName: String, sessionId: String) -> String? { + let standardizedRoot = (configRoot as NSString).standardizingPath + let projectsRoot = (standardizedRoot as NSString).appendingPathComponent("projects") + let projectRoot = ((projectsRoot as NSString).appendingPathComponent(projectDirName) as NSString) + .standardizingPath + let key = cacheKey(projectRoot, sessionId) + if let cached = transcriptPathByProjectRootAndSession[key] { return cached } + if missingTranscriptPathByProjectRootAndSession.contains(key) { return nil } + + let path = transcriptPath(inProjectRoot: projectRoot, sessionId: sessionId) + if let path { + transcriptPathByProjectRootAndSession[key] = path + } else { + missingTranscriptPathByProjectRootAndSession.insert(key) + } + return path + } + + private func transcriptPath(inProjectRoot projectRoot: String, sessionId: String) -> String? { + guard directoryExists(atPath: projectRoot) else { return nil } + let directPath = (projectRoot as NSString).appendingPathComponent("\(sessionId).jsonl") + if regularNonEmptyFileExists(atPath: directPath) { return directPath } + + let nestedMessagesPath = (((projectRoot as NSString) + .appendingPathComponent(sessionId) as NSString) + .appendingPathComponent("messages") as NSString) + .appendingPathComponent("\(sessionId).jsonl") + if regularNonEmptyFileExists(atPath: nestedMessagesPath) { return nestedMessagesPath } + return nil + } + + private func regularNonEmptyFileExists(atPath path: String) -> Bool { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), + !isDirectory.boolValue, + let attrs = try? fileManager.attributesOfItem(atPath: path), + let size = attrs[.size] as? NSNumber else { + return false + } + return size.intValue > 0 + } + + private func directoryExists(atPath path: String) -> Bool { + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue + } + + private func normalized(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private func expandedPath(_ value: String) -> String { + (value as NSString).expandingTildeInPath + } + + private func cacheKey(_ prefix: String, _ sessionId: String) -> String { + prefix + "\u{0}" + sessionId + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8b69d7ef828b..0fe9dac88418 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -12,1609 +12,13 @@ import LocalAuthentication import Security #endif -struct CLIError: Error, CustomStringConvertible { - let message: String - let exitCode: Int32 - /// Structured v2 protocol error code when the failure came from a v2 error response. - let v2Code: String? +private let agentHookWrapperProcessNames: Set = ["sh", "bash", "zsh", "env"] - init(message: String, exitCode: Int32 = 1, v2Code: String? = nil) { - self.message = message - self.exitCode = exitCode - self.v2Code = v2Code - } - - var description: String { message } -} - -struct WindowInfo { - let index: Int - let id: String - let key: Bool - let selectedWorkspaceId: String? - let workspaceCount: Int -} - -struct NotificationInfo { - let id: String - let workspaceId: String - let surfaceId: String? - let isRead: Bool - let title: String - let subtitle: String - let body: String - let createdAt: String? - let tabTitle: String? -} - -struct ClaudeHookParsedInput { - let rawObject: [String: Any]? - let object: [String: Any]? - let rawFallback: String? - let sessionId: String? - let turnId: String? - let cwd: String? - let transcriptPath: String? -} - -enum AgentHookRuntimeStatus: String, Codable { - case running - case idle - case needsInput - case error -} - -#if DEBUG -private func agentHookDebugLog( - _ message: @autoclosure () -> String, - socketPath: String? = nil, - env: [String: String] = ProcessInfo.processInfo.environment -) { - let logPath = agentHookDebugLogPath(socketPath: socketPath, env: env) - let timestamp = String(format: "%.3f", Date().timeIntervalSince1970) - let line = "\(timestamp) \(message())\n" - guard let data = line.data(using: .utf8) else { return } - - if let handle = FileHandle(forWritingAtPath: logPath) { - defer { try? handle.close() } - guard (try? handle.seekToEnd()) != nil else { return } - try? handle.write(contentsOf: data) - } else { - FileManager.default.createFile(atPath: logPath, contents: data) - } -} - -private func agentHookDebugLogPath(socketPath: String?, env: [String: String]) -> String { - if let explicit = agentHookDebugNonEmpty(env["CMUX_DEBUG_LOG"]) { - return NSString(string: explicit).expandingTildeInPath - } - - if let socketPath { - let socketName = URL(fileURLWithPath: socketPath).lastPathComponent - if socketName.hasPrefix("cmux-debug-"), socketName.hasSuffix(".sock") { - let logName = String(socketName.dropLast(".sock".count)) + ".log" - return URL(fileURLWithPath: "/tmp", isDirectory: true) - .appendingPathComponent(logName, isDirectory: false) - .path - } - } - - if let lastPath = try? String(contentsOfFile: "/tmp/cmux-last-debug-log-path", encoding: .utf8), - let normalized = agentHookDebugNonEmpty(lastPath) { - return NSString(string: normalized).expandingTildeInPath - } - - return "/tmp/cmux-debug.log" -} - -private func agentHookDebugNonEmpty(_ value: String?) -> String? { - guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), - !trimmed.isEmpty else { - return nil - } - return trimmed -} - -private func agentHookDebugShort(_ value: String?) -> String { - guard let value = agentHookDebugNonEmpty(value) else { return "nil" } - return String(value.prefix(12)) -} - -private func agentHookDebugSocketName(_ socketPath: String?) -> String { - guard let socketPath = agentHookDebugNonEmpty(socketPath) else { return "nil" } - return URL(fileURLWithPath: socketPath).lastPathComponent -} -#endif - -struct ClaudeHookSessionRecord: Codable { - var sessionId: String - var workspaceId: String - var surfaceId: String - var cwd: String? - var transcriptPath: String? - var pid: Int? - var launchCommand: AgentHookLaunchCommandRecord? - /// Last hook-observed `permission_mode`, re-applied as `--permission-mode` - /// on user-owned session restore (https://github.com/manaflow-ai/cmux/issues/8066). - var lastPermissionMode: String? - var isRestorable: Bool? - var agentLifecycle: AgentHibernationLifecycleState? - var lastSubtitle: String? - var lastBody: String? - var lastNotificationStatus: AgentHookNotificationStatus? - var lastEmittedNotificationFingerprint: String? - var lastEmittedNotificationAt: TimeInterval? - var recentEmittedNotificationFingerprints: [String: TimeInterval]? - var runtimeStatus: AgentHookRuntimeStatus? - var activePromptDepth: Int? - var activePromptTurnId: String? - var activePromptTurnIds: [String]? - var lastPromptTurnId: String? - var terminalPromptTurnIds: [String]? - var startedAt: TimeInterval - var updatedAt: TimeInterval - // Auto-naming engine state (all optional so stores written before the - // feature decode unchanged). The durable baseline advances only after a - // confirmed title apply; the in-flight marker dedupes concurrent Stops. - var autoNameLastTitle: String? - var autoNameLastLineCount: Int? - var autoNameLastNamedAt: TimeInterval? - var autoNameInFlightAt: TimeInterval? - /// Wall-clock of the last summarization attempt (success OR failure), so a - /// persistently failing summarizer (rate-limited, signed out, timing out) - /// gets the same minInterval cooldown instead of respawning every turn. - var autoNameLastAttemptAt: TimeInterval? - var autoNameRecentMessages: [AutoNamingTranscriptMessage]? - var autoNameMessageSequence: Int? - /// Whether the most recent Stop reported unfinished background work - /// (a running `background_tasks` entry or a pending `session_crons`). - /// Cached here because the ~60s-later `idle_prompt` Notification payload - /// does not carry `background_tasks`, so the idle-reminder gate reads this. - /// Optional so stores written before this field decode unchanged. - var hadPendingBackgroundWorkAtStop: Bool? -} - -struct ClaudeHookActiveSessionRecord: Codable { - var sessionId: String - var turnId: String? - var allowsNewSessionReplacement: Bool? - var updatedAt: TimeInterval -} - -struct AgentHookLaunchCommandRecord: Codable { - var launcher: String? - var executablePath: String? - var arguments: [String] - var workingDirectory: String? - var environment: [String: String]? - var capturedAt: TimeInterval? - var source: String? -} - -private struct CodexMonitorLeaseRecord: Codable { - var leaseId: String - var sessionId: String - var turnId: String? - var workspaceId: String - var surfaceId: String? - var createdAt: TimeInterval - var retiredAt: TimeInterval? -} - -struct ClaudeHookSessionStoreFile: Codable { - var version: Int = 1 - var sessions: [String: ClaudeHookSessionRecord] = [:] - var activeSessionsByWorkspace: [String: ClaudeHookActiveSessionRecord] = [:] - // The pane-scoped active boundary. The workspace slot only remembers ONE - // active session, so once another pane promotes (e.g. a forked conversation - // in a split), it can no longer prove that a late hook from a superseded - // session in this pane is stale. Keyed by surface id. - // https://github.com/manaflow-ai/cmux/issues/5908 - var activeSessionsBySurface: [String: ClaudeHookActiveSessionRecord] = [:] - - enum CodingKeys: String, CodingKey { - case version - case sessions - case activeSessionsByWorkspace - case activeSessionsBySurface - } - - init() {} - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 - sessions = try container.decodeIfPresent([String: ClaudeHookSessionRecord].self, forKey: .sessions) ?? [:] - activeSessionsByWorkspace = try container.decodeIfPresent( - [String: ClaudeHookActiveSessionRecord].self, - forKey: .activeSessionsByWorkspace - ) ?? [:] - activeSessionsBySurface = try container.decodeIfPresent( - [String: ClaudeHookActiveSessionRecord].self, - forKey: .activeSessionsBySurface - ) ?? [:] - } -} - -final class ClaudeHookSessionStore { - private static let defaultStatePath = "~/.cmuxterm/claude-hook-sessions.json" - private static let maxStateAgeSeconds: TimeInterval = 60 * 60 * 24 * 7 - private static let maxRememberedTerminalPromptTurnIds = 32 - private static let maxAutoNameRecentMessages = 24 - private static let maxAutoNameMessageCharacters = 1_000 - - private let statePath: String - private let fileManager: FileManager - private let decoder = JSONDecoder() - private let encoder = JSONEncoder() - - init( - processEnv: [String: String] = ProcessInfo.processInfo.environment, - fileManager: FileManager = .default - ) { - if let overridePath = processEnv["CMUX_CLAUDE_HOOK_STATE_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !overridePath.isEmpty { - self.statePath = NSString(string: overridePath).expandingTildeInPath - } else if let overrideDirectory = processEnv["CMUX_AGENT_HOOK_STATE_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !overrideDirectory.isEmpty { - self.statePath = URL(fileURLWithPath: NSString(string: overrideDirectory).expandingTildeInPath, isDirectory: true) - .appendingPathComponent("claude-hook-sessions.json", isDirectory: false) - .path - } else { - self.statePath = NSString(string: Self.defaultStatePath).expandingTildeInPath - } - self.fileManager = fileManager - self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - } - - func lookup(sessionId: String) throws -> ClaudeHookSessionRecord? { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return nil } - return try withLockedState { state in - state.sessions[normalized] - } - } - - /// Records the hook-observed permission mode on an existing session record. - /// The already-current check happens INSIDE the lock: an unlocked pre-check - /// can race an overlapping hook's write and skip persisting the newest mode, - /// leaving restore on a stale (possibly more permissive) mode. Unknown - /// sessions are left alone (the session-start upsert owns record creation). - func updateLastPermissionMode(sessionId: String, permissionMode: String) throws { - let normalized = normalizeSessionId(sessionId) - let mode = permissionMode.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalized.isEmpty, !mode.isEmpty else { return } - try withLockedState { state in - guard var record = state.sessions[normalized], - record.lastPermissionMode != mode else { return } - record.lastPermissionMode = mode - state.sessions[normalized] = record - } - } - - struct AutoNamingRecentMessagesSnapshot { - var messages: [AutoNamingTranscriptMessage] - var totalMessageCount: Int - } - - func autoNamingRecentMessages(sessionId: String) throws -> [AutoNamingTranscriptMessage] { - try autoNamingRecentMessagesSnapshot(sessionId: sessionId).messages - } - - func autoNamingRecentMessagesSnapshot(sessionId: String) throws -> AutoNamingRecentMessagesSnapshot { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { - return AutoNamingRecentMessagesSnapshot(messages: [], totalMessageCount: 0) - } - return try withLockedState { state in - let record = state.sessions[normalized] - let messages = record?.autoNameRecentMessages ?? [] - return AutoNamingRecentMessagesSnapshot( - messages: messages, - totalMessageCount: max(messages.count, record?.autoNameMessageSequence ?? 0) - ) - } - } - - struct AutoNamingBeginOutcome { - var decision: AutoNamingThrottleDecision - var lastTitle: String? - } - - /// Atomically evaluates the auto-naming throttle for a session and, when - /// the decision is to proceed, records the in-flight marker inside the - /// same locked transaction so a concurrent Stop hook sees it and skips. - /// When no session record exists yet (the auto-name hook can race the - /// sync Stop hook's upsert), a minimal record is synthesized so the - /// marker and baseline writes are never silently dropped. - func beginAutoNaming( - sessionId: String, - workspaceId: String, - surfaceId: String, - transcriptLineCount: Int, - now: Date, - engine: AutoNamingEngine - ) throws -> AutoNamingBeginOutcome { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { - return AutoNamingBeginOutcome(decision: .skipShortTranscript, lastTitle: nil) - } - return try withLockedState { state in - var record = state.sessions[normalized] ?? ClaudeHookSessionRecord( - sessionId: normalized, - workspaceId: workspaceId, - surfaceId: surfaceId, - startedAt: now.timeIntervalSince1970, - updatedAt: now.timeIntervalSince1970 - ) - let snapshot = AutoNamingSessionSnapshot( - lastTitle: record.autoNameLastTitle, - lastLineCount: record.autoNameLastLineCount, - lastNamedAt: record.autoNameLastNamedAt, - inFlightAt: record.autoNameInFlightAt, - lastAttemptAt: record.autoNameLastAttemptAt - ) - let decision = engine.throttleDecision( - snapshot: snapshot, - transcriptLineCount: transcriptLineCount, - now: now - ) - switch decision { - case .proceed: - record.autoNameInFlightAt = now.timeIntervalSince1970 - case .reseedBaseline(let to): - record.autoNameLastLineCount = to - case .skipShortTranscript, .skipInFlight, .skipTooSoon, .skipInsufficientGrowth: - break - } - record.updatedAt = Date().timeIntervalSince1970 - state.sessions[normalized] = record - return AutoNamingBeginOutcome(decision: decision, lastTitle: snapshot.lastTitle) - } - } - - /// Records a completed naming pass. On a confirmed apply, the durable - /// baseline (title, line count, timestamp) advances; on failure only the - /// in-flight marker clears, so the next qualifying Stop retries. - func finishAutoNaming( - sessionId: String, - appliedTitle: String?, - baselineLineCount: Int?, - now: Date - ) throws { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return } - try withLockedState { state in - guard var record = state.sessions[normalized] else { return } - record.autoNameInFlightAt = nil - // Stamp every completed pass (success or failure) so the throttle - // enforces a cooldown before retrying a failing summarizer. - record.autoNameLastAttemptAt = now.timeIntervalSince1970 - if let appliedTitle, let baselineLineCount { - record.autoNameLastTitle = appliedTitle - record.autoNameLastLineCount = baselineLineCount - record.autoNameLastNamedAt = now.timeIntervalSince1970 - } - record.updatedAt = Date().timeIntervalSince1970 - state.sessions[normalized] = record - } - } - - func clearAgentLifecycleIfPresent( - sessionId: String, - workspaceId: String?, - surfaceId: String? - ) throws { - let normalizedSessionId = normalizeSessionId(sessionId) - guard !normalizedSessionId.isEmpty else { return } - try withLockedState { state in - guard var record = state.sessions[normalizedSessionId] else { return } - record.agentLifecycle = .unknown - record.updatedAt = Date().timeIntervalSince1970 - state.sessions[normalizedSessionId] = record - } - } - - @discardableResult - func recordPromptSubmit( - sessionId: String, - workspaceId: String, - surfaceId: String, - cwd: String?, - transcriptPath: String? = nil, - turnId: String? = nil, - previousActivePromptTurnIsTerminal: Bool = false, - terminalActivePromptTurnIds: Set = [], - pid: Int?, - launchCommand: AgentHookLaunchCommandRecord?, - agentLifecycle: AgentHibernationLifecycleState? = nil, - runtimeStatus: AgentHookRuntimeStatus? = nil, - updateRuntimeStatus: Bool = false, - autoNameMessages: [AutoNamingTranscriptMessage] = [], - rejectTerminalTurn: Bool = false - ) throws -> (staleTerminalTurn: Bool, nested: Bool) { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return (staleTerminalTurn: false, nested: false) } - return try withLockedState { state in - let now = Date().timeIntervalSince1970 - var record = makeSessionRecord( - state: state, - sessionId: normalized, - workspaceId: workspaceId, - surfaceId: surfaceId, - now: now - ) - let normalizedTurnId = normalizeOptional(turnId) - if rejectTerminalTurn, - let normalizedTurnId, - terminalPromptTurnSet(from: record).contains(normalizedTurnId) { - return (staleTerminalTurn: true, nested: false) - } - update( - &record, - workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: cwd, - transcriptPath: transcriptPath, - pid: pid, - launchCommand: launchCommand, - isRestorable: nil, - agentLifecycle: agentLifecycle, - lastSubtitle: nil, - lastBody: nil, - lastNotificationStatus: nil, - updateLastNotificationStatus: false, - runtimeStatus: runtimeStatus, - updateRuntimeStatus: updateRuntimeStatus, - now: now - ) - appendAutoNameMessages(autoNameMessages, to: &record) - if let normalizedTurnId { - markPromptTurnActive(normalizedTurnId, on: &record) - var turnStack = activePromptTurnStack(from: record) - let legacyDepth = max(0, record.activePromptDepth ?? 0) - if turnStack.isEmpty, legacyDepth > 0 { - record.activePromptDepth = legacyDepth + 1 - record.activePromptTurnId = nil - record.activePromptTurnIds = nil - record.lastPromptTurnId = normalizedTurnId - state.sessions[normalized] = record - return (staleTerminalTurn: false, nested: true) - } else if let activeTurnId = turnStack.last, - activeTurnId != normalizedTurnId { - var removedTurnCount = 0 - var removedTerminalTurnIds: [String] = [] - if previousActivePromptTurnIsTerminal { - removedTerminalTurnIds.append(turnStack.removeLast()) - removedTurnCount += 1 - while let activeTurnId = turnStack.last, - terminalActivePromptTurnIds.contains(activeTurnId) { - removedTerminalTurnIds.append(turnStack.removeLast()) - removedTurnCount += 1 - } - } - let totalDepth = max(0, max(legacyDepth, turnStack.count + removedTurnCount) - removedTurnCount) + 1 - turnStack.append(normalizedTurnId) - setActivePromptTurnStack(turnStack, totalDepth: totalDepth, on: &record) - markPromptTurnsTerminal(removedTerminalTurnIds, on: &record) - record.lastPromptTurnId = normalizedTurnId - state.sessions[normalized] = record - return (staleTerminalTurn: false, nested: totalDepth > 1) - } - if turnStack.last == normalizedTurnId { - let totalDepth = max(legacyDepth, turnStack.count) - setActivePromptTurnStack(turnStack, totalDepth: totalDepth, on: &record) - record.lastPromptTurnId = normalizedTurnId - state.sessions[normalized] = record - return (staleTerminalTurn: false, nested: totalDepth > 1) - } - let totalDepth = max(legacyDepth, turnStack.count) + 1 - turnStack.append(normalizedTurnId) - setActivePromptTurnStack(turnStack, totalDepth: totalDepth, on: &record) - record.lastPromptTurnId = normalizedTurnId - state.sessions[normalized] = record - return (staleTerminalTurn: false, nested: totalDepth > 1) - } - let existingTurnStackDepth = activePromptTurnStack(from: record).count - record.activePromptDepth = max(max(0, record.activePromptDepth ?? 0), existingTurnStackDepth) + 1 - state.sessions[normalized] = record - return (staleTerminalTurn: false, nested: (record.activePromptDepth ?? 0) > 1) - } - } - - @discardableResult - func recordPromptStop( - sessionId: String, - workspaceId: String, - surfaceId: String, - cwd: String?, - transcriptPath: String? = nil, - turnId: String? = nil, - terminalActivePromptTurnIds: Set = [], - pid: Int?, - launchCommand: AgentHookLaunchCommandRecord?, - agentLifecycle: AgentHibernationLifecycleState? = nil, - lastSubtitle: String?, - lastBody: String?, - lastNotificationStatus: AgentHookNotificationStatus? = nil, - updateLastNotificationStatus: Bool = false, - runtimeStatus: AgentHookRuntimeStatus? = nil, - updateRuntimeStatus: Bool = false, - autoNameMessages: [AutoNamingTranscriptMessage] = [] - ) throws -> Bool { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return false } - return try withLockedState { state in - let now = Date().timeIntervalSince1970 - var record = makeSessionRecord( - state: state, - sessionId: normalized, - workspaceId: workspaceId, - surfaceId: surfaceId, - now: now - ) - let depthBeforeStop = max(0, record.activePromptDepth ?? 0) - let depthAfterStop = max(0, depthBeforeStop - 1) - update( - &record, - workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: cwd, - transcriptPath: transcriptPath, - pid: pid, - launchCommand: launchCommand, - isRestorable: nil, - agentLifecycle: depthAfterStop == 0 ? agentLifecycle : .running, - lastSubtitle: lastSubtitle, - lastBody: lastBody, - lastNotificationStatus: lastNotificationStatus, - updateLastNotificationStatus: updateLastNotificationStatus, - runtimeStatus: runtimeStatus, - updateRuntimeStatus: updateRuntimeStatus, - now: now - ) - appendAutoNameMessages(autoNameMessages, to: &record) - let normalizedTurnId = normalizeOptional(turnId) - if let normalizedTurnId { - var turnStack = activePromptTurnStack(from: record) - var totalDepthBeforeStop = max(depthBeforeStop, turnStack.count) - let terminalTurnIdsToPrune = terminalActivePromptTurnIds.subtracting([normalizedTurnId]) - if !terminalTurnIdsToPrune.isEmpty { - var removedTerminalTurnIds: [String] = [] - turnStack.removeAll { activeTurnId in - if terminalTurnIdsToPrune.contains(activeTurnId) { - removedTerminalTurnIds.append(activeTurnId) - return true - } - return false - } - if !removedTerminalTurnIds.isEmpty { - totalDepthBeforeStop = max(0, totalDepthBeforeStop - removedTerminalTurnIds.count) - setActivePromptTurnStack(turnStack, totalDepth: totalDepthBeforeStop, on: &record) - markPromptTurnsTerminal(removedTerminalTurnIds, on: &record) - } - } - if let lastTurnId = turnStack.last { - if lastTurnId == normalizedTurnId { - let nested = totalDepthBeforeStop > 1 - turnStack.removeLast() - setActivePromptTurnStack( - turnStack, - totalDepth: max(0, totalDepthBeforeStop - 1), - on: &record - ) - markPromptTurnTerminal(normalizedTurnId, on: &record) - state.sessions[normalized] = record - return nested - } - if let staleIndex = turnStack.lastIndex(of: normalizedTurnId) { - turnStack.remove(at: staleIndex) - setActivePromptTurnStack( - turnStack, - totalDepth: max(0, totalDepthBeforeStop - 1), - on: &record - ) - markPromptTurnTerminal(normalizedTurnId, on: &record) - } else if depthBeforeStop > turnStack.count { - setActivePromptTurnStack( - turnStack, - totalDepth: max(0, totalDepthBeforeStop - 1), - on: &record - ) - markPromptTurnTerminal(normalizedTurnId, on: &record) - } - state.sessions[normalized] = record - return true - } - if totalDepthBeforeStop == 0, terminalPromptTurnSet(from: record).contains(normalizedTurnId) { - state.sessions[normalized] = record - return true - } - markPromptTurnTerminal(normalizedTurnId, on: &record) - if totalDepthBeforeStop == 0 { - state.sessions[normalized] = record - return false - } - let depthAfterTurnStop = max(0, totalDepthBeforeStop - 1) - if depthAfterTurnStop == 0 { - record.activePromptDepth = nil - } else { - record.activePromptDepth = depthAfterTurnStop - } - record.activePromptTurnId = nil - record.activePromptTurnIds = nil - state.sessions[normalized] = record - return totalDepthBeforeStop > 1 - } - if depthAfterStop == 0 { - record.activePromptDepth = nil - record.activePromptTurnId = nil - record.activePromptTurnIds = nil - } else { - let turnStack = activePromptTurnStack(from: record) - if !turnStack.isEmpty { - setActivePromptTurnStack( - Array(turnStack.prefix(depthAfterStop)), - totalDepth: depthAfterStop, - on: &record - ) - } else { - record.activePromptDepth = depthAfterStop - } - if let normalizedTurnId, turnStack.isEmpty { - record.activePromptTurnId = normalizedTurnId - record.activePromptTurnIds = Array(repeating: normalizedTurnId, count: depthAfterStop) - } - } - state.sessions[normalized] = record - return depthBeforeStop > 1 - } - } - - func upsert( - sessionId: String, - workspaceId: String, - surfaceId: String, - cwd: String?, - transcriptPath: String? = nil, - pid: Int? = nil, - launchCommand: AgentHookLaunchCommandRecord? = nil, - isRestorable: Bool? = nil, - agentLifecycle: AgentHibernationLifecycleState? = nil, - lastSubtitle: String? = nil, - lastBody: String? = nil, - lastNotificationStatus: AgentHookNotificationStatus? = nil, - updateLastNotificationStatus: Bool = false, - runtimeStatus: AgentHookRuntimeStatus? = nil, - updateRuntimeStatus: Bool = false, - hadPendingBackgroundWorkAtStop: Bool? = nil, - markActive: Bool = false, - turnId: String? = nil, - allowsNewSessionReplacement: Bool = false - ) throws { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return } - try withLockedState { state in - let now = Date().timeIntervalSince1970 - var record = state.sessions[normalized] ?? ClaudeHookSessionRecord( - sessionId: normalized, - workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: nil, - transcriptPath: nil, - pid: nil, - launchCommand: nil, - isRestorable: nil, - agentLifecycle: nil, - lastSubtitle: nil, - lastBody: nil, - lastNotificationStatus: nil, - lastEmittedNotificationFingerprint: nil, - lastEmittedNotificationAt: nil, - runtimeStatus: nil, - activePromptDepth: nil, - activePromptTurnId: nil, - activePromptTurnIds: nil, - lastPromptTurnId: nil, - terminalPromptTurnIds: nil, - startedAt: now, - updatedAt: now - ) - update( - &record, - workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: cwd, - transcriptPath: transcriptPath, - pid: pid, - launchCommand: launchCommand, - isRestorable: isRestorable, - agentLifecycle: agentLifecycle, - lastSubtitle: lastSubtitle, - lastBody: lastBody, - lastNotificationStatus: lastNotificationStatus, - updateLastNotificationStatus: updateLastNotificationStatus, - runtimeStatus: runtimeStatus, - updateRuntimeStatus: updateRuntimeStatus, - hadPendingBackgroundWorkAtStop: hadPendingBackgroundWorkAtStop, - now: now - ) - state.sessions[normalized] = record - if markActive { - let activeRecord = ClaudeHookActiveSessionRecord( - sessionId: normalized, - turnId: normalizeOptional(turnId), - allowsNewSessionReplacement: allowsNewSessionReplacement ? true : nil, - updatedAt: now - ) - if let normalizedWorkspace = normalizeOptional(workspaceId) { - state.activeSessionsByWorkspace[normalizedWorkspace] = activeRecord - } - if let normalizedSurface = normalizeOptional(surfaceId) { - state.activeSessionsBySurface[normalizedSurface] = activeRecord - } - } - } - } - - @discardableResult - func upsertCodexSessionStartIfFresh( - sessionId: String, - workspaceId: String, - surfaceId: String, - cwd: String?, - transcriptPath: String? = nil, - pid: Int? = nil, - launchCommand: AgentHookLaunchCommandRecord? = nil, - agentLifecycle: AgentHibernationLifecycleState? = nil, - runtimeStatus: AgentHookRuntimeStatus? = nil, - updateRuntimeStatus: Bool = false - ) throws -> Bool { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return false } - return try withLockedState { state in - let now = Date().timeIntervalSince1970 - var record = makeSessionRecord( - state: state, - sessionId: normalized, - workspaceId: workspaceId, - surfaceId: surfaceId, - now: now - ) - if codexSessionStartIsStale(record, incomingPID: pid) { - return false - } - clearCodexSessionStartTurnState(on: &record) - update( - &record, - workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: cwd, - transcriptPath: transcriptPath, - pid: pid, - launchCommand: launchCommand, - isRestorable: nil, - agentLifecycle: agentLifecycle, - lastSubtitle: nil, - lastBody: nil, - lastNotificationStatus: nil, - updateLastNotificationStatus: false, - runtimeStatus: runtimeStatus, - updateRuntimeStatus: updateRuntimeStatus, - now: now - ) - state.sessions[normalized] = record - return true - } - } - - @discardableResult - func upsertCodexPromptRunningIfFresh( - sessionId: String, - workspaceId: String, - surfaceId: String, - cwd: String?, - transcriptPath: String? = nil, - turnId: String? = nil, - pid: Int? = nil, - launchCommand: AgentHookLaunchCommandRecord? = nil - ) throws -> Bool { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return false } - return try withLockedState { state in - let now = Date().timeIntervalSince1970 - var record = makeSessionRecord( - state: state, - sessionId: normalized, - workspaceId: workspaceId, - surfaceId: surfaceId, - now: now - ) - if let normalizedTurnId = normalizeOptional(turnId), - terminalPromptTurnSet(from: record).contains(normalizedTurnId) { - return false - } - update( - &record, - workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: cwd, - transcriptPath: transcriptPath, - pid: pid, - launchCommand: launchCommand, - isRestorable: nil, - agentLifecycle: .running, - lastSubtitle: nil, - lastBody: nil, - lastNotificationStatus: nil, - updateLastNotificationStatus: false, - runtimeStatus: .running, - updateRuntimeStatus: true, - now: now - ) - state.sessions[normalized] = record - return true - } - } - - func codexSessionStartIsStale( - sessionId: String, - incomingPID: Int?, - includeTerminalPromptTurnIds: Bool = true - ) throws -> Bool { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return false } - return try withLockedState { state in - guard let record = state.sessions[normalized] else { return false } - return codexSessionStartIsStale( - record, - incomingPID: incomingPID, - includeTerminalPromptTurnIds: includeTerminalPromptTurnIds - ) - } - } - - func codexPromptTurnIsTerminal(sessionId: String, turnId: String?) throws -> Bool { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty, let normalizedTurnId = normalizeOptional(turnId) else { return false } - return try withLockedState { state in - guard let record = state.sessions[normalized] else { return false } - return terminalPromptTurnSet(from: record).contains(normalizedTurnId) - } - } - - func markNotificationResolved( - sessionId: String, - workspaceId: String, - surfaceId: String, - cwd: String?, - transcriptPath: String? = nil, - pid: Int? = nil, - launchCommand: AgentHookLaunchCommandRecord? = nil, - agentLifecycle: AgentHibernationLifecycleState? = nil, - runtimeStatus: AgentHookRuntimeStatus? = nil - ) throws { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return } - try withLockedState { state in - let now = Date().timeIntervalSince1970 - var record = makeSessionRecord( - state: state, - sessionId: normalized, - workspaceId: workspaceId, - surfaceId: surfaceId, - now: now - ) - update( - &record, - workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: cwd, - transcriptPath: transcriptPath, - pid: pid, - launchCommand: launchCommand, - isRestorable: nil, - agentLifecycle: agentLifecycle, - lastSubtitle: nil, - lastBody: nil, - lastNotificationStatus: nil, - updateLastNotificationStatus: true, - runtimeStatus: runtimeStatus, - updateRuntimeStatus: runtimeStatus != nil, - now: now - ) - record.lastSubtitle = nil - record.lastBody = nil - record.lastNotificationStatus = nil - state.sessions[normalized] = record - } - } - - private func makeSessionRecord( - state: ClaudeHookSessionStoreFile, - sessionId: String, - workspaceId: String, - surfaceId: String, - now: TimeInterval - ) -> ClaudeHookSessionRecord { - state.sessions[sessionId] ?? ClaudeHookSessionRecord( - sessionId: sessionId, - workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: nil, - transcriptPath: nil, - pid: nil, - launchCommand: nil, - isRestorable: nil, - agentLifecycle: nil, - lastSubtitle: nil, - lastBody: nil, - lastNotificationStatus: nil, - lastEmittedNotificationFingerprint: nil, - lastEmittedNotificationAt: nil, - runtimeStatus: nil, - activePromptDepth: nil, - activePromptTurnId: nil, - activePromptTurnIds: nil, - lastPromptTurnId: nil, - terminalPromptTurnIds: nil, - startedAt: now, - updatedAt: now - ) - } - - private func activePromptTurnStack(from record: ClaudeHookSessionRecord) -> [String] { - if let activePromptTurnIds = record.activePromptTurnIds { - let normalized = activePromptTurnIds.compactMap { normalizeOptional($0) } - if !normalized.isEmpty { - return normalized - } - } - if let activePromptTurnId = normalizeOptional(record.activePromptTurnId) { - return [activePromptTurnId] - } - return [] - } - - private func setActivePromptTurnStack(_ stack: [String], totalDepth: Int? = nil, on record: inout ClaudeHookSessionRecord) { - let normalizedStack = stack.compactMap { normalizeOptional($0) } - let resolvedDepth = max(max(0, totalDepth ?? normalizedStack.count), normalizedStack.count) - if resolvedDepth == 0 { - record.activePromptDepth = nil - record.activePromptTurnId = nil - record.activePromptTurnIds = nil - } else { - record.activePromptDepth = resolvedDepth - record.activePromptTurnId = normalizedStack.last - record.activePromptTurnIds = normalizedStack.isEmpty ? nil : normalizedStack - } - } - - private func terminalPromptTurnStack(from record: ClaudeHookSessionRecord) -> [String] { - record.terminalPromptTurnIds?.compactMap { normalizeOptional($0) } ?? [] - } - - private func terminalPromptTurnSet(from record: ClaudeHookSessionRecord) -> Set { - Set(terminalPromptTurnStack(from: record)) - } - - private func codexSessionStartIsStale( - _ record: ClaudeHookSessionRecord, - incomingPID: Int?, - includeTerminalPromptTurnIds: Bool = true - ) -> Bool { - if max(record.activePromptDepth ?? 0, record.activePromptTurnIds?.count ?? 0) > 0 { - return true - } - let hasCompletedTurnState = normalizeOptional(record.lastPromptTurnId) != nil - || (includeTerminalPromptTurnIds && !terminalPromptTurnSet(from: record).isEmpty) - guard hasCompletedTurnState, - let incomingPID, - let existingPID = record.pid else { - return false - } - return incomingPID == existingPID - } - - private func clearCodexSessionStartTurnState(on record: inout ClaudeHookSessionRecord) { - record.activePromptDepth = nil - record.activePromptTurnId = nil - record.activePromptTurnIds = nil - record.lastPromptTurnId = nil - } - - private func markPromptTurnActive(_ turnId: String, on record: inout ClaudeHookSessionRecord) { - var terminalTurnIds = terminalPromptTurnStack(from: record) - terminalTurnIds.removeAll { $0 == turnId } - record.terminalPromptTurnIds = terminalTurnIds.isEmpty ? nil : terminalTurnIds - } - - private func markPromptTurnsTerminal(_ turnIds: [String], on record: inout ClaudeHookSessionRecord) { - for turnId in turnIds { - markPromptTurnTerminal(turnId, on: &record) - } - } - - private func markPromptTurnTerminal(_ turnId: String, on record: inout ClaudeHookSessionRecord) { - guard let normalizedTurnId = normalizeOptional(turnId) else { return } - var terminalTurnIds = terminalPromptTurnStack(from: record) - terminalTurnIds.removeAll { $0 == normalizedTurnId } - terminalTurnIds.append(normalizedTurnId) - if terminalTurnIds.count > Self.maxRememberedTerminalPromptTurnIds { - terminalTurnIds.removeFirst(terminalTurnIds.count - Self.maxRememberedTerminalPromptTurnIds) - } - record.lastPromptTurnId = normalizedTurnId - record.terminalPromptTurnIds = terminalTurnIds.isEmpty ? nil : terminalTurnIds - } - - private func appendAutoNameMessages( - _ messages: [AutoNamingTranscriptMessage], - to record: inout ClaudeHookSessionRecord - ) { - guard !messages.isEmpty else { return } - var recent = record.autoNameRecentMessages ?? [] - var appendedCount = 0 - for message in messages { - guard let normalized = normalizedAutoNameMessage(message) else { continue } - if recent.last == normalized { continue } - recent.append(normalized) - appendedCount += 1 - } - if recent.count > Self.maxAutoNameRecentMessages { - recent.removeFirst(recent.count - Self.maxAutoNameRecentMessages) - } - record.autoNameRecentMessages = recent.isEmpty ? nil : recent - if appendedCount > 0 { - record.autoNameMessageSequence = (record.autoNameMessageSequence ?? 0) + appendedCount - } - } - - private func normalizedAutoNameMessage(_ message: AutoNamingTranscriptMessage) -> AutoNamingTranscriptMessage? { - let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard role == "user" || role == "assistant" else { return nil } - let text = autoNameNormalizedSingleLine(message.text) - guard !text.isEmpty else { return nil } - return AutoNamingTranscriptMessage( - role: role, - text: autoNameTruncate(text, maxLength: Self.maxAutoNameMessageCharacters) - ) - } - - private func autoNameNormalizedSingleLine(_ value: String) -> String { - let collapsed = value.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - return collapsed.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func autoNameTruncate(_ value: String, maxLength: Int) -> String { - guard value.count > maxLength else { return value } - let index = value.index(value.startIndex, offsetBy: max(0, maxLength - 1)) - return String(value[.. Bool { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return false } - let normalizedFingerprint = fingerprint.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedFingerprint.isEmpty else { return false } - return try withLockedState { state in - guard let record = state.sessions[normalized] else { return false } - let now = Date().timeIntervalSince1970 - if let emittedAt = record.recentEmittedNotificationFingerprints?[normalizedFingerprint], - now - emittedAt <= interval { - return true - } - guard record.lastEmittedNotificationFingerprint == normalizedFingerprint, - let emittedAt = record.lastEmittedNotificationAt else { - return false - } - return now - emittedAt <= interval - } - } - - func markNotificationEmitted(sessionId: String, fingerprint: String) throws { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return } - let normalizedFingerprint = fingerprint.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedFingerprint.isEmpty else { return } - try withLockedState { state in - guard var record = state.sessions[normalized] else { return } - let now = Date().timeIntervalSince1970 - record.lastEmittedNotificationFingerprint = normalizedFingerprint - record.lastEmittedNotificationAt = now - var recent = record.recentEmittedNotificationFingerprints ?? [:] - recent[normalizedFingerprint] = now - recent = recent.filter { now - $0.value <= 60 * 60 } - if recent.count > 16 { - let keep = recent.sorted { lhs, rhs in - if lhs.value == rhs.value { return lhs.key < rhs.key } - return lhs.value > rhs.value - }.prefix(16) - recent = Dictionary(uniqueKeysWithValues: keep.map { ($0.key, $0.value) }) - } - record.recentEmittedNotificationFingerprints = recent.isEmpty ? nil : recent - record.updatedAt = now - state.sessions[normalized] = record - } - } - - func hasRunningSession( - workspaceId: String, - surfaceId: String?, - excludingSessionId: String?, - onlyNewerThanExcludedSession: Bool = false, - requireLiveProcess: Bool = false - ) throws -> Bool { - guard let normalizedWorkspace = normalizeOptional(workspaceId) else { - return false - } - let normalizedSurface = normalizeOptional(surfaceId) - let excluded = normalizeOptional(excludingSessionId) - return try withLockedState { state in - let excludedUpdatedAt = excluded.flatMap { state.sessions[$0]?.updatedAt } - var foundRunningSession = false - let now = Date().timeIntervalSince1970 - - for sessionId in Array(state.sessions.keys) { - guard var record = state.sessions[sessionId] else { continue } - guard normalizeOptional(record.workspaceId) == normalizedWorkspace, - record.sessionId != excluded, - record.runtimeStatus == .running else { - continue - } - if let normalizedSurface, normalizeOptional(record.surfaceId) != normalizedSurface { - continue - } - if onlyNewerThanExcludedSession, let excludedUpdatedAt { - guard record.updatedAt > excludedUpdatedAt else { - continue - } - } - - if requireLiveProcess, !Self.processExists(record.pid) { - record.runtimeStatus = nil - record.updatedAt = now - state.sessions[sessionId] = record - continue - } - - foundRunningSession = true - break - } - - return foundRunningSession - } - } - - private static func processExists(_ pid: Int?) -> Bool { - guard let pid, pid > 0 else { return false } - if kill(pid_t(pid), 0) == 0 { - return true - } - return errno == EPERM - } - - /// Returns true when an event belongs to the workspace's active Claude session. - /// It fails open when the event cannot identify a session/workspace, when no - /// active session is registered yet, or when either side lacks a turnId so - /// multi-turn continuations can proceed after Stop clears the active turn. - func isCurrent( - sessionId: String?, - workspaceId: String, - surfaceId: String? = nil, - turnId: String? = nil - ) throws -> Bool { - guard let normalizedSessionId = normalizeOptional(sessionId), - let normalizedWorkspace = normalizeOptional(workspaceId) else { - return true - } - return try withLockedState { state in - // The pane's own active boundary decides first: a hook is stale when a - // DIFFERENT session was promoted in the SAME surface (post-/clear or - // replaced-session races in one pane). This stays true even after a - // sibling pane — e.g. a forked conversation in a split — later takes - // the single workspace-active slot. - // https://github.com/manaflow-ai/cmux/issues/5908 - if let normalizedSurfaceId = normalizeOptional(surfaceId), - let surfaceActive = state.activeSessionsBySurface[normalizedSurfaceId] { - guard surfaceActive.sessionId == normalizedSessionId else { - return false - } - guard let activeTurnId = normalizeOptional(surfaceActive.turnId), - let normalizedTurnId = normalizeOptional(turnId) else { - return true - } - return activeTurnId == normalizedTurnId - } - guard let active = state.activeSessionsByWorkspace[normalizedWorkspace] else { - return true - } - guard active.sessionId == normalizedSessionId else { - // Legacy fallback for stores written before per-surface tracking: - // a different active session only makes this hook stale when that - // session lives in the SAME surface; concurrent sessions in - // sibling panes stay current for their own surface. - guard let normalizedSurfaceId = normalizeOptional(surfaceId), - let activeRecord = state.sessions[active.sessionId], - let activeSurfaceId = normalizeOptional(activeRecord.surfaceId) else { - // Cross-surface protection needs both surfaces; when the caller - // omits surfaceId or the active session's record is gone/surface- - // less, fall back to the stricter workspace-scoped staleness. - return false - } - return activeSurfaceId != normalizedSurfaceId - } - guard let activeTurnId = normalizeOptional(active.turnId), - let normalizedTurnId = normalizeOptional(turnId) else { - return true - } - return activeTurnId == normalizedTurnId - } - } - - func canReplaceActiveSession( - sessionId: String?, - workspaceId: String, - surfaceId: String? = nil - ) throws -> Bool { - guard let normalizedSessionId = normalizeOptional(sessionId), - let normalizedWorkspace = normalizeOptional(workspaceId) else { - return false - } - return try withLockedState { state in - // Replacement is pane-scoped like staleness: a stopped session in - // THIS surface allows its own pane to start a new session even when - // another pane currently holds the workspace-active slot. - // https://github.com/manaflow-ai/cmux/issues/5908 - if let normalizedSurfaceId = normalizeOptional(surfaceId), - let surfaceActive = state.activeSessionsBySurface[normalizedSurfaceId] { - guard surfaceActive.sessionId != normalizedSessionId else { - return false - } - return surfaceActive.allowsNewSessionReplacement == true - } - guard let active = state.activeSessionsByWorkspace[normalizedWorkspace], - active.sessionId != normalizedSessionId else { - return false - } - return active.allowsNewSessionReplacement == true - } - } - - func consume( - sessionId: String?, - workspaceId: String?, - surfaceId: String?, - turnId: String? = nil - ) throws -> ClaudeHookSessionRecord? { - let normalizedSessionId = normalizeOptional(sessionId) - let normalizedWorkspace = normalizeOptional(workspaceId) - let normalizedSurface = normalizeOptional(surfaceId) - return try withLockedState { state in - if let normalizedSessionId, - let existing = state.sessions[normalizedSessionId] { - guard !hasActiveTurnMismatch(state, record: existing, turnId: turnId) else { - return nil - } - let removed = state.sessions.removeValue(forKey: normalizedSessionId) ?? existing - clearActiveSessionIfMatching(&state, removed: removed, turnId: turnId) - return removed - } - - guard let fallback = fallbackRecord( - sessions: Array(state.sessions.values), - workspaceId: normalizedWorkspace, - surfaceId: normalizedSurface - ) else { - return nil - } - guard !hasActiveTurnMismatch(state, record: fallback, turnId: turnId) else { - return nil - } - state.sessions.removeValue(forKey: fallback.sessionId) - clearActiveSessionIfMatching(&state, removed: fallback, turnId: turnId) - return fallback - } - } - - private func hasActiveTurnMismatch( - _ state: ClaudeHookSessionStoreFile, - record: ClaudeHookSessionRecord, - turnId: String? - ) -> Bool { - guard let incomingTurnId = normalizeOptional(turnId) else { - return false - } - // Consult the pane-scoped slot alongside the workspace slot: once a - // sibling pane takes the single workspace-active slot, only the - // surface slot still proves that this session is mid-turn in its own - // pane and a stale SessionEnd from an older turn must not consume it. - // https://github.com/manaflow-ai/cmux/issues/5908 - var activeRecords: [ClaudeHookActiveSessionRecord] = [] - if let workspaceId = normalizeOptional(record.workspaceId), - let active = state.activeSessionsByWorkspace[workspaceId] { - activeRecords.append(active) - } - if let surfaceId = normalizeOptional(record.surfaceId), - let active = state.activeSessionsBySurface[surfaceId] { - activeRecords.append(active) - } - return activeRecords.contains { active in - guard active.sessionId == record.sessionId, - let activeTurnId = normalizeOptional(active.turnId) else { - return false - } - return activeTurnId != incomingTurnId - } - } - - private func clearActiveSessionIfMatching( - _ state: inout ClaudeHookSessionStoreFile, - removed: ClaudeHookSessionRecord, - turnId: String? - ) { - let incomingTurnId = normalizeOptional(turnId) - func matches(_ active: ClaudeHookActiveSessionRecord) -> Bool { - guard active.sessionId == removed.sessionId else { return false } - if let activeTurnId = normalizeOptional(active.turnId), - let incomingTurnId, - activeTurnId != incomingTurnId { - return false - } - return true - } - if let workspaceId = normalizeOptional(removed.workspaceId), - let active = state.activeSessionsByWorkspace[workspaceId], - matches(active) { - state.activeSessionsByWorkspace.removeValue(forKey: workspaceId) - } - for (surfaceId, active) in state.activeSessionsBySurface where matches(active) { - state.activeSessionsBySurface.removeValue(forKey: surfaceId) - } - } - - private func fallbackRecord( - sessions: [ClaudeHookSessionRecord], - workspaceId: String?, - surfaceId: String? - ) -> ClaudeHookSessionRecord? { - if let surfaceId { - let matches = sessions.filter { $0.surfaceId == surfaceId } - return matches.max(by: { $0.updatedAt < $1.updatedAt }) - } - if let workspaceId { - let matches = sessions.filter { $0.workspaceId == workspaceId } - if matches.count == 1 { - return matches[0] - } - } - return nil - } - - private func withLockedState(_ body: (inout ClaudeHookSessionStoreFile) throws -> T) throws -> T { - let lockPath = statePath + ".lock" - let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR)) - if fd < 0 { - throw CLIError(message: "Failed to open Claude hook state lock: \(lockPath)") - } - defer { Darwin.close(fd) } - - if flock(fd, LOCK_EX) != 0 { - throw CLIError(message: "Failed to lock Claude hook state: \(lockPath)") - } - defer { _ = flock(fd, LOCK_UN) } - - var state = loadUnlocked() - pruneExpired(&state) - let result = try body(&state) - try saveUnlocked(state) - return result - } - - private func loadUnlocked() -> ClaudeHookSessionStoreFile { - guard fileManager.fileExists(atPath: statePath) else { - return ClaudeHookSessionStoreFile() - } - guard let data = try? Data(contentsOf: URL(fileURLWithPath: statePath)), - var decoded = try? decoder.decode(ClaudeHookSessionStoreFile.self, from: data) else { - return ClaudeHookSessionStoreFile() - } - backfillSurfaceActiveSlots(&decoded) - return decoded - } - - /// Stores written before per-surface tracking (or rewritten by an older - /// CLI, which drops the unknown key) carry only workspace-active slots. - /// Rebuild the pane boundary from each workspace-active session's recorded - /// surface so pre-upgrade panes keep suppressing stale hooks after a - /// sibling pane takes the workspace slot. - /// https://github.com/manaflow-ai/cmux/issues/5908 - private func backfillSurfaceActiveSlots(_ state: inout ClaudeHookSessionStoreFile) { - guard state.activeSessionsBySurface.isEmpty else { return } - for active in state.activeSessionsByWorkspace.values { - guard let surfaceId = normalizeOptional(state.sessions[active.sessionId]?.surfaceId) else { - continue - } - state.activeSessionsBySurface[surfaceId] = active - } - } - - private func saveUnlocked(_ state: ClaudeHookSessionStoreFile) throws { - let stateURL = URL(fileURLWithPath: statePath) - let parentURL = stateURL.deletingLastPathComponent() - try fileManager.createDirectory( - at: parentURL, - withIntermediateDirectories: true, - attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] - ) - try? fileManager.setAttributes([.posixPermissions: NSNumber(value: Int16(0o700))], ofItemAtPath: parentURL.path) - let data = try encoder.encode(state) - let tempURL = parentURL.appendingPathComponent(".\(stateURL.lastPathComponent).\(UUID().uuidString).tmp") - guard fileManager.createFile(atPath: tempURL.path, contents: data, attributes: [ - .posixPermissions: NSNumber(value: Int16(0o600)) - ]) else { - throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: statePath]) - } - let renameResult = tempURL.path.withCString { source in - stateURL.path.withCString { destination in - Darwin.rename(source, destination) - } - } - if renameResult != 0 { - let code = POSIXErrorCode(rawValue: errno) ?? .EIO - try? fileManager.removeItem(at: tempURL) - throw POSIXError(code) - } - try? fileManager.setAttributes([.posixPermissions: NSNumber(value: Int16(0o600))], ofItemAtPath: stateURL.path) - } - - private func pruneExpired(_ state: inout ClaudeHookSessionStoreFile) { - let now = Date().timeIntervalSince1970 - let cutoff = now - Self.maxStateAgeSeconds - state.sessions = state.sessions.filter { _, record in - record.updatedAt >= cutoff - } - state.activeSessionsByWorkspace = state.activeSessionsByWorkspace.filter { workspaceId, active in - guard active.updatedAt >= cutoff, let record = state.sessions[active.sessionId] else { return false } - // Self-heal cross-workspace/pane pollution: a session may only be active - // for its own recorded workspace (and surface, below). Stale focused/TTY - // misroutes from older builds could register a session as active for an - // unrelated tab or pane, stealing its notifications (isCurrent trusts the - // surface slot first) and suppressing that pane's own session. - return normalizeOptional(record.workspaceId) == workspaceId - } - state.activeSessionsBySurface = state.activeSessionsBySurface.filter { surfaceId, active in - active.updatedAt >= cutoff && normalizeOptional(state.sessions[active.sessionId]?.surfaceId) == surfaceId - } - } - - private func normalizeSessionId(_ value: String) -> String { - value.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func normalizeOptional(_ value: String?) -> String? { - guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { - return nil - } - return value - } -} - -private let agentHookWrapperProcessNames: Set = [ - "sh", - "bash", - "zsh", - "env" -] - -private let suppressSubagentNotificationsDefaultsKey = "suppressSubagentNotifications" -private let suppressSubagentNotificationsEnvironmentKey = "CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS" private let managedSubagentEnvironmentKey = "CMUX_AGENT_MANAGED_SUBAGENT" private let codexTeamsThreadEnvironmentKey = "CMUX_CODEX_TEAMS_THREAD_ID" private let codexTeamsParentThreadEnvironmentKey = "CMUX_CODEX_TEAMS_PARENT_THREAD_ID" private let codexTeamsDepthEnvironmentKey = "CMUX_CODEX_TEAMS_DEPTH" -enum CLIIDFormat: String { - case refs - case uuids - case both - - static func parse(_ raw: String?) throws -> CLIIDFormat? { - guard let raw else { return nil } - guard let parsed = CLIIDFormat(rawValue: raw.lowercased()) else { - throw CLIError(message: "--id-format must be one of: refs, uuids, both") - } - return parsed - } -} private enum TopSortKey: Equatable { case cpu @@ -2800,10 +1204,18 @@ final class SocketClient { struct CMUXCLI { let args: [String] let initialSIGPIPEInspectionPayload: [String: Any]? + let agentSessionGraphOrdering: AgentSessionGraphOrdering + let agentSessionRunCanonicalizer: AgentSessionRunCanonicalizer + let agentStableProcessIdentityValidator: AgentStableProcessIdentityValidator + private let copilotHookConfig: CopilotHookConfig private static let vmCreateIdempotencyTTLSeconds: TimeInterval = 10 * 60 private static let vmCreateResponseTimeoutSeconds: TimeInterval = 16 * 60 private static let vmAttachResponseTimeoutSeconds: TimeInterval = 16 * 60 + /// Agent inspection uses the socket only for cached runtime metadata. A + /// stale inherited socket must not delay the durable registry fallback, + /// while an explicit socket still surfaces the timeout as an error. + private static let agentsInspectionResponseTimeoutSeconds: TimeInterval = 1 // Stable per-user slot for the pinned Cloud VM. This value is intentionally reused as // both the backend create idempotency key and the local daemon slot so every open, // reconnect, session restore, and mobile attach targets the same provider VM once @@ -2819,9 +1231,20 @@ struct CMUXCLI { return keys } - init(args: [String], initialSIGPIPEInspectionPayload: [String: Any]? = nil) { + init( + args: [String], + initialSIGPIPEInspectionPayload: [String: Any]? = nil, + agentSessionGraphOrdering: AgentSessionGraphOrdering = AgentSessionGraphOrdering(), + agentSessionRunCanonicalizer: AgentSessionRunCanonicalizer = AgentSessionRunCanonicalizer(), + agentStableProcessIdentityValidator: AgentStableProcessIdentityValidator = AgentStableProcessIdentityValidator(), + copilotHookConfig: CopilotHookConfig = CopilotHookConfig() + ) { self.args = args self.initialSIGPIPEInspectionPayload = initialSIGPIPEInspectionPayload + self.agentSessionGraphOrdering = agentSessionGraphOrdering + self.agentSessionRunCanonicalizer = agentSessionRunCanonicalizer + self.agentStableProcessIdentityValidator = agentStableProcessIdentityValidator + self.copilotHookConfig = copilotHookConfig } private func captureSocketTransportError(telemetry: CLISocketSentryTelemetry, stage: String, error: Error, client: SocketClient) { @@ -3240,7 +1663,55 @@ struct CMUXCLI { if command == "vm-pty-connect" { try runVMPtyConnect(commandArgs: commandArgs); return } if command == "docs" { try runDocsCommand(commandArgs: commandArgs, jsonOutput: jsonOutput); return } if command == "welcome" { printWelcome(); return } - if command == "sessions" || command == "session-debug" { try runSessionsCommand(commandArgs: command == "session-debug" ? ["debug"] + commandArgs : commandArgs, jsonOutput: jsonOutput, processEnv: processEnv); return } + if command == "agents" || command == "sessions" || command == "session-debug" { + var queryEnvironment = processEnv + var terminalObservations: [CmuxAgentTerminalObservation] = [] + var runtimeInspectionError: Error? + let ambientSocketPath = (processEnv["CMUX_SOCKET_PATH"] ?? processEnv["CMUX_SOCKET"])? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let querySocketPath = explicitSocketPath + ?? ambientSocketPath.flatMap({ $0.isEmpty ? nil : $0 }) { + do { + let client = SocketClient(path: querySocketPath) + defer { client.close() } + try client.connect() + try authenticateClientIfNeeded( + client, + explicitPassword: socketPasswordArg, + socketPath: querySocketPath + ) + let capabilities = try client.sendV2( + method: "system.capabilities", + responseTimeout: Self.agentsInspectionResponseTimeoutSeconds + ) + queryEnvironment = agentSessionQueryEnvironment( + environment: queryEnvironment, + socketCapabilities: capabilities + ) + if (capabilities["methods"] as? [String])?.contains("agents.observations") == true { + terminalObservations = try decodeAgentTerminalObservations( + client.sendV2( + method: "agents.observations", + responseTimeout: Self.agentsInspectionResponseTimeoutSeconds + ), + expectedRuntimeID: capabilities["runtime_id"] as? String + ) + } + } catch { + if explicitSocketPath != nil { runtimeInspectionError = error } + } + } + try runAgentsCommand( + commandArgs: command == "session-debug" ? ["debug"] + commandArgs : commandArgs, + jsonOutput: jsonOutput, + processEnv: queryEnvironment, + terminalObservations: terminalObservations, + invocation: command == "agents" ? .agents : .sessions, + runtimeInspectionError: runtimeInspectionError, + runtimeSocketPath: explicitSocketPath + ) + return + } if command == "__sigpipe-probe" { try runSIGPIPEProbe(commandArgs: commandArgs); return } if command == "__sigpipe-stdin-pipe-probe" { try runSIGPIPEStdinPipeProbe(); return } if command == "__sigpipe-inspect" { try runSIGPIPEInspect(commandArgs: commandArgs); return } @@ -9684,16 +8155,17 @@ struct CMUXCLI { var lines: [String] = [ "cmux_workspace_id=\"${CMUX_WORKSPACE_ID:-}\"", "cmux_surface_id=\"${CMUX_SURFACE_ID:-}\"", + "cmux_runtime_id=\"${CMUX_RUNTIME_ID:-}\"", "cmux_remote_bootstrap_b64=\(shellQuote(encodedBootstrapScript))", "cmux_remote_bootstrap=\"$(printf %s \"$cmux_remote_bootstrap_b64\" | base64 -d 2>/dev/null || printf %s \"$cmux_remote_bootstrap_b64\" | base64 -D 2>/dev/null)\"", - "cmux_remote_bootstrap=\"$(printf '%s' \"$cmux_remote_bootstrap\" | sed \"s/__CMUX_WORKSPACE_ID__/$cmux_workspace_id/g; s/__CMUX_SURFACE_ID__/$cmux_surface_id/g\")\"", + "cmux_remote_bootstrap=\"$(printf '%s' \"$cmux_remote_bootstrap\" | sed \"s/__CMUX_WORKSPACE_ID__/$cmux_workspace_id/g; s/__CMUX_SURFACE_ID__/$cmux_surface_id/g; s/__CMUX_RUNTIME_ID__/$cmux_runtime_id/g\")\"", "printf '%s' \"$cmux_remote_bootstrap\" | command \(installSSHPrefix) -T \(shellQuote(options.destination)) \(shellQuote(remoteBootstrapInstallCommand))", "cmux_remote_install_status=$?", "if [ \"$cmux_remote_install_status\" -ne 0 ]; then", " exit \"$cmux_remote_install_status\"", "fi", "cmux_remote_command_template=\(shellQuote(remoteCommandTemplate))", - "cmux_remote_command=\"$(printf '%s' \"$cmux_remote_command_template\" | sed \"s/__CMUX_WORKSPACE_ID__/$cmux_workspace_id/g; s/__CMUX_SURFACE_ID__/$cmux_surface_id/g\")\"", + "cmux_remote_command=\"$(printf '%s' \"$cmux_remote_command_template\" | sed \"s/__CMUX_WORKSPACE_ID__/$cmux_workspace_id/g; s/__CMUX_SURFACE_ID__/$cmux_surface_id/g; s/__CMUX_RUNTIME_ID__/$cmux_runtime_id/g\")\"", ] var sshInvocation = "command \(sessionSSHPrefix) -o \"RemoteCommand=$cmux_remote_command\"" @@ -12222,6 +10694,10 @@ struct CMUXCLI { of: "__CMUX_SURFACE_ID__", with: ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] ?? "" ) + .replacingOccurrences( + of: "__CMUX_RUNTIME_ID__", + with: ProcessInfo.processInfo.environment["CMUX_RUNTIME_ID"] ?? "" + ) return decoded } var bridgeReachedReady = false @@ -15076,7 +13552,7 @@ struct CMUXCLI { If the app is already running, this restores the last saved session into the current app. If the app is not running, this launches cmux and lets startup restore reopen the saved session. """ - case "sessions", "session-debug": return sessionsUsage() + case "agents", "sessions", "session-debug": return agentsUsage() case "feedback": return """ Usage: cmux feedback @@ -15107,48 +13583,7 @@ struct CMUXCLI { --legacy Force the older built-in Swift TUI """ case "hooks": - return """ - Usage: cmux hooks setup [agent] [--agent ] [--yes|-y] - cmux hooks uninstall [agent] [--agent ] [--yes|-y] - cmux hooks install [--yes|-y] (opencode supports --project) - cmux hooks uninstall [--yes|-y] (opencode supports --project) - cmux hooks [flags] - cmux hooks feed --source [--event ] - - Manage and run cmux agent hooks without adding one top-level command per - agent. Claude Code hooks are injected automatically by the cmux Claude wrapper. - - Agents: - codex, grok, opencode, pi, omp, campfire, amp, cursor, gemini, kiro, antigravity (alias: agy), rovodev (alias: rovo), hermes-agent, copilot, codebuddy, factory, qoder - - Hook targets: - setup Install hooks for all supported agents on PATH - uninstall Remove hooks for all supported agents - install Install one agent integration - uninstall Remove one agent integration - Internal hook entrypoint used by generated configs - feed Internal Feed decision bridge - - Generated files: - ~/.config/opencode/plugins/cmux-session.js - ~/.config/opencode/plugins/cmux-feed.js - ~/.pi/agent/extensions/cmux-session.ts - ~/.omp/agent/extensions/cmux-omp-session.ts - ~/.campfire/agent/extensions/cmux-campfire-session.ts - ~/.config/amp/plugins/cmux-session.ts - ~/.kiro/agents/cmux.json - See docs/agent-hooks.md for the full integration matrix. - - Examples: - cmux hooks setup - cmux hooks setup --agent codex - cmux hooks setup rovo - cmux hooks setup omp - cmux hooks uninstall rovo - cmux hooks codex install - cmux hooks opencode install --project - cmux hooks uninstall - """ + return hooksUsage() case "themes": return """ Usage: cmux themes @@ -23784,15 +22219,15 @@ struct CMUXCLI { callerTerminalBinding: callerTTYBindingProvider, agentPid: claudeAgentPID(from: ProcessInfo.processInfo.environment) ) - let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" + guard let rawInputData = Self.readBoundedHookStdin() else { + printClaudeHookAck() + return + } + let rawInput = String(data: rawInputData, encoding: .utf8) ?? "" let parsedInput = parseClaudeHookInput(rawInput: rawInput) - let sessionStore = ClaudeHookSessionStore() - // Record the hook-observed permission mode (shift+tab auto-accept, plan - // mode, bypass toggle): it is runtime state that never appears in the - // captured launch argv, and session restore re-applies it as - // `--permission-mode`. Read from rawObject — the compacted hook object - // keeps only an allowlist of keys and does not retain permission_mode. - // https://github.com/manaflow-ai/cmux/issues/8066 + let sessionStore = ClaudeHookSessionStore(processEnv: agentHookStoreEnvironment(environment: ProcessInfo.processInfo.environment, client: client)) + // Runtime permission mode is absent from the captured launch argv, so + // preserve the hook-observed state for subsequent session restores. let observedHookPermissionMode = (parsedInput.rawObject?["permission_mode"] as? String) ?? (parsedInput.rawObject?["permissionMode"] as? String) if let hookSessionId = parsedInput.sessionId, @@ -23849,6 +22284,7 @@ struct CMUXCLI { let claudePid = claudeAgentPID(from: ProcessInfo.processInfo.environment) let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, + agentName: "claude", env: ProcessInfo.processInfo.environment ) let launchCommand = agentLaunchCommandFromEnvironment( @@ -23877,11 +22313,12 @@ struct CMUXCLI { telemetry: telemetry ) let shouldPromoteActiveSession = !isForkSessionLaunch && (isClearSessionStart || canReplaceStoppedSession) + var acceptedSessionStart = true if let sessionId = parsedInput.sessionId, !isForkSessionLaunch { // Non-clear SessionStart can arrive late from startup/resume/compact // after /clear, so only /clear or replacement of a stopped owner // establishes a new active boundary. - try? sessionStore.upsert( + acceptedSessionStart = (try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, @@ -23893,8 +22330,8 @@ struct CMUXCLI { agentLifecycle: shouldPromoteActiveSession ? .running : .unknown, markActive: shouldPromoteActiveSession, turnId: parsedInput.turnId - ) - if shouldPromoteActiveSession { + )) ?? false + if shouldPromoteActiveSession, acceptedSessionStart { publishAgentSurfaceResumeBinding( client: client, workspaceId: workspaceId, @@ -23908,6 +22345,10 @@ struct CMUXCLI { ) } } + guard acceptedSessionStart else { + telemetry.breadcrumb("claude-hook.session-start.rejected-generation"); didSendFeedTelemetry = true + printClaudeHookAck(); return + } // Register PID for stale-session detection and OSC suppression. // Startup/resume SessionStart remains non-visible; /clear is a // new active boundary and must keep the sidebar Running before @@ -23977,46 +22418,90 @@ struct CMUXCLI { let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, + agentName: "claude", + env: ProcessInfo.processInfo.environment + ) + let suppressNotification = shouldSuppressNestedAgentNotification( + visibleMutationsSuppressed: suppressVisibleMutations, env: ProcessInfo.processInfo.environment ) sendClaudeFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId) - guard shouldApplyClaudeHookVisibleMutation( + if suppressVisibleMutations { + ClaudeChildSessionObserver().recordStop( + input: parsedInput, + store: sessionStore, + workspaceId: workspaceId, + surfaceId: surfaceId, + pid: claudePid, + launchCommand: mappedSession?.launchCommand, + environment: ProcessInfo.processInfo.environment + ) + telemetry.breadcrumb("claude-hook.stop.nested-suppressed") + guard !suppressNotification else { + printClaudeHookAck() + return + } + } else if !shouldApplyClaudeHookVisibleMutation( sessionStore: sessionStore, parsedInput: parsedInput, workspaceId: workspaceId, surfaceId: resolvedSurface.isAuthoritative ? surfaceId : nil, telemetry: telemetry - ) else { + ) { telemetry.breadcrumb("claude-hook.stop.stale") printClaudeHookAck() return } - guard !suppressVisibleMutations else { - telemetry.breadcrumb("claude-hook.stop.nested-suppressed") - printClaudeHookAck() - return - } - // Whether this turn ended with unfinished background work (a running // background task or a pending cron). Cached on the session record so // the ~60s-later idle_prompt Notification can consult it, and forwarded // to the app so it can suppress the done-ping until work truly drains. let hasPendingBackgroundWork = hasActiveClaudeBackgroundWork(parsedInput) + let semanticWorkloads = ClaudeAgentWorkloadAdapter().workloads( + from: parsedInput, + now: Date().timeIntervalSince1970 + ) + let stopWasInterrupted = AgentStopStateAdapter().isInterrupted( + provider: "claude", + input: parsedInput, + transcriptPath: parsedInput.transcriptPath ?? mappedSession?.transcriptPath + ) // Update session with transcript summary and send completion notification. let completion = summarizeClaudeHookStop( parsedInput: parsedInput, sessionRecord: mappedSession ) - if let sessionId = parsedInput.sessionId { - try? sessionStore.upsert( + let sendCompletionNotification = { + guard let completion, !suppressNotification else { return } + let title = String( + localized: "cli.claude-hook.notification.title", + defaultValue: "Claude Code" + ) + let payload = notificationPayload( + title: title, + subtitle: completion.subtitle, + body: completion.body, + meta: AgentHookNotifyCategory.turnComplete.metaSegment( + pending: hasPendingBackgroundWork + ) + ) + _ = try? sendV1Command( + "notify_target_async \(workspaceId) \(surfaceId) \(payload)", + client: client + ) + } + var completedPromptStopGeneration = false + if !suppressVisibleMutations, let sessionId = parsedInput.sessionId { + let promptStopResult = (try? sessionStore.upsertPromptStop( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: parsedInput.cwd, transcriptPath: parsedInput.transcriptPath, + pid: claudePid, isRestorable: true, // Pending background work keeps the pane out of the // hibernatable .idle state so the planner cannot SIGTERM @@ -24027,64 +22512,90 @@ struct CMUXCLI { hadPendingBackgroundWorkAtStop: hasPendingBackgroundWork, markActive: true, allowsNewSessionReplacement: true - ) - publishAgentSurfaceResumeBinding( - client: client, - workspaceId: workspaceId, - surfaceId: surfaceId, - kind: "claude", - displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"), - sessionId: sessionId, - cwd: parsedInput.cwd ?? mappedSession?.cwd, - launchCommand: mappedSession?.launchCommand, - observedPermissionMode: observedHookPermissionMode - ?? mappedSession?.lastPermissionMode - ) + )) ?? AgentPromptStopResult(accepted: false, nested: false) + completedPromptStopGeneration = promptStopResult.completedGeneration + guard promptStopResult.accepted || promptStopResult.completedGeneration else { + telemetry.breadcrumb("claude-hook.stop.rejected-generation") + // A Stop can be the first hook observed after install or + // upgrade. It has no generation proof, so it must not + // create lifecycle state, but its routed completion is + // still safe to notify when no saved record was rejected. + if mappedSession == nil { + sendCompletionNotification() + } + printClaudeHookAck() + return + } + if promptStopResult.shouldClearVisibleState { + clearAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + sessionId: sessionId + ) + _ = try? sendV1Command( + "clear_agent_pid \(Self.claudeCodeStatusKey) --tab=\(workspaceId)\(socketPanelOption(surfaceId)) --clear-status", + client: client + ) + } else if promptStopResult.accepted { + try? sessionStore.reconcileSemanticState( + sessionId: sessionId, + foregroundState: stopWasInterrupted ? .interrupted : .completed, + attentionState: AgentAttentionState.none, + workloads: semanticWorkloads + ) + publishAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + kind: "claude", + displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"), + sessionId: sessionId, + cwd: parsedInput.cwd ?? mappedSession?.cwd, + launchCommand: mappedSession?.launchCommand, + observedPermissionMode: observedHookPermissionMode + ?? mappedSession?.lastPermissionMode + ) + } else { + telemetry.breadcrumb("claude-hook.stop.completed-stale-generation") + printClaudeHookAck() + return + } } - setAgentLifecycle( - client: client, - key: Self.claudeCodeStatusKey, - lifecycle: hasPendingBackgroundWork ? .running : .idle, - workspaceId: workspaceId, - surfaceId: surfaceId - ) - if hasPendingBackgroundWork { - // The turn ended but a background task or scheduled wakeup is - // still live, so the pane is not idle — show it as still - // running rather than the misleading "Idle". Reuse the shared - // generic-agent status strings so the pill stays localized. - try? setClaudeStatus( - client: client, - workspaceId: workspaceId, - surfaceId: surfaceId, - value: String(localized: "agent.generic.status.running", defaultValue: "Running"), - icon: "bolt.fill", - color: "#4C8DFF" - ) - } else { - try? setClaudeStatus( + if !suppressVisibleMutations, !completedPromptStopGeneration { + setAgentLifecycle( client: client, + key: Self.claudeCodeStatusKey, + lifecycle: hasPendingBackgroundWork ? .running : .idle, workspaceId: workspaceId, - surfaceId: surfaceId, - value: String(localized: "agent.generic.notification.status.idle", defaultValue: "Idle"), - icon: "pause.circle.fill", - color: "#8E8E93" - ) - } - if let completion { - let title = String( - localized: "cli.claude-hook.notification.title", - defaultValue: "Claude Code" - ) - let payload = notificationPayload( - title: title, - subtitle: completion.subtitle, - body: completion.body, - meta: AgentHookNotifyCategory.turnComplete.metaSegment(pending: hasPendingBackgroundWork) + surfaceId: surfaceId ) - _ = try? sendV1Command("notify_target_async \(workspaceId) \(surfaceId) \(payload)", client: client) + if hasPendingBackgroundWork { + // The turn ended but a background task or scheduled wakeup is + // still live, so the pane is not idle — show it as still + // running rather than the misleading "Idle". Reuse the shared + // generic-agent status strings so the pill stays localized. + try? setClaudeStatus( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + value: String(localized: "agent.generic.status.running", defaultValue: "Running"), + icon: "bolt.fill", + color: "#4C8DFF" + ) + } else { + try? setClaudeStatus( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + value: String(localized: "agent.generic.notification.status.idle", defaultValue: "Idle"), + icon: "pause.circle.fill", + color: "#8E8E93" + ) + } } + sendCompletionNotification() printClaudeHookAck() } catch { if shouldIgnoreClaudeHookTeardownError(error) { @@ -24114,9 +22625,24 @@ struct CMUXCLI { let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, + agentName: "claude", env: ProcessInfo.processInfo.environment ) sendClaudeFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId) + if suppressVisibleMutations { + ClaudeChildSessionObserver().recordPrompt( + input: parsedInput, + store: sessionStore, + workspaceId: workspaceId, + surfaceId: surfaceId, + pid: claudePid, + launchCommand: mappedSession?.launchCommand, + environment: ProcessInfo.processInfo.environment + ) + telemetry.breadcrumb("claude-hook.prompt-submit.nested-suppressed") + printClaudeHookAck() + return + } let shouldApplyPromptSubmit = shouldApplyClaudeHookVisibleMutation( sessionStore: sessionStore, @@ -24137,11 +22663,6 @@ struct CMUXCLI { printClaudeHookAck() return } - guard !suppressVisibleMutations else { - telemetry.breadcrumb("claude-hook.prompt-submit.nested-suppressed") - printClaudeHookAck() - return - } if let sessionId = parsedInput.sessionId { // A forked session's first hook is this prompt-submit — its // SessionStart fired under the parent session id — so capture the @@ -24157,18 +22678,26 @@ struct CMUXCLI { cwd: parsedInput.cwd ) : nil - try? sessionStore.upsert( + let acceptedPromptSubmit = (try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: parsedInput.cwd, transcriptPath: parsedInput.transcriptPath, - pid: mappedSession == nil ? claudePid : nil, + pid: claudePid, launchCommand: firstSightingLaunchCommand, isRestorable: true, agentLifecycle: .running, markActive: true, turnId: parsedInput.turnId + )) ?? false + guard acceptedPromptSubmit else { + telemetry.breadcrumb("claude-hook.prompt-submit.rejected-generation"); printClaudeHookAck(); return + } + try? sessionStore.reconcileSemanticState( + sessionId: sessionId, + foregroundState: .working, + attentionState: AgentAttentionState.none ) publishAgentSurfaceResumeBinding( client: client, @@ -24266,6 +22795,11 @@ struct CMUXCLI { let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, + agentName: "claude", + env: ProcessInfo.processInfo.environment + ) + let suppressNotification = shouldSuppressNestedAgentNotification( + visibleMutationsSuppressed: suppressVisibleMutations, env: ProcessInfo.processInfo.environment ) let resolvedSurface = resolvedTarget @@ -24282,7 +22816,7 @@ struct CMUXCLI { printClaudeHookAck() return } - guard !suppressVisibleMutations else { + guard !suppressNotification else { telemetry.breadcrumb("claude-hook.notification.nested-suppressed") printClaudeHookAck() return @@ -24359,20 +22893,30 @@ struct CMUXCLI { meta: notifyCategory.metaSegment(pending: notifyPending) ) - if let sessionId = parsedInput.sessionId, !suppressNeedsInputState { - try? sessionStore.upsert( + if !suppressVisibleMutations, + let sessionId = parsedInput.sessionId, + !suppressNeedsInputState { + let acceptedNotification = (try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: parsedInput.cwd, transcriptPath: parsedInput.transcriptPath, + pid: claudePid, agentLifecycle: .needsInput, lastSubtitle: summary.subtitle, lastBody: summary.body + )) ?? false + guard acceptedNotification else { + telemetry.breadcrumb("claude-hook.notification.rejected-generation"); printClaudeHookAck(); return + } + try? sessionStore.reconcileSemanticState( + sessionId: sessionId, + attentionState: .needsInput ) } - if !suppressNeedsInputState { + if !suppressVisibleMutations, !suppressNeedsInputState { setAgentLifecycle( client: client, key: Self.claudeCodeStatusKey, @@ -24416,6 +22960,7 @@ struct CMUXCLI { let forkClaudePid = claudeAgentPID(from: ProcessInfo.processInfo.environment) let suppressForkVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: forkClaudePid, + agentName: "claude", env: ProcessInfo.processInfo.environment ) // Resolve through the live target resolver so the cleanup @@ -24512,6 +23057,7 @@ struct CMUXCLI { let claudePid = consumedSession.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, + agentName: "claude", env: ProcessInfo.processInfo.environment ) if shouldClearVisibleState, !suppressVisibleMutations { @@ -24571,6 +23117,7 @@ struct CMUXCLI { let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, + agentName: "claude", env: ProcessInfo.processInfo.environment ) guard shouldApplyClaudeHookVisibleMutation( @@ -24629,16 +23176,20 @@ struct CMUXCLI { let existingSurfaceId = resolvedSurface.isAuthoritative ? surfaceId : (nonEmptyClaudeHookIdentifier(mappedSession?.surfaceId) ?? surfaceId) - try? sessionStore.upsert( + let acceptedNeedsInput = (try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: existingSurfaceId, cwd: parsedInput.cwd, transcriptPath: parsedInput.transcriptPath, + pid: claudePid, agentLifecycle: .needsInput, lastSubtitle: waitingSubtitle, lastBody: needsInputBody - ) + )) ?? false + guard acceptedNeedsInput else { + telemetry.breadcrumb("claude-hook.pre-tool-use.rejected-generation"); printClaudeHookAck(); return + } setAgentLifecycle( client: client, key: Self.claudeCodeStatusKey, @@ -24694,14 +23245,18 @@ struct CMUXCLI { } if let sessionId = parsedInput.sessionId { - try? sessionStore.upsert( + let acceptedToolUse = (try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: parsedInput.cwd, transcriptPath: parsedInput.transcriptPath, + pid: claudePid, agentLifecycle: .running - ) + )) ?? false + guard acceptedToolUse else { + telemetry.breadcrumb("claude-hook.pre-tool-use.rejected-generation"); printClaudeHookAck(); return + } } _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client) setAgentLifecycle( @@ -27180,6 +25735,7 @@ struct CMUXCLI { case "codex": envKey = "CMUX_CODEX_PID" case "cursor": envKey = "CMUX_CURSOR_PID" case "gemini": envKey = "CMUX_GEMINI_PID" + case "grok": envKey = "CMUX_GROK_PID" case "antigravity": envKey = "CMUX_ANTIGRAVITY_PID" case "rovodev": envKey = "CMUX_ROVODEV_PID" case "hermes-agent": envKey = "CMUX_HERMES_AGENT_PID" @@ -27223,99 +25779,6 @@ struct CMUXCLI { return pid } - func shouldSuppressNestedAgentVisibleMutations( - currentAgentPID: Int?, - nestedPromptEvent: Bool = false, - transcriptSubagentSession: Bool = false, - env: [String: String] - ) -> Bool { - if let override = normalizedHookValue(env["CMUX_AGENT_HOOK_SUPPRESS_VISIBLE_MUTATIONS"])?.lowercased(), - Self.parseHookBoolean(override) == true { - return true - } - - guard subagentNotificationSuppressionEnabled(env: env) else { - return false - } - - if nestedPromptEvent { - return true - } - - if managedSubagentVisibleMutationSuppressionRequested(env: env) { - return true - } - - if transcriptSubagentSession { - return true - } - - guard let currentAgentPID, currentAgentPID > 1 else { - return false - } - - var candidate = pid_t(currentAgentPID) - var agentProcessCount = 0 - var remainingAncestors = 32 - while candidate > 1, remainingAncestors > 0 { - if nativeProcessDescribesKnownAgent(for: candidate) { - agentProcessCount += 1 - if agentProcessCount >= 2 { - return true - } - } - let next = parentPID(of: candidate) - guard next > 1, next != candidate else { - break - } - candidate = next - remainingAncestors -= 1 - } - return false - } - - private func managedSubagentVisibleMutationSuppressionRequested(env: [String: String]) -> Bool { - guard let raw = normalizedHookValue(env[managedSubagentEnvironmentKey]), - let parsed = Self.parseHookBoolean(raw) else { - return false - } - return parsed - } - - private func subagentNotificationSuppressionEnabled(env: [String: String]) -> Bool { - if let raw = normalizedHookValue(env[suppressSubagentNotificationsEnvironmentKey]), - let parsed = Self.parseHookBoolean(raw) { - return parsed - } - for defaults in appDefaultsCandidates(env: env) { - if defaults.object(forKey: suppressSubagentNotificationsDefaultsKey) != nil { - return defaults.bool(forKey: suppressSubagentNotificationsDefaultsKey) - } - } - return true - } - - private func appDefaultsCandidates(env: [String: String]) -> [UserDefaults] { - var candidates: [UserDefaults] = [] - if let bundleId = normalizedHookValue(env["CMUX_BUNDLE_ID"]), - let defaults = UserDefaults(suiteName: bundleId) { - candidates.append(defaults) - } - candidates.append(.standard) - return candidates - } - - private static func parseHookBoolean(_ rawValue: String) -> Bool? { - switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "1", "true", "yes", "on", "enabled": - return true - case "0", "false", "no", "off", "disabled": - return false - default: - return nil - } - } - private func nativeProcessDescribesKnownAgent(for pid: pid_t) -> Bool { AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( processName: processName(for: pid), @@ -27482,9 +25945,18 @@ struct CMUXCLI { envLauncher, kind: fallbackKind ) - let envArguments = envCaptureIsTrusted + let decodedEnvArguments = envCaptureIsTrusted ? decodeNULSeparatedBase64(env["CMUX_AGENT_LAUNCH_ARGV_B64"]) : nil + let envExecutablePath = normalizedHookValue(env["CMUX_AGENT_LAUNCH_EXECUTABLE"]) + let validEnvArguments = decodedEnvArguments.flatMap { arguments in + AgentLaunchCaptureTrust.capturedArgumentsDescribeKind( + launcher: envLauncher, + executablePath: envExecutablePath, + arguments: arguments, + kind: fallbackKind + ) ? arguments : nil + } var processArguments = fallbackPID.flatMap { fallbackPID -> [String]? in let pid = pid_t(fallbackPID) let candidate = self.processArguments(for: pid) @@ -27501,64 +25973,99 @@ struct CMUXCLI { // `sh -c …` wrapper), not the agent. That argv is not a launch. processArguments = nil } - let arguments = envArguments ?? processArguments - let launcher = envCaptureIsTrusted ? (envLauncher ?? fallbackKind) : fallbackKind + let exactEnvironmentLauncher = normalizedHookValue(envLauncher)?.lowercased() + == normalizedHookValue(fallbackKind)?.lowercased() + let environmentEvidence: AgentLaunchCaptureEvidence = { + if exactEnvironmentLauncher { + return .exactEnvironmentLauncher + } + if envCaptureIsTrusted, envLauncher != nil { + return .wrapperEnvironmentLauncher + } + return .unavailable + }() + let arguments: [String]? + let evidence: AgentLaunchCaptureEvidence + let launcher: String + let executablePath: String? + if let validEnvArguments { + arguments = validEnvArguments + evidence = environmentEvidence + launcher = envLauncher ?? fallbackKind + executablePath = envExecutablePath ?? validEnvArguments.first + } else if let processArguments { + arguments = processArguments + evidence = .nativeProcess + launcher = fallbackKind + executablePath = processArguments.first + } else { + // Retain an exact launcher's truncated capture as evidence for the + // canonical provider command. The planner will never replay it. + arguments = decodedEnvArguments + evidence = environmentEvidence + launcher = envCaptureIsTrusted ? (envLauncher ?? fallbackKind) : fallbackKind + executablePath = envCaptureIsTrusted + ? (envExecutablePath ?? decodedEnvArguments?.first) + : nil + } let workingDirectory = (envCaptureIsTrusted ? normalizedHookValue(env["CMUX_AGENT_LAUNCH_CWD"]) : nil) ?? normalizedHookValue(cwd) ?? normalizedHookValue(env["PWD"]) let environment = selectedAgentLaunchEnvironment(from: env, kind: launcher) - // Fallback when the launch argv is genuinely UNAVAILABLE: plain `codex` with no cmux launcher - // (no CMUX_AGENT_LAUNCH_ARGV_B64) and an unresolved/exited PID, so processArguments returns nil. - // The argv is gone, but the agent's launch env may still carry a non-default home that - // resume/fork MUST reproduce or the session won't be found — above all CODEX_HOME when codex - // runs under the subrouter account manager (~/.codex-accounts/), also CLAUDE_CONFIG_DIR - // for Claude. AgentResumeCommandBuilder then prefixes it ahead of the kind's fallback verb - // (`CODEX_HOME= codex resume `), while launcher/kind resolution still gates whether a - // resume command is produced (omx/omc and unknown kinds stay non-resumable). Empty selected env - // keeps the historical nil. This deliberately does NOT cover a captured-but-rejected argv (see - // the sanitizer guard below), so non-restorable invocations stay non-resumable. - func environmentOnlyRecord() -> AgentHookLaunchCommandRecord? { - guard !environment.isEmpty else { - return fallbackKind == "codex" ? AgentHookLaunchCommandRecord(launcher: launcher, executablePath: nil, arguments: [], workingDirectory: workingDirectory, environment: nil, capturedAt: Date().timeIntervalSince1970, source: "default") : nil - } + let sanitizedArguments = arguments.flatMap { + sanitizedAgentLaunchArguments( + $0, + launcher: launcher, + fallbackKind: fallbackKind + ) + } + let plan = AgentLaunchReplayPlanner().plan( + kind: fallbackKind, + launcher: launcher, + executablePath: executablePath, + capturedArguments: arguments, + sanitizedArguments: sanitizedArguments, + evidence: evidence, + hasSelectedEnvironment: !environment.isEmpty + ) + let capturedAt = Date().timeIntervalSince1970 + switch plan { + case .captured(let sanitizedArguments, let captureEvidence): + let source = captureEvidence == .nativeProcess ? "process" : "environment" + return AgentHookLaunchCommandRecord( + launcher: launcher, + executablePath: executablePath, + arguments: sanitizedArguments, + workingDirectory: workingDirectory, + environment: environment.isEmpty ? nil : environment, + capturedAt: capturedAt, + source: source + ) + case .canonical: + let source = !environment.isEmpty && arguments == nil ? "environment" : "default" return AgentHookLaunchCommandRecord( launcher: launcher, executablePath: nil, arguments: [], workingDirectory: workingDirectory, - environment: environment, - capturedAt: Date().timeIntervalSince1970, - source: "environment" + environment: environment.isEmpty ? nil : environment, + capturedAt: capturedAt, + source: source ) + case .rejected: + return AgentHookLaunchCommandRecord( + launcher: launcher, + executablePath: executablePath, + arguments: [], + workingDirectory: workingDirectory, + environment: nil, + capturedAt: capturedAt, + source: "rejected" + ) + case .unavailable: + return nil } - - guard let arguments, !arguments.isEmpty else { - return environmentOnlyRecord() - } - - let executablePath = (envCaptureIsTrusted ? normalizedHookValue(env["CMUX_AGENT_LAUNCH_EXECUTABLE"]) : nil) - ?? arguments.first - guard let sanitizedArguments = sanitizedAgentLaunchArguments( - arguments, - launcher: launcher, - fallbackKind: fallbackKind - ) else { - // Sanitized-away argv means a non-restorable invocation. Do not - // replace it with an env-only fallback. - return AgentHookLaunchCommandRecord(launcher: launcher, executablePath: executablePath, arguments: [], workingDirectory: workingDirectory, environment: nil, capturedAt: Date().timeIntervalSince1970, source: "rejected") - } - let source = envArguments == nil ? "process" : "environment" - - return AgentHookLaunchCommandRecord( - launcher: launcher, - executablePath: executablePath, - arguments: sanitizedArguments, - workingDirectory: workingDirectory, - environment: environment.isEmpty ? nil : environment, - capturedAt: Date().timeIntervalSince1970, - source: source - ) } private func publishAgentSurfaceResumeBinding( @@ -27568,12 +26075,25 @@ struct CMUXCLI { kind: String, displayName: String, sessionId: String, + transcriptPath: String? = nil, cwd: String?, launchCommand: AgentHookLaunchCommandRecord?, observedPermissionMode: String? = nil ) { - if !agentHookSessionHasDurableResumeEvidence(kind: kind, launchCommand: launchCommand) { - clearAgentSurfaceResumeBinding(client: client, workspaceId: workspaceId, surfaceId: surfaceId, sessionId: sessionId) + guard agentHookSessionHasDurableResumeEvidence( + kind: kind, + launchCommand: launchCommand, + transcriptPath: transcriptPath + ) else { + // A visible top-level hook now owns this surface, but its launch is + // not safely resumable. Remove any binding left by the previous + // session so snapshots cannot restore the wrong conversation. + clearAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + sessionId: nil + ) return } let resumeEnvironment = agentSurfaceResumeEnvironment(kind: kind, environment: launchCommand?.environment) @@ -27586,6 +26106,7 @@ struct CMUXCLI { guard let command = agentSurfaceResumeCommand( kind: kind, sessionId: sessionId, + transcriptPath: transcriptPath, launchCommand: launchCommand, workingDirectory: resumeWorkingDirectory, environment: resumeEnvironment, @@ -27595,7 +26116,7 @@ struct CMUXCLI { client: client, workspaceId: workspaceId, surfaceId: surfaceId, - sessionId: sessionId + sessionId: nil ) return } @@ -27638,6 +26159,7 @@ struct CMUXCLI { private func agentSurfaceResumeCommand( kind: String, sessionId: String, + transcriptPath: String?, launchCommand: AgentHookLaunchCommandRecord?, workingDirectory: String?, environment: [String: String]?, @@ -27664,7 +26186,8 @@ struct CMUXCLI { sessionId: normalizedSessionId, executablePath: launchCommand?.executablePath, arguments: launchCommand?.arguments ?? [], - observedPermissionMode: observedPermissionMode + observedPermissionMode: observedPermissionMode, + transcriptPath: transcriptPath ) } @@ -27694,16 +26217,28 @@ struct CMUXCLI { let resumeCommandParts = kind == "hermes-agent" ? hermesAgentArgumentsByReplacingOpenAICodexProvider(sanitizedCommandParts) : sanitizedCommandParts - // Route the claude executable through the wrapper shim token so the executed + // Route claude and codex through their wrapper shim tokens so the executed // command re-injects cmux hooks even when run via the `$SHELL -lic` restore - // launcher (where the integration's PATH shim / `claude()` function are not - // active). The token is POSIX-only and the launcher dispatches through the + // launcher (where the integration's PATH shims / shell functions are not + // active). The tokens are POSIX-only and the launcher dispatches through the // user's shell (fish/csh/tcsh included), so token-bearing commands are wrapped // in `/bin/sh -c '…'` to parse everywhere; the cwd guard below stays outside so // cd-prefix rewriting keeps composing. https://github.com/manaflow-ai/cmux/issues/5639 - var command = kind == "claude" - ? AgentResumeArgv.renderedPortableClaudeResumeShellCommand(parts: resumeCommandParts, quote: cliShellQuote) - : resumeCommandParts.map(cliShellQuote).joined(separator: " ") + var command: String + switch kind { + case "claude": + command = AgentResumeArgv.renderedPortableClaudeResumeShellCommand( + parts: resumeCommandParts, + quote: cliShellQuote + ) + case "codex": + command = AgentResumeArgv.renderedPortableCodexResumeShellCommand( + parts: resumeCommandParts, + quote: cliShellQuote + ) + default: + command = resumeCommandParts.map(cliShellQuote).joined(separator: " ") + } if kind == "hermes-agent" { command = hermesAgentSubrouterResumeCommand( command, @@ -28029,19 +26564,39 @@ struct CMUXCLI { private static let openCodeSessionPluginMarker = "cmux-opencode-session-plugin-marker" private static let openCodeSessionPluginFilename = "cmux-session.js" private static let openCodeSessionPluginSource = #""" -// cmux-opencode-session-plugin-marker v1 +// cmux-opencode-session-plugin-marker v3 // Bridges OpenCode session lifecycle events into cmux's restorable session store. // Installed by `cmux hooks opencode install` or `cmux hooks setup`. // DO NOT EDIT MANUALLY. cmux upgrades this file in place. -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; const CMUX_PLUGIN_INSTALLED_KEY = Symbol.for("cmux.session.restore.plugin.installed"); const MAX_TRACKED_SESSIONS = 100; +const MAX_ACTIVE_HOOKS = 4; +const MAX_PENDING_HOOKS = 256; +const MAX_PENDING_RECONCILIATION_HOOKS = 10000; +const HOOK_TIMEOUT_MS = 5000; +const HOOK_SHUTDOWN_TIMEOUT_MS = 10000; const messageRoles = new Map(); const sessions = new Map(); +const pendingHooks = []; +const pendingReconciliationHooks = new Map(); +const shutdownFinalHooks = new Map(); +const activeHookSessions = new Set(); +const activeHookDispatches = new Map(); +const reservedTerminalSessions = new Set(); +let activeHookCount = 0; +let hookDrainScheduled = false; +let nextHookSequence = 1; +let drainingHooksForShutdown = false; +let hookShutdownDeadlineExpired = false; +let hookShutdownPromise = null; +let hookShutdownResolve = null; +let hookShutdownDeadline = null; +let activePluginGeneration = null; function firstString(...values) { for (const value of values) { @@ -28068,6 +26623,7 @@ function sessionState(sessionId) { lastUserMessage: null, assistantPreamble: null, cwd: null, + sessionStartSent: false, updatedAt: Date.now(), }); } @@ -28207,12 +26763,284 @@ function hookEnvironment(cwd) { return env; } +function scheduleHookDrain() { + if (hookDrainScheduled) return; + hookDrainScheduled = true; + queueMicrotask(() => { + hookDrainScheduled = false; + drainHookQueue(); + }); +} + +function finishHookDispatch(sessionId) { + activeHookDispatches.delete(sessionId); + activeHookSessions.delete(sessionId); + activeHookCount = Math.max(0, activeHookCount - 1); + drainHookQueue(); +} + +function hookDispatcherIsDrained() { + if (drainingHooksForShutdown) { + return activeHookCount === 0 && shutdownFinalHooks.size === 0; + } + return activeHookCount === 0 + && pendingHooks.length === 0 + && pendingReconciliationHooks.size === 0; +} + +function resolveHookShutdownIfNeeded() { + if (!drainingHooksForShutdown || !hookShutdownResolve || !hookDispatcherIsDrained()) return; + if (hookShutdownDeadline) clearTimeout(hookShutdownDeadline); + hookShutdownDeadline = null; + const resolve = hookShutdownResolve; + hookShutdownResolve = null; + resolve(); +} + +function resetHookDispatcherAfterShutdown() { + if (!drainingHooksForShutdown || !hookDispatcherIsDrained()) return false; + if (hookShutdownDeadline) clearTimeout(hookShutdownDeadline); + messageRoles.clear(); + sessions.clear(); + pendingHooks.length = 0; + pendingReconciliationHooks.clear(); + shutdownFinalHooks.clear(); + activeHookSessions.clear(); + activeHookDispatches.clear(); + reservedTerminalSessions.clear(); + activeHookCount = 0; + hookDrainScheduled = false; + nextHookSequence = 1; + drainingHooksForShutdown = false; + hookShutdownDeadlineExpired = false; + hookShutdownPromise = null; + hookShutdownResolve = null; + hookShutdownDeadline = null; + return true; +} + +function dispatchHook(invocation) { + let child = null; + let timeout = null; + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + finishHookDispatch(invocation.sessionId); + }; + + try { + child = spawn(invocation.cmux, ["hooks", "opencode", invocation.subcommand], { + env: hookEnvironment(invocation.cwd), + stdio: ["pipe", "ignore", "ignore"], + detached: true, + }); + child.once("error", settle); + child.once("close", settle); + child.stdin.on("error", () => {}); + child.stdin.end(invocation.payload); + // Neither a slow hook process nor its stdin pipe may keep OpenCode's event + // loop alive. The timeout bounds the four detached children we retain. + child.stdin.unref?.(); + child.unref(); + timeout = setTimeout(() => { + try { + // Wait for close before dispatching this session's next transition. + // SIGKILL cannot be ignored, so this keeps the per-session ordering + // guarantee without letting a wedged hook hold a slot indefinitely. + if (!child.kill("SIGKILL")) settle(); + } catch (_) { + settle(); + } + }, HOOK_TIMEOUT_MS); + activeHookDispatches.set(invocation.sessionId, { child, timeout, invocation, settle }); + if (drainingHooksForShutdown) { + child.ref(); + timeout.ref?.(); + } else { + timeout.unref?.(); + } + } catch (_) { + settle(); + } +} + +function drainHookQueue() { + if (hookShutdownDeadlineExpired) { + resolveHookShutdownIfNeeded(); + return; + } + while (activeHookCount < MAX_ACTIVE_HOOKS) { + let invocation = null; + if (drainingHooksForShutdown) { + for (const [sessionId, pending] of shutdownFinalHooks) { + if (activeHookSessions.has(sessionId)) continue; + shutdownFinalHooks.delete(sessionId); + invocation = pending; + break; + } + } else { + for (const [sessionId, pending] of pendingReconciliationHooks) { + if (activeHookSessions.has(sessionId)) continue; + const hasEarlierOrderedHook = pendingHooks.some( + (ordered) => ordered.sessionId === sessionId + && ordered.sequence < pending.sequence + ); + if (hasEarlierOrderedHook) continue; + pendingReconciliationHooks.delete(sessionId); + invocation = pending; + break; + } + if (!invocation) { + const index = pendingHooks.findIndex( + (pending) => !activeHookSessions.has(pending.sessionId) + ); + if (index >= 0) [invocation] = pendingHooks.splice(index, 1); + } + } + if (!invocation) break; + activeHookSessions.add(invocation.sessionId); + activeHookCount += 1; + dispatchHook(invocation); + } + resolveHookShutdownIfNeeded(); +} + +function enqueueShutdownFinal(invocation) { + if (hookShutdownDeadlineExpired) return false; + const existing = shutdownFinalHooks.get(invocation.sessionId); + if (existing || shutdownFinalHooks.size < MAX_PENDING_RECONCILIATION_HOOKS) { + if (!existing || invocation.sequence >= existing.sequence) { + shutdownFinalHooks.set(invocation.sessionId, invocation); + } + scheduleHookDrain(); + return true; + } + return false; +} + +function enqueueReconciliationHook(invocation) { + if (pendingReconciliationHooks.has(invocation.sessionId)) { + pendingReconciliationHooks.set(invocation.sessionId, invocation); + scheduleHookDrain(); + return true; + } + // The canonical provider store retains at most 10,000 inactive rows. Keep a + // compact final-state intent per session while the 256-slot ordered queue is + // saturated. An earlier ordered hook for that session still dispatches first. + if (pendingReconciliationHooks.size >= MAX_PENDING_RECONCILIATION_HOOKS) return false; + pendingReconciliationHooks.set(invocation.sessionId, invocation); + scheduleHookDrain(); + return true; +} + +function enqueueHook(invocation) { + invocation.sequence = nextHookSequence; + nextHookSequence += 1; + if (drainingHooksForShutdown) return enqueueShutdownFinal(invocation); + + // Coalesce only the newest uninterrupted run of this session's same event. + // Crossing another lifecycle event is an ordering boundary: start, stop, + // end, start must remain four ordered transitions even when dispatch stalls. + for (let index = pendingHooks.length - 1; index >= 0; index -= 1) { + const pending = pendingHooks[index]; + if (pending.sessionId !== invocation.sessionId) continue; + if (pending.subcommand === invocation.subcommand) { + pendingHooks[index] = invocation; + scheduleHookDrain(); + return true; + } + break; + } + + const isStart = invocation.subcommand === "session-start"; + const isEnd = invocation.subcommand === "session-end"; + const hasTerminalReservation = reservedTerminalSessions.has(invocation.sessionId); + if (isEnd && !hasTerminalReservation + && pendingReconciliationHooks.has(invocation.sessionId)) { + pendingReconciliationHooks.set(invocation.sessionId, invocation); + scheduleHookDrain(); + return true; + } + if (isStart && hasTerminalReservation) { + // The durable session already has an accepted start. A pruned in-memory + // metadata entry must not generate another one. + return true; + } + + // Every accepted start reserves room for its matching end. End admission + // consumes that reservation, so a full queue cannot strand a durable active + // session. Starts and ends may evict nonterminal stop refreshes under load. + const reservationDelta = isStart ? 1 : (isEnd && hasTerminalReservation ? -1 : 0); + let projectedCost = pendingHooks.length + 1 + + reservedTerminalSessions.size + reservationDelta; + while (projectedCost > MAX_PENDING_HOOKS && invocation.subcommand !== "stop") { + const stopIndex = pendingHooks.findIndex((pending) => pending.subcommand === "stop"); + if (stopIndex < 0) break; + pendingHooks.splice(stopIndex, 1); + projectedCost -= 1; + } + if (projectedCost > MAX_PENDING_HOOKS) { + return invocation.subcommand === "stop" || (isEnd && !hasTerminalReservation) + ? enqueueReconciliationHook(invocation) + : false; + } + + pendingHooks.push(invocation); + if (isStart) reservedTerminalSessions.add(invocation.sessionId); + if (isEnd && hasTerminalReservation) { + reservedTerminalSessions.delete(invocation.sessionId); + } + scheduleHookDrain(); + return true; +} + +function drainHooksForShutdown() { + if (hookShutdownPromise) return hookShutdownPromise; + drainingHooksForShutdown = true; + // Once OpenCode begins teardown, intermediate transitions are stale. Retain + // only each session's newest queued durable outcome, while allowing its one + // already-running hook to finish first. This preserves start/end order + // without serializing a saturated same-session backlog during process exit. + for (const invocation of pendingHooks) enqueueShutdownFinal(invocation); + for (const invocation of pendingReconciliationHooks.values()) { + enqueueShutdownFinal(invocation); + } + pendingHooks.length = 0; + pendingReconciliationHooks.clear(); + reservedTerminalSessions.clear(); + for (const { child, timeout } of activeHookDispatches.values()) { + child.ref(); + timeout.ref?.(); + } + hookShutdownPromise = new Promise((resolve) => { + hookShutdownResolve = resolve; + }); + hookShutdownDeadline = setTimeout(() => { + hookShutdownDeadlineExpired = true; + shutdownFinalHooks.clear(); + for (const { child, settle } of Array.from(activeHookDispatches.values())) { + try { child.kill("SIGKILL"); } catch (_) {} + child.unref(); + settle(); + } + resolveHookShutdownIfNeeded(); + }, HOOK_SHUTDOWN_TIMEOUT_MS); + drainHookQueue(); + return hookShutdownPromise; +} + +process.once("beforeExit", () => { + if (!hookDispatcherIsDrained()) void drainHooksForShutdown(); +}); + function sendHook(subcommand, ctx, event, extra = {}) { - if (process.env.CMUX_OPENCODE_HOOKS_DISABLED === "1") return; - if (!process.env.CMUX_SURFACE_ID) return; + if (process.env.CMUX_OPENCODE_HOOKS_DISABLED === "1") return false; + if (!process.env.CMUX_SURFACE_ID) return false; const sessionId = sessionIdFor(event); - if (!sessionId) return; + if (!sessionId) return false; const cwd = cwdFor(ctx, event); const state = sessionState(sessionId); @@ -28226,16 +27054,22 @@ function sendHook(subcommand, ctx, event, extra = {}) { }; const context = extra.context || contextForSession(sessionId); if (context) payload.context = context; - const cmux = process.env.CMUX_OPENCODE_CMUX_BIN || "cmux"; - try { - spawnSync(cmux, ["hooks", "opencode", subcommand], { - input: JSON.stringify(payload), - encoding: "utf8", - env: hookEnvironment(cwd), - stdio: ["pipe", "ignore", "ignore"], - timeout: 5000, - }); - } catch (_) {} + const cmux = process.env.CMUX_OPENCODE_CMUX_BIN || process.env.CMUX_BUNDLED_CLI_PATH || "cmux"; + return enqueueHook({ + cmux, + subcommand, + sessionId, + payload: JSON.stringify(payload), + cwd, + }); +} + +function sendSessionStartOnce(ctx, event) { + const sessionId = sessionIdFor(event); + if (!sessionId) return; + const state = sessionState(sessionId); + if (state.sessionStartSent) return; + if (sendHook("session-start", ctx, event)) state.sessionStartSent = true; } function trackMessage(event) { @@ -28271,21 +27105,51 @@ function trackMessage(event) { const CMUXSessionRestore = async (ctx) => { if (globalThis[CMUX_PLUGIN_INSTALLED_KEY]) return {}; - globalThis[CMUX_PLUGIN_INSTALLED_KEY] = true; + const generation = Symbol("cmux.session.restore.plugin.generation"); + globalThis[CMUX_PLUGIN_INSTALLED_KEY] = generation; + activePluginGeneration = generation; + let disposed = false; + let disposalPromise = null; return { + dispose: () => { + if (disposalPromise) return disposalPromise; + disposed = true; + if (activePluginGeneration !== generation + || globalThis[CMUX_PLUGIN_INSTALLED_KEY] !== generation) { + disposalPromise = Promise.resolve(); + return disposalPromise; + } + disposalPromise = (async () => { + try { + await drainHooksForShutdown(); + } finally { + if (activePluginGeneration === generation + && resetHookDispatcherAfterShutdown()) { + activePluginGeneration = null; + if (globalThis[CMUX_PLUGIN_INSTALLED_KEY] === generation) { + delete globalThis[CMUX_PLUGIN_INSTALLED_KEY]; + } + } + } + })(); + return disposalPromise; + }, event: async ({ event }) => { + if (disposed || activePluginGeneration !== generation + || globalThis[CMUX_PLUGIN_INSTALLED_KEY] !== generation) return; trackMessage(event); const props = eventProperties(event); switch (event && event.type) { case "session.created": - sendHook("session-start", ctx, event); + sendSessionStartOnce(ctx, event); break; case "session.updated": if (props.info && props.info.time && props.info.time.archived) { - sendHook("session-end", ctx, event); - dropSession(sessionIdFor(event)); + if (sendHook("session-end", ctx, event)) { + dropSession(sessionIdFor(event)); + } } else { - sendHook("session-start", ctx, event); + sendSessionStartOnce(ctx, event); } break; case "session.status": @@ -28297,8 +27161,9 @@ const CMUXSessionRestore = async (ctx) => { sendHook("stop", ctx, event); break; case "session.deleted": - sendHook("session-end", ctx, event); - dropSession(sessionIdFor(event)); + if (sendHook("session-end", ctx, event)) { + dropSession(sessionIdFor(event)); + } break; default: break; @@ -28710,6 +27575,224 @@ export default CMUXSessionRestore; return false } + private static func copilotHookEvents(for def: AgentHookDef) -> [CopilotHookConfig.Event] { + let lifecycleEvents = def.events.map { event in + CopilotHookConfig.Event( + name: event.agentEvent, + command: hookCommandString(for: def, event: event), + timeoutSeconds: 5 + ) + } + let feedEvents = def.feedHookEvents.map { event in + CopilotHookConfig.Event( + name: event, + command: feedHookCommandString(for: def, agentEvent: event), + timeoutSeconds: 120 + ) + } + return lifecycleEvents + feedEvents + } + + private static func copilotLegacyConfigURL(for def: AgentHookDef) -> URL { + URL(fileURLWithPath: def.resolvedConfigDir(), isDirectory: true) + .deletingLastPathComponent() + .appendingPathComponent("config.json", isDirectory: false) + } + + private func installCopilotHooks(_ def: AgentHookDef) throws { + let fm = FileManager.default + let configDir = def.resolvedConfigDir() + let configURL = URL(fileURLWithPath: configDir, isDirectory: true) + let hooksURL = configURL.appendingPathComponent(def.configFile, isDirectory: false) + let legacyURL = Self.copilotLegacyConfigURL(for: def) + let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") + || ProcessInfo.processInfo.arguments.contains("-y") + let isOwnedCommand: (String) -> Bool = { command in + Self.isCmuxOwnedHookCommand(command, for: def) + } + + let existingData = fm.contents(atPath: hooksURL.path) + let newData: Data + do { + newData = try copilotHookConfig.installing( + events: Self.copilotHookEvents(for: def), + in: existingData, + isOwnedCommand: isOwnedCommand + ) + } catch { + throw CLIError(message: String.localizedStringWithFormat( + String( + localized: "cli.hooks.copilot.error.invalidConfig", + defaultValue: "%@ has an unsupported Copilot hook schema. Fix or remove it before changing hooks." + ), + hooksURL.path + )) + } + let legacyRemoval: CopilotHookConfig.RemovalResult? + do { + legacyRemoval = try fm.contents(atPath: legacyURL.path).map { data in + try copilotHookConfig.removingOwnedHooks( + from: data, + isOwnedCommand: isOwnedCommand + ) + } + } catch { + throw CLIError(message: String.localizedStringWithFormat( + String( + localized: "cli.hooks.copilot.error.invalidConfig", + defaultValue: "%@ has an unsupported Copilot hook schema. Fix or remove it before changing hooks." + ), + legacyURL.path + )) + } + + let hooksChanged = existingData != newData + let legacyChanged = (legacyRemoval?.removedCount ?? 0) > 0 + if !hooksChanged, !legacyChanged { + print(String.localizedStringWithFormat( + String( + localized: "cli.hooks.copilot.alreadyUpToDate", + defaultValue: "%@ hooks already up to date at %@" + ), + def.displayName, + hooksURL.path + )) + return + } + + var isConfigDirectory = ObjCBool(false) + let configPathExists = fm.fileExists(atPath: configDir, isDirectory: &isConfigDirectory) + if configPathExists, !isConfigDirectory.boolValue { + throw CLIError(message: String.localizedStringWithFormat( + String( + localized: "cli.hooks.error.configDirectoryIsFile", + defaultValue: "cmux could not create the hooks directory: a file exists at %@; remove or rename the conflicting file and re-run `cmux hooks setup`" + ), + configDir + )) + } + + if !skipConfirm { + if hooksChanged { + Self.printInstallPreview( + path: hooksURL.path, + oldContent: existingData.flatMap { String(data: $0, encoding: .utf8) } ?? "", + newContent: String(data: newData, encoding: .utf8) ?? "{}", + fallbackContent: String(data: newData, encoding: .utf8) ?? "{}" + ) + } + if legacyChanged, + let oldLegacyData = fm.contents(atPath: legacyURL.path), + let legacyRemoval { + Self.printInstallPreview( + path: legacyURL.path, + oldContent: String(data: oldLegacyData, encoding: .utf8) ?? "", + newContent: legacyRemoval.data.flatMap { String(data: $0, encoding: .utf8) } ?? "", + fallbackContent: legacyRemoval.data.flatMap { String(data: $0, encoding: .utf8) } ?? "" + ) + } + print(String( + localized: "cli.hooks.copilot.confirmProceed", + defaultValue: "\nProceed? [y/N] " + ), terminator: "") + guard readLine()?.lowercased().hasPrefix("y") == true else { + print(String(localized: "cli.hooks.copilot.aborted", defaultValue: "Aborted.")) + return + } + } + + if !configPathExists { + do { + try fm.createDirectory(at: configURL, withIntermediateDirectories: true) + } catch { + throw CLIError(message: String.localizedStringWithFormat( + String( + localized: "cli.hooks.error.configDirectoryIsFile", + defaultValue: "cmux could not create the hooks directory: a file exists at %@; remove or rename the conflicting file and re-run `cmux hooks setup`" + ), + configDir + )) + } + } + if hooksChanged { + try newData.write(to: hooksURL, options: .atomic) + } + if legacyChanged, let legacyRemoval { + if let rewritten = legacyRemoval.data { + try rewritten.write(to: legacyURL, options: .atomic) + } else if fm.fileExists(atPath: legacyURL.path) { + try fm.removeItem(at: legacyURL) + } + } + print(String.localizedStringWithFormat( + String( + localized: "cli.hooks.copilot.installed", + defaultValue: "%@ hooks installed at %@" + ), + def.displayName, + hooksURL.path + )) + } + + private func uninstallCopilotHooks(_ def: AgentHookDef) throws { + let fm = FileManager.default + let configURL = URL(fileURLWithPath: def.resolvedConfigDir(), isDirectory: true) + let hooksURL = configURL.appendingPathComponent(def.configFile, isDirectory: false) + let legacyURL = Self.copilotLegacyConfigURL(for: def) + let isOwnedCommand: (String) -> Bool = { command in + Self.isCmuxOwnedHookCommand(command, for: def) + } + var mutations: [(url: URL, result: CopilotHookConfig.RemovalResult)] = [] + let candidates: [( + url: URL, + transform: (Data, (String) -> Bool) throws -> CopilotHookConfig.RemovalResult + )] = [ + (hooksURL, { try copilotHookConfig.uninstalling(from: $0, isOwnedCommand: $1) }), + (legacyURL, { try copilotHookConfig.removingOwnedHooks(from: $0, isOwnedCommand: $1) }), + ] + for (url, transform) in candidates { + guard let data = fm.contents(atPath: url.path) else { continue } + do { + mutations.append((url, try transform(data, isOwnedCommand))) + } catch { + throw CLIError(message: String.localizedStringWithFormat( + String( + localized: "cli.hooks.copilot.error.invalidConfig", + defaultValue: "%@ has an unsupported Copilot hook schema. Fix or remove it before changing hooks." + ), + url.path + )) + } + } + + var removedCount = 0 + for mutation in mutations where mutation.result.removedCount > 0 { + removedCount += mutation.result.removedCount + if let rewritten = mutation.result.data { + try rewritten.write(to: mutation.url, options: .atomic) + } else if fm.fileExists(atPath: mutation.url.path) { + try fm.removeItem(at: mutation.url) + } + } + if removedCount == 0 { + print(String.localizedStringWithFormat( + String( + localized: "cli.hooks.copilot.noneFound", + defaultValue: "No Copilot cmux hooks found at %@" + ), + hooksURL.path + )) + } else { + print(String.localizedStringWithFormat( + String( + localized: "cli.hooks.copilot.removed", + defaultValue: "Removed %lld Copilot cmux hook(s)" + ), + removedCount + )) + } + } + private func installAgentHooks(_ def: AgentHookDef) throws { if def.name == "opencode" { try installOpenCodePluginHooks(def); return } if def.name == "pi" { try installPiExtensionHooks(def); return } @@ -28727,6 +27810,10 @@ export default CMUXSessionRestore; try installHermesAgentHooks(def) return } + if def.name == "copilot" { + try installCopilotHooks(def) + return + } if case .antigravityJSON = def.format { try installAntigravityHooks(def) return @@ -29084,6 +28171,10 @@ export default CMUXSessionRestore; try uninstallHermesAgentHooks(def) return } + if def.name == "copilot" { + try uninstallCopilotHooks(def) + return + } if case .antigravityJSON = def.format { try uninstallAntigravityHooks(def) return @@ -30037,29 +29128,57 @@ export default CMUXSessionRestore; let explicitSurfaceFlag = optionValue(hookArgs, name: "--surface") let directSurfaceArg = explicitSurfaceFlag ?? (hookWsFlag == nil ? normalizedHookValue(env["CMUX_SURFACE_ID"]) : nil) + var surfaceListCache: [String: [[String: Any]]] = [:] + var failedSurfaceListWorkspaceIds = Set() + func accessibleSurfaces(workspaceId: String) -> [[String: Any]]? { + if let cached = surfaceListCache[workspaceId] { + return cached + } + guard !failedSurfaceListWorkspaceIds.contains(workspaceId), + let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) else { + failedSurfaceListWorkspaceIds.insert(workspaceId) + return nil + } + let items = listed["surfaces"] as? [[String: Any]] ?? [] + surfaceListCache[workspaceId] = items + return items + } func resolveAccessibleWorkspaceId(_ raw: String?) -> String? { guard let raw = nonEmptyClaudeHookIdentifier(raw) else { return nil } guard let candidate = try? resolveWorkspaceId(raw, client: client), - (try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate])) != nil else { + accessibleSurfaces(workspaceId: candidate) != nil else { return nil } return candidate } func resolveAccessibleSurfaceId(_ raw: String?, workspaceId: String) -> String? { guard let raw = nonEmptyClaudeHookIdentifier(raw), - let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client), - let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) else { + let items = accessibleSurfaces(workspaceId: workspaceId) else { return nil } - let items = listed["surfaces"] as? [[String: Any]] ?? [] + let candidate: String? + if isUUID(raw) { + candidate = raw + } else if isHandleRef(raw) { + candidate = items.first(where: { ($0["ref"] as? String) == raw })?["id"] as? String + } else if let index = Int(raw) { + candidate = items.first(where: { intFromAny($0["index"]) == index })?["id"] as? String + } else { + candidate = nil + } + guard let candidate else { return nil } return items.contains(where: { ($0["id"] as? String) == candidate || ($0["ref"] as? String) == candidate }) ? candidate : nil } func resolveDefaultSurfaceId(workspaceId: String) -> String? { - try? resolveSurfaceId(nil, workspaceId: workspaceId, client: client) + guard let items = accessibleSurfaces(workspaceId: workspaceId), + let focused = items.first(where: { ($0["focused"] as? Bool) == true }) else { + return nil + } + return focused["id"] as? String } let resolvedDirectWorkspaceArg = resolveAccessibleWorkspaceId(directWorkspaceArg) // Only an EXPLICIT --workspace flag that fails to resolve is a hard, hook-dropping error. A @@ -30104,14 +29223,18 @@ export default CMUXSessionRestore; resolvedDirectWorkspaceArg ?? processBinding()?.workspaceId } - let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" + guard let rawInputData = Self.readBoundedHookStdin() else { + print("{}") + return + } + let rawInput = String(data: rawInputData, encoding: .utf8) ?? "" let input = parseClaudeHookInput(rawInput: rawInput) let store = ClaudeHookSessionStore( - processEnv: env.merging( + processEnv: agentHookStoreEnvironment(environment: env, client: client).merging( ["CMUX_CLAUDE_HOOK_STATE_PATH": agentHookStatePath(sessionStoreSuffix: def.sessionStoreSuffix, env: env)], - uniquingKeysWith: { _, new in new } - ) + uniquingKeysWith: { _, new in new }), + agentName: def.name ) let hookCwd = input.cwd @@ -30134,7 +29257,11 @@ export default CMUXSessionRestore; func performAgentSessionTeardown() { guard let mapped = sessionId.isEmpty ? nil : (try? store.lookup(sessionId: sessionId)) else { return } sendAgentFeedTelemetry(workspaceId: mapped.workspaceId) - let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations(currentAgentPID: mapped.pid, env: env) + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: mapped.pid, + agentName: def.name, + env: env + ) if suppressVisibleMutations { telemetry.breadcrumb("\(def.name)-hook.session-end.nested-suppressed") } else if let consumed = try? store.consume(sessionId: sessionId, workspaceId: nil, surfaceId: nil) { @@ -30389,7 +29516,11 @@ export default CMUXSessionRestore; let workspaceId = target.workspaceId let surfaceId = target.surfaceId let pid = inferredPID - let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations(currentAgentPID: pid, env: env) + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: pid, + agentName: def.name, + env: env + ) let launchCommand = agentLaunchCommandFromEnvironment( env, fallbackPID: pid, @@ -30423,7 +29554,7 @@ export default CMUXSessionRestore; updateRuntimeStatus: !suppressVisibleMutations )) ?? false } else { - try? store.upsert( + acceptedSessionStart = (try? store.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, @@ -30434,8 +29565,7 @@ export default CMUXSessionRestore; agentLifecycle: .unknown, runtimeStatus: suppressVisibleMutations ? nil : .running, updateRuntimeStatus: !suppressVisibleMutations - ) - acceptedSessionStart = true + )) ?? false } if !acceptedSessionStart { telemetry.breadcrumb("\(def.name)-hook.session-start.stale-after-turn") @@ -30450,6 +29580,14 @@ export default CMUXSessionRestore; print("{}") return } + if !sessionId.isEmpty, + !suppressVisibleMutations || (try? store.projectedRestoreAuthority(sessionId: sessionId)) == false { + try? store.reconcileSemanticState( + sessionId: sessionId, + foregroundState: .idle, + attentionState: AgentAttentionState.none + ) + } sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) if !suppressVisibleMutations { if codexSessionStartWentStaleAfterAccept() { @@ -30489,6 +29627,7 @@ export default CMUXSessionRestore; kind: def.name, displayName: def.displayName, sessionId: sessionId, + transcriptPath: input.transcriptPath ?? mapped?.transcriptPath, cwd: preferredAgentHookResumeWorkingDirectory(kind: def.name, current: launchCommand, currentCwd: hookCwd, mapped: mapped), launchCommand: resumeLaunchCommand ) @@ -30506,13 +29645,15 @@ export default CMUXSessionRestore; client: client ) } - setAgentLifecycle( - client: client, - key: def.statusKey, - lifecycle: .unknown, - workspaceId: workspaceId, - surfaceId: surfaceId - ) + if !suppressVisibleMutations { + setAgentLifecycle( + client: client, + key: def.statusKey, + lifecycle: .unknown, + workspaceId: workspaceId, + surfaceId: surfaceId + ) + } case .promptSubmit: let mapped = sessionId.isEmpty ? nil : (try? store.lookup(sessionId: sessionId)) @@ -30555,6 +29696,7 @@ export default CMUXSessionRestore; kind: def.name, displayName: def.displayName, sessionId: sessionId, + transcriptPath: latest.transcriptPath, cwd: latest.cwd, launchCommand: latest.launchCommand ) @@ -30678,8 +29820,8 @@ export default CMUXSessionRestore; workspaceId: workspaceId ), rejectTerminalTurn: def.name == "codex" - )) ?? (staleTerminalTurn: false, nested: false) - if recordResult.staleTerminalTurn { + )) ?? AgentPromptSubmitResult(accepted: false, staleTerminalTurn: false, nested: false) + if !recordResult.accepted || recordResult.staleTerminalTurn { stopStaleCodexPromptSubmit() return } @@ -30690,6 +29832,7 @@ export default CMUXSessionRestore; } let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: pid, + agentName: def.name, nestedPromptEvent: nestedPromptSubmit, env: env ) @@ -30729,7 +29872,7 @@ export default CMUXSessionRestore; launchCommand: resumeLaunchCommand )) ?? false } else { - try? store.upsert( + acceptedRunningUpdate = (try? store.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, @@ -30740,8 +29883,7 @@ export default CMUXSessionRestore; agentLifecycle: .running, runtimeStatus: .running, updateRuntimeStatus: true - ) - acceptedRunningUpdate = true + )) ?? false } if !acceptedRunningUpdate || codexPromptTurnWentTerminal() { stopStaleCodexPromptSubmit() @@ -30755,6 +29897,7 @@ export default CMUXSessionRestore; kind: def.name, displayName: def.displayName, sessionId: sessionId, + transcriptPath: input.transcriptPath ?? mapped?.transcriptPath, cwd: preferredAgentHookResumeWorkingDirectory(kind: def.name, current: launchCommand, currentCwd: hookCwd, mapped: mapped), launchCommand: resumeLaunchCommand ) @@ -30763,6 +29906,14 @@ export default CMUXSessionRestore; return } } + if !sessionId.isEmpty, + !suppressVisibleMutations || (try? store.projectedRestoreAuthority(sessionId: sessionId)) == false { + try? store.reconcileSemanticState( + sessionId: sessionId, + foregroundState: .working, + attentionState: AgentAttentionState.none + ) + } if codexPromptTurnWentTerminal() { stopStaleCodexPromptSubmit() return @@ -30863,7 +30014,7 @@ export default CMUXSessionRestore; let workspaceId = target.workspaceId let surfaceId = target.surfaceId sendAgentFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId) - let pid = mapped?.pid ?? inferredPID + let pid = inferredPID let codexFailure: CodexHookFailureSummary? let codexSubagentSignals: CodexTranscriptSubagentSignals if def.name == "codex" { @@ -30944,6 +30095,12 @@ export default CMUXSessionRestore; ) let antigravityHasActiveBackgroundWork = hasActiveAntigravityBackgroundWork() let stopNotificationStatus: AgentHookNotificationStatus = (codexFailure == nil && antigravityFailure == nil) ? .idle : .error + let stopWasInterrupted = AgentStopStateAdapter().isInterrupted( + provider: def.name, + input: input, + transcriptPath: input.transcriptPath ?? mapped?.transcriptPath + ?? (def.name == "codex" ? findCodexTranscriptPath(sessionId: sessionId, env: env) : nil) + ) let lifecycleAfterStop: AgentHibernationLifecycleState = { if antigravityHasActiveBackgroundWork && stopNotificationStatus == .idle { return .running @@ -30977,8 +30134,10 @@ export default CMUXSessionRestore; terminalActivePromptTurnIdsForStop = [] } let nestedPromptStop: Bool + var completedPromptStopGeneration = false + var shouldClearCompletedPromptStopVisibleState = false if !sessionId.isEmpty, !staleIdleStopHasNewerRunningSession { - nestedPromptStop = (try? store.recordPromptStop( + let promptStopResult = (try? store.recordPromptStop( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, @@ -30991,27 +30150,58 @@ export default CMUXSessionRestore; agentLifecycle: lifecycleAfterStop, lastSubtitle: nil, lastBody: nil, + hadPendingBackgroundWorkAtStop: antigravityHasActiveBackgroundWork, autoNameMessages: autoNamingMessages( for: def, parsedInput: input, client: client, workspaceId: workspaceId ) - )) ?? false + )) ?? AgentPromptStopResult(accepted: false, nested: false) + completedPromptStopGeneration = promptStopResult.completedGeneration + shouldClearCompletedPromptStopVisibleState = promptStopResult.shouldClearVisibleState + guard promptStopResult.accepted || promptStopResult.completedGeneration else { + telemetry.breadcrumb("\(def.name)-hook.stop.rejected-generation"); didSendFeedTelemetry = true + print("{}"); return + } + if promptStopResult.completedGeneration, + !promptStopResult.shouldClearVisibleState { + telemetry.breadcrumb("\(def.name)-hook.stop.completed-stale-generation") + didSendFeedTelemetry = true + print("{}") + return + } + nestedPromptStop = promptStopResult.nested } else { nestedPromptStop = false } - let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + let suppressNestedVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: pid, + agentName: def.name, nestedPromptEvent: nestedPromptStop, transcriptSubagentSession: codexSubagentSignals.isSubagentSession, env: env - ) || staleIdleStopHasNewerRunningSession - let suppressCompletionNotification = suppressVisibleMutations + ) + let suppressVisibleMutations = suppressNestedVisibleMutations || staleIdleStopHasNewerRunningSession + let suppressCompletionNotification = staleIdleStopHasNewerRunningSession + || shouldSuppressNestedAgentNotification( + visibleMutationsSuppressed: suppressNestedVisibleMutations, + env: env + ) || codexSubagentSignals.hasSubagentNotificationRelay - - if !sessionId.isEmpty, !suppressVisibleMutations { - try? store.upsert(sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: cwd, + let genericWorkloads: [AgentWorkloadRecord]? = antigravityHasActiveBackgroundWork + ? [AgentWorkloadRecord( + id: "provider-background-work", + kind: .monitor, + phase: .watching, + keepsSessionBusy: true, + startedAt: Date().timeIntervalSince1970, + updatedAt: Date().timeIntervalSince1970 + )] + : [] + + if !sessionId.isEmpty, !suppressVisibleMutations, !completedPromptStopGeneration { + let acceptedStopUpdate = (try? store.upsert(sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: cwd, transcriptPath: input.transcriptPath ?? mapped?.transcriptPath, pid: pid, launchCommand: resumeLaunchCommand, @@ -31021,19 +30211,55 @@ export default CMUXSessionRestore; lastNotificationStatus: stopNotificationStatus, updateLastNotificationStatus: true, runtimeStatus: (antigravityHasActiveBackgroundWork && stopNotificationStatus == .idle) ? .running : runtimeStatus(for: stopNotificationStatus), - updateRuntimeStatus: true) - publishAgentSurfaceResumeBinding( - client: client, - workspaceId: workspaceId, - surfaceId: surfaceId, - kind: def.name, - displayName: def.displayName, + updateRuntimeStatus: true)) ?? false + guard acceptedStopUpdate else { + telemetry.breadcrumb("\(def.name)-hook.stop.rejected-generation") + didSendFeedTelemetry = true + print("{}") + return + } + } + if !sessionId.isEmpty, + !suppressVisibleMutations || (try? store.projectedRestoreAuthority(sessionId: sessionId)) == false { + try? store.reconcileSemanticState( sessionId: sessionId, - cwd: cwd, - launchCommand: resumeLaunchCommand + foregroundState: stopWasInterrupted + ? .interrupted + : (stopNotificationStatus == .error ? .failed : .completed), + attentionState: stopNotificationStatus == .error ? .error : AgentAttentionState.none, + workloads: genericWorkloads ) } - if let pid, !suppressVisibleMutations { + if !sessionId.isEmpty, !suppressVisibleMutations { + if completedPromptStopGeneration { + clearAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + sessionId: sessionId + ) + } else { + publishAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + kind: def.name, + displayName: def.displayName, + sessionId: sessionId, + transcriptPath: input.transcriptPath ?? mapped?.transcriptPath, + cwd: cwd, + launchCommand: resumeLaunchCommand + ) + } + } + if completedPromptStopGeneration, + shouldClearCompletedPromptStopVisibleState, + !suppressVisibleMutations { + _ = try? sendV1Command( + "clear_agent_pid \(pidKey) --tab=\(workspaceId)\(socketPanelOption(surfaceId)) --clear-status", + client: client + ) + } else if let pid, !suppressVisibleMutations { _ = try? sendV1Command( "set_agent_pid \(pidKey) \(pid) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client @@ -31114,7 +30340,7 @@ export default CMUXSessionRestore; ) #endif } - if !suppressVisibleMutations { + if !suppressVisibleMutations, !completedPromptStopGeneration { if let codexFailure { setAgentLifecycle( client: client, @@ -31173,7 +30399,10 @@ export default CMUXSessionRestore; // Gate the fork on the live setting (one cheap socket probe) so a // disabled feature spawns nothing extra on turn end; the detached // process re-probes to honor a toggle that lands mid-pass. - if autoNamingSource(for: def) != nil, !suppressVisibleMutations, !sessionId.isEmpty, + if autoNamingSource(for: def) != nil, + !suppressVisibleMutations, + !completedPromptStopGeneration, + !sessionId.isEmpty, let autoNameProbe = try? client.sendV2( method: "workspace.set_auto_title", params: ["probe": true, "workspace_id": workspaceId] @@ -31213,9 +30442,13 @@ export default CMUXSessionRestore; kind: def.name, current: launchCommand, mapped: mapped, transcriptPath: input.transcriptPath ?? mapped?.transcriptPath, currentPID: inferredPID ) - let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations(currentAgentPID: pid, env: env) + var suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: pid, + agentName: def.name, + env: env + ) if !sessionId.isEmpty, !suppressVisibleMutations { - try? store.markNotificationResolved( + let accepted = (try? store.markNotificationResolved( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, @@ -31225,17 +30458,20 @@ export default CMUXSessionRestore; launchCommand: resumeLaunchCommand, agentLifecycle: .running, runtimeStatus: .running - ) - publishAgentSurfaceResumeBinding( - client: client, - workspaceId: workspaceId, - surfaceId: surfaceId, - kind: def.name, - displayName: def.displayName, - sessionId: sessionId, - cwd: preferredAgentHookResumeWorkingDirectory(kind: def.name, current: launchCommand, currentCwd: hookCwd, mapped: mapped), - launchCommand: resumeLaunchCommand - ) + )) ?? false + if accepted { + publishAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + kind: def.name, + displayName: def.displayName, + sessionId: sessionId, + transcriptPath: input.transcriptPath ?? mapped?.transcriptPath, + cwd: preferredAgentHookResumeWorkingDirectory(kind: def.name, current: launchCommand, currentCwd: hookCwd, mapped: mapped), + launchCommand: resumeLaunchCommand + ) + } else { suppressVisibleMutations = true } } if let pid, !suppressVisibleMutations { _ = try? sendV1Command( @@ -31273,6 +30509,16 @@ export default CMUXSessionRestore; } let workspaceId = target.workspaceId let surfaceId = target.surfaceId + let pid = mapped?.pid ?? inferredPID + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: pid, + agentName: def.name, + env: env + ) + let suppressNotification = shouldSuppressNestedAgentNotification( + visibleMutationsSuppressed: suppressVisibleMutations, + env: env + ) let notificationCwd = hookCwd ?? mapped?.cwd #if DEBUG @@ -31397,8 +30643,7 @@ export default CMUXSessionRestore; return } - if !sessionId.isEmpty { - let pid = mapped?.pid ?? inferredPID + if !sessionId.isEmpty, !suppressVisibleMutations { let launchCommand = agentLaunchCommandFromEnvironment( env, fallbackPID: pid, @@ -31407,11 +30652,12 @@ export default CMUXSessionRestore; ) let lifecycle = suppressPendingWaitingState ? .running : agentLifecycle(for: summary.status) let storedRuntimeStatus: AgentHookRuntimeStatus? = suppressPendingWaitingState ? .running : runtimeStatus(for: summary.status) + let acceptedNotificationUpdate: Bool // These agents use completion notifications as turn boundaries; // keep the route but close nested prompt depth. if (def.name == "grok" || def.name == "antigravity"), summary.status == .idle || summary.status == .error { - _ = try? store.recordPromptStop( + acceptedNotificationUpdate = (try? store.recordPromptStop( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, @@ -31426,15 +30672,16 @@ export default CMUXSessionRestore; updateLastNotificationStatus: true, runtimeStatus: storedRuntimeStatus, updateRuntimeStatus: true, + hadPendingBackgroundWorkAtStop: suppressPendingWaitingState, autoNameMessages: autoNamingMessages( for: def, parsedInput: input, client: client, workspaceId: workspaceId ) - ) + ).accepted) ?? false } else { - try? store.upsert( + acceptedNotificationUpdate = (try? store.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, @@ -31449,7 +30696,13 @@ export default CMUXSessionRestore; updateLastNotificationStatus: true, runtimeStatus: storedRuntimeStatus, updateRuntimeStatus: summary.status != nil - ) + )) ?? false + } + guard acceptedNotificationUpdate else { + telemetry.breadcrumb("\(def.name)-hook.notification.rejected-generation") + didSendFeedTelemetry = true + print("{}") + return } } @@ -31458,7 +30711,9 @@ export default CMUXSessionRestore; category: summary.notifyCategory, body: summary.body ) - if shouldSendNotification(fingerprint: notificationFingerprint) { + if suppressNotification { + telemetry.breadcrumb("\(def.name)-hook.notification.nested-suppressed") + } else if shouldSendNotification(fingerprint: notificationFingerprint) { // Tag by the classifier's category so the app's agent notification // settings cover every built-in agent: approval prompts gate under // "Agent Needs Permission", waiting-for-input cues under "Agent @@ -31510,56 +30765,58 @@ export default CMUXSessionRestore; #endif } - switch summary.status { - case .needsInput? where suppressPendingWaitingState: - // Suppressed pending waiting cue: leave the Running pill and - // lifecycle in place; the fullyIdle turn boundary reconciles. - break - case .needsInput?: - setAgentLifecycle( - client: client, - key: def.statusKey, - lifecycle: .needsInput, - workspaceId: workspaceId, - surfaceId: surfaceId - ) - let statusValue = String.localizedStringWithFormat( - String(localized: "agent.generic.notification.status.needsInput", defaultValue: "%@ needs input"), - def.displayName - ) - _ = try? sendV1Command( - "set_status \(def.statusKey) \(statusValue) --icon=bell.fill --color=#4C8DFF --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", - client: client - ) - case .error?: - setAgentLifecycle( - client: client, - key: def.statusKey, - lifecycle: .needsInput, - workspaceId: workspaceId, - surfaceId: surfaceId - ) - let statusValue = String.localizedStringWithFormat( - String(localized: "agent.generic.notification.status.error", defaultValue: "%@ error"), - def.displayName - ) - _ = try? sendV1Command( - "set_status \(def.statusKey) \(statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", - client: client - ) - case .idle?: - if !hasNewerRunningSession(workspaceId: workspaceId, surfaceId: surfaceId) { + if !suppressVisibleMutations { + switch summary.status { + case .needsInput? where suppressPendingWaitingState: + // Suppressed pending waiting cue: leave the Running pill and + // lifecycle in place; the fullyIdle turn boundary reconciles. + break + case .needsInput?: setAgentLifecycle( client: client, key: def.statusKey, - lifecycle: .idle, + lifecycle: .needsInput, workspaceId: workspaceId, surfaceId: surfaceId ) + let statusValue = String.localizedStringWithFormat( + String(localized: "agent.generic.notification.status.needsInput", defaultValue: "%@ needs input"), + def.displayName + ) + _ = try? sendV1Command( + "set_status \(def.statusKey) \(statusValue) --icon=bell.fill --color=#4C8DFF --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + case .error?: + setAgentLifecycle( + client: client, + key: def.statusKey, + lifecycle: .needsInput, + workspaceId: workspaceId, + surfaceId: surfaceId + ) + let statusValue = String.localizedStringWithFormat( + String(localized: "agent.generic.notification.status.error", defaultValue: "%@ error"), + def.displayName + ) + _ = try? sendV1Command( + "set_status \(def.statusKey) \(statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + case .idle?: + if !hasNewerRunningSession(workspaceId: workspaceId, surfaceId: surfaceId) { + setAgentLifecycle( + client: client, + key: def.statusKey, + lifecycle: .idle, + workspaceId: workspaceId, + surfaceId: surfaceId + ) + } + setIdleStatusUnlessAnotherSessionIsRunning(workspaceId: workspaceId, surfaceId: surfaceId) + case nil: + break } - setIdleStatusUnlessAnotherSessionIsRunning(workspaceId: workspaceId, surfaceId: surfaceId) - case nil: - break } sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) @@ -33624,22 +32881,12 @@ export default CMUXSessionRestore; let commandEvent = optionValue(commandArgs, name: "--event") - // Read stdin. Claude, Codex, and the other agents all pipe hook - // JSON through stdin; unknown inputs fall through to `{}`. Codex feed - // events are telemetry, and native lifecycle hooks can carry arbitrary - // transcript fragments or tool output, so cap every Codex feed - // invocation before JSON decoding without changing other agents' - // actionable hook reads. - let stdinData: Data - let shouldBoundCodexFeedStdin = source == "codex" - if shouldBoundCodexFeedStdin { - guard let boundedData = Self.readBoundedFeedHookStdin() else { - print("{}") - return - } - stdinData = boundedData - } else { - stdinData = FileHandle.standardInput.readDataToEndOfFile() + // Every provider controls the hook payload piped into this process. + // Bound allocation before JSON decoding, while draining overflow so a + // provider writing to the pipe does not receive SIGPIPE. + guard let stdinData = Self.readBoundedHookStdin() else { + print("{}") + return } guard !stdinData.isEmpty, let stdinObj = try? JSONSerialization.jsonObject(with: stdinData) as? [String: Any] @@ -33847,20 +33094,20 @@ export default CMUXSessionRestore; print("{}") } - private static let feedHookMaxStdinBytes = 1 * 1024 * 1024 + private static let hookMaxStdinBytes = 1 * 1024 * 1024 - private static func readBoundedFeedHookStdin( + private static func readBoundedHookStdin( handle: FileHandle = .standardInput ) -> Data? { var data = Data() - while data.count <= feedHookMaxStdinBytes { - let remainingBytes = feedHookMaxStdinBytes + 1 - data.count + while data.count <= hookMaxStdinBytes { + let remainingBytes = hookMaxStdinBytes + 1 - data.count let chunkSize = min(64 * 1024, remainingBytes) let chunk = (try? handle.read(upToCount: chunkSize)) ?? Data() guard !chunk.isEmpty else { return data } data.append(chunk) } - guard data.count <= feedHookMaxStdinBytes else { + guard data.count <= hookMaxStdinBytes else { while !((try? handle.read(upToCount: 64 * 1024)) ?? Data()).isEmpty {} return nil } @@ -35050,6 +34297,7 @@ export default CMUXSessionRestore; shortcuts disable-browser | enable-browser | browser-status agent-hibernation + agents [list|tree] [options] restore-session open ... [--workspace ] [--surface ] [--pane ] [--window ] [--focus ] [--no-focus] diff [patch-file|-] [--source ] [--unstaged|--staged|--branch|--last-turn] [--workspace ] [--surface ] [--window ] [--cwd ] [--base ] [--focus ] [--no-focus] [--title ] [--layout ] [--font-size ] diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift index 434b3c7d4f94..f07de158be09 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift @@ -580,7 +580,6 @@ import Testing // The host stops answering two independent mobile.events.subscribe probes, // confirming a dead push path rather than a transient stall. await router.holdSubscribeRequest(number: 2) - await router.holdSubscribeRequest(number: 3) clock.advance(by: 10) store.debugRunRenderGridLivenessCheckForTesting() #expect(await router.waitForCount(of: "mobile.events.subscribe", atLeast: 2)) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentForkArgv.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentForkArgv.swift index 7f63a82bc686..669bfa89e20f 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentForkArgv.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentForkArgv.swift @@ -89,7 +89,7 @@ public struct AgentForkArgv: Sendable, Equatable { guard let preserved = preservedCodexForkArguments( args: parts.tail, preservePromptTags: true, - stripCmuxHooks: parts.executable == "codex" + stripCmuxHooks: true ) else { return nil } @@ -97,25 +97,59 @@ public struct AgentForkArgv: Sendable, Equatable { capturedExecutable: parts.executable, launchTail: parts.tail ) - return [replayExecutable, "fork", sessionId] + preserved + return AgentResumeArgv.codexWrapperRoutedArgv( + capturedExecutable: replayExecutable, + arguments: ["fork", sessionId] + preserved + ) case "opencode": let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "opencode") + if parts.tail.first == "run" { + guard let preserved = AgentLaunchSanitizer.preservedOpenCodeInteractiveRunArguments( + args: parts.tail + ) else { return nil } + return [ + parts.executable, + "run", + "--interactive", + "--session", sessionId, + "--fork", + ] + preserved + } guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "opencode", args: parts.tail) else { return nil } return [parts.executable, "--session", sessionId, "--fork"] + preserved - case "pi": - return withForkSessionValue( - kind: "pi", - executable: "pi", - sessionId: sessionId, + case "grok", "codebuddy", "qoder": + let fallbackExecutable: String + switch kind { + case "codebuddy": fallbackExecutable = "codebuddy" + case "qoder": fallbackExecutable = "qodercli" + default: fallbackExecutable = "grok" + } + let parts = commandParts( executablePath: executablePath, - arguments: arguments + arguments: arguments, + fallbackExecutable: fallbackExecutable ) - case "omp": + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: kind, args: parts.tail) else { + return nil + } + return [parts.executable, "--resume", sessionId, "--fork-session"] + preserved + case "amp": + // Amp removed `threads fork`. Its replacement is a new thread with + // an @thread mention, which cannot be represented as a faithful, + // single-argv fork without synthesizing user prompt content. + return nil + case "factory": + let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "droid") + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "factory", args: parts.tail) else { + return nil + } + return [parts.executable, "--fork", sessionId] + preserved + case "pi", "campfire": return withForkSessionValue( - kind: "omp", - executable: "omp", + kind: kind, + executable: kind, sessionId: sessionId, executablePath: executablePath, arguments: arguments diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentHibernationResumeEvidence.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentHibernationResumeEvidence.swift new file mode 100644 index 000000000000..b71222e8c0c5 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentHibernationResumeEvidence.swift @@ -0,0 +1,5 @@ +import Foundation + +public enum AgentHibernationResumeEvidence { + public static let environmentKey = "CMUX_AGENT_HIBERNATION_RESUME_ATTEMPT_ID" +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCaptureTrust.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCaptureTrust.swift index 2ab29ba05461..967a4e02cb0f 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCaptureTrust.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCaptureTrust.swift @@ -19,22 +19,43 @@ public enum AgentLaunchCaptureTrust { ] private static let nativeProcessAliasesByKind: [String: Set] = [ - "antigravity": ["agy"], + "amp": ["amp"], + "antigravity": ["agy", "antigravity"], "campfire": ["campfire"], - "claude": ["claude"], + "claude": ["claude", "claude-code", "claude_code"], + "cline": ["cline", "cline-cli"], "codex": ["codex"], "codebuddy": ["codebuddy"], - "copilot": ["copilot"], + "copilot": ["copilot", "github-copilot-cli"], "cursor": ["cursor-agent", "cursor"], + "devin": ["devin", "devin-cli"], "factory": ["droid", "factory"], "gemini": ["gemini"], "grok": ["grok", "grok-macos-aarch64", "grok-macos-aarch"], + "hermes-agent": ["hermes", "hermes-agent"], "kiro": ["kiro", "kiro-cli"], + "kilo": ["kilo", "kilo-code"], + "kimi": ["kimi", "kimi-cli", "kimi-code"], + "maki": ["maki"], + "mastracode": ["mastracode", "mastra-code"], + "ollama": ["ollama"], "omp": ["omp"], - "opencode": ["opencode", "omo", "omx", "omc"], - "pi": ["pi", "omp"], + "opencode": ["open-code", "opencode", "opencode-ai", "omo", "omx", "omc"], + "pi": ["pi", "pi-coding-agent", "omp"], "qoder": ["qodercli", "qoder"], - "rovodev": ["rovodev", "rovo", "rovo-dev"], + "rovodev": ["acli", "rovodev", "rovo", "rovo-dev"], + ] + + private static let interpreterHostBases: Set = [ + "bun", "deno", "node", "python", "python3", "ruby", "ts-node", "tsx", + ] + + /// Script paths whose entrypoint name is generic (`cli.js`, `index.ts`, ...). + /// Direct entrypoints are recognized from their basename without an entry here. + private static let scriptPathMarkersByKind: [String: Set] = [ + "campfire": ["/packages/session/bin/campfire.ts", "/packages/session/dist/campfire"], + "claude": ["/.claude/", "/@anthropic-ai/claude-code/", "/claude/versions/"], + "opencode": ["/@opencode-ai/", "/opencode-ai/", "/opencode/"], ] /// True when `launcher` plausibly describes a launch of agent `kind`. @@ -52,6 +73,35 @@ public enum AgentLaunchCaptureTrust { return wrapperLaunchersByKind[normalizedKind]?.contains(normalizedLauncher) == true } + /// Validates argv captured under a declared launcher. Wrapper launchers + /// legitimately differ from the hook kind and retain their own sanitizer. + /// An exact launcher, however, must also have argv that identifies that + /// agent. This rejects interpreter-only prefixes such as `node --max-…` + /// after Node has hidden the script path from `KERN_PROCARGS2`. + public static func capturedArgumentsDescribeKind( + launcher: String?, + executablePath: String?, + arguments: [String], + kind: String + ) -> Bool { + guard launcherDescribesKind(launcher, kind: kind), + let normalizedKind = normalizedAgentName(kind) else { + return false + } + // Older hook wrappers can provide argv without a launch-kind marker. + // `launcherDescribesKind` intentionally trusts that absence, so treat + // it as the hook's own kind rather than invalidating the capture here. + let normalizedLauncher = normalizedAgentName(launcher) ?? normalizedKind + if normalizedLauncher != normalizedKind { + return true + } + return nativeProcessDescribesKind( + processName: executablePath, + arguments: arguments, + kind: normalizedKind + ) + } + /// True when a captured argv describes a shell dispatcher (`sh -c …`, /// `zsh -lc …`) rather than an agent launch. This happens when the /// launch-capture PID fallback resolves to the hook's own dispatch shell @@ -89,11 +139,20 @@ public enum AgentLaunchCaptureTrust { let arguments else { return false } - return nativeProcessDescriptors(processName: processName, arguments: arguments).contains { descriptor in + if nativeProcessDescriptors(processName: processName, arguments: arguments).contains(where: { descriptor in descriptor == expectedKind || nativeProcessAliasesByKind[expectedKind]?.contains(descriptor) == true || descriptor == "\(expectedKind)-cli" + }) { + return true + } + guard let entrypointIndex = interpreterScriptEntrypointIndex( + processName: processName, + arguments: arguments + ) else { + return false } + return scriptArgument(arguments[entrypointIndex], describes: expectedKind) } public static func nativeProcessDescribesKnownAgent( @@ -106,6 +165,178 @@ public enum AgentLaunchCaptureTrust { } } + /// Resolves a live native process to its canonical built-in agent kind. + /// Exact kind basenames win over aliases, so `omp` remains OMP even though + /// it is also a supported Pi launcher. Ambiguous or unknown descriptors + /// fail closed instead of guessing from prompt or option argv tokens. + public static func nativeAgentKind( + processName: String?, + arguments: [String] + ) -> String? { + let descriptors = nativeProcessDescriptors( + processName: processName, + arguments: arguments + ) + let exactKinds = descriptors.intersection(nativeProcessAliasesByKind.keys) + guard exactKinds.count <= 1 else { return nil } + if let exactKind = exactKinds.first { return exactKind } + + let aliasedKinds = Set(descriptors.flatMap { descriptor in + nativeProcessAliasesByKind.compactMap { kind, aliases in + aliases.contains(descriptor) ? kind : nil + } + }) + return aliasedKinds.count == 1 ? aliasedKinds.first : nil + } + + /// Returns only the provider-owned argv tail for a trusted live process. + /// Interpreter and package-manager flags before the agent script are not + /// provider launch options and must not affect restorability classification. + static func nativeAgentLaunchArguments( + processName: String?, + arguments: [String], + kind: String + ) -> [String]? { + guard let normalizedKind = normalizedAgentName(kind), + nativeProcessDescribesKind( + processName: processName, + arguments: arguments, + kind: normalizedKind + ), + !arguments.isEmpty else { + return nil + } + let hostBases = Set([ + processBasename(processName), + processBasename(arguments.first), + ].compactMap { $0 }) + guard hostBases.contains(where: isInterpreterHost) else { + return Array(arguments.dropFirst()) + } + if let entrypointIndex = execAInterpreterScriptEntrypointIndex( + processName: processName, + arguments: arguments, + kind: normalizedKind + ) { + return Array(arguments[arguments.index(after: entrypointIndex)...]) + } + guard let entrypointIndex = interpreterScriptEntrypointIndex( + processName: processName, + arguments: arguments + ), scriptArgument(arguments[entrypointIndex], describes: normalizedKind) else { + return nil + } + return Array(arguments[arguments.index(after: entrypointIndex)...]) + } + + /// True when `parent` is a thin interpreter launcher that immediately + /// relays into the real process for the same agent. Package-manager shims + /// commonly use `node ` and then either exec a native binary or + /// re-exec Node with runtime options. That relay is one launch, not a + /// parent agent session. + public static func nativeProcessIsSameAgentLauncherRelay( + parentProcessName: String?, + parentArguments: [String], + childProcessName: String?, + childArguments: [String], + kind: String + ) -> Bool { + guard !parentArguments.isEmpty, + let parentExecutable = processBasename(parentArguments.first), + isInterpreterHost(parentExecutable), + let parentEntrypointIndex = interpreterScriptEntrypointIndex( + processName: parentProcessName, + arguments: parentArguments + ), + nativeProcessDescribesKind( + processName: parentProcessName, + arguments: parentArguments, + kind: kind + ), + nativeProcessDescribesKind( + processName: childProcessName, + arguments: childArguments, + kind: kind + ) else { + return false + } + + let forwardedArguments = parentArguments[parentEntrypointIndex...] + let childHosts = Set([ + processBasename(childProcessName), + processBasename(childArguments.first), + ].compactMap { $0 }) + if !childHosts.contains(where: isInterpreterHost) { + // Codex's JavaScript package entrypoint is a known thin shim for + // its native worker. Other interpreter-hosted providers may be the + // real interactive agent, so treating their native descendants as + // relays would hide a nested same-provider session. + guard normalizedAgentName(kind) == "codex", + directScriptArgument(parentArguments[parentEntrypointIndex], describes: "codex") else { + return false + } + return true + } + guard normalizedAgentName(kind) == "gemini", + let childEntrypointIndex = interpreterScriptEntrypointIndex( + processName: childProcessName, + arguments: childArguments + ), + childEntrypointIndex > childArguments.startIndex + 1 else { + return false + } + return childArguments[childEntrypointIndex...].elementsEqual(forwardedArguments) + } + + /// True when a process is running a script but its argv cannot identify a + /// supported agent. Lineage callers treat this as uncertain ownership and + /// fail closed, preventing future interpreter-hosted child agents from + /// taking restore authority before a dedicated adapter is added. + public static func nativeProcessIsAmbiguousInterpreterHost( + processName: String?, + arguments: [String] + ) -> Bool { + let hostBases = Set([processBasename(processName), processBasename(arguments.first)].compactMap { $0 }) + guard hostBases.contains(where: isInterpreterHost), + arguments.dropFirst().contains(where: looksLikeScriptPath) else { + return false + } + return !nativeProcessDescribesKnownAgent(processName: processName, arguments: arguments) + } + + /// Node launchers such as Cursor use `exec -a cursor-agent node .../index.js` + /// so argv[0] identifies the provider while `proc_pidpath` identifies the + /// interpreter. Keep the provider alias as the identity proof, then advance + /// past the interpreter's own flags and script path before classifying the + /// provider arguments. A generic `index.js` never proves identity by itself. + private static func execAInterpreterScriptEntrypointIndex( + processName: String?, + arguments: [String], + kind: String + ) -> Int? { + guard arguments.count > 1, + let interpreter = processBasename(processName), + isInterpreterHost(interpreter), + let invokedAs = processBasename(arguments.first), + !isInterpreterHost(invokedAs) else { + return nil + } + let aliases = nativeProcessAliasesByKind[kind] ?? [] + guard invokedAs == kind + || aliases.contains(invokedAs) + || invokedAs == "\(kind)-cli", + let entrypointIndex = interpreterScriptEntrypointIndex( + processName: processName, + arguments: arguments, + startingAt: 1 + ), + entrypointIndex > 0, + looksLikeScriptPath(arguments[entrypointIndex]) else { + return nil + } + return entrypointIndex + } + private static func nativeProcessDescriptors( processName: String?, arguments: [String] @@ -119,27 +350,29 @@ public enum AgentLaunchCaptureTrust { if let executableBase { descriptors.insert(executableBase) } - // Hosts that can run a Campfire script entrypoint; mirrors - // CampfireLaunchArgumentNormalizer's supported runtime set. - let scriptHostBases: Set = ["node", "bun", "deno", "tsx", "ts-node"] let hostBases = Set([nameBase, executableBase].compactMap { $0 }) - if !hostBases.isDisjoint(with: scriptHostBases) { - if nameBase == "node" || nameBase == "bun" || executableBase == "node" || executableBase == "bun" { - if arguments.dropFirst().contains(where: { argument in - let lowered = argument.lowercased() - return processBasename(argument) == "claude" - || lowered.contains("/.claude/") - || lowered.contains("/claude/versions/") - }) { - descriptors.insert("claude") - } + if hostBases.contains(where: isInterpreterHost) { + let knownNames = Set(nativeProcessAliasesByKind.keys) + .union(nativeProcessAliasesByKind.values.flatMap { $0 }) + guard let entrypointIndex = interpreterScriptEntrypointIndex( + processName: processName, + arguments: arguments + ) else { + return descriptors } - if arguments.dropFirst().contains(where: { argument in - let lowered = argument.replacingOccurrences(of: "\\", with: "/").lowercased() - return lowered.contains("packages/session/bin/campfire.ts") - || lowered.contains("packages/session/dist/campfire") - }) { - descriptors.insert("campfire") + let argument = arguments[entrypointIndex] + if looksLikeScriptPath(argument) { + let normalizedPath = "/" + argument + .replacingOccurrences(of: "\\", with: "/") + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + .lowercased() + if let basename = scriptDescriptorBasename(argument), knownNames.contains(basename) { + descriptors.insert(basename) + } + for (kind, markers) in scriptPathMarkersByKind + where markers.contains(where: normalizedPath.contains) { + descriptors.insert(kind) + } } return descriptors } @@ -169,4 +402,208 @@ public enum AgentLaunchCaptureTrust { } return URL(fileURLWithPath: value).lastPathComponent.lowercased() } + + private static func isInterpreterHost(_ basename: String) -> Bool { + interpreterHostBases.contains(basename) || basename.hasPrefix("python3.") + } + + /// Returns the script token interpreted by a runtime, excluding runtime + /// flags and their values. Only this token can establish agent identity: + /// later argv may be a prompt, input file, or unrelated executable path. + private static func interpreterScriptEntrypointIndex( + processName: String?, + arguments: [String], + startingAt requestedStartIndex: Int? = nil + ) -> Int? { + guard !arguments.isEmpty else { return nil } + let argumentHost = processBasename(arguments.first) + let host = [argumentHost, processBasename(processName)] + .compactMap { $0 } + .first(where: isInterpreterHost) + guard let host else { return nil } + + var index = requestedStartIndex + ?? (argumentHost.map(isInterpreterHost) == true ? 1 : 0) + guard index >= 0, index < arguments.count else { return nil } + switch normalizedInterpreterHost(host) { + case "node": + return scriptIndex( + in: arguments, + startingAt: index, + evaluationOptions: ["-c", "--check", "-e", "--eval", "-i", "--interactive", "-p", "--print"], + valueOptions: [ + "-C", "--conditions", "--diagnostic-dir", "--icu-data-dir", "--import", + "--inspect-port", "--loader", "--openssl-config", "-r", "--require", + "--snapshot-blob", "--test-name-pattern", "--test-reporter", + "--test-reporter-destination", "--title", "--experimental-loader", + ], + booleanOptions: [ + "--enable-source-maps", "--experimental-strip-types", + "--experimental-transform-types", "--no-addons", "--no-deprecation", + "--no-warnings", "--preserve-symlinks", "--preserve-symlinks-main", + "--test", "--trace-deprecation", "--trace-warnings", "--use-bundled-ca", + "--use-openssl-ca", "--use-system-ca", "--watch", + ] + ) + case "bun": + if index < arguments.count, arguments[index] == "run" { index += 1 } + guard index >= arguments.count || !["build", "create", "install", "test", "x"].contains(arguments[index]) else { + return nil + } + return scriptIndex( + in: arguments, + startingAt: index, + evaluationOptions: ["-e", "--eval", "-p", "--print"], + valueOptions: ["--config", "--cwd", "--env-file", "--preload", "-r", "--require", "--title"], + booleanOptions: [ + "--bun", "--hot", "--no-clear-screen", "--no-install", "--silent", + "--smol", "--watch", + ] + ) + case "deno": + while index < arguments.count { + let argument = arguments[index] + if argument == "run" { + index += 1 + break + } + if !argument.hasPrefix("-") { + return looksLikeScriptPath(argument) ? index : nil + } + guard let width = runtimeOptionWidth( + argument, + evaluationOptions: ["eval"], + valueOptions: ["--config", "--import-map", "--location", "--lock", "--node-modules-dir", "--seed"], + booleanOptions: ["--no-config", "--no-lock", "--quiet", "-q", "--unstable"] + ) else { return nil } + index += width + } + return scriptIndex( + in: arguments, + startingAt: index, + evaluationOptions: [], + valueOptions: ["--cert", "--config", "--env-file", "--import-map", "--location", "--lock", "--seed"], + booleanOptions: [ + "-A", "--allow-all", "--allow-env", "--allow-ffi", "--allow-hrtime", + "--allow-net", "--allow-read", "--allow-run", "--allow-sys", "--allow-write", + "--cached-only", "--no-check", "--no-config", "--no-lock", "--quiet", "-q", + "--reload", "--unstable", "--watch", + ] + ) + case "python": + return scriptIndex( + in: arguments, + startingAt: index, + evaluationOptions: ["-c", "-m"], + valueOptions: ["-W", "-X"], + booleanOptions: ["-B", "-b", "-bb", "-d", "-E", "-I", "-i", "-O", "-OO", "-q", "-s", "-S", "-u", "-v", "-V"] + ) + case "ruby": + return scriptIndex( + in: arguments, + startingAt: index, + evaluationOptions: ["-e", "-S"], + valueOptions: ["-C", "-I", "-r"], + booleanOptions: ["-d", "-w", "-W0", "-W1", "-W2"] + ) + case "ts-node", "tsx": + if index < arguments.count, arguments[index] == "watch" { index += 1 } + return scriptIndex( + in: arguments, + startingAt: index, + evaluationOptions: ["-e", "--eval", "-i", "--interactive", "-p", "--print"], + valueOptions: ["--compiler", "--compiler-options", "--cwd", "--project", "-P", "--require", "-r", "--swc"], + booleanOptions: ["--esm", "--files", "--skip-project", "--transpile-only", "-T", "--type-check"] + ) + default: + return nil + } + } + + private static func normalizedInterpreterHost(_ host: String) -> String { + host.hasPrefix("python3.") || host == "python3" ? "python" : host + } + + private static func scriptIndex( + in arguments: [String], + startingAt startIndex: Int, + evaluationOptions: Set, + valueOptions: Set, + booleanOptions: Set + ) -> Int? { + var index = startIndex + while index < arguments.count { + let argument = arguments[index] + if argument == "--" { + let scriptIndex = index + 1 + return scriptIndex < arguments.count ? scriptIndex : nil + } + if !argument.hasPrefix("-") || argument == "-" { + return argument == "-" ? nil : index + } + guard let width = runtimeOptionWidth( + argument, + evaluationOptions: evaluationOptions, + valueOptions: valueOptions, + booleanOptions: booleanOptions + ), index + width <= arguments.count else { + return nil + } + index += width + } + return nil + } + + /// Unknown split-form runtime options fail closed because the following + /// path could be their value rather than the executed script. Equals-form + /// options are self-contained and cannot shift the entrypoint boundary. + private static func runtimeOptionWidth( + _ argument: String, + evaluationOptions: Set, + valueOptions: Set, + booleanOptions: Set + ) -> Int? { + let name = argument.split(separator: "=", maxSplits: 1).first.map(String.init) ?? argument + if evaluationOptions.contains(name) { return nil } + if argument.contains("=") { return 1 } + if valueOptions.contains(name) { return 2 } + if booleanOptions.contains(name) { return 1 } + return nil + } + + private static func looksLikeScriptPath(_ argument: String) -> Bool { + guard !argument.hasPrefix("-") else { return false } + let normalized = argument.replacingOccurrences(of: "\\", with: "/").lowercased() + let scriptExtensions = [".cjs", ".js", ".mjs", ".py", ".rb", ".ts"] + return normalized.contains("/") || scriptExtensions.contains(where: normalized.hasSuffix) + } + + private static func scriptArgument(_ argument: String, describes kind: String) -> Bool { + guard looksLikeScriptPath(argument) else { return false } + let aliases = nativeProcessAliasesByKind[kind] ?? [] + if let basename = scriptDescriptorBasename(argument), + basename == kind || aliases.contains(basename) || basename == "\(kind)-cli" { + return true + } + let normalizedPath = "/" + argument + .replacingOccurrences(of: "\\", with: "/") + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + .lowercased() + return scriptPathMarkersByKind[kind]?.contains(where: normalizedPath.contains) == true + } + + private static func directScriptArgument(_ argument: String, describes kind: String) -> Bool { + guard let basename = scriptDescriptorBasename(argument) else { return false } + let aliases = nativeProcessAliasesByKind[kind] ?? [] + return basename == kind || aliases.contains(basename) || basename == "\(kind)-cli" + } + + private static func scriptDescriptorBasename(_ argument: String) -> String? { + guard var basename = processBasename(argument) else { return nil } + for suffix in [".cjs", ".js", ".mjs", ".py", ".rb", ".ts"] where basename.hasSuffix(suffix) { + basename.removeLast(suffix.count) + break + } + return basename + } } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index dd8529325e06..6637ebfcede6 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -40,6 +40,11 @@ public struct AgentLaunchEnvironmentPolicy: Sendable { "HERMES_CODEX_BASE_URL", ] + /// One-launch authority proofs must never become replay configuration. + private static let transientEnvironmentKeys: Set = [ + AgentHibernationResumeEvidence.environmentKey, + ] + /// Keys campfire manages itself and must not inherit from a captured Pi /// environment. Replaying a captured PI_PACKAGE_DIR would pin a resumed /// campfire to the previous binary's extracted asset cache @@ -144,7 +149,8 @@ public struct AgentLaunchEnvironmentPolicy: Sendable { /// Returns a replay-safe value for a single environment variable, or `nil` when it should drop. public func sanitizedValue(key: String, value: String?) -> String? { - guard Self.safeEnvironmentKeys.contains(key) else { return nil } + guard !Self.transientEnvironmentKeys.contains(key), + Self.safeEnvironmentKeys.contains(key) else { return nil } switch key { case "CLAUDE_CONFIG_DIR": return value.map { ClaudeConfigDirectoryPath.preferredPath($0) } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchModeClassifier.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchModeClassifier.swift new file mode 100644 index 000000000000..fc22d8918e64 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchModeClassifier.swift @@ -0,0 +1,675 @@ +import Foundation + +/// Whether a provider process is expected to remain alive after a turn event. +/// This is independent from replay safety: an interactive launch can be +/// intentionally non-restorable and must still retain live session authority. +public enum AgentProcessLaunchMode: Sendable, Equatable { + case interactive + case oneShot + case nonSession + case unknown +} + +public struct AgentLaunchModeClassifier: Sendable { + public init() {} + + public func processMode( + processName: String?, + arguments: [String]?, + kind: String + ) -> AgentProcessLaunchMode { + guard let arguments, + let providerArguments = AgentLaunchCaptureTrust.nativeAgentLaunchArguments( + processName: processName, + arguments: arguments, + kind: kind + ) else { + return .unknown + } + return mode(kind: kind, arguments: providerArguments) + } + + func mode(kind: String, arguments: [String]) -> AgentProcessLaunchMode { + let kind = kind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard var policy = policy(for: kind) else { return .unknown } + if containsOption(nonSessionOptions(for: kind), in: arguments, policy: policy) { + return .nonSession + } + if kind == "amp", let ampMode = ampMode(arguments: arguments, policy: policy) { + return ampMode + } + if kind == "claude", + containsOption(["--forward-subagent-text"], in: arguments, policy: policy), + (!containsOption(["--print", "-p"], in: arguments, policy: policy) + || optionValue("--output-format", in: arguments, policy: policy) != "stream-json") { + return .unknown + } + if let protocolMode = longLivedProtocolMode(kind: kind, arguments: arguments, policy: policy) { + return protocolMode + } + if kind == "rovodev" { + return rovoDevMode(arguments: arguments, policy: policy) + } + if kind == "kiro", + containsOption(["--no-interactive"], in: arguments, policy: policy) { + guard firstPositional(in: arguments, policy: policy) == "chat", + !containsUnknownOption(in: arguments, policy: policy) else { + return .unknown + } + return .oneShot + } + if kind == "hermes-agent", arguments.first == "chat", + containsOption(["--query", "-q"], in: Array(arguments.dropFirst()), policy: policy) { + return .oneShot + } + if kind == "hermes-agent", + let command = firstPositional(in: arguments, policy: policy), + command != "chat" { + return .nonSession + } + if kind == "claude", + containsOption(["--no-session-persistence"], in: arguments, policy: policy) { + guard containsOption(["--print", "-p"], in: arguments, policy: policy), + !containsUnknownOption(in: arguments, policy: policy) else { + return .unknown + } + return .oneShot + } + let arguments = normalizedArguments(kind: kind, arguments: arguments) + let baseCommand = firstPositional(in: arguments, policy: policy) + if kind == "opencode", baseCommand == "run" { + policy = AgentLaunchSanitizer.openCodeInteractiveRunPolicy + } else if kind == "opencode", baseCommand == "attach" { + // `attach` owns a small option grammar that is not valid on the root + // TUI. Recognize it only after proving the subcommand so those names + // cannot make an invalid root launch look replay-safe. + policy.valueOptions.formUnion([ + "--dir", "--password", "-p", "--username", "-u", + ]) + } else if kind == "codex", baseCommand == "resume" { + // Picker-only selector introduced by Codex 0.144.3. It must be known + // for resume lifetime classification but is invalid on `fork` and + // never meaningful on replay. + policy.booleanOptions.insert("--include-non-interactive") + policy.droppedOptions.insert("--include-non-interactive") + } + let hasOneShotOption = containsOption(oneShotOptions(for: kind), in: arguments, policy: policy) + let hasInteractiveOption = containsOption(interactiveOptions(for: kind), in: arguments, policy: policy) + let hasExplicitUnknownOption = containsOption(unknownOptions(for: kind), in: arguments, policy: policy) + let hasUnknownOption = containsUnknownOption(in: arguments, policy: policy) + if hasOneShotOption && hasExplicitUnknownOption { + return .unknown + } + if hasOneShotOption && hasInteractiveOption { + guard oneShotOptionOverridesInteractiveModifier(kind: kind), + !hasUnknownOption else { + return .unknown + } + return .oneShot + } + if hasInteractiveOption { + return hasUnknownOption ? .unknown : .interactive + } + if hasExplicitUnknownOption { + return .unknown + } + if hasOneShotOption { + return hasUnknownOption ? .unknown : .oneShot + } + + let commandLocation = firstPositionalLocation(in: arguments, policy: policy) + let command = commandLocation?.value + if let command { + if oneShotCommands(for: kind).contains(command) { + if let commandIndex = commandLocation?.index, + containsUnknownOption( + in: Array(arguments.prefix(upTo: commandIndex)), + policy: policy + ) { + return .unknown + } + if !oneShotCommandAllowsUnknownTrailingOptions(kind: kind, command: command), + containsUnknownOption(in: arguments, policy: policy) { + return .unknown + } + return .oneShot + } + if interactiveCommands(for: kind).contains(command) { + return hasUnknownOption ? .unknown : .interactive + } + } + if hasUnknownOption { + return .unknown + } + if let command, policy.nonRestorableCommands.contains(command) { + return .nonSession + } + return .interactive + } + + /// Protocol and service modes own a live process across multiple turns. + /// They take precedence over terminal-looking flags so malformed or mixed + /// argv can never retire a server at its first Stop event. + private func longLivedProtocolMode( + kind: String, + arguments: [String], + policy: AgentLaunchSanitizer.Policy + ) -> AgentProcessLaunchMode? { + let requestsHelp = containsOption(["--help", "-h"], in: arguments, policy: policy) + let commandLocation = firstPositionalLocation(in: arguments, policy: policy) + let positionals = positionalValues(in: arguments, policy: policy, limit: 3) + let command = commandLocation?.value + switch kind { + case "amp": + if containsOption(["--no-tui"], in: arguments, policy: policy) { + return requestsHelp ? .nonSession : .interactive + } + case "claude": + if optionValue("--input-format", in: arguments, policy: policy) == "stream-json" { + return requestsHelp ? .nonSession : .interactive + } + case "pi": + if optionValue("--mode", in: arguments, policy: policy) == "rpc" { + return requestsHelp ? .nonSession : .interactive + } + case "omp": + if ["rpc", "rpc-ui"].contains(optionValue("--mode", in: arguments, policy: policy)) + || ["acp", "auth-gateway", "join", "shell"].contains(command) { + return requestsHelp ? .nonSession : .interactive + } + case "campfire": + if optionValue("--mode", in: arguments, policy: policy) == "rpc" { + return requestsHelp ? .nonSession : .interactive + } + case "gemini": + if containsOption(["--acp", "--experimental-acp"], in: arguments, policy: policy) { + return requestsHelp ? .nonSession : .interactive + } + case "factory": + if command == "exec", + optionValue("--input-format", in: arguments, policy: policy) == "stream-jsonrpc", + optionValue("--output-format", in: arguments, policy: policy) == "stream-jsonrpc" { + return requestsHelp ? .nonSession : .interactive + } + case "kimi": + if containsOption(["--acp", "--wire"], in: arguments, policy: policy) + || ["acp", "term", "web"].contains(command) + || optionValue("--input-format", in: arguments, policy: policy) == "stream-json" { + return requestsHelp ? .nonSession : .interactive + } + case "hermes-agent": + if command == "acp" { + if containsOption( + ["--check", "--help", "-h", "--setup", "--setup-browser", "--version"], + in: arguments, + policy: policy + ) { + return .nonSession + } + return .interactive + } + if command == "gateway" { + return positionals.dropFirst().first == "run" && !requestsHelp + ? .interactive + : .nonSession + } + case "grok": + if command == "agent", + let commandIndex = commandLocation?.index, + let agentCommand = nestedSubcommand( + in: arguments, + after: commandIndex, + valueOptions: [ + "--agent-profile", "--cli-chat-proxy-base-url", "--debug-file", + "--grok-ws-origin", "--grok-ws-url", "--leader-socket", "--model", "-m", + "--plugin-dir", "--reasoning-effort", "--xai-api-base-url", + ] + ), + ["stdio", "serve", "leader", "headless"].contains(agentCommand) { + return requestsHelp ? .nonSession : .interactive + } + case "opencode": + if ["acp", "serve", "web"].contains(command) { + return requestsHelp ? .nonSession : .interactive + } + case "cursor": + if command == "worker", let commandIndex = commandLocation?.index { + let workerCommand = nestedSubcommand( + in: arguments, + after: commandIndex, + valueOptions: [ + "--auth-token-file", "--data-dir", "--idle-release-timeout", + "--label", "--labels-file", "--management-addr", "--name", + "--pool-name", "--worker-dir", + ] + ) + switch workerCommand { + case "start": + return requestsHelp ? .nonSession : .interactive + case nil, "debug", "help": + return .nonSession + default: + return .unknown + } + } + case "codebuddy": + if containsOption(["--acp", "--prewarm", "--serve"], in: arguments, policy: policy) { + return requestsHelp ? .nonSession : .interactive + } + case "rovodev": + if positionals.starts(with: ["rovodev", "serve"]) || command == "serve" { + return requestsHelp ? .nonSession : .interactive + } + case "qoder": + if containsOption(["--acp"], in: arguments, policy: policy) + || optionValue("--input-format", in: arguments, policy: policy) == "stream-json" { + return requestsHelp ? .nonSession : .interactive + } + case "codex": + if command == "app-server" { + guard !requestsHelp else { return .nonSession } + let appServerCommand = commandLocation.flatMap { + codexAppServerSubcommand(in: arguments, after: $0.index) + } + switch appServerCommand { + case nil, "proxy": + return .interactive + case "daemon", "generate-ts", "generate-json-schema", "help": + return .nonSession + default: + return .unknown + } + } + if ["mcp-server", "exec-server"].contains(command) { + return requestsHelp ? .nonSession : .interactive + } + if ["app", "mcp"].contains(command) { + return .unknown + } + default: + break + } + return nil + } + + /// `app-server` has its own option grammar. Parse its optional nested command without + /// letting an option value such as `--listen ws://...` masquerade as that command. + private func codexAppServerSubcommand( + in arguments: [String], + after appServerIndex: Int + ) -> String? { + nestedSubcommand( + in: arguments, + after: appServerIndex, + valueOptions: [ + "--config", "-c", "--disable", "--enable", "--listen", "--ws-audience", + "--ws-auth", "--ws-issuer", "--ws-max-clock-skew-seconds", + "--ws-shared-secret-file", "--ws-token-file", "--ws-token-sha256", + ] + ) + } + + private func nestedSubcommand( + in arguments: [String], + after parentCommandIndex: Int, + valueOptions: Set + ) -> String? { + var index = arguments.index(after: parentCommandIndex) + while index < arguments.endIndex { + let argument = arguments[index] + if argument == "--" { + let next = arguments.index(after: index) + return next < arguments.endIndex ? arguments[next] : nil + } + if argument.hasPrefix("-"), argument != "-" { + let name = optionName(argument) + index = arguments.index(after: index) + if !argument.contains("="), valueOptions.contains(name), index < arguments.endIndex { + index = arguments.index(after: index) + } + continue + } + return argument + } + return nil + } + + private func rovoDevMode( + arguments: [String], + policy: AgentLaunchSanitizer.Policy + ) -> AgentProcessLaunchMode { + let runArguments: [String] + if arguments.starts(with: ["rovodev", "run"]) { + runArguments = Array(arguments.dropFirst(2)) + } else if arguments.first == "run" { + runArguments = Array(arguments.dropFirst()) + } else if arguments.isEmpty { + return .interactive + } else { + return .unknown + } + let hasInteractiveOption = containsOption( + interactiveOptions(for: "rovodev"), + in: runArguments, + policy: policy + ) + let hasOneShotOption = containsOption( + oneShotOptions(for: "rovodev"), + in: runArguments, + policy: policy + ) + let hasUnknownOption = containsOption( + unknownOptions(for: "rovodev"), + in: runArguments, + policy: policy + ) || containsUnknownOption(in: runArguments, policy: policy) + if hasUnknownOption || (hasInteractiveOption && hasOneShotOption) { + return .unknown + } + if hasInteractiveOption { return .interactive } + if hasOneShotOption { + return .oneShot + } + return firstPositional(in: runArguments, policy: policy) == nil ? .interactive : .oneShot + } + + /// Amp's nested command aliases overlap (`l` means root `last`, but nested + /// `threads l` means `list`), so model the documented command grammar before + /// the generic first-positional classifier. Stream JSON input is a live + /// multi-turn protocol only when its two required companion flags are present. + private func ampMode( + arguments: [String], + policy: AgentLaunchSanitizer.Policy + ) -> AgentProcessLaunchMode? { + let hasStreamInput = containsOption(["--stream-json-input"], in: arguments, policy: policy) + if hasStreamInput { + let hasExecute = containsOption(["--execute", "-x"], in: arguments, policy: policy) + let hasStreamOutput = containsOption(["--stream-json"], in: arguments, policy: policy) + return hasExecute && hasStreamOutput ? .interactive : .unknown + } + if containsOption(["--execute", "-x"], in: arguments, policy: policy) { + return nil + } + + let positionals = positionalValues(in: arguments, policy: policy, limit: 3) + guard let command = positionals.first else { return nil } + if ["last", "l"].contains(command) { + return .interactive + } + if ["threads", "thread", "t"].contains(command) { + guard positionals.count >= 2 else { return .nonSession } + return ["continue", "c"].contains(positionals[1]) ? .interactive : .nonSession + } + return AgentLaunchSanitizer.ampPolicy.nonRestorableCommands.contains(command) + ? .nonSession + : nil + } + + private func policy(for kind: String) -> AgentLaunchSanitizer.Policy? { + switch kind { + case "claude": AgentLaunchSanitizer.claudePolicy + case "codex": AgentLaunchSanitizer.codexPolicy + case "grok": AgentLaunchSanitizer.grokPolicy + case "pi": AgentLaunchSanitizer.piPolicy + case "omp": AgentLaunchSanitizer.ompPolicy + case "campfire": AgentLaunchSanitizer.campfirePolicy + case "amp": AgentLaunchSanitizer.ampPolicy + case "gemini": AgentLaunchSanitizer.geminiPolicy + case "antigravity": AgentLaunchSanitizer.antigravityPolicy + case "cursor": AgentLaunchSanitizer.cursorPolicy + case "opencode": AgentLaunchSanitizer.openCodePolicy + case "rovodev": AgentLaunchSanitizer.rovoDevPolicy + case "hermes-agent": AgentLaunchSanitizer.hermesAgentPolicy + case "copilot": AgentLaunchSanitizer.copilotPolicy + case "codebuddy": AgentLaunchSanitizer.codeBuddyPolicy + case "factory": AgentLaunchSanitizer.factoryPolicy + case "qoder": AgentLaunchSanitizer.qoderPolicy + case "kiro": AgentLaunchSanitizer.kiroPolicy + case "kimi": AgentLaunchSanitizer.kimiPolicy + default: nil + } + } + + private func oneShotOptions(for kind: String) -> Set { + switch kind { + case "claude": ["--print", "-p"] + case "grok": ["--single", "-p", "--prompt-file", "--prompt-json"] + case "pi", "omp", "campfire": ["--print", "-p"] + case "amp": ["--execute", "--print", "-x"] + case "gemini": ["--prompt", "-p"] + case "antigravity": ["--prompt", "-p", "--print"] + case "cursor": ["--print", "-p"] + case "rovodev": ["--prompt", "-p", "--print"] + case "hermes-agent": ["--oneshot", "-z"] + case "copilot": ["--prompt", "-p"] + case "codebuddy": ["--print", "-p"] + case "qoder": ["--print", "-p", "--remote"] + case "kimi": ["--print", "--quiet"] + default: [] + } + } + + private func interactiveOptions(for kind: String) -> Set { + switch kind { + case "pi", "omp", "campfire": ["--no-session"] + case "gemini", "antigravity", "rovodev", "qoder": ["--prompt-interactive", "-i"] + case "kimi": ["--prompt", "--command", "-p", "-c"] + case "opencode": ["--interactive", "-i"] + default: [] + } + } + + private func unknownOptions(for kind: String) -> Set { + switch kind { + case "claude": ["--background", "--bg"] + case "pi", "omp": ["--prompt"] + case "campfire": ["--prompt"] + case "hermes-agent": ["--query", "-q"] + case "kiro": ["--no-interactive"] + default: [] + } + } + + private func oneShotCommands(for kind: String) -> Set { + switch kind { + case "claude": ["ultrareview"] + case "codex": ["exec", "e", "review"] + case "opencode": ["run"] + case "factory": ["exec"] + default: [] + } + } + + private func nonSessionOptions(for kind: String) -> Set { + var options = AgentLaunchSanitizer.nonSessionMetadataOptions(kind: kind) + switch kind { + case "gemini": + options.formUnion(["--list-sessions", "--delete-session", "--list-extensions", "-l"]) + case "pi", "campfire": + options.formUnion(["--export", "--list-models"]) + case "omp": + options.formUnion(["--alias", "--export", "--list-models"]) + case "cursor": + options.insert("--list-models") + case "factory": + options.insert("--list-tools") + case "kiro": + options.formUnion(["--delete-session", "--list-models", "--list-sessions"]) + case "codebuddy": + options.formUnion(["--background", "--bg"]) + case "qoder": + options.formUnion(["--delete-session", "--list-sessions"]) + default: + break + } + return options + } + + private func oneShotCommandAllowsUnknownTrailingOptions( + kind: String, + command: String + ) -> Bool { + kind == "codex" && ["exec", "e", "review"].contains(command) + } + + /// These options change persistence or provide the initial prompt; they do + /// not force a TUI when the provider's explicit print flag is also present. + private func oneShotOptionOverridesInteractiveModifier(kind: String) -> Bool { + ["pi", "omp", "campfire", "kimi"].contains(kind) + } + + private func interactiveCommands(for kind: String) -> Set { + switch kind { + case "codex": ["resume", "fork"] + case "opencode": ["attach", "pr"] + case "kiro": ["chat"] + default: [] + } + } + + private func normalizedArguments(kind: String, arguments: [String]) -> [String] { + var arguments = arguments + if kind == "cursor", arguments.first == "agent" { + arguments.removeFirst() + } + if kind == "rovodev", arguments.first == "rovodev" { + arguments.removeFirst() + if arguments.first == "run" { arguments.removeFirst() } + } + if kind == "hermes-agent", arguments.first == "chat" { + arguments.removeFirst() + } + return arguments + } + + private func containsOption( + _ options: Set, + in arguments: [String], + policy: AgentLaunchSanitizer.Policy + ) -> Bool { + guard !options.isEmpty else { return false } + var index = 0 + while index < arguments.count { + let argument = arguments[index] + if argument == "--" { return false } + if argument.hasPrefix("-"), argument != "-" { + let name = optionName(argument) + if options.contains(name) { return true } + index += max(1, AgentLaunchSanitizer.optionWidth(arguments, index: index, policy: policy)) + } else { + index += 1 + } + } + return false + } + + private func containsUnknownOption( + in arguments: [String], + policy: AgentLaunchSanitizer.Policy + ) -> Bool { + let knownOptions = policy.valueOptions + .union(policy.optionalValueOptions) + .union(policy.booleanOptions) + .union(policy.droppedOptions) + .union(policy.rejectOptions) + var index = 0 + while index < arguments.count { + let argument = arguments[index] + if argument == "--" { return false } + guard argument.hasPrefix("-"), argument != "-" else { + index += 1 + continue + } + let name = optionName(argument) + let knownPrefix = policy.droppedOptionPrefixes.contains(where: argument.hasPrefix) + if !knownOptions.contains(name), !knownPrefix { + return true + } + index += max(1, AgentLaunchSanitizer.optionWidth(arguments, index: index, policy: policy)) + } + return false + } + + private func firstPositional( + in arguments: [String], + policy: AgentLaunchSanitizer.Policy + ) -> String? { + firstPositionalLocation(in: arguments, policy: policy)?.value + } + + private func firstPositionalLocation( + in arguments: [String], + policy: AgentLaunchSanitizer.Policy + ) -> (index: Int, value: String)? { + var index = 0 + while index < arguments.count { + let argument = arguments[index] + if argument == "--" { return nil } + if argument.hasPrefix("-"), argument != "-" { + index += max(1, AgentLaunchSanitizer.optionWidth(arguments, index: index, policy: policy)) + continue + } + return (index, argument) + } + return nil + } + + private func positionalValues( + in arguments: [String], + policy: AgentLaunchSanitizer.Policy, + limit: Int + ) -> [String] { + var values: [String] = [] + var index = 0 + while index < arguments.count, values.count < limit { + let argument = arguments[index] + if argument == "--" { break } + if argument.hasPrefix("-"), argument != "-" { + index += max(1, AgentLaunchSanitizer.optionWidth(arguments, index: index, policy: policy)) + } else { + values.append(argument) + index += 1 + } + } + return values + } + + private func optionValue( + _ option: String, + in arguments: [String], + policy: AgentLaunchSanitizer.Policy + ) -> String? { + var index = 0 + while index < arguments.count { + let argument = arguments[index] + if argument == "--" { return nil } + guard argument.hasPrefix("-"), argument != "-" else { + index += 1 + continue + } + let name = optionName(argument) + if name == option { + if argument.hasPrefix("\(option)=") { + return String(argument.dropFirst(option.count + 1)).lowercased() + } + let width = AgentLaunchSanitizer.optionWidth( + arguments, + index: index, + policy: policy + ) + guard width > 1, index + 1 < arguments.count else { return nil } + return arguments[index + 1].lowercased() + } + index += max( + 1, + AgentLaunchSanitizer.optionWidth(arguments, index: index, policy: policy) + ) + } + return nil + } + + private func optionName(_ argument: String) -> String { + guard let equals = argument.firstIndex(of: "=") else { return argument } + return String(argument[.. Bool? { guard promptBoundaryOption(arg, options: policy.promptBoundaryOptions) != nil else { return false } + // Claude 2.1.214 defines bare --tmux as a boolean. Preserve the legacy + // claude-teams prompt boundary only when a prompt-like token follows it; + // an adjacent option starts a new option and must remain visible. + if arg == "--tmux", + (index + 1 >= args.count || isOptionToken(args[index + 1])) { + index += 1 + return true + } if let modeEnd = promptBoundaryLaunchModeEnd(args, index: index) { index = modeEnd return true diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchReplayPlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchReplayPlanner.swift new file mode 100644 index 000000000000..5ca12ab45413 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchReplayPlanner.swift @@ -0,0 +1,82 @@ +import Foundation + +/// The strongest evidence available for associating a captured launch with an +/// agent hook. This is intentionally separate from the persisted launch-record +/// source so replay policy cannot be inferred from loosely coupled strings. +public enum AgentLaunchCaptureEvidence: Sendable, Equatable { + case exactEnvironmentLauncher + case wrapperEnvironmentLauncher + case nativeProcess + case unavailable +} + +/// The only replay states a hook capture can produce. +public enum AgentLaunchReplayPlan: Sendable, Equatable { + case captured(arguments: [String], evidence: AgentLaunchCaptureEvidence) + case canonical + case rejected + case unavailable +} + +/// Converts launch evidence and sanitizer output into one durable replay plan. +/// +/// Capture validation proves the original invocation belongs to the agent. +/// Sanitization separately proves which arguments are safe to replay. If +/// sanitization removes an interpreter-hosted agent's script identity, the +/// original executable must not be replayed; trusted native or exact-launcher +/// evidence instead selects the provider's canonical resume command. +public struct AgentLaunchReplayPlanner: Sendable, Equatable { + public init() {} + + public func plan( + kind: String, + launcher: String?, + executablePath: String?, + capturedArguments: [String]?, + sanitizedArguments: [String]?, + evidence: AgentLaunchCaptureEvidence, + hasSelectedEnvironment: Bool + ) -> AgentLaunchReplayPlan { + guard let capturedArguments, !capturedArguments.isEmpty else { + if evidence == .exactEnvironmentLauncher + || hasSelectedEnvironment + || normalized(kind) == "codex" { + return .canonical + } + return .unavailable + } + + let originalDescribesKind = AgentLaunchCaptureTrust.capturedArgumentsDescribeKind( + launcher: launcher, + executablePath: executablePath, + arguments: capturedArguments, + kind: kind + ) + guard originalDescribesKind else { + return evidence == .exactEnvironmentLauncher ? .canonical : .unavailable + } + + guard let sanitizedArguments else { + return .rejected + } + if AgentLaunchCaptureTrust.capturedArgumentsDescribeKind( + launcher: launcher, + executablePath: executablePath, + arguments: sanitizedArguments, + kind: kind + ) { + return .captured(arguments: sanitizedArguments, evidence: evidence) + } + + switch evidence { + case .exactEnvironmentLauncher, .nativeProcess: + return .canonical + case .wrapperEnvironmentLauncher, .unavailable: + return .unavailable + } + } + + private func normalized(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index b0aedb586b81..638017fcead4 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -54,6 +54,14 @@ public enum AgentLaunchSanitizer { ) -> [String]? { guard let executable = arguments.first, !executable.isEmpty else { return nil } var tail = Array(arguments.dropFirst()) + let metadataKind: String + switch launcher { + case "claudeTeams": metadataKind = "claude" + case "codexTeams": metadataKind = "codex" + case "omo": metadataKind = "opencode" + default: metadataKind = fallbackKind + } + guard !containsNonSessionMetadataOption(kind: metadataKind, args: tail) else { return nil } switch launcher { case "claudeTeams": @@ -104,6 +112,7 @@ public enum AgentLaunchSanitizer { } public static func preservedArguments(kind: String, args: [String]) -> [String]? { + guard !containsNonSessionMetadataOption(kind: kind, args: args) else { return nil } switch kind { case "claude": return ClaudeLaunchArgumentsPreserver().preservedArguments(args: args) @@ -113,26 +122,31 @@ public enum AgentLaunchSanitizer { case "codex-fork-restore": return preservedCodexForkArguments(args: args, preservePromptTags: false) case "grok": return preserveOptions(args, policy: grokPolicy) - case "pi", "omp": + case "pi": return preserveOptions(args, policy: piPolicy) + case "omp": + return preserveOptions(args, policy: ompPolicy) case "campfire": return preserveOptions(args, policy: campfirePolicy) case "amp": - // Strip the `threads continue ` resume sub-subcommand if the - // captured launch already started by resuming a thread, so we - // don't double-add it. Supports the documented short aliases: - // `t`/`thread` for `threads`, and `c` for `continue`. + // Strip an existing continuation selector before constructing a + // fresh resume command. Amp's old `threads fork` form is accepted + // here only for restoring legacy captures; current Amp rejects that + // command and CMUX no longer offers Amp forks. var tail = args let threadsAliases: Set = ["threads", "thread", "t"] - let continueAliases: Set = ["continue", "c"] + let sessionCommandAliases: Set = ["continue", "c", "fork"] if let first = tail.first, threadsAliases.contains(first) { tail.removeFirst() - if let next = tail.first, continueAliases.contains(next) { + guard let next = tail.first, sessionCommandAliases.contains(next) else { + return nil + } + tail.removeFirst() + if let candidate = tail.first, !candidate.hasPrefix("-") { tail.removeFirst() - if let candidate = tail.first, !candidate.hasPrefix("-") { - tail.removeFirst() - } } + } else if let first = tail.first, ["last", "l"].contains(first) { + tail.removeFirst() } return preserveOptions(tail, policy: ampPolicy) case "cursor": @@ -165,6 +179,16 @@ public enum AgentLaunchSanitizer { } return preserveOptions(tail, policy: openCodePolicy) case "rovodev": + // `acli rovodev run ` is a documented single-instruction + // invocation. It still writes a session, but replaying that record as + // `run --restore ` after the process exits manufactures an + // interactive restore for a one-shot command. Keep lifecycle and + // replay safety as separate decisions, but reject this replay shape + // at the sanitizer boundary too so stale persisted records cannot + // resurrect it after an app restart. + if AgentLaunchModeClassifier().mode(kind: "rovodev", arguments: args) == .oneShot { + return nil + } var tail = args if tail.first == "rovodev" { tail.removeFirst() @@ -193,6 +217,8 @@ public enum AgentLaunchSanitizer { return preserveOptions(args, policy: factoryPolicy) case "qoder": return preserveOptions(args, policy: qoderPolicy) + case "kimi": + return preserveOptions(args, policy: kimiPolicy) case "ollama": return OllamaLaunchArgumentsPreserver().preservedArguments(args) default: @@ -200,8 +226,89 @@ public enum AgentLaunchSanitizer { } } + /// Preserves only the replay-safe options from OpenCode's direct + /// `run --interactive` mode. + /// + /// `opencode run` is normally one-shot, while `run --interactive` owns a + /// multi-turn split-footer UI. The root TUI sanitizer intentionally rejects + /// every `run` subcommand, so this explicit path keeps the interactive mode + /// across resume/fork without replaying its startup message, files, command, + /// remote credentials, or stale session selector. + static func preservedOpenCodeInteractiveRunArguments(args: [String]) -> [String]? { + guard args.first == "run", + AgentLaunchModeClassifier().mode(kind: "opencode", arguments: args) == .interactive else { + return nil + } + let tail = Array(args.dropFirst()) + guard openCodeInteractiveRunOptionsAreKnown(tail) else { return nil } + return preserveOptions(tail, policy: openCodeInteractiveRunPolicy) + } + + private static func openCodeInteractiveRunOptionsAreKnown(_ args: [String]) -> Bool { + let policy = openCodeInteractiveRunPolicy + let knownOptions = policy.valueOptions + .union(policy.booleanOptions) + .union(policy.droppedOptions) + .union(policy.rejectOptions) + var index = 0 + while index < args.count { + let argument = args[index] + if argument == "--" || !argument.hasPrefix("-") || argument == "-" { + break + } + let option = argument.split(separator: "=", maxSplits: 1).first.map(String.init) ?? argument + guard knownOptions.contains(option) else { return false } + if policy.valueOptions.contains(option), + !argument.contains("="), + (index + 1 >= args.count || args[index + 1].hasPrefix("-")) { + return false + } + index += max(1, optionWidth(args, index: index, policy: policy)) + } + return true + } + /// Preserves restorable `claude-teams` `args` with the Teams policy, keeping routing flags while dropping `--tmux` prompt payloads; returns `nil` for unsafe replay shapes. - public static func preservedClaudeTeamsLaunchArguments(args: [String]) -> [String]? { preserveOptions(args, policy: claudeTeamsPolicy) } + public static func preservedClaudeTeamsLaunchArguments(args: [String]) -> [String]? { + guard !containsNonSessionMetadataOption(kind: "claude", args: args) else { return nil } + return preserveOptions(args, policy: claudeTeamsPolicy) + } + + static func nonSessionMetadataOptions(kind: String) -> Set { + var options: Set = ["--help", "-h", "--version"] + switch kind { + case "claude": + options.insert("-v") + case "codex", "codex-fork-replay", "codex-fork-restore": + options.insert("-V") + case "grok", "pi", "omp", "campfire", "opencode", "cursor": + options.insert("-v") + case "amp": + options.formUnion(["-V", "-v"]) + case "hermes-agent": + options.insert("-V") + case "gemini": + options.insert("-v") + case "kimi", "kiro": + options.insert("-V") + case "copilot", "factory": + options.insert("-v") + default: + break + } + return options + } + + static func containsNonSessionMetadataOption(kind: String, args: [String]) -> Bool { + let options = nonSessionMetadataOptions(kind: kind) + for argument in args { + if argument == "--" { return false } + guard argument.hasPrefix("-"), argument != "-" else { continue } + let name = argument.split(separator: "=", maxSplits: 1).first.map(String.init) ?? argument + if options.contains(name) { return true } + } + return false + } /// Whether `option` appears as a real Claude *option* in claude-teams launch /// `args`. Unlike restore preservation, this does NOT stop at the first diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift index 8379ebb2aeaf..163c55eb0086 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift @@ -3,6 +3,7 @@ import Foundation extension AgentLaunchSanitizer { static let copilotPolicy = Policy( valueOptions: [ + "--attachment", "--add-dir", "--add-github-mcp-tool", "--add-github-mcp-toolset", @@ -11,7 +12,7 @@ extension AgentLaunchSanitizer { "--allow-tool", "--allow-url", "--available-tools", - "--bash-env", + "-C", "--connect", "--deny-tool", "--deny-url", @@ -33,6 +34,7 @@ extension AgentLaunchSanitizer { "-p", "--reasoning-effort", "--resume", + "--session-id", "--secret-env-vars", "--share", "--stream" @@ -41,29 +43,60 @@ extension AgentLaunchSanitizer { "--allow-tool", "--allow-url", "--available-tools", - "--bash-env", "--connect", "--deny-tool", "--deny-url", "--excluded-tools", - "--mouse", "--resume", "--secret-env-vars", - "--share" + "--share", + "--worktree", + "-w" + ], + booleanOptions: [ + "--allow-all", + "--allow-all-mcp-server-instructions", + "--allow-all-paths", + "--allow-all-tools", + "--allow-all-urls", + "--autopilot", + "--banner", + "--bash-env", + "--continue", + "--disable-builtin-mcps", + "--disallow-temp-dir", + "--enable-all-github-mcp-tools", + "--enable-memory", + "--enable-reasoning-summaries", + "--experimental", + "--mouse", + "--no-ask-user", + "--no-auto-update", + "--no-banner", + "--no-bash-env", + "--no-color", + "--no-custom-instructions", + "--no-experimental", + "--no-mouse", + "--no-remote", + "--no-remote-export", + "--no-sandbox", + "--plain-diff", + "--plan", + "--remote", + "--remote-export", + "--sandbox", + "--screen-reader", + "--show-secrets", + "--yolo", ], variadicOptions: [ - "--add-dir", - "--add-github-mcp-tool", - "--add-github-mcp-toolset", - "--additional-mcp-config", "--allow-tool", "--allow-url", "--available-tools", "--deny-tool", "--deny-url", - "--disable-mcp-server", "--excluded-tools", - "--plugin-dir", "--secret-env-vars" ], nonRestorableCommands: [ @@ -81,13 +114,21 @@ extension AgentLaunchSanitizer { "--continue", "--interactive", "-i", - "--resume" + "--resume", + "--session-id", + "--attachment", + "--worktree", + "-w" ], droppedOptionPrefixes: [ "--connect=", "--interactive=", "-i=", - "--resume=" + "--resume=", + "--session-id=", + "--attachment=", + "--worktree=", + "-w=" ], rejectOptions: [ "--acp", @@ -122,9 +163,11 @@ extension AgentLaunchSanitizer { "--model", "--name", "--output-format", + "--permission-prompt-tool", "--permission-mode", "--plugin-dir", "--port", + "--prewarm-id", "--resume", "-r", "--sandbox", @@ -150,6 +193,22 @@ extension AgentLaunchSanitizer { "--worktree", "-w" ], + booleanOptions: [ + "--background", + "--bg", + "--continue", + "-c", + "--dangerously-skip-permissions", + "--fork-session", + "--ide", + "--include-partial-messages", + "--prewarm", + "--serve", + "--strict-mcp-config", + "--tmux", + "--tmux-classic", + "--verbose", + ], variadicOptions: [ "--add-dir", "--allowedTools", @@ -207,6 +266,7 @@ extension AgentLaunchSanitizer { "--output-format", "--print", "-p", + "--prewarm", "--serve" ] ) @@ -215,11 +275,30 @@ extension AgentLaunchSanitizer { valueOptions: [ "--append-system-prompt", "--append-system-prompt-file", + "--auto", "--cwd", + "--disabled-tools", + "--enabled-tools", + "--file", + "-f", "--fork", + "--input-format", + "--log-group-id", + "--model", + "-m", + "--output-format", + "-o", + "--reasoning-effort", "--resume", "-r", "--settings", + "--spec-model", + "--spec-reasoning-effort", + "--tag", + "--validator-model", + "--validator-reasoning-effort", + "--worker-model", + "--worker-reasoning-effort", "--worktree", "-w", "--worktree-dir" @@ -230,6 +309,11 @@ extension AgentLaunchSanitizer { "--worktree", "-w" ], + booleanOptions: [ + "--mission", + "--skip-permissions-unsafe", + "--use-spec", + ], nonRestorableCommands: [ "computer", "daemon", @@ -256,11 +340,15 @@ extension AgentLaunchSanitizer { "--worktree=", "-w=", "--worktree-dir=" + ], + rejectOptions: [ + "--list-tools", ] ) static let qoderPolicy = Policy( valueOptions: [ + "--add-dir", "--agent", "--agents", "--allowed-mcp-server-names", @@ -271,6 +359,7 @@ extension AgentLaunchSanitizer { "--delete-session", "--disallowed-tools", "--input-format", + "--max-turns", "--max-output-tokens", "--mcp-config", "--model", @@ -286,6 +375,7 @@ extension AgentLaunchSanitizer { "-i", "--resume", "-r", + "--remote", "--session-id", "--setting-sources", "--settings", @@ -294,6 +384,15 @@ extension AgentLaunchSanitizer { "--workspace", "-w" ], + optionalValueOptions: [ + "--worktree", + ], + booleanOptions: [ + "--continue", + "-c", + "--fork-session", + "--yolo", + ], variadicOptions: [ "--allowed-mcp-server-names", "--allowed-tools", @@ -325,12 +424,14 @@ extension AgentLaunchSanitizer { "--fork-session", "--resume", "-r", - "--session-id" + "--session-id", + "--worktree" ], droppedOptionPrefixes: [ "--resume=", "-r=", - "--session-id=" + "--session-id=", + "--worktree=" ], rejectOptions: [ "--acp", @@ -343,7 +444,8 @@ extension AgentLaunchSanitizer { "--print", "-p", "--prompt-interactive", - "-i" + "-i", + "--remote" ] ) @@ -359,12 +461,17 @@ extension AgentLaunchSanitizer { valueOptions: [ "--agent", "--delete-session", + "--effort", "--format", "-f", "--resume-id", "--trust-tools", "--wrap" ], + booleanOptions: [ + "--require-mcp-startup", + "--trust-all-tools", + ], nonRestorableCommands: [ "agent", "diagnostic", @@ -413,7 +520,12 @@ extension AgentLaunchSanitizer { "--restore" ], optionalValueOptions: [ - "--restore" + "--restore", + "--worktree", + ], + booleanOptions: [ + "--web", + "--yolo", ], nonRestorableCommands: [ "auth", @@ -421,15 +533,18 @@ extension AgentLaunchSanitizer { "help", "mcp", "server", + "serve", "update", "upgrade", "version" ], droppedOptions: [ - "--restore" + "--restore", + "--worktree", ], droppedOptionPrefixes: [ - "--restore=" + "--restore=", + "--worktree=", ], rejectOptions: [ "--prompt", @@ -456,20 +571,32 @@ extension AgentLaunchSanitizer { "--profile", "-p", "--provider", + "--query", + "-q", "--resume", "-r", "--skills", "-s", "--source", "--toolsets", - "-t", - "--worktree", - "-w" + "-t" ], optionalValueOptions: [ "--continue", "-c" ], + booleanOptions: [ + "--accept-hooks", + "--checkpoints", + "--dev", + "--ignore-rules", + "--ignore-user-config", + "--pass-session-id", + "--tui", + "--worktree", + "-w", + "--yolo", + ], nonRestorableCommands: [], droppedOptions: [ "--api-key", @@ -503,7 +630,9 @@ extension AgentLaunchSanitizer { "--quiet", "-Q", "--list-tools", - "--list-toolsets" + "--list-toolsets", + "--version", + "-V", ] ) } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerCodexLaunch.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerCodexLaunch.swift index ae1805cbd6c7..f5080ed18513 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerCodexLaunch.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerCodexLaunch.swift @@ -1,6 +1,9 @@ import Foundation func preservedCodexLaunchArguments(args: [String], stripCmuxHooks: Bool = true) -> [String]? { + guard !AgentLaunchSanitizer.containsNonSessionMetadataOption(kind: "codex", args: args) else { + return nil + } let args = stripCmuxHooks ? removingCmuxInjectedCodexHookArguments(args) : args if let forkCommand = codexForkCommand(in: args) { return CodexForkLaunchCapture( @@ -10,7 +13,7 @@ func preservedCodexLaunchArguments(args: [String], stripCmuxHooks: Bool = true) preserveOptions: AgentLaunchSanitizer.preserveOptions ).arguments() } - return AgentLaunchSanitizer.preserveOptions(args, policy: AgentLaunchSanitizer.codexPolicy) + return AgentLaunchSanitizer.preserveOptions(args, policy: codexReplayPolicy()) } func preservedCodexForkArguments( @@ -18,6 +21,9 @@ func preservedCodexForkArguments( preservePromptTags: Bool, stripCmuxHooks: Bool = true ) -> [String]? { + guard !AgentLaunchSanitizer.containsNonSessionMetadataOption(kind: "codex", args: args) else { + return nil + } func dropForkPositionals(_ args: [String], forkCommand: CodexForkCommand) -> [String] { var result: [String] = [] var index = 0 @@ -67,7 +73,7 @@ func preservedCodexForkArguments( tail = dropForkPositionals(tail, forkCommand: forkCommand) preservePositionals = preservePromptTags } - var policy = AgentLaunchSanitizer.codexPolicy + var policy = codexReplayPolicy() policy.preservePositionals = preservePositionals if preservePositionals { policy.nonRestorableCommands = [] @@ -75,6 +81,17 @@ func preservedCodexForkArguments( return AgentLaunchSanitizer.preserveOptions(tail, policy: policy) } +/// Replay strips resume-picker selectors that are valid on `codex resume` but +/// invalid on `codex fork`. The launch-mode classifier keeps the stricter +/// subcommand-specific grammar, while both canonical replay builders share this +/// selector-free policy. +private func codexReplayPolicy() -> AgentLaunchSanitizer.Policy { + var policy = AgentLaunchSanitizer.codexPolicy + policy.booleanOptions.insert("--include-non-interactive") + policy.droppedOptions.insert("--include-non-interactive") + return policy +} + func removingCmuxInjectedCodexHookArguments(_ args: [String]) -> [String] { guard let injectedPrefixEnd = cmuxInjectedCodexHookArgumentPrefixEnd(args) else { return args } return Array(args.dropFirst(injectedPrefixEnd)) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerGrokPolicy.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerGrokPolicy.swift index 754614e6eb6f..d8ec198443ef 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerGrokPolicy.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerGrokPolicy.swift @@ -8,8 +8,11 @@ extension AgentLaunchSanitizer { "--allow", "--cwd", "--deny", + "--debug-file", "--disallowed-tools", "--effort", + "--json-schema", + "--leader-socket", "--max-turns", "--model", "-m", @@ -19,9 +22,12 @@ extension AgentLaunchSanitizer { "-r", "--rules", "--sandbox", + "--session-id", + "-s", "--system-prompt-override", "--tools", "--worktree", + "--worktree-ref", "-w" ], optionalValueOptions: [ @@ -30,16 +36,36 @@ extension AgentLaunchSanitizer { "--worktree", "-w" ], + booleanOptions: [ + "--always-approve", + "--debug", + "--disable-web-search", + "--experimental-memory", + "--fullscreen", + "--minimal", + "--no-alt-screen", + "--no-memory", + "--no-plan", + "--no-subagents", + "--oauth", + "--verbatim", + "--check", + ], nonRestorableCommands: [ "agent", + "completions", + "dashboard", + "export", "help", "import", "inspect", "leader", "login", + "logout", "mcp", "memory", "models", + "plugin", "sessions", "setup", "share", @@ -48,7 +74,8 @@ extension AgentLaunchSanitizer { "update", "version", "v", - "worktree" + "worktree", + "wrap" ], droppedOptions: [ "--continue", @@ -56,13 +83,21 @@ extension AgentLaunchSanitizer { "--restore-code", "--resume", "-r", + "--fork-session", + "--session-id", + "-s", "--worktree", + "--worktree-ref", "-w" ], droppedOptionPrefixes: [ "--resume=", "-r=", + "--fork-session=", + "--session-id=", + "-s=", "--worktree=", + "--worktree-ref=", "-w=" ], rejectOptions: [ diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerKimiPolicy.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerKimiPolicy.swift new file mode 100644 index 000000000000..b9966137d822 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerKimiPolicy.swift @@ -0,0 +1,55 @@ +import Foundation + +extension AgentLaunchSanitizer { + static let kimiPolicy = Policy( + valueOptions: [ + "--work-dir", "-w", + "--add-dir", + "--session", "--resume", "-S", "-r", + "--config", "--config-file", + "--model", "-m", + "--prompt", "--command", "-p", "-c", + "--input-format", "--output-format", + "--agent", "--agent-file", + "--mcp-config-file", "--mcp-config", "--skills-dir", + "--max-steps-per-turn", "--max-retries-per-step", "--max-ralph-iterations", + ], + optionalValueOptions: [ + "--session", "--resume", "-S", "-r", + ], + booleanOptions: [ + "--auto-approve", + "--debug", + "--no-thinking", + "--plan", + "--thinking", + "--verbose", + "--yolo", + "--yes", + "-y", + ], + variadicOptions: [ + "--add-dir", "--mcp-config-file", "--mcp-config", "--skills-dir", + ], + nonRestorableCommands: [ + "login", "logout", "term", "acp", "info", "export", "mcp", "plugin", "vis", "web", + ], + droppedOptions: [ + "--session", "--resume", "-S", "-r", + "--continue", "-C", + "--prompt", "--command", "-p", "-c", + "--config", "--mcp-config", + "--output-format", "--final-message-only", + ], + droppedOptionPrefixes: [ + "--session=", "--resume=", "-S=", "-r=", + "--prompt=", "--command=", "-p=", "-c=", + "--config=", "--mcp-config=", + "--output-format=", + ], + rejectOptions: [ + "--print", "--quiet", + "--acp", "--wire", "--input-format", + ] + ) +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift index 9bb184762ff7..efa57fa65cbb 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift @@ -18,7 +18,6 @@ extension AgentLaunchSanitizer { "--effort", "--fallback-model", "--file", - "--from-pr", "--input-format", "--json-schema", "--max-budget-usd", @@ -31,22 +30,24 @@ extension AgentLaunchSanitizer { "--plugin-dir", "--plugin-url", "--remote-control-session-name-prefix", - "--resume", - "-r", "--session-id", "--setting-sources", "--settings", "--system-prompt", "--system-prompt-file", "--teammate-mode", - "--tmux", - "--tools", - "--worktree", - "-w" + "--tools" ], optionalValueOptions: [ "--debug", - "-d" + "-d", + "--from-pr", + "--prompt-suggestions", + "--remote-control", + "--resume", + "-r", + "--worktree", + "-w", ], // Claude booleans (from `claude --help`) pinned to width 1 so a following // one-word prompt is never inferred as the flag's value and replayed on @@ -68,6 +69,7 @@ extension AgentLaunchSanitizer { "--dangerously-skip-permissions", "--disable-slash-commands", "--exclude-dynamic-system-prompt-sections", + "--forward-subagent-text", "--fork-session", "--ide", "--include-hook-events", @@ -76,6 +78,7 @@ extension AgentLaunchSanitizer { "--replay-user-messages", "--safe-mode", "--strict-mcp-config", + "--tmux", "--verbose" ], variadicOptions: [ @@ -97,15 +100,18 @@ extension AgentLaunchSanitizer { "api-key", "config", "doctor", + "gateway", "install", "mcp", "plugin", "plugins", + "project", "rc", "remote-control", "setup-token", "update", - "upgrade" + "upgrade", + "ultrareview", ], droppedOptions: [ // Replaying --bg/--background would turn an interactive pane restore @@ -136,6 +142,7 @@ extension AgentLaunchSanitizer { rejectOptions: [ "--print", "-p", + "--forward-subagent-text", "--no-session-persistence" ], scansOptionsPastPositionals: true, @@ -165,11 +172,22 @@ extension AgentLaunchSanitizer { "--enable", "--disable" ], + booleanOptions: [ + "--dangerously-bypass-approvals-and-sandbox", + "--dangerously-bypass-hook-trust", + "--no-alt-screen", + "--oss", + "--search", + "--strict-config", + ], variadicOptions: [ "--image", "-i" ], nonRestorableCommands: [ + "archive", + "delete", + "doctor", "exec", "e", "review", @@ -180,6 +198,8 @@ extension AgentLaunchSanitizer { "app-server", "app", "completion", + "plugin", + "remote-control", "sandbox", "debug", "apply", @@ -188,7 +208,9 @@ extension AgentLaunchSanitizer { "cloud", "exec-server", "features", - "help" + "help", + "unarchive", + "update" ], droppedOptions: [ "--last", @@ -213,19 +235,52 @@ extension AgentLaunchSanitizer { "--fork", "--model", "--models", + "--mode", "--prompt-template", "--provider", "--resume", "--session", + "--session-id", "--session-dir", "--skill", "--system-prompt", "--theme", "--thinking", "--tools", + "--exclude-tools", + "--export", + "--name", "-e", + "-n", "-r", - "-t" + "-t", + "-xt" + ], + optionalValueOptions: [ + "--list-models", + "--resume", + "-r" + ], + booleanOptions: [ + "--approve", + "-a", + "--no-approve", + "-na", + "--no-builtin-tools", + "-nbt", + "--no-context-files", + "-nc", + "--no-extensions", + "-ne", + "--no-prompt-templates", + "-np", + "--no-skills", + "-ns", + "--no-themes", + "--no-tools", + "-nt", + "--offline", + "--verbose", ], nonRestorableCommands: [ "config", @@ -244,6 +299,7 @@ extension AgentLaunchSanitizer { "--fork", "--resume", "--session", + "--session-id", "-c", "-r" ], @@ -251,7 +307,8 @@ extension AgentLaunchSanitizer { "--api-key=", "--fork=", "--resume=", - "--session=" + "--session=", + "--session-id=" ], rejectOptions: [ "--export", @@ -267,6 +324,74 @@ extension AgentLaunchSanitizer { ] ) + /// OMP forwards Pi-compatible options but has additional model/profile controls whose + /// values must not be interpreted as prompts. Keep these widths out of Pi/Campfire because + /// Pi extensions may define the same spellings with different arity. + static let ompPolicy: Policy = { + var policy = piPolicy + // OMP 16.x has no Pi-compatible `-xt` alias. + policy.valueOptions.remove("-xt") + policy.valueOptions.formUnion([ + "--approval-mode", + "--config", + "--cwd", + "--hook", + "--max-time", + "--plan", + "--plugin-dir", + "--profile", + "--skills", + "--slow", + "--smol", + ]) + policy.booleanOptions.formUnion([ + "--advisor", + "--allow-home", + "--auto-approve", + "--hide-thinking", + "--no-extensions", + "--no-lsp", + "--no-pty", + "--no-rules", + "--no-skills", + "--no-title", + "--no-tools", + "--print-thoughts", + ]) + policy.valueOptions.insert("--alias") + policy.rejectOptions.insert("--alias") + policy.nonRestorableCommands.formUnion([ + "acp", + "agents", + "auth-broker", + "auth-gateway", + "bench", + "commit", + "completions", + "dry-balance", + "gallery", + "gc", + "grep", + "grievances", + "join", + "models", + "plugin", + "read", + "say", + "search", + "setup", + "shell", + "ssh", + "stats", + "tiny-models", + "token", + "ttsr", + "usage", + "worktree", + ]) + return policy + }() + /// Campfire embeds vanilla pi and forwards unrecognized flags to it, so its /// policy is pi's plus the campfire-only surface. `--relay` is safe to /// replay (a relay URL, not a credential); `--join-as`/`--name` are @@ -294,22 +419,41 @@ extension AgentLaunchSanitizer { "--log-level", "--mcp-config", "--mode", + "--runner-id", "--settings-file", "--visibility", "-l", "-m" ], + optionalValueOptions: [ + "--plugin-ready-timeout", + ], + booleanOptions: [ + "--color", + "--ide", + "--no-archive-after-execute", + "--no-color", + "--no-ide", + "--no-notifications", + "--no-tui", + "--notifications", + ], nonRestorableCommands: [ + "clone", + "config", "login", "logout", "mcp", + "orb", "permissions", "permission", + "projects", "review", "skill", "skills", "tool", "tools", + "top", "update", "up", "usage", @@ -320,12 +464,14 @@ extension AgentLaunchSanitizer { "--label", "-l", "--stream-json", - "--stream-json-input", "--stream-json-thinking" ], rejectOptions: [ "--execute", + "--no-tui", "--print", + "--runner-id", + "--stream-json-input", "-V", "-x" ] @@ -335,8 +481,6 @@ extension AgentLaunchSanitizer { valueOptions: [ "--model", "-m", - "--sandbox", - "-s", "--approval-mode", "--policy", "--admin-policy", @@ -347,9 +491,8 @@ extension AgentLaunchSanitizer { "--include-directories", "--resume", "-r", + "--session-file", "--session-id", - "--worktree", - "-w", "--prompt", "-p", "--prompt-interactive", @@ -360,7 +503,19 @@ extension AgentLaunchSanitizer { ], optionalValueOptions: [ "--resume", - "-r" + "-r", + "--worktree", + "-w", + ], + booleanOptions: [ + "--debug", + "-d", + "--sandbox", + "-s", + "--screen-reader", + "--skip-trust", + "--yolo", + "-y", ], variadicOptions: [ "--policy", @@ -374,20 +529,25 @@ extension AgentLaunchSanitizer { nonRestorableCommands: [ "mcp", "extensions", + "extension", "skills", + "skill", "hooks", + "hook", "gemma", "help" ], droppedOptions: [ "--resume", "-r", + "--session-file", "--session-id", "--worktree", "-w" ], droppedOptionPrefixes: [ "--resume=", + "--session-file=", "--session-id=", "--worktree=" ], @@ -404,7 +564,8 @@ extension AgentLaunchSanitizer { "--accept-raw-output-risk", "--acp", "--experimental-acp", - "--list-extensions" + "--list-extensions", + "-l" ] ) @@ -413,8 +574,12 @@ extension AgentLaunchSanitizer { "--add-dir", "--conversation", "--log-file", + "--model", + "--new-project", "--print-timeout", + "--project", "--prompt", + "--prompt-interactive", "-p", "--sandbox", ], @@ -422,10 +587,14 @@ extension AgentLaunchSanitizer { "--continue", "-c", ], + booleanOptions: [ + "--dangerously-skip-permissions", + ], nonRestorableCommands: [ "changelog", "help", "install", + "models", "plugin", "plugins", "update", @@ -434,9 +603,13 @@ extension AgentLaunchSanitizer { "--continue", "-c", "--conversation", + "--new-project", + "--project", ], droppedOptionPrefixes: [ "--conversation=", + "--new-project=", + "--project=", ], rejectOptions: [ "--prompt", @@ -449,12 +622,14 @@ extension AgentLaunchSanitizer { static let cursorPolicy = Policy( valueOptions: [ + "--add-dir", "--api-key", "-H", "--header", "--mode", "--model", "--output-format", + "--plugin-dir", "--resume", "--sandbox", "--workspace", @@ -467,6 +642,16 @@ extension AgentLaunchSanitizer { "--resume", "--worktree" ], + booleanOptions: [ + "--approve-mcps", + "--auto-review", + "--force", + "-f", + "--plan", + "--skip-worktree-setup", + "--trust", + "--yolo", + ], nonRestorableCommands: [ "about", "create-chat", @@ -475,13 +660,14 @@ extension AgentLaunchSanitizer { "install-shell-integration", "login", "logout", - "ls", "mcp", "models", + "plugin", "rule", "status", "uninstall-shell-integration", "update", + "worker", "whoami" ], droppedOptions: [ @@ -507,6 +693,7 @@ extension AgentLaunchSanitizer { ], rejectOptions: [ "--cloud", + "--list-models", "--output-format", "--print", "-p", @@ -529,7 +716,16 @@ extension AgentLaunchSanitizer { "--session", "-s", "--prompt", - "--agent" + "--agent", + "--replay-limit" + ], + booleanOptions: [ + "--auto", + "--mdns", + "--mini", + "--no-replay", + "--print-logs", + "--pure", ], variadicOptions: [ "--cors" @@ -578,4 +774,94 @@ extension AgentLaunchSanitizer { ], preserveFirstPositional: true ) + + /// OpenCode `run --interactive` is a multi-turn terminal mode with a + /// different option surface from the root TUI. Startup inputs and remote + /// connection material are consumed and dropped; the builder re-adds a + /// canonical `--interactive --session ` selector. + static let openCodeInteractiveRunPolicy = Policy( + valueOptions: [ + "--agent", + "--attach", + "--command", + "--dir", + "--file", + "-f", + "--format", + "--log-level", + "--model", + "-m", + "--password", + "-p", + "--port", + "--replay-limit", + "--session", + "-s", + "--title", + "--username", + "-u", + "--variant", + ], + booleanOptions: [ + "--auto", + "--continue", + "-c", + "--dangerously-skip-permissions", + "--demo", + "--fork", + "--interactive", + "-i", + "--mini", + "--no-replay", + "--print-logs", + "--pure", + "--replay", + "--share", + "--thinking", + "--yolo", + ], + nonRestorableCommands: [], + droppedOptions: [ + "--attach", + "--command", + "--continue", + "-c", + "--dir", + "--file", + "-f", + "--fork", + "--format", + "--interactive", + "-i", + "--password", + "-p", + "--port", + "--session", + "-s", + "--title", + "--username", + "-u", + ], + droppedOptionPrefixes: [ + "--attach=", + "--command=", + "--dir=", + "--file=", + "-f=", + "--format=", + "--password=", + "-p=", + "--port=", + "--session=", + "-s=", + "--title=", + "--username=", + "-u=", + ], + rejectOptions: [ + "--demo", + "--mini", + "--replay-limit", + ] + ) } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift index 6a1eb33e9121..8ede6607ccc7 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift @@ -236,6 +236,23 @@ public struct AgentResumeArgv: Sendable, Equatable { } } + /// Routes a Codex replay through cmux's logical `codex` wrapper while retaining the + /// exact captured real binary. The wrapper validates the custom path and falls back to + /// PATH when it moved or is no longer executable. + public static func codexWrapperRoutedArgv( + capturedExecutable: String, + arguments: [String] + ) -> [String] { + guard capturedExecutable != "codex" else { + return ["codex"] + arguments + } + return [ + "env", + "CMUX_CUSTOM_CODEX_PATH=\(capturedExecutable)", + "codex", + ] + arguments + } + /// The result of resolving a cmux wrapper launcher (the `claude-teams` / `codex-teams` / `omo` /// style launchers cmux injects), checked before the per-kind verb. public enum LauncherResolution: Sendable, Equatable { @@ -306,12 +323,14 @@ public struct AgentResumeArgv: Sendable, Equatable { /// - observedPermissionMode: the hook-observed Claude permission mode the session last ran /// in, re-applied via ``claudeArgvApplyingObservedPermissionMode(_:observedPermissionMode:)`` /// for user-owned claude restore; ignored for every other kind. + /// - transcriptPath: the recorded session file used by agents whose CLI restores from a file. public func builtInKind( kind: String, sessionId: String, executablePath: String?, arguments: [String], - observedPermissionMode: String? = nil + observedPermissionMode: String? = nil, + transcriptPath: String? = nil ) -> [String]? { switch kind { case "claude": @@ -329,20 +348,23 @@ public struct AgentResumeArgv: Sendable, Equatable { guard let preserved = preservedCodexForkArguments( args: parts.tail, preservePromptTags: false, - stripCmuxHooks: parts.executable == "codex" + stripCmuxHooks: true ) else { return nil } let replayExecutable = codexReplayExecutable( capturedExecutable: parts.executable, launchTail: parts.tail ) - return [replayExecutable, "resume", sessionId] - + codexResumeConfigOverrides(preserved: preserved) + preserved + return Self.codexWrapperRoutedArgv( + capturedExecutable: replayExecutable, + arguments: ["resume", sessionId] + + codexResumeConfigOverrides(preserved: preserved) + preserved + ) case "grok": return withOption("grok", executable: "grok", option: "-r", sessionId: sessionId, executablePath: executablePath, arguments: arguments) case "pi": return withOption("pi", executable: "pi", option: "--session", sessionId: sessionId, executablePath: executablePath, arguments: arguments) case "omp": - return withOption("omp", executable: "omp", option: "--session", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + return withOption("omp", executable: "omp", option: "--resume", sessionId: sessionId, executablePath: executablePath, arguments: arguments) case "campfire": return withOption("campfire", executable: "campfire", option: "--session", sessionId: sessionId, executablePath: executablePath, arguments: arguments) case "amp": @@ -352,7 +374,16 @@ public struct AgentResumeArgv: Sendable, Equatable { case "cursor": return withOption("cursor", executable: "cursor-agent", option: "--resume", sessionId: sessionId, executablePath: executablePath, arguments: arguments) case "gemini": - return withOption("gemini", executable: "gemini", option: "--resume", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + guard let transcriptPath = transcriptPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !transcriptPath.isEmpty else { return nil } + return withOption( + "gemini", + executable: "gemini", + option: "--session-file", + sessionId: transcriptPath, + executablePath: executablePath, + arguments: arguments + ) case "kiro": let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "kiro-cli") guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "kiro", args: parts.tail) else { return nil } @@ -361,6 +392,12 @@ public struct AgentResumeArgv: Sendable, Equatable { return withOption("antigravity", executable: "agy", option: "--conversation", sessionId: sessionId, executablePath: executablePath, arguments: arguments) case "opencode": let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "opencode") + if parts.tail.first == "run" { + guard let preserved = AgentLaunchSanitizer.preservedOpenCodeInteractiveRunArguments( + args: parts.tail + ) else { return nil } + return [parts.executable, "run", "--interactive", "--session", sessionId] + preserved + } guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "opencode", args: parts.tail) else { return nil } return [parts.executable, "--session", sessionId] + preserved case "rovodev": @@ -379,6 +416,8 @@ public struct AgentResumeArgv: Sendable, Equatable { return withOption("factory", executable: "droid", option: "--resume", sessionId: sessionId, executablePath: executablePath, arguments: arguments) case "qoder": return withOption("qoder", executable: "qodercli", option: "--resume", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + case "kimi": + return withOption("kimi", executable: "kimi", option: "--session", sessionId: sessionId, executablePath: executablePath, arguments: arguments) default: return nil } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeLaunchArgumentsPreserver.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeLaunchArgumentsPreserver.swift index c3d490466ccd..9ccd9bb56091 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeLaunchArgumentsPreserver.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeLaunchArgumentsPreserver.swift @@ -13,6 +13,9 @@ struct ClaudeLaunchArgumentsPreserver { args: [String], stripCmuxHookSettings: Bool = true ) -> [String]? { + guard !AgentLaunchSanitizer.containsNonSessionMetadataOption(kind: "claude", args: args) else { + return nil + } var policy = AgentLaunchSanitizer.claudePolicy policy.skipClaudeHookSettings = stripCmuxHookSettings return preserveOptions(args, policy) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CopilotHookConfig.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CopilotHookConfig.swift new file mode 100644 index 000000000000..83cf33ccf7e1 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CopilotHookConfig.swift @@ -0,0 +1,175 @@ +import Foundation + +public struct CopilotHookConfig: Sendable { + public init() {} + + public struct Event: Equatable, Sendable { + public var name: String + public var command: String + public var timeoutSeconds: Int + + public init(name: String, command: String, timeoutSeconds: Int) { + self.name = name + self.command = command + self.timeoutSeconds = timeoutSeconds + } + } + + public struct RemovalResult { + public var data: Data? + public var removedCount: Int + + public init(data: Data?, removedCount: Int) { + self.data = data + self.removedCount = removedCount + } + } + + public enum ConfigError: Error, Equatable { + case invalidJSON + case invalidHooks + case invalidEvent(String) + } + + public func installing( + events: [Event], + in existing: Data?, + isOwnedCommand: (String) -> Bool + ) throws -> Data { + var root = try rootObject(from: existing) + var hooks = try hooksObject(from: root) + _ = try removeOwnedHooks(from: &hooks, isOwnedCommand: isOwnedCommand) + + for event in events { + guard !event.name.isEmpty, !event.command.isEmpty else { continue } + var entries = try eventEntries(named: event.name, in: hooks) + entries.append([ + "type": "command", + "command": event.command, + "timeoutSec": max(event.timeoutSeconds, 1), + ] as [String: Any]) + hooks[event.name] = entries + } + + root["version"] = 1 + root["hooks"] = hooks + return try serialized(root) + } + + public func uninstalling( + from existing: Data, + isOwnedCommand: (String) -> Bool + ) throws -> RemovalResult { + var root = try rootObject(from: existing) + var hooks = try hooksObject(from: root) + let removedCount = try removeOwnedHooks(from: &hooks, isOwnedCommand: isOwnedCommand) + if hooks.isEmpty { + root.removeValue(forKey: "hooks") + } else { + root["hooks"] = hooks + } + + let remainingKeys = Set(root.keys) + let data = remainingKeys.isEmpty || remainingKeys == ["version"] + ? nil + : try serialized(root) + return RemovalResult(data: data, removedCount: removedCount) + } + + /// Removes cmux-owned entries from an older Copilot settings/config file + /// without changing its schema or adding a version field. + public func removingOwnedHooks( + from existing: Data, + isOwnedCommand: (String) -> Bool + ) throws -> RemovalResult { + var root = try rootObject(from: existing) + var hooks = try hooksObject(from: root) + let removedCount = try removeOwnedHooks(from: &hooks, isOwnedCommand: isOwnedCommand) + if hooks.isEmpty { + root.removeValue(forKey: "hooks") + } else { + root["hooks"] = hooks + } + return RemovalResult( + data: root.isEmpty ? nil : try serialized(root), + removedCount: removedCount + ) + } + + private func rootObject(from data: Data?) throws -> [String: Any] { + guard let data, !data.isEmpty else { return [:] } + guard let object = try? JSONSerialization.jsonObject(with: data), + let root = object as? [String: Any] else { + throw ConfigError.invalidJSON + } + return root + } + + private func hooksObject(from root: [String: Any]) throws -> [String: Any] { + guard let rawHooks = root["hooks"] else { return [:] } + guard let hooks = rawHooks as? [String: Any] else { + throw ConfigError.invalidHooks + } + return hooks + } + + private func eventEntries(named name: String, in hooks: [String: Any]) throws -> [[String: Any]] { + guard let rawEntries = hooks[name] else { return [] } + guard let entries = rawEntries as? [[String: Any]] else { + throw ConfigError.invalidEvent(name) + } + return entries + } + + private func removeOwnedHooks( + from hooks: inout [String: Any], + isOwnedCommand: (String) -> Bool + ) throws -> Int { + var removedCount = 0 + for eventName in Array(hooks.keys) { + guard var entries = hooks[eventName] as? [[String: Any]] else { + throw ConfigError.invalidEvent(eventName) + } + var rewrittenEntries: [[String: Any]] = [] + for var entry in entries { + if let command = entry["command"] as? String, isOwnedCommand(command) { + removedCount += 1 + continue + } + if let rawNestedHooks = entry["hooks"] { + guard var nestedHooks = rawNestedHooks as? [[String: Any]] else { + throw ConfigError.invalidEvent(eventName) + } + let before = nestedHooks.count + nestedHooks.removeAll { hook in + guard let command = hook["command"] as? String else { return false } + return isOwnedCommand(command) + } + removedCount += before - nestedHooks.count + if nestedHooks.isEmpty { + continue + } + entry["hooks"] = nestedHooks + } + rewrittenEntries.append(entry) + } + entries = rewrittenEntries + if entries.isEmpty { + hooks.removeValue(forKey: eventName) + } else { + hooks[eventName] = entries + } + } + return removedCount + } + + private func serialized(_ root: [String: Any]) throws -> Data { + guard JSONSerialization.isValidJSONObject(root) else { + throw ConfigError.invalidJSON + } + return try JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys] + ) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamSource.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamSource.swift index c9a9275c2145..239774bc0c14 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamSource.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamSource.swift @@ -7,16 +7,23 @@ import Foundation public enum WorkstreamSource: String, Codable, Sendable, CaseIterable, Equatable { case claude case codex + case grok case pi + case omp + case campfire case amp case cursor case opencode case gemini + case kiro + case antigravity + case rovodev case hermesAgent = "hermes-agent" case copilot case codebuddy case factory case qoder + case kimi /// Parses a wire-frame `_source` string. Unknown sources fall back to /// `nil`; callers should persist the raw string separately when they want diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentForkArgvTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentForkArgvTests.swift index e81ad6bba45e..9b0a533c49dc 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentForkArgvTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentForkArgvTests.swift @@ -19,7 +19,7 @@ struct AgentForkArgvTests { sessionId: "SID", executablePath: "/opt/bin/codex", arguments: ["/opt/bin/codex", "--model", "gpt-5"] - ) == ["/opt/bin/codex", "fork", "SID", "--model", "gpt-5"] + ) == ["env", "CMUX_CUSTOM_CODEX_PATH=/opt/bin/codex", "codex", "fork", "SID", "--model", "gpt-5"] ) #expect( AgentForkArgv().builtInKind( @@ -39,11 +39,59 @@ struct AgentForkArgvTests { ) #expect( AgentForkArgv().builtInKind( - kind: "omp", + kind: "campfire", sessionId: "SID", - executablePath: "/opt/bin/omp", - arguments: ["/opt/bin/omp", "--model", "anthropic/claude-sonnet-4-6"] - ) == ["/opt/bin/omp", "--fork", "SID", "--model", "anthropic/claude-sonnet-4-6"] + executablePath: "/opt/bin/campfire", + arguments: ["/opt/bin/campfire", "--model", "anthropic/claude-sonnet-4-6"] + ) == ["/opt/bin/campfire", "--fork", "SID", "--model", "anthropic/claude-sonnet-4-6"] + ) + #expect( + AgentForkArgv().builtInKind( + kind: "grok", + sessionId: "SID", + executablePath: "/opt/bin/grok", + arguments: ["/opt/bin/grok", "--resume", "OLD", "--fork-session", "--model", "grok-4"] + ) == ["/opt/bin/grok", "--resume", "SID", "--fork-session", "--model", "grok-4"] + ) + #expect( + AgentForkArgv().builtInKind( + kind: "amp", + sessionId: "SID", + executablePath: "/opt/bin/amp", + arguments: ["/opt/bin/amp", "threads", "continue", "OLD", "--mode", "smart"] + ) == nil + ) + #expect( + AgentForkArgv().builtInKind( + kind: "amp", + sessionId: "CHILD", + executablePath: "/opt/bin/amp", + arguments: ["/opt/bin/amp", "threads", "fork", "PARENT", "--mode", "smart"] + ) == nil + ) + #expect( + AgentForkArgv().builtInKind( + kind: "factory", + sessionId: "SID", + executablePath: "/opt/bin/droid", + arguments: ["/opt/bin/droid", "--resume", "OLD", "--settings", "/tmp/settings.json"] + ) == ["/opt/bin/droid", "--fork", "SID", "--settings", "/tmp/settings.json"] + ) + #expect( + AgentForkArgv().builtInKind( + kind: "codebuddy", + sessionId: "SID", + executablePath: "/opt/bin/codebuddy", + arguments: ["/opt/bin/codebuddy", "--resume", "OLD", "--fork-session", "--model", "glm-5"] + ) == ["/opt/bin/codebuddy", "--resume", "SID", "--fork-session", "--model", "glm-5"] + ) + #expect( + AgentForkArgv().builtInKind( + kind: "qoder", + sessionId: "SID", + executablePath: "/opt/bin/qodercli", + arguments: ["/opt/bin/qodercli", "--resume", "OLD", "--fork-session", "--model", "qoder"] + ) == ["/opt/bin/qodercli", "--resume", "SID", "--fork-session", "--model", "qoder"] ) } @@ -75,7 +123,7 @@ struct AgentForkArgvTests { "--model", "gpt-5" ] - ) == ["/opt/bin/codex", "fork", "CHILD", "tag-one", "tag two", "--model", "gpt-5"] + ) == ["env", "CMUX_CUSTOM_CODEX_PATH=/opt/bin/codex", "codex", "fork", "CHILD", "tag-one", "tag two", "--model", "gpt-5"] ) } @@ -98,7 +146,7 @@ struct AgentForkArgvTests { "--model", "gpt-5" ] - ) == ["/opt/bin/codex", "fork", "CHILD", "exec", "review", "help", "fork", "resume", "--model", "gpt-5"] + ) == ["env", "CMUX_CUSTOM_CODEX_PATH=/opt/bin/codex", "codex", "fork", "CHILD", "exec", "review", "help", "fork", "resume", "--model", "gpt-5"] ) } @@ -115,7 +163,7 @@ struct AgentForkArgvTests { "gpt-5", "initial prompt should not replay", ] - ) == ["/opt/bin/codex", "fork", "CHILD", "--model", "gpt-5"] + ) == ["env", "CMUX_CUSTOM_CODEX_PATH=/opt/bin/codex", "codex", "fork", "CHILD", "--model", "gpt-5"] ) } @@ -134,7 +182,7 @@ struct AgentForkArgvTests { "--sandbox", "danger-full-access", ] - ) == ["/opt/bin/codex", "fork", "CHILD", "tag-one", "--sandbox", "danger-full-access"] + ) == ["env", "CMUX_CUSTOM_CODEX_PATH=/opt/bin/codex", "codex", "fork", "CHILD", "tag-one", "--sandbox", "danger-full-access"] ) } @@ -174,13 +222,64 @@ struct AgentForkArgvTests { ) } - @Test("Unsupported agents stay unsupported") + @Test("Agents without a documented fork entrypoint stay unsupported") func unsupportedAgentsStayUnsupported() { #expect( - AgentForkArgv().builtInKind(kind: "grok", sessionId: "SID", executablePath: nil, arguments: ["grok"]) == nil + AgentForkArgv().builtInKind(kind: "gemini", sessionId: "SID", executablePath: nil, arguments: ["gemini"]) == nil + ) + #expect( + AgentForkArgv().builtInKind(kind: "cursor", sessionId: "SID", executablePath: nil, arguments: ["cursor-agent"]) == nil ) #expect( - AgentForkArgv().builtInKind(kind: "amp", sessionId: "SID", executablePath: nil, arguments: ["amp"]) == nil + AgentForkArgv().builtInKind(kind: "omp", sessionId: "SID", executablePath: nil, arguments: ["omp"]) == nil + ) + } + + @Test("OpenCode direct interactive run forks in direct interactive mode") + func opencodeInteractiveRunForksInInteractiveMode() { + #expect( + AgentForkArgv().builtInKind( + kind: "opencode", + sessionId: "PARENT", + executablePath: "/opt/bin/opencode", + arguments: [ + "/opt/bin/opencode", + "run", + "-i", + "--session", "OLD", + "--model", "anthropic/claude-sonnet-4-6", + "--auto", + "do not replay this prompt", + ] + ) == [ + "/opt/bin/opencode", + "run", + "--interactive", + "--session", "PARENT", + "--fork", + "--model", "anthropic/claude-sonnet-4-6", + "--auto", + ] + ) + } + + @Test("One-shot provider launches are not forkable") + func oneShotProviderLaunchesAreNotForkable() { + #expect( + AgentForkArgv().builtInKind( + kind: "rovodev", + sessionId: "SID", + executablePath: nil, + arguments: ["acli", "rovodev", "run", "fix this"] + ) == nil + ) + #expect( + AgentForkArgv().builtInKind( + kind: "kimi", + sessionId: "SID", + executablePath: nil, + arguments: ["kimi", "--quiet", "fix this"] + ) == nil ) } } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchCaptureTrustTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchCaptureTrustTests.swift index 063de8c8b97f..ba2671084dd7 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchCaptureTrustTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchCaptureTrustTests.swift @@ -1,6 +1,22 @@ import Testing @testable import CMUXAgentLaunch +// Keep the existing table-driven call sites compact while exercising the +// constructable production classifier on every assertion. +extension AgentLaunchModeClassifier { + static func processMode( + processName: String?, + arguments: [String]?, + kind: String + ) -> AgentProcessLaunchMode { + AgentLaunchModeClassifier().processMode( + processName: processName, + arguments: arguments, + kind: kind + ) + } +} + @Suite("Agent launch capture trust") struct AgentLaunchCaptureTrustTests { @Test func exactKindMatchIsTrusted() { @@ -14,6 +30,62 @@ struct AgentLaunchCaptureTrustTests { #expect(AgentLaunchCaptureTrust.launcherDescribesKind(" ", kind: "codex")) } + @Test func absentLauncherUsesHookKindToValidateCapture() { + #expect( + AgentLaunchCaptureTrust.capturedArgumentsDescribeKind( + launcher: nil, + executablePath: "/opt/homebrew/bin/codex", + arguments: ["/opt/homebrew/bin/codex", "--yolo"], + kind: "codex" + ) + ) + } + + @Test func customInterpreterEntrypointDescribesKindButKeepsUnknownMode() { + let arguments = ["/usr/local/bin/node", "/opt/local-agent/bin/local-agent.js", "resume"] + #expect(AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: "node", + arguments: arguments, + kind: "local-agent" + )) + #expect(AgentLaunchModeClassifier.processMode( + processName: "node", + arguments: arguments, + kind: "local-agent" + ) == .unknown) + } + + @Test func nativeKindInferencePrefersExactKindsAndUniqueAliases() { + #expect(AgentLaunchCaptureTrust.nativeAgentKind( + processName: "/opt/bin/claude", + arguments: ["/opt/bin/claude", "--print", "fix this"] + ) == "claude") + #expect(AgentLaunchCaptureTrust.nativeAgentKind( + processName: "/usr/bin/node", + arguments: [ + "/usr/bin/node", + "/opt/node_modules/@anthropic-ai/claude-code/cli.js", + "--print", + ] + ) == "claude") + #expect(AgentLaunchCaptureTrust.nativeAgentKind( + processName: "/opt/bin/omp", + arguments: ["/opt/bin/omp", "--print", "fix this"] + ) == "omp") + #expect(AgentLaunchCaptureTrust.nativeAgentKind( + processName: "/opt/bin/droid", + arguments: ["/opt/bin/droid"] + ) == "factory") + #expect(AgentLaunchCaptureTrust.nativeAgentKind( + processName: "/opt/bin/future-agent", + arguments: ["/opt/bin/future-agent", "codex", "exec"] + ) == nil) + #expect(AgentLaunchCaptureTrust.nativeAgentKind( + processName: "/opt/bin/claude", + arguments: ["/opt/bin/codex"] + ) == nil) + } + @Test func wrapperLaunchersDescribeTheirKind() { #expect(AgentLaunchCaptureTrust.launcherDescribesKind("claudeTeams", kind: "claude")) #expect(AgentLaunchCaptureTrust.launcherDescribesKind("codexTeams", kind: "codex")) @@ -91,6 +163,12 @@ struct AgentLaunchCaptureTrustTests { arguments: ["/Users/alice/.local/bin/campfire", "--session", "session"] ) ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "hermes", + arguments: ["/Users/alice/.local/bin/hermes", "chat"] + ) + ) #expect( AgentLaunchCaptureTrust.nativeProcessDescribesKind( processName: "bun", @@ -124,6 +202,30 @@ struct AgentLaunchCaptureTrustTests { arguments: ["ts-node", "/Users/alice/campfire/packages/session/bin/campfire.ts"] ) ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "node", + arguments: ["node", "/opt/homebrew/lib/node_modules/opencode-ai/bin/opencode.js"] + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "bun", + arguments: ["bun", "/Users/alice/.bun/install/global/node_modules/opencode-ai/bin/cli.js"] + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessIsAmbiguousInterpreterHost( + processName: "deno", + arguments: ["deno", "run", "/Users/alice/future-agent/src/cli.ts"] + ) + ) + #expect( + !AgentLaunchCaptureTrust.nativeProcessIsAmbiguousInterpreterHost( + processName: "node", + arguments: ["node", "/opt/homebrew/lib/node_modules/opencode-ai/bin/opencode.js"] + ) + ) #expect( AgentLaunchCaptureTrust.nativeProcessDescribesKind( processName: "acme-agent", @@ -155,5 +257,716 @@ struct AgentLaunchCaptureTrustTests { kind: "antigravity" ) ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: "kimi", + arguments: ["/Users/alice/.local/bin/kimi"], + kind: "kimi" + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "kimi", + arguments: ["/Users/alice/.local/bin/kimi"] + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "kimi-cli", + arguments: ["/Users/alice/.local/bin/kimi-cli"] + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "kimi-code", + arguments: ["/Users/alice/.local/bin/kimi-code"] + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "github-copilot-cli", + arguments: ["/Users/alice/.local/bin/github-copilot-cli"] + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "opencode-ai", + arguments: ["/Users/alice/.local/bin/opencode-ai"] + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "open-code", + arguments: ["/Users/alice/.local/bin/open-code"] + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "amp", + arguments: ["/Users/alice/.local/bin/amp"] + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "acli", + arguments: ["/Users/alice/.local/bin/acli", "rovodev"] + ) + ) + } + + @Test func thinSameAgentLaunchersAreNotSessionAncestors() { + #expect( + AgentLaunchCaptureTrust.nativeProcessIsSameAgentLauncherRelay( + parentProcessName: "node", + parentArguments: ["node", "/Users/alice/.bun/bin/codex"], + childProcessName: "codex", + childArguments: ["/Users/alice/.bun/lib/node_modules/@openai/codex/vendor/codex", "--yolo"], + kind: "codex" + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessIsSameAgentLauncherRelay( + parentProcessName: "node", + parentArguments: [ + "node", + "/Users/alice/.bun/bin/gemini", + "--yolo", + "--model", + "gemini-3.1-pro-preview", + ], + childProcessName: "node", + childArguments: [ + "/Users/alice/.hermes/node/bin/node", + "--max-old-space-size=65536", + "/Users/alice/.bun/bin/gemini", + "--yolo", + "--model", + "gemini-3.1-pro-preview", + ], + kind: "gemini" + ) + ) + #expect( + !AgentLaunchCaptureTrust.nativeProcessIsSameAgentLauncherRelay( + parentProcessName: "node", + parentArguments: [ + "node", + "--use-system-ca", + "/Users/alice/.npm/lib/node_modules/@anthropic-ai/claude-code/cli.js", + "--model", "sonnet", + ], + childProcessName: "claude", + childArguments: [ + "/Users/alice/.local/share/claude/versions/2.1.214", + "--model", "sonnet", + ], + kind: "claude" + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessIsSameAgentLauncherRelay( + parentProcessName: "node", + parentArguments: [ + "node", "--use-system-ca", "/Users/alice/.bun/bin/codex", + "--yolo", + ], + childProcessName: "codex", + childArguments: [ + "/Users/alice/.bun/lib/node_modules/@openai/codex/vendor/codex", + "--yolo", + ], + kind: "codex" + ) + ) + #expect( + !AgentLaunchCaptureTrust.nativeProcessIsSameAgentLauncherRelay( + parentProcessName: "codex", + parentArguments: ["/Users/alice/.local/bin/codex", "--yolo"], + childProcessName: "codex", + childArguments: ["/Users/alice/.local/bin/codex", "resume", "child-session"], + kind: "codex" + ) + ) + } + + @Test func exactLauncherRejectsTruncatedInterpreterCapture() { + #expect( + !AgentLaunchCaptureTrust.capturedArgumentsDescribeKind( + launcher: "gemini", + executablePath: "/Users/alice/.hermes/node/bin/node", + arguments: [ + "/Users/alice/.hermes/node/bin/node", + "--max-old-space-size=65536", + ], + kind: "gemini" + ) + ) + #expect( + AgentLaunchCaptureTrust.capturedArgumentsDescribeKind( + launcher: "gemini", + executablePath: "/Users/alice/.hermes/node/bin/node", + arguments: [ + "/Users/alice/.hermes/node/bin/node", + "--max-old-space-size=65536", + "/Users/alice/.bun/bin/gemini", + ], + kind: "gemini" + ) + ) + } + + @Test func interpreterTrustUsesOnlyTheActualScriptEntrypoint() { + let misleadingArguments = [ + "node", + "/tmp/unrelated-tool.js", + "/tmp/codex", + "--print", + ] + #expect( + !AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: "node", + arguments: misleadingArguments, + kind: "codex" + ) + ) + #expect( + !AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "node", + arguments: misleadingArguments + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeAgentLaunchArguments( + processName: "node", + arguments: misleadingArguments, + kind: "codex" + ) == nil + ) + + let trustedArguments = [ + "node", + "--use-system-ca", + "/Users/alice/.npm/lib/node_modules/@anthropic-ai/claude-code/cli.js", + "--print", + "fix this", + ] + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: "node", + arguments: trustedArguments, + kind: "claude" + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeAgentLaunchArguments( + processName: "node", + arguments: trustedArguments, + kind: "claude" + ) == ["--print", "fix this"] + ) + + let preloadedArguments = [ + "node", + "--require", "/tmp/codex.js", + "/Users/alice/.npm/lib/node_modules/@anthropic-ai/claude-code/cli.js", + "--print", + ] + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: "node", + arguments: preloadedArguments, + kind: "claude" + ) + ) + #expect( + !AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: "node", + arguments: preloadedArguments, + kind: "codex" + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeAgentLaunchArguments( + processName: "node", + arguments: preloadedArguments, + kind: "claude" + ) == ["--print"] + ) + } + + @Test func liveProcessModeSeparatesOneShotInteractiveAndUnknownLaunches() { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "codex", + arguments: ["/opt/homebrew/bin/codex", "exec", "fix this"], + kind: "codex" + ) == .oneShot + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "codex", + arguments: ["/opt/homebrew/bin/codex", "--model", "o3"], + kind: "codex" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "opencode", + arguments: ["opencode", "run", "--interactive", "fix this"], + kind: "opencode" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "codex", + arguments: ["/opt/homebrew/bin/codex", "--future-launch-mode"], + kind: "codex" + ) == .unknown + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "sleep", + arguments: ["/bin/sleep", "30"], + kind: "codex" + ) == .unknown + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "acme-agent", + arguments: ["/usr/local/bin/acme-agent", "--print", "fix this"], + kind: "acme-agent" + ) == .unknown + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "opencode", + arguments: ["opencode", "pr", "123"], + kind: "opencode" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "claude", + arguments: ["claude", "--background", "fix this"], + kind: "claude" + ) == .unknown + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "hermes", + arguments: ["hermes", "-q", "fix this"], + kind: "hermes-agent" + ) == .unknown + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "hermes", + arguments: ["hermes", "chat", "-q", "fix this"], + kind: "hermes-agent" + ) == .oneShot + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "kimi", + arguments: ["kimi", "--quiet", "fix this"], + kind: "kimi" + ) == .oneShot + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "pi", + arguments: ["pi", "--no-session"], + kind: "pi" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "kimi", + arguments: ["kimi", "--prompt", "fix this"], + kind: "kimi" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "kiro-cli", + arguments: ["kiro-cli", "chat", "--no-interactive", "fix this"], + kind: "kiro" + ) == .oneShot + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "kiro-cli", + arguments: ["kiro-cli", "doctor", "--no-interactive"], + kind: "kiro" + ) == .unknown + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "acli", + arguments: ["acli", "rovodev", "run"], + kind: "rovodev" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "acli", + arguments: ["acli", "rovodev", "run", "fix this"], + kind: "rovodev" + ) == .oneShot + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "acli", + arguments: ["acli", "rovodev", "run", "--prompt-interactive", "fix this"], + kind: "rovodev" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "acli", + arguments: ["acli", "rovodev", "config"], + kind: "rovodev" + ) == .unknown + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "droid", + arguments: [ + "droid", "exec", + "--input-format", "stream-jsonrpc", + "--output-format", "stream-jsonrpc", + ], + kind: "factory" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "codex", + arguments: ["codex", "--future-launch-mode", "exec", "fix this"], + kind: "codex" + ) == .unknown + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "claude", + arguments: ["claude", "--background", "--print", "fix this"], + kind: "claude" + ) == .unknown + ) + } + + @Test func codexUtilityCommandsAreNonSessionAndNeverRestorable() { + let utilityCommands = [ + "plugin", + "remote-control", + "archive", + "delete", + "unarchive", + "update", + "doctor", + ] + + for command in utilityCommands { + let arguments = ["codex", command] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "codex", + arguments: arguments, + kind: "codex" + ) == .nonSession, + "codex \(command)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, + launcher: "codex", + fallbackKind: "codex" + ) == nil, + "codex \(command)" + ) + } + + for command in ["app-server", "mcp-server", "exec-server"] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "codex", + arguments: ["codex", command], + kind: "codex" + ) == .interactive, + "codex \(command)" + ) + } + } + + @Test func grokUtilityCommandsAreNonSessionAndNeverRestorable() { + for command in [ + "completions", + "dashboard", + "export", + "logout", + "plugin", + "wrap", + ] { + let arguments = ["grok", command] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "grok", + arguments: arguments, + kind: "grok" + ) == .nonSession, + "grok \(command)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, + launcher: "grok", + fallbackKind: "grok" + ) == nil, + "grok \(command)" + ) + } + } + + @Test func commonOneShotFlagsDoNotHideTerminalLaunches() { + let oneShotLaunches: [(kind: String, executable: String, arguments: [String])] = [ + ("kimi", "kimi", ["--print", "fix this", "--yolo"]), + ("gemini", "gemini", ["-p", "fix this", "--yolo"]), + ("grok", "grok", ["--single", "fix this", "--always-approve"]), + ("pi", "pi", ["-p", "fix this", "--verbose"]), + ("cursor", "cursor-agent", ["-p", "fix this", "--auto-review"]), + ("amp", "amp", ["-x", "fix this", "--no-archive-after-execute"]), + ("amp", "amp", ["-x", "fix this", "--plugin-ready-timeout", "30"]), + ] + for launch in oneShotLaunches { + #expect( + AgentLaunchModeClassifier.processMode( + processName: launch.executable, + arguments: [launch.executable] + launch.arguments, + kind: launch.kind + ) == .oneShot, + "\(launch.kind) \(launch.arguments)" + ) + } + + #expect( + AgentLaunchModeClassifier.processMode( + processName: "pi", + arguments: ["pi", "-p", "fix this", "--future-output-mode"], + kind: "pi" + ) == .unknown + ) + } + + @Test func openCodeRunUsesRunOptionContractsInBothModes() { + let runValues = [ + "--format", "json", + "--command", "build", + "--share", + "--pure", + ] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "opencode", + arguments: ["opencode", "run", "fix this"] + runValues, + kind: "opencode" + ) == .oneShot + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "opencode", + arguments: ["opencode", "run", "--interactive", "fix this"] + runValues, + kind: "opencode" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "opencode", + arguments: ["opencode", "run", "fix this", "--future-run-mode"], + kind: "opencode" + ) == .unknown + ) + } + + @Test func interpreterHostedLaunchRestorabilityStartsAfterTheAgentEntrypoint() { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "node", + arguments: [ + "node", + "--use-system-ca", + "/Users/alice/.npm/lib/node_modules/@anthropic-ai/claude-code/cli.js", + "--print", + "fix this", + ], + kind: "claude" + ) == .oneShot + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "bun", + arguments: [ + "bun", + "/Users/alice/.bun/install/global/node_modules/opencode-ai/bin/cli.js", + "run", + "fix this", + ], + kind: "opencode" + ) == .oneShot + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "deno", + arguments: [ + "deno", + "run", + "-A", + "/Users/alice/campfire/packages/session/bin/campfire.ts", + "--print", + "fix this", + ], + kind: "campfire" + ) == .oneShot + ) + } + + @Test func longLivedProtocolModesOverrideOneShotLookingArguments() { + let launches: [(kind: String, executable: String, arguments: [String])] = [ + ("claude", "claude", ["--print", "--input-format", "stream-json", "--output-format", "stream-json"]), + ("pi", "pi", ["--mode", "rpc", "--print", "fix this"]), + ("omp", "omp", ["--mode=rpc-ui", "--print", "fix this"]), + ("omp", "omp", ["acp", "--print", "fix this"]), + ("campfire", "campfire", ["--mode", "rpc", "--print", "fix this"]), + ("kimi", "kimi", ["--acp", "--print", "fix this"]), + ("kimi", "kimi", ["acp"]), + ("hermes-agent", "hermes", ["acp", "--oneshot", "fix this"]), + ("hermes-agent", "hermes", ["gateway", "run"]), + ("grok", "grok", ["agent", "stdio", "--single", "fix this"]), + ("grok", "grok", ["agent", "serve"]), + ("grok", "grok", ["agent", "leader"]), + ("opencode", "opencode", ["acp"]), + ("opencode", "opencode", ["serve"]), + ("opencode", "opencode", ["web"]), + ("qoder", "qodercli", ["--acp", "--print", "fix this"]), + ("qoder", "qodercli", ["--input-format", "stream-json", "--print", "fix this"]), + ("codex", "codex", ["app-server"]), + ("codex", "codex", ["mcp-server"]), + ("codex", "codex", ["exec-server"]), + ] + + for launch in launches { + #expect( + AgentLaunchModeClassifier.processMode( + processName: launch.executable, + arguments: [launch.executable] + launch.arguments, + kind: launch.kind + ) != .oneShot, + "\(launch.kind) \(launch.arguments) was classified as terminal" + ) + } + } + + @Test func commandContractsFailClosedAroundUnknownOptionsAndPromptBoundaries() { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "codex", + arguments: ["codex", "exec", "--future-output", "fix this"], + kind: "codex" + ) == .oneShot + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "opencode", + arguments: ["opencode", "run", "--future-protocol", "fix this"], + kind: "opencode" + ) == .unknown + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "droid", + arguments: ["droid", "exec", "--future-protocol", "fix this"], + kind: "factory" + ) == .unknown + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "claude", + arguments: ["claude", "--", "--print"], + kind: "claude" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "kiro-cli", + arguments: ["kiro-cli", "chat", "--", "--no-interactive"], + kind: "kiro" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "claude", + arguments: ["claude", "--print", "--input-format=stream-json"], + kind: "claude" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "pi", + arguments: ["pi", "--print", "--mode=rpc"], + kind: "pi" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "droid", + arguments: [ + "droid", "exec", + "--input-format=stream-jsonrpc", + "--output-format=stream-jsonrpc", + ], + kind: "factory" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "opencode", + arguments: [ + "opencode", "run", "fix this", + "--interactive", "--future-launch-mode", + ], + kind: "opencode" + ) == .unknown + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "claude", + arguments: [ + "claude", "--input-format", "stream-json", + "--future-protocol-option", + ], + kind: "claude" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "acli", + arguments: [ + "acli", "rovodev", "run", + "--prompt-interactive", "fix this", + "--future-launch-mode", + ], + kind: "rovodev" + ) == .unknown + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "acli", + arguments: [ + "acli", "rovodev", "run", + "--prompt-interactive", "fix this", + "--prompt", "one shot", + ], + kind: "rovodev" + ) == .unknown + ) } } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift index 4087d6f5e52f..3fb35c97832f 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift @@ -1,8 +1,26 @@ import CMUXAgentLaunch +import Foundation import Testing @Suite("AgentLaunchEnvironmentPolicy") struct AgentLaunchEnvironmentPolicyTests { + @Test("Drops app-issued hibernation resume authority") + func dropsHibernationResumeAuthority() { + let policy = AgentLaunchEnvironmentPolicy() + let environment = [ + AgentHibernationResumeEvidence.environmentKey: UUID().uuidString, + "CODEX_HOME": "/tmp/codex-home", + ] + + let selected = policy.selectedEnvironment(from: environment, kind: "codex") + + #expect(selected == ["CODEX_HOME": "/tmp/codex-home"]) + #expect(policy.sanitizedValue( + key: AgentHibernationResumeEvidence.environmentKey, + value: environment[AgentHibernationResumeEvidence.environmentKey] + ) == nil) + } + @Test("Preserves OMP config roots without persisting secrets") func preservesOmpConfigRootsWithoutPersistingSecrets() { let selected = AgentLaunchEnvironmentPolicy().selectedEnvironment( diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchReplayPlannerTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchReplayPlannerTests.swift new file mode 100644 index 000000000000..9eed817d7105 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchReplayPlannerTests.swift @@ -0,0 +1,135 @@ +import Testing +@testable import CMUXAgentLaunch + +@Suite("Agent launch replay planning") +struct AgentLaunchReplayPlannerTests { + private let planner = AgentLaunchReplayPlanner() + + @Test func nativeGeminiCaptureThatLosesScriptIdentityUsesCanonicalReplay() { + #expect( + planner.plan( + kind: "gemini", + launcher: "gemini", + executablePath: "/Users/alice/.hermes/node/bin/node", + capturedArguments: [ + "/Users/alice/.hermes/node/bin/node", + "--max-old-space-size=65536", + "/Users/alice/.bun/bin/gemini", + "--yolo", + ], + sanitizedArguments: [ + "/Users/alice/.hermes/node/bin/node", + "--max-old-space-size=65536", + ], + evidence: .nativeProcess, + hasSelectedEnvironment: false + ) == .canonical + ) + } + + @Test func validNativeCaptureKeepsSanitizedArguments() { + let sanitized = ["/opt/homebrew/bin/codex", "--yolo"] + #expect( + planner.plan( + kind: "codex", + launcher: "codex", + executablePath: "/opt/homebrew/bin/codex", + capturedArguments: ["/opt/homebrew/bin/codex", "--yolo", "prompt"], + sanitizedArguments: sanitized, + evidence: .nativeProcess, + hasSelectedEnvironment: false + ) == .captured(arguments: sanitized, evidence: .nativeProcess) + ) + } + + @Test func wrapperCaptureKeepsWrapperArguments() { + let sanitized = ["cmux", "claude-teams", "--permission-mode", "bypassPermissions"] + #expect( + planner.plan( + kind: "claude", + launcher: "claudeTeams", + executablePath: "cmux", + capturedArguments: sanitized, + sanitizedArguments: sanitized, + evidence: .wrapperEnvironmentLauncher, + hasSelectedEnvironment: false + ) == .captured(arguments: sanitized, evidence: .wrapperEnvironmentLauncher) + ) + } + + @Test func explicitlyNonRestorableCaptureStaysRejected() { + #expect( + planner.plan( + kind: "opencode", + launcher: "omx", + executablePath: "cmux", + capturedArguments: ["cmux", "omx"], + sanitizedArguments: nil, + evidence: .wrapperEnvironmentLauncher, + hasSelectedEnvironment: false + ) == .rejected + ) + } + + @Test func unsupportedMissingCaptureStaysUnavailable() { + #expect( + planner.plan( + kind: "gemini", + launcher: "gemini", + executablePath: nil, + capturedArguments: nil, + sanitizedArguments: nil, + evidence: .unavailable, + hasSelectedEnvironment: false + ) == .unavailable + ) + } + + @Test func codexRetainsHistoricalCanonicalFallbackWithoutCapture() { + #expect( + planner.plan( + kind: "codex", + launcher: "codex", + executablePath: nil, + capturedArguments: nil, + sanitizedArguments: nil, + evidence: .unavailable, + hasSelectedEnvironment: false + ) == .canonical + ) + } + + @Test func exactEnvironmentMarkerCanRecoverFromTruncatedCapture() { + #expect( + planner.plan( + kind: "gemini", + launcher: "gemini", + executablePath: "/Users/alice/.hermes/node/bin/node", + capturedArguments: [ + "/Users/alice/.hermes/node/bin/node", + "--max-old-space-size=65536", + ], + sanitizedArguments: [ + "/Users/alice/.hermes/node/bin/node", + "--max-old-space-size=65536", + ], + evidence: .exactEnvironmentLauncher, + hasSelectedEnvironment: false + ) == .canonical + ) + } + + @Test func selectedEnvironmentPreservesFallbackWhenArgumentsAreUnavailable() { + #expect( + planner.plan( + kind: "claude", + launcher: "claude", + executablePath: nil, + capturedArguments: nil, + sanitizedArguments: nil, + evidence: .unavailable, + hasSelectedEnvironment: true + ) == .canonical + ) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift index 25676dcedd6e..6af79ee78550 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift @@ -489,11 +489,11 @@ struct AgentLaunchSanitizerTests { ) } - @Test("Drops Hermes worktree value before preserving later options") - func dropsHermesWorktreeValueBeforePreservingLaterOptions() { + @Test("Drops Hermes worktree boolean without swallowing later options") + func dropsHermesWorktreeBooleanWithoutSwallowingLaterOptions() { #expect( AgentLaunchSanitizer.sanitizedLaunchArguments( - ["hermes", "--worktree", "/tmp/repo", "--model", "gpt-5.4"], + ["hermes", "--worktree", "--model", "gpt-5.4"], launcher: "hermes-agent", fallbackKind: "hermes-agent" ) == ["hermes", "--model", "gpt-5.4"] diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift index 68bb111db706..441c8b1687fd 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift @@ -7,15 +7,15 @@ struct AgentResumeArgvTests { ("claude", "claude", ["claude", "--resume", "SID"]), ("grok", "grok", ["grok", "-r", "SID"]), ("pi", "pi", ["pi", "--session", "SID"]), - ("omp", "omp", ["omp", "--session", "SID"]), + ("omp", "omp", ["omp", "--resume", "SID"]), ("campfire", "campfire", ["campfire", "--session", "SID"]), ("cursor", "cursor-agent", ["cursor-agent", "--resume", "SID"]), - ("gemini", "gemini", ["gemini", "--resume", "SID"]), ("antigravity", "agy", ["agy", "--conversation", "SID"]), ("copilot", "copilot", ["copilot", "--resume", "SID"]), ("codebuddy", "codebuddy", ["codebuddy", "--resume", "SID"]), ("factory", "droid", ["droid", "--resume", "SID"]), ("qoder", "qodercli", ["qodercli", "--resume", "SID"]), + ("kimi", "kimi", ["kimi", "--session", "SID"]), ]) func builtInWithOptionKinds(kind: String, executable: String, expected: [String]) { #expect( @@ -25,6 +25,89 @@ struct AgentResumeArgvTests { ) } + @Test("Gemini resumes from its recorded session file") + func geminiUsesSessionFile() { + #expect( + AgentResumeArgv().builtInKind( + kind: "gemini", + sessionId: "5839bed1-0a60-4c05-b6d1-2410d7a3741e", + executablePath: nil, + arguments: [ + "gemini", + "--session-file", "/tmp/previous.jsonl", + "--model", "gemini-2.5-pro", + ], + transcriptPath: "/tmp/session-2026-07-18T04-52-5839bed1.jsonl" + ) == [ + "gemini", "--session-file", "/tmp/session-2026-07-18T04-52-5839bed1.jsonl", + "--model", "gemini-2.5-pro", + ] + ) + #expect( + AgentResumeArgv().builtInKind( + kind: "gemini", + sessionId: "5839bed1-0a60-4c05-b6d1-2410d7a3741e", + executablePath: nil, + arguments: ["gemini"] + ) == nil + ) + } + + @Test("Kimi resume removes initial prompts and stale interactive sessions") + func kimiResumeSanitizesInteractiveArguments() { + #expect( + AgentResumeArgv().builtInKind( + kind: "kimi", + sessionId: "SID", + executablePath: nil, + arguments: [ + "kimi", + "--session", "OLD", + "--model", "kimi-k2", + "--yolo", + "--prompt", "do not replay this prompt", + ] + ) == ["kimi", "--session", "SID", "--model", "kimi-k2", "--yolo"] + ) + #expect( + AgentResumeArgv().builtInKind( + kind: "kimi", + sessionId: "SID", + executablePath: nil, + arguments: [ + "kimi", + "--session", "OLD", + "--model", "kimi-k2", + "--yolo", + ] + ) == ["kimi", "--session", "SID", "--model", "kimi-k2", "--yolo"] + ) + } + + @Test("Kimi resume drops inline configuration secrets but keeps configuration files") + func kimiResumeDropsInlineConfiguration() { + #expect( + AgentResumeArgv().builtInKind( + kind: "kimi", + sessionId: "SID", + executablePath: nil, + arguments: [ + "kimi", + "--config", #"model.api_key = "inline-secret""#, + "--mcp-config", #"{"mcpServers":{"private":{"env":{"TOKEN":"inline-secret"}}}}"#, + "--config-file", "/tmp/kimi.toml", + "--mcp-config-file", "/tmp/mcp.json", + "--model", "kimi-k2", + ] + ) == [ + "kimi", "--session", "SID", + "--config-file", "/tmp/kimi.toml", + "--mcp-config-file", "/tmp/mcp.json", + "--model", "kimi-k2", + ] + ) + } + @Test("Built-in special-shaped kinds") func builtInSpecialShapes() { #expect( @@ -109,16 +192,207 @@ struct AgentResumeArgvTests { ) } - @Test("Captured executable path overrides the fallback executable") + @Test("OpenCode direct interactive run resumes in direct interactive mode") + func opencodeInteractiveRunResumesInInteractiveMode() { + #expect( + AgentResumeArgv().builtInKind( + kind: "opencode", + sessionId: "SID", + executablePath: "/opt/bin/opencode", + arguments: [ + "/opt/bin/opencode", + "run", + "--interactive", + "--session", "OLD", + "--model", "anthropic/claude-sonnet-4-6", + "--auto", + "do not replay this prompt", + ] + ) == [ + "/opt/bin/opencode", + "run", + "--interactive", + "--session", "SID", + "--model", "anthropic/claude-sonnet-4-6", + "--auto", + ] + ) + } + + @Test("One-shot provider launches do not manufacture resume commands") + func oneShotProviderLaunchesDoNotResume() { + #expect( + AgentResumeArgv().builtInKind( + kind: "rovodev", + sessionId: "SID", + executablePath: nil, + arguments: ["acli", "rovodev", "run", "fix this"] + ) == nil + ) + #expect( + AgentResumeArgv().builtInKind( + kind: "kimi", + sessionId: "SID", + executablePath: nil, + arguments: ["kimi", "--quiet", "fix this"] + ) == nil + ) + } + + @Test("Captured Codex executable is preserved through the wrapper") func executablePathOverridesFallback() { - // Non-claude kinds replay the captured executable path verbatim. #expect( AgentResumeArgv().builtInKind( kind: "codex", sessionId: "SID", executablePath: "/opt/bin/codex", arguments: ["/opt/bin/codex"] - ) == ["/opt/bin/codex", "resume", "SID", "-c", "check_for_update_on_startup=false"] + ) == ["env", "CMUX_CUSTOM_CODEX_PATH=/opt/bin/codex", "codex", "resume", "SID", "-c", "check_for_update_on_startup=false"] + ) + } + + @Test("Captured Codex executable routes resume and fork through the wrapper") + func capturedCodexExecutableRoutesThroughWrapper() throws { + let executable = "/opt/company/Codex Builds/codex" + let wrapperPrefix = [ + "env", + "CMUX_CUSTOM_CODEX_PATH=\(executable)", + "codex", + ] + + let resume = try #require(AgentResumeArgv().builtInKind( + kind: "codex", + sessionId: "SID", + executablePath: executable, + arguments: [executable, "--model", "gpt-5.4"] + )) + #expect( + resume == wrapperPrefix + + ["resume", "SID", "-c", "check_for_update_on_startup=false", "--model", "gpt-5.4"] + ) + + let fork = try #require(AgentForkArgv().builtInKind( + kind: "codex", + sessionId: "SID", + executablePath: executable, + arguments: [executable, "--model", "gpt-5.4"] + )) + #expect(fork == wrapperPrefix + ["fork", "SID", "--model", "gpt-5.4"]) + } + + @Test("Pi-family replay replaces selectors and preserves provider-specific values") + func piFamilyReplayUsesCurrentOptionWidths() throws { + let piArguments = [ + "pi", + "--session-id", "OLD", + "--name", "refactor auth", + "--model", "anthropic/claude-sonnet-4-6", + ] + #expect( + AgentResumeArgv().builtInKind( + kind: "pi", sessionId: "SID", executablePath: nil, arguments: piArguments + ) == [ + "pi", "--session", "SID", + "--name", "refactor auth", + "--model", "anthropic/claude-sonnet-4-6", + ] + ) + #expect( + AgentForkArgv().builtInKind( + kind: "pi", sessionId: "SID", executablePath: nil, arguments: piArguments + ) == [ + "pi", "--fork", "SID", + "--name", "refactor auth", + "--model", "anthropic/claude-sonnet-4-6", + ] + ) + + let ompValues = [ + "--profile", "work", + "--smol", "haiku", + "--slow", "opus", + "--plan", "sonnet", + "--max-time", "300", + "--approval-mode", "write", + ] + #expect( + AgentResumeArgv().builtInKind( + kind: "omp", sessionId: "SID", executablePath: nil, arguments: ["omp"] + ompValues + ) == ["omp", "--resume", "SID"] + ompValues + ) + #expect( + AgentForkArgv().builtInKind( + kind: "omp", sessionId: "SID", executablePath: nil, arguments: ["omp"] + ompValues + ) == nil + ) + + let campfireArguments = [ + "campfire", + "--session-id", "OLD", + "--model", "anthropic/claude-sonnet-4-6", + ] + #expect( + AgentResumeArgv().builtInKind( + kind: "campfire", sessionId: "SID", executablePath: nil, arguments: campfireArguments + ) == ["campfire", "--session", "SID", "--model", "anthropic/claude-sonnet-4-6"] + ) + #expect( + AgentForkArgv().builtInKind( + kind: "campfire", sessionId: "SID", executablePath: nil, arguments: campfireArguments + ) == ["campfire", "--fork", "SID", "--model", "anthropic/claude-sonnet-4-6"] + ) + } + + @Test("Pi-family export and list modes are never replayable") + func piFamilyUtilityModesAreNotReplayable() { + for kind in ["pi", "omp", "campfire"] { + #expect( + AgentResumeArgv().builtInKind( + kind: kind, + sessionId: "SID", + executablePath: nil, + arguments: [kind, "--export", "/tmp/session.html"] + ) == nil, + "\(kind) export" + ) + #expect( + AgentForkArgv().builtInKind( + kind: kind, + sessionId: "SID", + executablePath: nil, + arguments: [kind, "list"] + ) == nil, + "\(kind) list" + ) + } + } + + @Test("Grok replay preserves value widths and drops worktree selectors") + func grokReplayUsesCurrentOptionWidths() { + let arguments = [ + "grok", + "--debug-file", "/tmp/grok debug.log", + "--json-schema", #"{"type":"object"}"#, + "--leader-socket", "/tmp/grok leader.sock", + "--worktree", "feature-old", + "--worktree-ref", "main", + "--model", "grok-code-fast-1", + ] + let preserved = [ + "--debug-file", "/tmp/grok debug.log", + "--json-schema", #"{"type":"object"}"#, + "--leader-socket", "/tmp/grok leader.sock", + "--model", "grok-code-fast-1", + ] + #expect( + AgentResumeArgv().builtInKind( + kind: "grok", sessionId: "SID", executablePath: nil, arguments: arguments + ) == ["grok", "-r", "SID"] + preserved + ) + #expect( + AgentForkArgv().builtInKind( + kind: "grok", sessionId: "SID", executablePath: nil, arguments: arguments + ) == ["grok", "--resume", "SID", "--fork-session"] + preserved ) } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeBooleanLaunchFlagTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeBooleanLaunchFlagTests.swift index fa7e00cd773e..f76a8d17f9b8 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeBooleanLaunchFlagTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeBooleanLaunchFlagTests.swift @@ -91,10 +91,10 @@ struct ClaudeBooleanLaunchFlagTests { ) } - @Test("Teams prompt payload never promotes a flag-shaped token to an option") - func teamsPromptPayloadNeverPromotesFlagShapedToken() { + @Test("Teams bare tmux exposes an adjacent permission option") + func teamsBareTmuxExposesAdjacentPermissionOption() { #expect( - !AgentLaunchSanitizer.claudeTeamsLaunchHasOption( + AgentLaunchSanitizer.claudeTeamsLaunchHasOption( "--dangerously-skip-permissions", args: ["--tmux", "--dangerously-skip-permissions"] ) diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexForkSanitizerTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexForkSanitizerTests.swift index 96680a70e47e..4d99e38df608 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexForkSanitizerTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexForkSanitizerTests.swift @@ -150,7 +150,7 @@ struct CodexForkSanitizerTests { sessionId: "CHILD", executablePath: "/opt/bin/codex", arguments: capturedArguments - ) == ["/opt/bin/codex", "fork", "CHILD", "tag-one", "--sandbox", "danger-full-access"] + ) == ["env", "CMUX_CUSTOM_CODEX_PATH=/opt/bin/codex", "codex", "fork", "CHILD", "tag-one", "--sandbox", "danger-full-access"] ) } @@ -164,7 +164,7 @@ struct CodexForkSanitizerTests { sessionId: "CHILD", executablePath: "/opt/bin/codex", arguments: capturedArguments - ) == ["/opt/bin/codex", "resume", "CHILD", "-c", "check_for_update_on_startup=false", "--sandbox", "danger-full-access"] + ) == ["env", "CMUX_CUSTOM_CODEX_PATH=/opt/bin/codex", "codex", "resume", "CHILD", "-c", "check_for_update_on_startup=false", "--sandbox", "danger-full-access"] ) } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookInjectionStrippingTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookInjectionStrippingTests.swift index cfeb05c2b123..c91896794ad1 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookInjectionStrippingTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookInjectionStrippingTests.swift @@ -125,35 +125,65 @@ struct CodexHookInjectionStrippingTests { ) } - @Test("Codex resume preservation keeps hooks with captured executable") - func codexResumePreservationKeepsHooksWithCapturedExecutable() throws { + @Test("Codex resume through captured executable refreshes cmux hooks") + func codexResumeThroughCapturedExecutableRefreshesCmuxHooks() throws { let resume = try #require(AgentResumeArgv().builtInKind( kind: "codex", sessionId: "019dad34-d218-7943-b81a-eddac5c87951", executablePath: codexExecutable, arguments: realisticCodexHookArgv() )) - #expect(resume.contains("--dangerously-bypass-hook-trust")) - #expect(resume.contains("--enable")) - #expect(resume.contains("hooks")) - #expect(resume.contains { $0.contains("cmux-codex-hook") }) - #expect(resume.first == codexExecutable) + #expect(Array(resume.prefix(3)) == ["env", "CMUX_CUSTOM_CODEX_PATH=\(codexExecutable)", "codex"]) + #expect(!resume.contains("--dangerously-bypass-hook-trust")) + #expect(!resume.contains { $0.contains("cmux-codex-hook") }) #expect(resume.contains("--dangerously-bypass-approvals-and-sandbox")) #expect(resume.contains("gpt-5.5")) #expect(resume.contains("model_reasoning_effort=xhigh")) } - @Test("Codex fork preservation keeps hooks with captured executable") - func codexForkPreservationKeepsHooksWithCapturedExecutable() throws { + @Test("Codex fork through captured executable refreshes cmux hooks") + func codexForkThroughCapturedExecutableRefreshesCmuxHooks() throws { let fork = try #require(AgentForkArgv().builtInKind( kind: "codex", sessionId: "019dad34-d218-7943-b81a-eddac5c87951", executablePath: codexExecutable, arguments: realisticCodexHookArgv() )) - #expect(fork.contains("--dangerously-bypass-hook-trust")) - #expect(fork.contains { $0.contains("cmux-codex-hook") }) - #expect(fork.first == codexExecutable) + #expect(Array(fork.prefix(3)) == ["env", "CMUX_CUSTOM_CODEX_PATH=\(codexExecutable)", "codex"]) + #expect(!fork.contains("--dangerously-bypass-hook-trust")) + #expect(!fork.contains { $0.contains("cmux-codex-hook") }) + #expect(fork.contains("--dangerously-bypass-approvals-and-sandbox")) + } + + @Test("Wrapper-routed Codex replay drops stale cmux hook injection") + func wrapperRoutedReplayDropsStaleCmuxHookInjection() throws { + let expectedPrefix = [ + "env", + "CMUX_CUSTOM_CODEX_PATH=\(codexExecutable)", + "codex", + ] + let resume = try #require(AgentResumeArgv().builtInKind( + kind: "codex", + sessionId: "019dad34-d218-7943-b81a-eddac5c87951", + executablePath: codexExecutable, + arguments: realisticCodexHookArgv() + )) + let fork = try #require(AgentForkArgv().builtInKind( + kind: "codex", + sessionId: "019dad34-d218-7943-b81a-eddac5c87951", + executablePath: codexExecutable, + arguments: realisticCodexHookArgv() + )) + + #expect(Array(resume.prefix(expectedPrefix.count)) == expectedPrefix) + #expect(Array(fork.prefix(expectedPrefix.count)) == expectedPrefix) + for replay in [resume, fork] { + #expect(!replay.contains("--dangerously-bypass-hook-trust")) + #expect(!replay.contains { $0.contains("/.cmux/hooks/cmux-codex-hook-") }) + #expect(replay.contains("--dangerously-bypass-approvals-and-sandbox")) + #expect(replay.contains("gpt-5.5")) + #expect(replay.contains("model_reasoning_effort=xhigh")) + } } @Test("Claude cmux hook settings preserve captured executable") diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CopilotHookConfigTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CopilotHookConfigTests.swift new file mode 100644 index 000000000000..af845e143065 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CopilotHookConfigTests.swift @@ -0,0 +1,202 @@ +import CMUXAgentLaunch +import Foundation +import Testing + +// Test-only compatibility keeps the assertions focused on JSON behavior while +// routing every call through an independently owned transformer instance. +extension CopilotHookConfig { + static func installing( + events: [Event], + in existing: Data?, + isOwnedCommand: (String) -> Bool + ) throws -> Data { + try CopilotHookConfig().installing( + events: events, + in: existing, + isOwnedCommand: isOwnedCommand + ) + } + + static func uninstalling( + from existing: Data, + isOwnedCommand: (String) -> Bool + ) throws -> RemovalResult { + try CopilotHookConfig().uninstalling( + from: existing, + isOwnedCommand: isOwnedCommand + ) + } + + static func removingOwnedHooks( + from existing: Data, + isOwnedCommand: (String) -> Bool + ) throws -> RemovalResult { + try CopilotHookConfig().removingOwnedHooks( + from: existing, + isOwnedCommand: isOwnedCommand + ) + } +} + +@Suite("Copilot hook configuration") +struct CopilotHookConfigTests { + private let owned: @Sendable (String) -> Bool = { command in + command.contains("cmux hooks copilot") || command.contains("cmux hooks feed --source copilot") + } + + @Test("Installs the native direct-entry schema idempotently") + func installsNativeSchemaIdempotently() throws { + let events = [ + CopilotHookConfig.Event( + name: "sessionStart", + command: "cmux hooks copilot session-start", + timeoutSeconds: 5 + ), + CopilotHookConfig.Event( + name: "preToolUse", + command: "cmux hooks feed --source copilot --event preToolUse", + timeoutSeconds: 120 + ), + ] + let existing = Data(#"{"version":1,"hooks":{"sessionStart":[{"type":"command","command":"user-hook","timeoutSec":9}]}}"#.utf8) + + let installed = try CopilotHookConfig.installing( + events: events, + in: existing, + isOwnedCommand: owned + ) + let object = try #require( + JSONSerialization.jsonObject(with: installed) as? [String: Any] + ) + #expect(object["version"] as? Int == 1) + let hooks = try #require(object["hooks"] as? [String: Any]) + let starts = try #require(hooks["sessionStart"] as? [[String: Any]]) + #expect(starts.count == 2) + #expect(starts[0]["command"] as? String == "user-hook") + #expect(starts[1]["command"] as? String == "cmux hooks copilot session-start") + #expect(starts[1]["timeoutSec"] as? Int == 5) + #expect(starts[1]["hooks"] == nil) + let tools = try #require(hooks["preToolUse"] as? [[String: Any]]) + #expect(tools.count == 1) + #expect(tools[0]["timeoutSec"] as? Int == 120) + + let reinstalled = try CopilotHookConfig.installing( + events: events, + in: installed, + isOwnedCommand: owned + ) + #expect(reinstalled == installed) + } + + @Test("Migrates legacy nested cmux groups without changing user hooks") + func removesLegacyNestedGroupsAndPreservesUsers() throws { + let legacy = Data(#""" + { + "theme":"dark", + "hooks":{ + "SessionStart":[ + {"matcher":"","hooks":[ + {"type":"command","command":"cmux hooks copilot session-start","timeout":5000}, + {"type":"command","command":"user-start"} + ]} + ], + "PreToolUse":[ + {"hooks":[{"type":"command","command":"cmux hooks feed --source copilot --event PreToolUse"}]} + ] + } + } + """#.utf8) + + let removal = try CopilotHookConfig.removingOwnedHooks( + from: legacy, + isOwnedCommand: owned + ) + #expect(removal.removedCount == 2) + let data = try #require(removal.data) + let object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + #expect(object["theme"] as? String == "dark") + let hooks = try #require(object["hooks"] as? [String: Any]) + #expect(hooks["PreToolUse"] == nil) + let groups = try #require(hooks["SessionStart"] as? [[String: Any]]) + let nested = try #require(groups.first?["hooks"] as? [[String: Any]]) + #expect(nested.count == 1) + #expect(nested.first?["command"] as? String == "user-start") + } + + @Test("Uninstall removes only owned direct entries") + func uninstallPreservesUserEntries() throws { + let installed = Data(#""" + { + "version":1, + "hooks":{ + "sessionStart":[ + {"type":"command","command":"user-start","timeoutSec":9}, + {"type":"command","command":"cmux hooks copilot session-start","timeoutSec":5} + ], + "agentStop":[ + {"type":"command","command":"cmux hooks copilot stop","timeoutSec":5} + ] + } + } + """#.utf8) + + let removal = try CopilotHookConfig.uninstalling( + from: installed, + isOwnedCommand: owned + ) + #expect(removal.removedCount == 2) + let data = try #require(removal.data) + let object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let hooks = try #require(object["hooks"] as? [String: Any]) + #expect(hooks["agentStop"] == nil) + let starts = try #require(hooks["sessionStart"] as? [[String: Any]]) + #expect(starts.count == 1) + #expect(starts.first?["command"] as? String == "user-start") + } + + @Test("Uninstall deletes a dedicated file that contains only cmux hooks") + func uninstallDeletesOwnedOnlyFile() throws { + let installed = Data(#""" + { + "version":1, + "hooks":{"sessionStart":[{"type":"command","command":"cmux hooks copilot session-start"}]} + } + """#.utf8) + + let removal = try CopilotHookConfig.uninstalling( + from: installed, + isOwnedCommand: owned + ) + #expect(removal.removedCount == 1) + #expect(removal.data == nil) + } + + @Test("Malformed or unsupported hook shapes fail closed") + func malformedShapesFailClosed() { + #expect(throws: CopilotHookConfig.ConfigError.invalidJSON) { + _ = try CopilotHookConfig.installing( + events: [], + in: Data("{not-json".utf8), + isOwnedCommand: owned + ) + } + #expect(throws: CopilotHookConfig.ConfigError.invalidHooks) { + _ = try CopilotHookConfig.installing( + events: [], + in: Data(#"{"hooks":[]}"#.utf8), + isOwnedCommand: owned + ) + } + #expect(throws: CopilotHookConfig.ConfigError.invalidEvent("sessionStart")) { + _ = try CopilotHookConfig.installing( + events: [], + in: Data(#"{"hooks":{"sessionStart":{}}}"#.utf8), + isOwnedCommand: owned + ) + } + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CursorExecAProcessArgumentsTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CursorExecAProcessArgumentsTests.swift new file mode 100644 index 000000000000..7d204b402ed1 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CursorExecAProcessArgumentsTests.swift @@ -0,0 +1,87 @@ +import Testing +@testable import CMUXAgentLaunch + +@Suite("Cursor exec-a process arguments") +struct CursorExecAProcessArgumentsTests { + @Test("Node runtime flags stay outside Cursor launch classification") + func nodeRuntimeFlagsStayOutsideCursorLaunchClassification() { + let interactive = [ + "/Users/alice/.local/bin/cursor-agent", + "--use-system-ca", + "/Users/alice/.local/share/cursor-agent/versions/current/index.js", + ] + + #expect( + AgentLaunchCaptureTrust.nativeAgentLaunchArguments( + processName: "node", + arguments: interactive, + kind: "cursor" + ) == [] + ) + #expect( + AgentLaunchModeClassifier().processMode( + processName: "node", + arguments: interactive, + kind: "cursor" + ) == .interactive + ) + + let printMode = interactive + ["--print", "reply exactly once"] + #expect( + AgentLaunchCaptureTrust.nativeAgentLaunchArguments( + processName: "node", + arguments: printMode, + kind: "cursor" + ) == ["--print", "reply exactly once"] + ) + #expect( + AgentLaunchModeClassifier().processMode( + processName: "node", + arguments: printMode, + kind: "cursor" + ) == .oneShot + ) + + let shortPrintMode = interactive + ["-p", "reply exactly once"] + #expect( + AgentLaunchCaptureTrust.nativeAgentLaunchArguments( + processName: "node", + arguments: shortPrintMode, + kind: "cursor" + ) == ["-p", "reply exactly once"] + ) + #expect( + AgentLaunchModeClassifier().processMode( + processName: "node", + arguments: shortPrintMode, + kind: "cursor" + ) == .oneShot + ) + } + + @Test("Generic Node entrypoints cannot establish Cursor identity") + func genericNodeEntrypointsCannotEstablishCursorIdentity() { + let unrelated = [ + "/Users/alice/bin/unrelated-tool", + "--use-system-ca", + "/Users/alice/project/index.js", + "--print", + "prompt", + ] + + #expect( + !AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: "node", + arguments: unrelated, + kind: "cursor" + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeAgentLaunchArguments( + processName: "node", + arguments: unrelated, + kind: "cursor" + ) == nil + ) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ProviderCurrentVersionContractTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ProviderCurrentVersionContractTests.swift new file mode 100644 index 000000000000..a2bf00586092 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ProviderCurrentVersionContractTests.swift @@ -0,0 +1,1283 @@ +import CMUXAgentLaunch +import Testing + +@Suite("Current provider CLI contracts") +struct ProviderCurrentVersionContractTests { + @Test("Claude 2.1.214 utility commands and option widths") + func claudeCurrentContracts() { + for command in ["gateway", "project"] { + let arguments = ["claude", command] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "claude", arguments: arguments, kind: "claude" + ) == .nonSession, + "claude \(command)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "claude", fallbackKind: "claude" + ) == nil, + "claude \(command)" + ) + } + + let ultrareview = ["claude", "ultrareview", "main"] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "claude", arguments: ultrareview, kind: "claude" + ) == .oneShot + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ultrareview, launcher: "claude", fallbackKind: "claude" + ) == nil + ) + + let safeFlags = [ + "claude", + "--bare", + "--safe-mode", + "--brief", + "--plugin-url", "https://example.test/plugin.zip", + "--name", "review agent", + "--prompt-suggestions", "true", + "--model", "sonnet", + ] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + safeFlags, launcher: "claude", fallbackKind: "claude" + ) == safeFlags + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["claude", "--prompt-suggestions", "--model", "sonnet"], + launcher: "claude", + fallbackKind: "claude" + ) == ["claude", "--prompt-suggestions", "--model", "sonnet"] + ) + } + + @Test("Claude 2.1.214 optional selectors preserve adjacent options") + func claudeCurrentOptionalSelectorsPreserveAdjacentOptions() { + for selector in ["--resume", "--from-pr", "--worktree", "-w", "--tmux"] { + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["claude", selector, "--model", "sonnet"], + launcher: "claude", + fallbackKind: "claude" + ) == ["claude", "--model", "sonnet"], + "direct \(selector)" + ) + } + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["claude", "--remote-control", "--model", "sonnet"], + launcher: "claude", + fallbackKind: "claude" + ) == ["claude", "--remote-control", "--model", "sonnet"] + ) + + for arguments in [ + ["--resume", "session-1", "--model", "sonnet"], + ["--from-pr", "123", "--model", "sonnet"], + ["--worktree", "feature-a", "--model", "sonnet"], + ["-w", "feature-b", "--model", "sonnet"], + ["--tmux=classic", "--model", "sonnet"], + ] { + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["claude"] + arguments, + launcher: "claude", + fallbackKind: "claude" + ) == ["claude", "--model", "sonnet"], + "direct \(arguments)" + ) + } + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["claude", "--remote-control", "pairing", "--model", "sonnet"], + launcher: "claude", + fallbackKind: "claude" + ) == ["claude", "--remote-control", "pairing", "--model", "sonnet"] + ) + + for selector in ["--resume", "--from-pr", "--tmux"] { + #expect( + AgentLaunchSanitizer.preservedClaudeTeamsLaunchArguments( + args: [selector, "--model", "sonnet"] + ) == ["--model", "sonnet"], + "teams \(selector)" + ) + } + for selector in ["--worktree", "-w", "--remote-control"] { + #expect( + AgentLaunchSanitizer.preservedClaudeTeamsLaunchArguments( + args: [selector, "--model", "sonnet"] + ) == [selector, "--model", "sonnet"], + "teams \(selector)" + ) + } + for arguments in [ + ["--resume", "session-1", "--model", "sonnet"], + ["--from-pr", "123", "--model", "sonnet"], + ["--tmux=classic", "--model", "sonnet"], + ] { + #expect( + AgentLaunchSanitizer.preservedClaudeTeamsLaunchArguments(args: arguments) + == ["--model", "sonnet"], + "teams \(arguments)" + ) + } + for arguments in [ + ["--worktree", "feature-a", "--model", "sonnet"], + ["-w", "feature-b", "--model", "sonnet"], + ["--remote-control", "pairing", "--model", "sonnet"], + ] { + #expect( + AgentLaunchSanitizer.preservedClaudeTeamsLaunchArguments(args: arguments) + == arguments, + "teams \(arguments)" + ) + } + } + + @Test("Cursor 2026.07.16 paths and booleans") + func cursorCurrentContracts() { + let launch = [ + "cursor-agent", "agent", + "--add-dir", "/tmp/source tree", + "--plugin-dir", "/tmp/plugin tree", + "--force", + "-f", + "--yolo", + "--approve-mcps", + "--trust", + "--skip-worktree-setup", + "--model", "composer-1.5", + "initial prompt", + ] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + launch, launcher: "cursor", fallbackKind: "cursor" + ) == [ + "cursor-agent", + "--add-dir", "/tmp/source tree", + "--plugin-dir", "/tmp/plugin tree", + "--force", + "-f", + "--yolo", + "--approve-mcps", + "--trust", + "--model", "composer-1.5", + ] + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "cursor-agent", arguments: launch, kind: "cursor" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "cursor-agent", + arguments: ["cursor-agent", "agent", "-p", "fix this", "--yolo"], + kind: "cursor" + ) == .oneShot + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "cursor-agent", + arguments: ["cursor-agent", "ls"], + kind: "cursor" + ) == .interactive + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["cursor-agent", "ls", "--model", "composer-1.5"], + launcher: "cursor", + fallbackKind: "cursor" + ) == ["cursor-agent"] + ) + } + + @Test("Gemini 0.51.0 booleans do not consume adjacent flags") + func geminiCurrentContracts() { + let launch = [ + "gemini", + "-s", + "--debug", + "-d", + "--yolo", + "-y", + "--skip-trust", + "--screen-reader", + "--worktree", + "--model", "gemini-2.5-pro", + ] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + launch, launcher: "gemini", fallbackKind: "gemini" + ) == [ + "gemini", + "-s", + "--debug", + "-d", + "--yolo", + "-y", + "--skip-trust", + "--screen-reader", + "--model", "gemini-2.5-pro", + ] + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "gemini", arguments: launch, kind: "gemini" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "gemini", + arguments: ["gemini", "-p", "fix this", "-y", "--screen-reader"], + kind: "gemini" + ) == .oneShot + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["gemini", "-w", "scratch", "--model", "gemini-2.5-pro"], + launcher: "gemini", + fallbackKind: "gemini" + ) == ["gemini", "--model", "gemini-2.5-pro"] + ) + for arguments in [ + ["gemini", "--list-sessions"], + ["gemini", "--delete-session", "session-1"], + ["gemini", "--list-extensions"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "gemini", arguments: arguments, kind: "gemini" + ) == .nonSession, + "\(arguments)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "gemini", fallbackKind: "gemini" + ) == nil, + "\(arguments)" + ) + } + } + + @Test("Gemini 0.51.0 short list-extensions alias exits without replay") + func geminiShortListExtensionsAliasIsNonSession() { + let arguments = ["gemini", "-l"] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "gemini", arguments: arguments, kind: "gemini" + ) == .nonSession + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "gemini", fallbackKind: "gemini" + ) == nil + ) + #expect( + AgentResumeArgv().builtInKind( + kind: "gemini", + sessionId: "SID", + executablePath: nil, + arguments: arguments, + transcriptPath: "/tmp/gemini-session.json" + ) == nil + ) + } + + @Test("Kimi 1.37.0 interactive booleans") + func kimiCurrentContracts() { + let launch = [ + "kimi", + "--verbose", + "--debug", + "--thinking", + "--no-thinking", + "--plan", + "--yolo", + "--yes", + "--auto-approve", + "-y", + "--model", "kimi-for-coding", + "initial prompt", + ] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + launch, launcher: "kimi", fallbackKind: "kimi" + ) == Array(launch.dropLast()) + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "kimi", arguments: launch, kind: "kimi" + ) == .interactive + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "kimi", + arguments: ["kimi", "--print", "fix this", "--auto-approve"], + kind: "kimi" + ) == .oneShot + ) + for selector in ["--session", "--resume", "-S", "-r"] { + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["kimi", selector, "--model", "kimi-for-coding"], + launcher: "kimi", + fallbackKind: "kimi" + ) == ["kimi", "--model", "kimi-for-coding"], + Comment(rawValue: selector) + ) + } + } + + @Test("Hermes Agent 0.15.1 booleans and utilities") + func hermesCurrentContracts() { + let safeFlags = [ + "hermes", + "--tui", + "--yolo", + "--accept-hooks", + "--pass-session-id", + "--ignore-user-config", + "--ignore-rules", + "--dev", + "--model", "gpt-5.4", + ] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + safeFlags, launcher: "hermes-agent", fallbackKind: "hermes-agent" + ) == safeFlags + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["hermes", "-w", "--model", "gpt-5.4"], + launcher: "hermes-agent", + fallbackKind: "hermes-agent" + ) == ["hermes", "--model", "gpt-5.4"] + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "hermes", arguments: safeFlags, kind: "hermes-agent" + ) == .interactive + ) + for arguments in [ + ["hermes", "--version"], + ["hermes", "-V"], + ["hermes", "skills", "list"], + ["hermes", "fallback", "list"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "hermes", arguments: arguments, kind: "hermes-agent" + ) == .nonSession, + "\(arguments)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "hermes-agent", fallbackKind: "hermes-agent" + ) == nil, + "\(arguments)" + ) + } + #expect( + AgentLaunchModeClassifier.processMode( + processName: "hermes", + arguments: ["hermes", "acp"], + kind: "hermes-agent" + ) == .interactive + ) + let checkpoints = ["hermes", "chat", "--checkpoints", "--model", "gpt-5.4"] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "hermes", arguments: checkpoints, kind: "hermes-agent" + ) == .interactive + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + checkpoints, launcher: "hermes-agent", fallbackKind: "hermes-agent" + ) == ["hermes", "--checkpoints", "--model", "gpt-5.4"] + ) + } + + @Test("Current provider root metadata flags always exit") + func currentProviderRootMetadataFlagsAlwaysExit() { + let providers: [(executable: String, launcher: String, kind: String, options: [String])] = [ + ("claude", "claude", "claude", ["--help", "-h", "--version", "-v"]), + ("codex", "codex", "codex", ["--help", "-h", "--version", "-V"]), + ("grok", "grok", "grok", ["--help", "-h", "--version", "-v"]), + ("pi", "pi", "pi", ["--help", "-h", "--version", "-v"]), + ("omp", "omp", "omp", ["--help", "-h", "--version", "-v"]), + ("campfire", "campfire", "campfire", ["--help", "-h", "--version", "-v"]), + ("opencode", "opencode", "opencode", ["--help", "-h", "--version", "-v"]), + ("cursor-agent", "cursor", "cursor", ["--help", "-h", "--version", "-v"]), + ("amp", "amp", "amp", ["--help", "-h", "--version", "-V", "-v"]), + ("kimi", "kimi", "kimi", ["--help", "-h", "--version", "-V"]), + ("hermes", "hermes-agent", "hermes-agent", ["--help", "-h", "--version", "-V"]), + ("gemini", "gemini", "gemini", ["--help", "-h", "--version", "-v"]), + ("kiro-cli", "kiro", "kiro", ["--help", "-h", "--version", "-V"]), + ("copilot", "copilot", "copilot", ["--help", "-h", "--version", "-v"]), + ("droid", "factory", "factory", ["--help", "-h", "--version", "-v"]), + ] + + for provider in providers { + for option in provider.options { + let arguments = [provider.executable, option] + #expect( + AgentLaunchModeClassifier.processMode( + processName: provider.executable, + arguments: arguments, + kind: provider.kind + ) == .nonSession, + "\(provider.kind) \(option)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, + launcher: provider.launcher, + fallbackKind: provider.kind + ) == nil, + "\(provider.kind) \(option)" + ) + } + } + + for arguments in [ + ["codex", "initial prompt", "--help"], + ["codex", "-i", "/tmp/a.png", "/tmp/b.png", "--help"], + ["codex", "resume", "019dad34-d218-7943-b81a-eddac5c87951", "--version"], + ["claude", "initial prompt", "--version"], + ] { + let kind = arguments[0] + #expect( + AgentLaunchModeClassifier.processMode( + processName: kind, + arguments: arguments, + kind: kind + ) == .nonSession, + "late metadata \(arguments)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, + launcher: kind, + fallbackKind: kind + ) == nil, + "late metadata \(arguments)" + ) + } + } + + @Test("Codex 0.144.3 interactive booleans preserve adjacent options") + func codexCurrentInteractiveBooleans() { + for option in [ + "--strict-config", + "--oss", + "--dangerously-bypass-approvals-and-sandbox", + "--dangerously-bypass-hook-trust", + "--search", + "--no-alt-screen", + ] { + let arguments = ["codex", option, "--model", "gpt-5.4"] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "codex", fallbackKind: "codex" + ) == arguments, + Comment(rawValue: option) + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "codex", arguments: arguments, kind: "codex" + ) == .interactive, + Comment(rawValue: option) + ) + } + } + + @Test("Codex 0.144.3 resume-only picker selector never leaks into canonical replay") + func codexResumeOnlyPickerSelectorIsNotReplayed() { + let arguments = ["codex", "resume", "--include-non-interactive", "--last"] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "codex", arguments: arguments, kind: "codex" + ) == .interactive + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "codex", fallbackKind: "codex" + ) == ["codex"] + ) + #expect( + AgentResumeArgv().builtInKind( + kind: "codex", + sessionId: "SID", + executablePath: nil, + arguments: arguments + ) == ["codex", "resume", "SID", "-c", "check_for_update_on_startup=false"] + ) + #expect( + AgentForkArgv().builtInKind( + kind: "codex", + sessionId: "SID", + executablePath: nil, + arguments: arguments + ) == ["codex", "fork", "SID"] + ) + } + + @Test("Pi 0.80.6 interactive booleans preserve adjacent options") + func piCurrentInteractiveBooleans() { + for option in [ + "--no-tools", "-nt", + "--no-builtin-tools", "-nbt", + "--no-extensions", "-ne", + "--no-skills", "-ns", + "--no-prompt-templates", "-np", + "--no-themes", + "--no-context-files", "-nc", + "--approve", "-a", + "--no-approve", "-na", + "--offline", + ] { + let arguments = ["pi", option, "--model", "anthropic/claude-sonnet"] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "pi", fallbackKind: "pi" + ) == arguments, + Comment(rawValue: option) + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "pi", arguments: arguments, kind: "pi" + ) == .interactive, + Comment(rawValue: option) + ) + } + + for provider in ["pi", "omp", "campfire"] { + for selector in ["--resume", "-r"] { + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + [provider, selector, "--model", "anthropic/claude-sonnet"], + launcher: provider, + fallbackKind: provider + ) == [provider, "--model", "anthropic/claude-sonnet"], + "\(provider) \(selector) picker" + ) + } + } + + for provider in ["pi", "campfire"] { + let arguments = [provider, "-xt", "read,bash", "--model", "anthropic/claude-sonnet"] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: provider, fallbackKind: provider + ) == arguments, + "\(provider) -xt" + ) + } + } + + @Test("Grok 0.2.103 interactive booleans preserve adjacent options") + func grokCurrentInteractiveBooleans() { + for option in [ + "--always-approve", + "--debug", + "--disable-web-search", + "--experimental-memory", + "--fullscreen", + "--minimal", + "--no-alt-screen", + "--no-memory", + "--no-plan", + "--no-subagents", + "--oauth", + "--verbatim", + ] { + let arguments = ["grok", option, "--model", "grok-code-fast-1"] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "grok", fallbackKind: "grok" + ) == arguments, + Comment(rawValue: option) + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "grok", arguments: arguments, kind: "grok" + ) == .interactive, + Comment(rawValue: option) + ) + } + + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["grok", "-s", "old-session", "--check", "--model", "grok-code-fast-1"], + launcher: "grok", + fallbackKind: "grok" + ) == ["grok", "--check", "--model", "grok-code-fast-1"] + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "grok", + arguments: ["grok", "--check", "--model", "grok-code-fast-1"], + kind: "grok" + ) == .interactive + ) + } + + @Test("OpenCode 1.18.3 root options preserve exact widths") + func openCodeCurrentRootOptions() { + for option in ["--print-logs", "--pure", "--mdns", "--auto", "--mini", "--no-replay"] { + let arguments = ["opencode", option, "--model", "openai/gpt-5.4"] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "opencode", fallbackKind: "opencode" + ) == arguments, + Comment(rawValue: option) + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "opencode", arguments: arguments, kind: "opencode" + ) == .interactive, + Comment(rawValue: option) + ) + } + + let replayLimit = ["opencode", "--replay-limit", "40", "--model", "openai/gpt-5.4"] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + replayLimit, launcher: "opencode", fallbackKind: "opencode" + ) == replayLimit + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "opencode", arguments: replayLimit, kind: "opencode" + ) == .interactive + ) + } + + @Test("Long-lived protocol entrypoints distinguish commands that exit") + func longLivedProtocolEntrypointsDistinguishCommandsThatExit() { + for arguments in [ + ["codex", "app-server"], + ["codex", "app-server", "proxy"], + ["codex", "mcp-server"], + ["codex", "exec-server"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "codex", arguments: arguments, kind: "codex" + ) == .interactive, + "\(arguments)" + ) + } + for arguments in [ + ["codex", "app-server", "daemon", "start"], + ["codex", "app-server", "generate-ts"], + ["codex", "app-server", "generate-json-schema"], + ["codex", "mcp-server", "--help"], + ["codex", "exec-server", "--help"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "codex", arguments: arguments, kind: "codex" + ) == .nonSession, + "\(arguments)" + ) + } + + for arguments in [ + ["hermes", "acp"], + ["hermes", "acp", "--accept-hooks"], + ["hermes", "gateway", "run"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "hermes", arguments: arguments, kind: "hermes-agent" + ) == .interactive, + "\(arguments)" + ) + } + for arguments in [ + ["hermes", "acp", "--check"], + ["hermes", "acp", "--help"], + ["hermes", "acp", "--setup"], + ["hermes", "acp", "--setup-browser"], + ["hermes", "acp", "--version"], + ["hermes", "gateway"], + ["hermes", "gateway", "run", "--help"], + ["hermes", "gateway", "status"], + ["hermes", "gateway", "start"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "hermes", arguments: arguments, kind: "hermes-agent" + ) == .nonSession, + "\(arguments)" + ) + } + + #expect( + AgentLaunchModeClassifier.processMode( + processName: "grok", + arguments: ["grok", "agent", "--agent-profile", "/tmp/profile.json", "stdio"], + kind: "grok" + ) == .interactive + ) + let protocolHelpCases: [(processName: String, kind: String, arguments: [String])] = [ + ("claude", "claude", ["claude", "--input-format", "stream-json", "--help"]), + ("pi", "pi", ["pi", "--mode", "rpc", "--help"]), + ("omp", "omp", ["omp", "--mode", "rpc-ui", "--help"]), + ("campfire", "campfire", ["campfire", "--mode", "rpc", "--help"]), + ("droid", "factory", [ + "droid", "exec", "--input-format", "stream-jsonrpc", + "--output-format", "stream-jsonrpc", "--help", + ]), + ("kimi", "kimi", ["kimi", "--acp", "--help"]), + ("grok", "grok", ["grok", "agent", "stdio", "--help"]), + ("opencode", "opencode", ["opencode", "serve", "--help"]), + ("qodercli", "qoder", ["qodercli", "--acp", "--help"]), + ] + for testCase in protocolHelpCases { + #expect( + AgentLaunchModeClassifier.processMode( + processName: testCase.processName, + arguments: testCase.arguments, + kind: testCase.kind + ) == .nonSession, + "\(testCase.arguments)" + ) + } + } + + @Test("Claude 2.1.214 no-persistence is terminal only with print mode") + func claudeNoPersistenceLifetimeContract() { + let oneShot = ["claude", "-p", "fix this", "--no-session-persistence"] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "claude", arguments: oneShot, kind: "claude" + ) == .oneShot + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + oneShot, launcher: "claude", fallbackKind: "claude" + ) == nil + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "claude", + arguments: ["claude", "--no-session-persistence"], + kind: "claude" + ) == .unknown + ) + } + + @Test("Amp 0.0.1784376855 command and runner lifetimes") + func ampCurrentContracts() { + for arguments in [ + ["amp", "--no-tui", "--runner-id", "cmux-dogfood"], + ["amp", "-x", "first", "--stream-json", "--stream-json-input"], + ["amp", "last"], + ["amp", "l"], + ["amp", "threads", "continue", "T-123"], + ["amp", "t", "c", "T-123"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "amp", arguments: arguments, kind: "amp" + ) == .interactive, + "\(arguments)" + ) + } + for arguments in [ + ["amp", "threads", "new"], + ["amp", "threads", "list"], + ["amp", "config", "edit"], + ["amp", "orb", "service", "status"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "amp", arguments: arguments, kind: "amp" + ) == .nonSession, + "\(arguments)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "amp", fallbackKind: "amp" + ) == nil, + "\(arguments)" + ) + } + #expect( + AgentLaunchModeClassifier.processMode( + processName: "amp", + arguments: ["amp", "--stream-json-input"], + kind: "amp" + ) == .unknown + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["amp", "threads", "continue", "OLD", "--mode", "high"], + launcher: "amp", + fallbackKind: "amp" + ) == ["amp", "--mode", "high"] + ) + } + + @Test("Pi 0.80.6 and OMP 16.2.11 utility modes exit") + func piAndOMPUtilityContracts() { + for arguments in [ + ["pi", "--export", "/tmp/session.jsonl"], + ["pi", "--list-models"], + ["pi", "--list-models", "sonnet"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "pi", arguments: arguments, kind: "pi" + ) == .nonSession, + "\(arguments)" + ) + } + for arguments in [ + ["omp", "--alias", "omp-work"], + ["omp", "--export", "/tmp/session.jsonl"], + ["omp", "agents"], + ["omp", "bench"], + ["omp", "models"], + ["omp", "worktree"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "omp", arguments: arguments, kind: "omp" + ) == .nonSession, + "\(arguments)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "omp", fallbackKind: "omp" + ) == nil, + "\(arguments)" + ) + } + for command in ["acp", "auth-gateway", "join", "shell"] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "omp", arguments: ["omp", command], kind: "omp" + ) == .interactive, + Comment(rawValue: command) + ) + } + } + + @Test("Cursor 2026.07.16 list-models exits") + func cursorListModelsContract() { + let arguments = ["cursor-agent", "--list-models"] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "cursor-agent", arguments: arguments, kind: "cursor" + ) == .nonSession + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "cursor", fallbackKind: "cursor" + ) == nil + ) + } + + @Test("Cursor 2026.07.16 plugin command exits without replay") + func cursorPluginCommandIsNonSession() { + let arguments = ["cursor-agent", "plugin", "list"] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "cursor-agent", arguments: arguments, kind: "cursor" + ) == .nonSession + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "cursor", fallbackKind: "cursor" + ) == nil + ) + #expect( + AgentResumeArgv().builtInKind( + kind: "cursor", + sessionId: "SID", + executablePath: nil, + arguments: arguments + ) == nil + ) + } + + @Test("Current long-lived protocol entrypoints never become canonical TUI resumes") + func protocolEntrypointsAreNotReplayableAsTUISessions() { + for command in ["acp", "auth-gateway", "join", "shell"] { + let arguments = ["omp", command] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "omp", arguments: arguments, kind: "omp" + ) == .interactive, + Comment(rawValue: command) + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "omp", fallbackKind: "omp" + ) == nil, + Comment(rawValue: command) + ) + #expect( + AgentResumeArgv().builtInKind( + kind: "omp", + sessionId: "SID", + executablePath: nil, + arguments: arguments + ) == nil, + Comment(rawValue: command) + ) + } + + for arguments in [ + ["kimi", "--acp"], + ["kimi", "--wire"], + ["kimi", "--input-format", "stream-json"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "kimi", arguments: arguments, kind: "kimi" + ) == .interactive, + "\(arguments)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "kimi", fallbackKind: "kimi" + ) == nil, + "\(arguments)" + ) + #expect( + AgentResumeArgv().builtInKind( + kind: "kimi", + sessionId: "SID", + executablePath: nil, + arguments: arguments + ) == nil, + "\(arguments)" + ) + } + } + + @Test("Factory documented root and exec option widths") + func factoryDocumentedContracts() { + let interactive = [ + "droid", "--model", "claude-sonnet-4-6", "--auto", "medium", + "--enabled-tools", "ApplyPatch,Bash", "--worktree", "feature-a", + ] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "droid", arguments: interactive, kind: "factory" + ) == .interactive + ) + let oneShot = [ + "droid", "exec", "--file", "mission.md", "--model", "claude-sonnet-4-6", + "--reasoning-effort=high", "--use-spec", "--spec-model", "claude-opus-4-7", + "--worker-model", "claude-sonnet-4-6", "--validator-model", "claude-opus-4-7", + ] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "droid", arguments: oneShot, kind: "factory" + ) == .oneShot + ) + #expect( + AgentLaunchModeClassifier.processMode( + processName: "droid", + arguments: ["droid", "exec", "--list-tools"], + kind: "factory" + ) == .nonSession + ) + } + + @Test("Documented absent-provider contracts fail closed or preserve exact widths") + func documentedAbsentProviderContracts() { + let cases: [(process: String, kind: String, arguments: [String], mode: AgentProcessLaunchMode)] = [ + ("kiro-cli", "kiro", ["kiro-cli", "chat", "--list-models"], .nonSession), + ("kiro-cli", "kiro", ["kiro-cli", "chat", "--no-interactive", "--effort", "high", "--trust-all-tools", "fix"], .oneShot), + ("agy", "antigravity", ["agy", "models"], .nonSession), + ("acli", "rovodev", ["acli", "rovodev", "serve", "8080"], .interactive), + ("acli", "rovodev", ["acli", "rovodev", "run", "--worktree", "--web", "--yolo"], .interactive), + ("codebuddy", "codebuddy", ["codebuddy", "--bg", "fix"], .nonSession), + ("codebuddy", "codebuddy", ["codebuddy", "--serve", "--port", "8080"], .interactive), + ("codebuddy", "codebuddy", ["codebuddy", "--prewarm", "--prewarm-id", "pool-1"], .interactive), + ("qodercli", "qoder", ["qodercli", "--remote", "fix this"], .oneShot), + ] + for testCase in cases { + #expect( + AgentLaunchModeClassifier.processMode( + processName: testCase.process, + arguments: testCase.arguments, + kind: testCase.kind + ) == testCase.mode, + "\(testCase.arguments)" + ) + } + + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["copilot", "--session-id", "OLD", "--attachment", "prompt.png", "-C", "/tmp/repo", "--model", "gpt-5.4"], + launcher: "copilot", + fallbackKind: "copilot" + ) == ["copilot", "-C", "/tmp/repo", "--model", "gpt-5.4"] + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["qodercli", "--worktree", "feature-a", "--max-turns", "10", "--yolo", "--model", "qoder"], + launcher: "qoder", + fallbackKind: "qoder" + ) == ["qodercli", "--max-turns", "10", "--yolo", "--model", "qoder"] + ) + } + + @Test("Interactive subcommands fail closed on unknown options") + func interactiveSubcommandsFailClosedOnUnknownOptions() { + let cases: [(process: String, kind: String, arguments: [String])] = [ + ("codex", "codex", ["codex", "resume", "session-1", "--cmux-unknown"]), + ("codex", "codex", ["codex", "fork", "session-1", "--cmux-unknown=value"]), + ("opencode", "opencode", ["opencode", "attach", "http://127.0.0.1:4096", "--cmux-unknown"]), + ("opencode", "opencode", ["opencode", "pr", "123", "--cmux-unknown=value"]), + ("kiro-cli", "kiro", ["kiro-cli", "chat", "--cmux-unknown"]), + ] + + for testCase in cases { + #expect( + AgentLaunchModeClassifier.processMode( + processName: testCase.process, + arguments: testCase.arguments, + kind: testCase.kind + ) == .unknown, + "\(testCase.arguments)" + ) + } + } + + @Test("Current interactive subcommand options remain recognized") + func currentInteractiveSubcommandOptionsRemainRecognized() { + let cases: [(process: String, kind: String, arguments: [String])] = [ + ("codex", "codex", ["codex", "resume", "--include-non-interactive", "--last"]), + ("codex", "codex", ["codex", "fork", "--all", "--last"]), + ("opencode", "opencode", [ + "opencode", "attach", "http://127.0.0.1:4096", + "--dir", "/tmp/project", "--username", "cmux", "--password", "secret", + ]), + ("opencode", "opencode", ["opencode", "pr", "123", "--pure"]), + ("kiro-cli", "kiro", ["kiro-cli", "chat", "--effort", "high", "--trust-all-tools"]), + ] + + for testCase in cases { + #expect( + AgentLaunchModeClassifier.processMode( + processName: testCase.process, + arguments: testCase.arguments, + kind: testCase.kind + ) == .interactive, + "\(testCase.arguments)" + ) + } + } + + @Test("One-shot output modifiers retain terminal lifetime") + func oneShotOutputModifiersRetainTerminalLifetime() { + let cases: [(process: String, kind: String, arguments: [String])] = [ + ("pi", "pi", ["pi", "--print", "--no-session", "fix this"]), + ("omp", "omp", ["omp", "-p", "--no-session", "fix this"]), + ("campfire", "campfire", ["campfire", "-p", "--no-session", "fix this"]), + ("kimi", "kimi", ["kimi", "--print", "--prompt", "fix this"]), + ("kimi", "kimi", ["kimi", "--quiet", "--command", "fix this"]), + ] + + for testCase in cases { + #expect( + AgentLaunchModeClassifier.processMode( + processName: testCase.process, + arguments: testCase.arguments, + kind: testCase.kind + ) == .oneShot, + "\(testCase.arguments)" + ) + } + } + + @Test("Claude forwarding output remains one-shot only in its documented print mode") + func claudeForwardingOutputContract() { + let oneShot = [ + "claude", "--print", "--output-format", "stream-json", + "--forward-subagent-text", "fix this", + ] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "claude", arguments: oneShot, kind: "claude" + ) == .oneShot + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + oneShot, launcher: "claude", fallbackKind: "claude" + ) == nil + ) + + for invalid in [ + ["claude", "--forward-subagent-text", "fix this"], + ["claude", "--print", "--forward-subagent-text", "fix this"], + [ + "claude", "--print", "--input-format", "stream-json", + "--forward-subagent-text", + ], + [ + "claude", "--input-format", "stream-json", + "--output-format", "stream-json", "--forward-subagent-text", + ], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "claude", arguments: invalid, kind: "claude" + ) == .unknown, + "\(invalid)" + ) + } + } + + @Test("Gemini ACP overrides terminal-looking prompt flags") + func geminiACPProtocolLifetime() { + for arguments in [ + ["gemini", "--acp", "--prompt", "ignored by ACP"], + ["gemini", "--experimental-acp", "-p", "ignored by ACP"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "gemini", arguments: arguments, kind: "gemini" + ) == .interactive, + "\(arguments)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "gemini", fallbackKind: "gemini" + ) == nil, + "\(arguments)" + ) + } + } + + @Test("Gemini utility command aliases never restore as sessions") + func geminiUtilityAliasesAreNonSession() { + for command in ["extension", "skill", "hook"] { + let arguments = ["gemini", command, "list"] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "gemini", arguments: arguments, kind: "gemini" + ) == .nonSession, + Comment(rawValue: command) + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "gemini", fallbackKind: "gemini" + ) == nil, + Comment(rawValue: command) + ) + } + } + + @Test("Codex resume-only picker option is rejected by fork") + func codexForkRejectsResumeOnlyPickerOption() { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "codex", + arguments: ["codex", "fork", "--include-non-interactive", "--last"], + kind: "codex" + ) == .unknown + ) + } + + @Test("Cursor plan and worker modes match the current command grammar") + func cursorPlanAndWorkerModes() { + let plan = ["cursor-agent", "--plan", "inspect this"] + #expect( + AgentLaunchModeClassifier.processMode( + processName: "cursor-agent", arguments: plan, kind: "cursor" + ) == .interactive + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + plan, launcher: "cursor", fallbackKind: "cursor" + ) == ["cursor-agent", "--plan"] + ) + + for arguments in [ + ["cursor-agent", "worker", "start"], + ["cursor-agent", "worker", "--worker-dir", "/tmp/cmux-worker", "start"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "cursor-agent", arguments: arguments, kind: "cursor" + ) == .interactive, + "\(arguments)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "cursor", fallbackKind: "cursor" + ) == nil, + "\(arguments)" + ) + } + + for arguments in [ + ["cursor-agent", "worker"], + ["cursor-agent", "worker", "debug"], + ] { + #expect( + AgentLaunchModeClassifier.processMode( + processName: "cursor-agent", arguments: arguments, kind: "cursor" + ) == .nonSession, + "\(arguments)" + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, launcher: "cursor", fallbackKind: "cursor" + ) == nil, + "\(arguments)" + ) + } + } + + @Test("Protocol-looking prompt tokens do not extend one-shot lifetime") + func protocolLookingPromptTokensRemainOneShot() { + let cases: [(process: String, kind: String, arguments: [String])] = [ + ("claude", "claude", [ + "claude", "--print", "--", "--input-format", "stream-json", + ]), + ("pi", "pi", ["pi", "--print", "--", "--mode", "rpc"]), + ("omp", "omp", ["omp", "--print", "--", "--mode=rpc-ui"]), + ("campfire", "campfire", [ + "campfire", "--print", "--", "--mode", "rpc", + ]), + ("gemini", "gemini", [ + "gemini", "--prompt", "fix this", "--", "--acp", + ]), + ("kimi", "kimi", ["kimi", "--print", "--", "--acp"]), + ("codebuddy", "codebuddy", [ + "codebuddy", "--print", "--", "--serve", + ]), + ("qodercli", "qoder", ["qodercli", "--print", "--", "--acp"]), + ("amp", "amp", ["amp", "--execute", "--", "--no-tui"]), + ] + + for testCase in cases { + #expect( + AgentLaunchModeClassifier.processMode( + processName: testCase.process, + arguments: testCase.arguments, + kind: testCase.kind + ) == .oneShot, + "\(testCase.arguments)" + ) + } + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift index e5a7c58d19e2..c7a914e57058 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift @@ -5,6 +5,44 @@ import Testing @MainActor @Suite("WorkstreamStore") struct WorkstreamStoreTests { + @Test("Every hook-backed provider keeps its wire source identity") + func allHookProvidersKeepSourceIdentity() { + let cases: [(String, WorkstreamSource)] = [ + ("claude", .claude), + ("codex", .codex), + ("grok", .grok), + ("opencode", .opencode), + ("pi", .pi), + ("omp", .omp), + ("campfire", .campfire), + ("amp", .amp), + ("cursor", .cursor), + ("gemini", .gemini), + ("kiro", .kiro), + ("antigravity", .antigravity), + ("rovodev", .rovodev), + ("hermes-agent", .hermesAgent), + ("copilot", .copilot), + ("codebuddy", .codebuddy), + ("factory", .factory), + ("qoder", .qoder), + ("kimi", .kimi), + ] + let store = WorkstreamStore(ringCapacity: cases.count) + + for (wireSource, _) in cases { + store.ingest(WorkstreamEvent( + sessionId: "\(wireSource)-session", + hookEventName: .preToolUse, + source: wireSource, + toolName: "Read" + )) + } + + #expect(store.items.map(\.source) == cases.map(\.1)) + #expect(cases.allSatisfy { WorkstreamSource(wireName: $0.0) == $0.1 }) + } + @Test("ingest creates a pending item for permission requests") func ingestPending() { let store = WorkstreamStore(ringCapacity: 10) diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index e56f10296c05..7ba2e9815d4f 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -78,6 +78,7 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { static let socketWorkerMethods: Set = [ "system.ping", "system.capabilities", + "agents.observations", "auth.status", "auth.sign_in_url", "auth.begin_sign_in", diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index ca1c7e3fe99b..2b0a5b864442 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -139,6 +139,7 @@ struct ControlCommandExecutionPolicyTests { @Test func onlyPureProbesAreMainThreadCallable() { #expect(ControlCommandExecutionPolicy(forMethod: "system.ping") == .socketWorker(mainThreadCallable: true)) #expect(ControlCommandExecutionPolicy(forMethod: "system.capabilities") == .socketWorker(mainThreadCallable: true)) + #expect(ControlCommandExecutionPolicy(forMethod: "agents.observations") == .socketWorker(mainThreadCallable: false)) #expect(ControlCommandExecutionPolicy(forMethod: "system.top") == .socketWorker(mainThreadCallable: false)) #expect(ControlCommandExecutionPolicy(forMethod: "vm.create") == .socketWorker(mainThreadCallable: false)) } diff --git a/Packages/macOS/CmuxFoundation/Package.swift b/Packages/macOS/CmuxFoundation/Package.swift index 5a8eacc82a82..ffc33a1cee78 100644 --- a/Packages/macOS/CmuxFoundation/Package.swift +++ b/Packages/macOS/CmuxFoundation/Package.swift @@ -25,6 +25,9 @@ let package = Package( .swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InternalImportsByDefault"), + ], + linkerSettings: [ + .linkedLibrary("sqlite3"), ] ), .testTarget( diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentObservedState.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentObservedState.swift new file mode 100644 index 000000000000..ae779b49979a --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentObservedState.swift @@ -0,0 +1,9 @@ +/// A provider-neutral semantic state observed from a live agent terminal. +public enum CmuxAgentObservedState: String, Codable, Sendable, CaseIterable, Equatable { + /// The agent is ready for another turn. + case idle + /// The agent is executing a turn or tool. + case working + /// The agent requires a human decision or credential. + case blocked +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionLegacyJSONScanner.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionLegacyJSONScanner.swift new file mode 100644 index 000000000000..50cf2c0a6335 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionLegacyJSONScanner.swift @@ -0,0 +1,767 @@ +public import Foundation + +extension CmuxAgentSessionRegistry { + /// Structural size metadata collected before Foundation materializes a + /// compatibility JSON object graph. + public struct HookLegacySourceMetrics: Equatable, Sendable { + /// Unique direct records in the wrapped `sessions` object or flat root. + public var sessionCount: Int + /// Unique canonical runs, with one fallback node for a session without runs. + public var graphNodeCount: Int + /// Largest encoded direct session value. + public var largestRecordBytes: Int64 + /// Session owning `largestRecordBytes`. + public var largestRecordSessionID: String? + /// Creates compatibility-file structural metrics. + public init( + sessionCount: Int, + graphNodeCount: Int, + largestRecordBytes: Int64, + largestRecordSessionID: String? + ) { + self.sessionCount = sessionCount + self.graphNodeCount = graphNodeCount + self.largestRecordBytes = largestRecordBytes + self.largestRecordSessionID = largestRecordSessionID + } + } + + /// A compatibility file exceeded a structural limit before JSON decoding. + public struct HookLegacySourceInspectionLimitError: Error, Equatable, Sendable { + /// Structural resource that exceeded its limit. + public enum Scope: String, Equatable, Sendable { + /// Unique direct session entries. + case sessions + /// Canonical graph nodes after per-session run de-duplication. + case graphNodes = "graph_nodes" + /// Bytes in one direct session value. + case recordBytes = "record_bytes" + /// Bytes in a session or run identifier needed for exact de-duplication. + case identifierBytes = "identifier_bytes" + } + + /// Compatibility file path. + public var path: String + /// Limit category. + public var scope: Scope + /// Session associated with a record-local failure. + public var sessionID: String? + /// Minimum observed value. + public var observed: Int64 + /// Largest accepted value. + public var maximum: Int64 + + /// Creates a structural-limit failure. + public init( + path: String, + scope: Scope, + sessionID: String? = nil, + observed: Int64, + maximum: Int64 + ) { + self.path = path + self.scope = scope + self.sessionID = sessionID + self.observed = observed + self.maximum = maximum + } + } + + /// A compatibility file was not structurally valid JSON. + public struct HookLegacySourceMalformedError: Error, Equatable, Sendable { + /// Compatibility file path. + public var path: String + /// Byte offset at which parsing stopped. + public var offset: Int64 + + /// Creates a malformed-source failure. + public init(path: String, offset: Int64) { + self.path = path + self.offset = offset + } + } + + /// One exact compatibility revision admitted for a later transactional + /// import. Retaining the scanned bytes prevents a newer file revision from + /// entering between allocation preflight and SQLite import. + public struct HookLegacySourceAdmission: Sendable { + public let source: LegacySource + public let stamp: LegacyStamp + public let json: Data + public let metrics: HookLegacySourceMetrics + private let scannerIssued: Bool + + fileprivate init( + source: LegacySource, + stamp: LegacyStamp, + json: Data, + metrics: HookLegacySourceMetrics + ) { + self.source = source + self.stamp = stamp + self.json = json + self.metrics = metrics + scannerIssued = true + } + + var wasIssuedByHookLegacyScanner: Bool { scannerIssued } + } + + public struct HookLegacySourceRevisionChangedError: Error, Equatable, Sendable { + public var path: String + + public init(path: String) { + self.path = path + } + } + + /// Scans compatibility JSON before `JSONSerialization` can allocate its + /// full object graph. Strings, escapes, nesting, duplicate session keys, and + /// duplicate run IDs are handled with JSON semantics. Both the canonical + /// `{ "sessions": ... }` layout and older flat session maps are accepted. + /// Identifier storage is bounded by the record and graph limits. + public func hookLegacySourceMetrics( + at url: URL, + maximumBytes: Int64 = 64 * 1_024 * 1_024, + maximumSessions: Int = 20_000, + maximumGraphNodes: Int = 20_000, + maximumRecordBytes: Int64 = 4 * 1_024 * 1_024 + ) throws -> HookLegacySourceMetrics { + let data = try readHookLegacySourceDataUnvalidated(at: url, maximumBytes: maximumBytes) + return try scanHookLegacySourceData( + data, + path: url.path, + maximumSessions: maximumSessions, + maximumGraphNodes: maximumGraphNodes, + maximumRecordBytes: maximumRecordBytes + ) + } + + /// Reads, validates, and pins one exact compatibility revision. The caller + /// can import `json` later without reopening the source path. + public func hookLegacySourceAdmission( + source: LegacySource, + expectedStamp: LegacyStamp, + fileManager: FileManager = .default, + maximumBytes: Int64 = 64 * 1_024 * 1_024, + maximumSessions: Int = 20_000, + maximumGraphNodes: Int = 20_000, + maximumRecordBytes: Int64 = 4 * 1_024 * 1_024 + ) throws -> HookLegacySourceAdmission { + _ = fileManager + let revision = try readHookLegacySourceRevisionUnvalidated( + at: source.url, + maximumBytes: maximumBytes + ) + guard revision.stamp == expectedStamp else { + throw HookLegacySourceRevisionChangedError(path: source.url.path) + } + let metrics = try scanHookLegacySourceData( + revision.data, + path: source.url.path, + maximumSessions: maximumSessions, + maximumGraphNodes: maximumGraphNodes, + maximumRecordBytes: maximumRecordBytes, + validateRecordIdentity: true + ) + return HookLegacySourceAdmission( + source: source, + stamp: revision.stamp, + json: revision.data, + metrics: metrics + ) + } + + func scanHookLegacySourceData( + _ data: Data, + path: String, + maximumSessions: Int = 20_000, + maximumGraphNodes: Int = 20_000, + maximumRecordBytes: Int64 = 4 * 1_024 * 1_024, + validateRecordIdentity: Bool = false + ) throws -> HookLegacySourceMetrics { + try data.withUnsafeBytes { rawBuffer in + var scanner = HookLegacyJSONScanner( + bytes: rawBuffer.bindMemory(to: UInt8.self), + path: path, + maximumSessions: max(0, maximumSessions), + maximumGraphNodes: max(0, maximumGraphNodes), + maximumRecordBytes: max(0, maximumRecordBytes), + validateRecordIdentity: validateRecordIdentity + ) + return try scanner.scan() + } + } +} + +private struct HookLegacyJSONScanner { + private struct RecordMetrics { + var graphNodes: Int + var bytes: Int64 + } + + private struct SessionRecordMetrics { + var runIDs: Set + var embeddedSessionID: String? + } + + let bytes: UnsafeBufferPointer + let path: String + let maximumSessions: Int + let maximumGraphNodes: Int + let maximumRecordBytes: Int64 + let validateRecordIdentity: Bool + private let maximumNestingDepth = 512 + private let maximumKeyBytes = 1_536 + private let maximumIdentifierBytes = 16 * 1_024 + private var offset = 0 + + init( + bytes: UnsafeBufferPointer, + path: String, + maximumSessions: Int, + maximumGraphNodes: Int, + maximumRecordBytes: Int64, + validateRecordIdentity: Bool + ) { + self.bytes = bytes + self.path = path + self.maximumSessions = maximumSessions + self.maximumGraphNodes = maximumGraphNodes + self.maximumRecordBytes = maximumRecordBytes + self.validateRecordIdentity = validateRecordIdentity + } + + mutating func scan() throws -> CmuxAgentSessionRegistry.HookLegacySourceMetrics { + if let wrapped = try scanWrappedSessions() { return wrapped } + offset = 0 + return try scanFlatRoot() + } + + private mutating func scanWrappedSessions() throws + -> CmuxAgentSessionRegistry.HookLegacySourceMetrics? { + try skipWhitespace() + try expect(0x7B) // { + try skipWhitespace() + var metrics: CmuxAgentSessionRegistry.HookLegacySourceMetrics? + if consume(0x7D) { + try skipWhitespace() + guard offset == bytes.count else { throw malformed() } + return nil + } + while true { + let key = try parseString(maximumCapturedBytes: maximumKeyBytes, overflowIsError: false) + try skipWhitespace() + try expect(0x3A) // : + try skipWhitespace() + if key == "sessions", peek() == 0x7B { + metrics = try parseSessionsObject() + } else { + if key == "sessions" { metrics = nil } + try skipValue(depth: 1) + } + try skipWhitespace() + if consume(0x7D) { break } + try expect(0x2C) // , + try skipWhitespace() + } + try skipWhitespace() + guard offset == bytes.count else { throw malformed() } + return metrics + } + + /// Older app-side consumers accepted a root dictionary keyed directly by + /// session ID. Scalar metadata is validated and counted toward the unique + /// root-key budget but is not exposed as a record, matching their decode + /// behavior. Duplicate keys use the last JSON value. + private mutating func scanFlatRoot() throws -> CmuxAgentSessionRegistry.HookLegacySourceMetrics { + try skipWhitespace() + try expect(0x7B) + try skipWhitespace() + var directKeys: Set = [] + var records: [String: RecordMetrics] = [:] + directKeys.reserveCapacity(min(maximumSessions, 8_192)) + records.reserveCapacity(min(maximumSessions, 8_192)) + var totalNodes = 0 + if consume(0x7D) { + try skipWhitespace() + guard offset == bytes.count else { throw malformed() } + return .init( + sessionCount: 0, + graphNodeCount: 0, + largestRecordBytes: 0, + largestRecordSessionID: nil + ) + } + while true { + guard let sessionID = try parseString( + maximumCapturedBytes: maximumIdentifierBytes, + overflowIsError: true + ) else { throw malformed() } + directKeys.insert(sessionID) + try skipWhitespace() + try expect(0x3A) + try skipWhitespace() + let recordStart = offset + let recordMetrics: RecordMetrics? + switch peek() { + case 0x7B: + let sessionMetrics = try parseSessionRecord( + sessionID: sessionID, + recordStart: recordStart + ) + try validateSessionIdentity(sessionID, metrics: sessionMetrics) + recordMetrics = .init( + graphNodes: max(1, sessionMetrics.runIDs.count), + bytes: Int64(offset - recordStart) + ) + case 0x5B: + try skipValue(depth: 1) + if validateRecordIdentity { throw malformed() } + recordMetrics = .init( + graphNodes: 1, + bytes: Int64(offset - recordStart) + ) + default: + try skipValue(depth: 1) + recordMetrics = nil + } + if let recordMetrics, + recordMetrics.bytes > maximumRecordBytes { + throw limit( + .recordBytes, + sessionID: sessionID, + observed: recordMetrics.bytes, + maximum: maximumRecordBytes + ) + } + if let previous = records.removeValue(forKey: sessionID) { + totalNodes -= previous.graphNodes + } + if let recordMetrics { + records[sessionID] = recordMetrics + totalNodes += recordMetrics.graphNodes + } + guard totalNodes <= maximumGraphNodes else { + throw limit( + .graphNodes, + observed: Int64(totalNodes), + maximum: Int64(maximumGraphNodes) + ) + } + guard directKeys.count <= maximumSessions else { + throw limit( + .sessions, + observed: Int64(directKeys.count), + maximum: Int64(maximumSessions) + ) + } + try skipWhitespace() + if consume(0x7D) { break } + try expect(0x2C) + try skipWhitespace() + } + try skipWhitespace() + guard offset == bytes.count else { throw malformed() } + let largest = records.max { + if $0.value.bytes != $1.value.bytes { return $0.value.bytes < $1.value.bytes } + return $0.key > $1.key + } + return .init( + sessionCount: records.count, + graphNodeCount: totalNodes, + largestRecordBytes: largest?.value.bytes ?? 0, + largestRecordSessionID: largest?.key + ) + } + + private mutating func parseSessionsObject() throws -> CmuxAgentSessionRegistry.HookLegacySourceMetrics { + try expect(0x7B) + try skipWhitespace() + var records: [String: RecordMetrics] = [:] + records.reserveCapacity(min(maximumSessions, 8_192)) + var totalNodes = 0 + if consume(0x7D) { + return .init( + sessionCount: 0, + graphNodeCount: 0, + largestRecordBytes: 0, + largestRecordSessionID: nil + ) + } + while true { + guard let sessionID = try parseString( + maximumCapturedBytes: maximumIdentifierBytes, + overflowIsError: true + ) else { throw malformed() } + try skipWhitespace() + try expect(0x3A) + try skipWhitespace() + let recordStart = offset + let sessionMetrics = try parseSessionRecord( + sessionID: sessionID, + recordStart: recordStart + ) + try validateSessionIdentity(sessionID, metrics: sessionMetrics) + let recordBytes = Int64(offset - recordStart) + guard recordBytes <= maximumRecordBytes else { + throw limit( + .recordBytes, + sessionID: sessionID, + observed: recordBytes, + maximum: maximumRecordBytes + ) + } + let recordMetrics = RecordMetrics( + graphNodes: max(1, sessionMetrics.runIDs.count), + bytes: recordBytes + ) + if let previous = records.updateValue(recordMetrics, forKey: sessionID) { + totalNodes -= previous.graphNodes + } + totalNodes += recordMetrics.graphNodes + guard totalNodes <= maximumGraphNodes else { + throw limit( + .graphNodes, + observed: Int64(totalNodes), + maximum: Int64(maximumGraphNodes) + ) + } + guard records.count <= maximumSessions else { + throw limit( + .sessions, + observed: Int64(records.count), + maximum: Int64(maximumSessions) + ) + } + try skipWhitespace() + if consume(0x7D) { break } + try expect(0x2C) + try skipWhitespace() + } + let largest = records.max { + if $0.value.bytes != $1.value.bytes { return $0.value.bytes < $1.value.bytes } + return $0.key > $1.key + } + return .init( + sessionCount: records.count, + graphNodeCount: totalNodes, + largestRecordBytes: largest?.value.bytes ?? 0, + largestRecordSessionID: largest?.key + ) + } + + private mutating func parseSessionRecord( + sessionID: String, + recordStart: Int + ) throws -> SessionRecordMetrics { + try expect(0x7B) + try skipWhitespace() + var runIDs: Set = [] + var embeddedSessionID: String? + if consume(0x7D) { + return SessionRecordMetrics( + runIDs: runIDs, + embeddedSessionID: embeddedSessionID + ) + } + while true { + let key = try parseString(maximumCapturedBytes: maximumKeyBytes, overflowIsError: false) + try skipWhitespace() + try expect(0x3A) + try skipWhitespace() + if key == "runs" { + runIDs = try parseRuns(sessionID: sessionID) + } else if key == "sessionId" { + if peek() == 0x22 { + embeddedSessionID = try parseString( + maximumCapturedBytes: maximumIdentifierBytes, + overflowIsError: true, + sessionID: sessionID + ) + } else { + try skipValue(depth: 2) + embeddedSessionID = nil + } + } else { + try skipValue(depth: 2) + } + let observedBytes = Int64(offset - recordStart) + guard observedBytes <= maximumRecordBytes else { + throw limit( + .recordBytes, + sessionID: sessionID, + observed: observedBytes, + maximum: maximumRecordBytes + ) + } + try skipWhitespace() + if consume(0x7D) { break } + try expect(0x2C) + try skipWhitespace() + } + return SessionRecordMetrics( + runIDs: runIDs, + embeddedSessionID: embeddedSessionID + ) + } + + private func validateSessionIdentity( + _ sessionID: String, + metrics: SessionRecordMetrics + ) throws { + guard !validateRecordIdentity || metrics.embeddedSessionID == sessionID else { + throw malformed() + } + } + + private mutating func parseRuns(sessionID: String) throws -> Set { + if peek() == 0x6E { // null + try parseLiteral([0x6E, 0x75, 0x6C, 0x6C]) + return [] + } + try expect(0x5B) // [ + try skipWhitespace() + var runIDs: Set = [] + if consume(0x5D) { return runIDs } + while true { + let runID = try parseRunObject(sessionID: sessionID) + if let runID { runIDs.insert(runID) } + guard runIDs.count <= maximumGraphNodes else { + throw limit( + .graphNodes, + sessionID: sessionID, + observed: Int64(runIDs.count), + maximum: Int64(maximumGraphNodes) + ) + } + try skipWhitespace() + if consume(0x5D) { break } + try expect(0x2C) + try skipWhitespace() + } + return runIDs + } + + private mutating func parseRunObject(sessionID: String) throws -> String? { + try expect(0x7B) + try skipWhitespace() + var runID: String? + if consume(0x7D) { return nil } + while true { + let key = try parseString(maximumCapturedBytes: maximumKeyBytes, overflowIsError: false) + try skipWhitespace() + try expect(0x3A) + try skipWhitespace() + if key == "runId" { + if peek() == 0x22 { + runID = try parseString( + maximumCapturedBytes: maximumIdentifierBytes, + overflowIsError: true, + sessionID: sessionID + ) + } else { + try skipValue(depth: 4) + runID = nil + } + } else { + try skipValue(depth: 4) + } + try skipWhitespace() + if consume(0x7D) { break } + try expect(0x2C) + try skipWhitespace() + } + return runID + } + + private mutating func skipValue(depth: Int) throws { + guard depth <= maximumNestingDepth, let byte = peek() else { throw malformed() } + switch byte { + case 0x7B: // { + _ = read() + try skipWhitespace() + if consume(0x7D) { return } + while true { + _ = try parseString(maximumCapturedBytes: nil, overflowIsError: false) + try skipWhitespace() + try expect(0x3A) + try skipWhitespace() + try skipValue(depth: depth + 1) + try skipWhitespace() + if consume(0x7D) { return } + try expect(0x2C) + try skipWhitespace() + } + case 0x5B: // [ + _ = read() + try skipWhitespace() + if consume(0x5D) { return } + while true { + try skipValue(depth: depth + 1) + try skipWhitespace() + if consume(0x5D) { return } + try expect(0x2C) + try skipWhitespace() + } + case 0x22: + _ = try parseString(maximumCapturedBytes: nil, overflowIsError: false) + case 0x74: + try parseLiteral([0x74, 0x72, 0x75, 0x65]) + case 0x66: + try parseLiteral([0x66, 0x61, 0x6C, 0x73, 0x65]) + case 0x6E: + try parseLiteral([0x6E, 0x75, 0x6C, 0x6C]) + case 0x2D, 0x30...0x39: + try parseNumber() + default: + throw malformed() + } + } + + private mutating func parseString( + maximumCapturedBytes: Int?, + overflowIsError: Bool, + sessionID: String? = nil + ) throws -> String? { + try expect(0x22) + var captured = maximumCapturedBytes == nil ? nil : [UInt8]() + captured?.reserveCapacity(min(maximumCapturedBytes ?? 0, 128)) + var overflowed = false + while let byte = read() { + if byte == 0x22 { + guard !overflowed else { + if overflowIsError { + throw limit( + .identifierBytes, + sessionID: sessionID, + observed: Int64((maximumCapturedBytes ?? 0) + 1), + maximum: Int64(maximumCapturedBytes ?? 0) + ) + } + return nil + } + guard let captured else { return nil } + var quoted = Data([0x22]) + quoted.append(contentsOf: captured) + quoted.append(0x22) + guard let decoded = try? JSONSerialization.jsonObject( + with: quoted, + options: [.fragmentsAllowed] + ) as? String else { + throw malformed() + } + return decoded + } + guard byte >= 0x20 else { throw malformed() } + appendCaptured(byte, to: &captured, limit: maximumCapturedBytes, overflowed: &overflowed) + if byte == 0x5C { // \ + guard let escaped = read() else { throw malformed() } + appendCaptured(escaped, to: &captured, limit: maximumCapturedBytes, overflowed: &overflowed) + switch escaped { + case 0x22, 0x5C, 0x2F, 0x62, 0x66, 0x6E, 0x72, 0x74: + break + case 0x75: + for _ in 0..<4 { + guard let hex = read(), isHexDigit(hex) else { throw malformed() } + appendCaptured(hex, to: &captured, limit: maximumCapturedBytes, overflowed: &overflowed) + } + default: + throw malformed() + } + } + } + throw malformed() + } + + private func appendCaptured( + _ byte: UInt8, + to captured: inout [UInt8]?, + limit: Int?, + overflowed: inout Bool + ) { + guard captured != nil, !overflowed, let limit else { return } + if captured!.count < limit { + captured!.append(byte) + } else { + captured = nil + overflowed = true + } + } + + private mutating func parseNumber() throws { + _ = consume(0x2D) + if consume(0x30) { + if let byte = peek(), (0x30...0x39).contains(byte) { throw malformed() } + } else { + guard let first = peek(), (0x31...0x39).contains(first) else { throw malformed() } + _ = read() + while let byte = peek(), (0x30...0x39).contains(byte) { _ = read() } + } + if consume(0x2E) { + guard let first = peek(), (0x30...0x39).contains(first) else { throw malformed() } + while let byte = peek(), (0x30...0x39).contains(byte) { _ = read() } + } + if consume(0x65) || consume(0x45) { + _ = consume(0x2B) || consume(0x2D) + guard let first = peek(), (0x30...0x39).contains(first) else { throw malformed() } + while let byte = peek(), (0x30...0x39).contains(byte) { _ = read() } + } + } + + private mutating func parseLiteral(_ literal: [UInt8]) throws { + for expectedByte in literal { try expect(expectedByte) } + } + + private mutating func skipWhitespace() throws { + while let byte = peek(), byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D { + _ = read() + } + } + + private func peek() -> UInt8? { + offset < bytes.count ? bytes[offset] : nil + } + + @discardableResult + private mutating func read() -> UInt8? { + guard offset < bytes.count else { return nil } + defer { offset += 1 } + return bytes[offset] + } + + private mutating func consume(_ byte: UInt8) -> Bool { + guard peek() == byte else { return false } + offset += 1 + return true + } + + private mutating func expect(_ byte: UInt8) throws { + guard consume(byte) else { throw malformed() } + } + + private func isHexDigit(_ byte: UInt8) -> Bool { + (0x30...0x39).contains(byte) + || (0x41...0x46).contains(byte) + || (0x61...0x66).contains(byte) + } + + private func malformed() -> CmuxAgentSessionRegistry.HookLegacySourceMalformedError { + .init(path: path, offset: Int64(offset)) + } + + private func limit( + _ scope: CmuxAgentSessionRegistry.HookLegacySourceInspectionLimitError.Scope, + sessionID: String? = nil, + observed: Int64, + maximum: Int64 + ) -> CmuxAgentSessionRegistry.HookLegacySourceInspectionLimitError { + .init( + path: path, + scope: scope, + sessionID: sessionID, + observed: observed, + maximum: maximum + ) + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+AdmittedInspection.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+AdmittedInspection.swift new file mode 100644 index 000000000000..b1c928081206 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+AdmittedInspection.swift @@ -0,0 +1,622 @@ +public import Foundation +import SQLite3 + +extension CmuxAgentSessionRegistry { + public struct HookInspectionStorageLimitError: Error, Equatable, Sendable { + public enum Scope: String, Equatable, Sendable { + case record + case provider + case selection + } + + public var scope: Scope + public var provider: String + public var sessionID: String? + public var observed: Int64 + public var maximum: Int64 + + public init( + scope: Scope, + provider: String, + sessionID: String? = nil, + observed: Int64, + maximum: Int64 + ) { + self.scope = scope + self.provider = provider + self.sessionID = sessionID + self.observed = observed + self.maximum = maximum + } + } + + /// Exact secondary ordering shared by the registry's bounded projection + /// and the CLI's final list accumulator. Swift string comparison is + /// intentional: it keeps canonically equivalent Unicode identifiers on + /// the same ordering path as the final user-visible sort. + public struct HookListSortValues: Equatable, Sendable { + public var sessionID: String? + public var agent: String? + public var runID: String? + public var workspaceID: String? + public var surfaceID: String? + public var identitySource: String? + public var pid: Int? + public var processStartedAt: TimeInterval? + + public init( + sessionID: String?, + agent: String?, + runID: String?, + workspaceID: String?, + surfaceID: String?, + identitySource: String?, + pid: Int?, + processStartedAt: TimeInterval? + ) { + self.sessionID = sessionID + self.agent = agent + self.runID = runID + self.workspaceID = workspaceID + self.surfaceID = surfaceID + self.identitySource = identitySource + self.pid = pid + self.processStartedAt = processStartedAt + } + + public static func isOrderedBefore(_ lhs: Self, _ rhs: Self) -> Bool { + if let result = stringPrecedes(lhs.sessionID, rhs.sessionID) { return result } + if let result = stringPrecedes(lhs.agent, rhs.agent) { return result } + if let result = stringPrecedes(lhs.runID, rhs.runID) { return result } + if let result = stringPrecedes(lhs.workspaceID, rhs.workspaceID) { return result } + if let result = stringPrecedes(lhs.surfaceID, rhs.surfaceID) { return result } + if let result = stringPrecedes(lhs.identitySource, rhs.identitySource) { return result } + let lhsPID = lhs.pid ?? Int.min + let rhsPID = rhs.pid ?? Int.min + if lhsPID != rhsPID { return lhsPID < rhsPID } + return (lhs.processStartedAt ?? -.infinity) < (rhs.processStartedAt ?? -.infinity) + } + + private static func stringPrecedes(_ lhs: String?, _ rhs: String?) -> Bool? { + let lhs = lhs ?? "" + let rhs = rhs ?? "" + return lhs == rhs ? nil : lhs < rhs + } + } + + public struct HookListOrderKey: Equatable, Sendable { + public var updatedAt: TimeInterval + public var sortValues: HookListSortValues + + public init(updatedAt: TimeInterval, sortValues: HookListSortValues) { + self.updatedAt = updatedAt + self.sortValues = sortValues + } + + public static func isOrderedBefore(_ lhs: Self, _ rhs: Self) -> Bool { + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + return HookListSortValues.isOrderedBefore(lhs.sortValues, rhs.sortValues) + } + } + + /// Imports only compatibility bytes that a caller already admitted, then + /// validates and materializes the selected registry in one SQLite snapshot. + public func snapshotsImportingAdmittedLegacy( + sources: [LegacySource], + admissions: [HookLegacySourceAdmission], + maximumGraphNodes: Int = 20_000, + maximumRecordBytes: Int64 = 4 * 1_024 * 1_024, + maximumProviderBytes: Int64 = 64 * 1_024 * 1_024, + maximumSelectionBytes: Int64 = 128 * 1_024 * 1_024 + ) throws -> [String: Snapshot] { + let sources = uniqueInspectionSources(sources) + let admitted = try validatedInspectionAdmissions( + sources: sources, + admissions: admissions + ) + return try withDatabase { database in + try ensureHookHotPathSchema(database) + return try inspectionTransaction(database, needsWrite: !admitted.isEmpty) { + try importAdmittedLegacy(database: database, admissions: admitted) + try validateHookInspectionGraph( + database: database, + providers: sources.map(\.provider), + admissions: admitted, + maximumGraphNodes: maximumGraphNodes + ) + try validateAdmittedInspectionStorage( + database: database, + providers: sources.map(\.provider), + maximumRecordBytes: maximumRecordBytes, + maximumProviderBytes: maximumProviderBytes, + maximumSelectionBytes: maximumSelectionBytes + ) + return try Dictionary( + uniqueKeysWithValues: sources.map { source in + ( + source.provider, + Snapshot( + records: try readRecords( + database: database, provider: source.provider), + activeSlots: try readSlots( + database: database, provider: source.provider) + ) + ) + }) + } + } + } + + /// Bounded-list counterpart to `snapshotsImportingAdmittedLegacy`. + public func boundedRecentSnapshotsImportingAdmittedLegacy( + sources: [LegacySource], + admissions: [HookLegacySourceAdmission], + maximumRecordsPerProvider: Int, + maximumGraphNodes: Int = 20_000, + maximumRecordBytes: Int64 = 4 * 1_024 * 1_024, + maximumProviderBytes: Int64 = 64 * 1_024 * 1_024, + maximumSelectionBytes: Int64 = 128 * 1_024 * 1_024, + validateRecord: ((String, Record) throws -> Void)? = nil, + validateActiveSlot: ((String, ActiveSlot) throws -> Void)? = nil + ) throws -> [String: BoundedRecentSnapshot] { + let sources = uniqueInspectionSources(sources) + let admitted = try validatedInspectionAdmissions( + sources: sources, + admissions: admissions + ) + let maximumRecordsPerProvider = max(0, maximumRecordsPerProvider) + return try withDatabase { database in + try ensureHookHotPathSchema(database) + return try inspectionTransaction(database, needsWrite: !admitted.isEmpty) { + try importAdmittedLegacy(database: database, admissions: admitted) + try validateHookInspectionGraph( + database: database, + providers: sources.map(\.provider), + admissions: admitted, + maximumGraphNodes: maximumGraphNodes + ) + try validateAdmittedInspectionStorage( + database: database, + providers: sources.map(\.provider), + maximumRecordBytes: maximumRecordBytes, + maximumProviderBytes: maximumProviderBytes, + maximumSelectionBytes: maximumSelectionBytes + ) + return try Dictionary( + uniqueKeysWithValues: sources.map { source in + if let validateRecord { + try validateListRecordPayloads( + database: database, + provider: source.provider, + validate: { try validateRecord(source.provider, $0) } + ) + } + let recent = try readBoundedListRecords( + database: database, + provider: source.provider, + limit: maximumRecordsPerProvider + ) + return ( + source.provider, + BoundedRecentSnapshot( + snapshot: Snapshot( + records: recent, + activeSlots: try readListSlots( + database: database, + provider: source.provider, + selectedSessionIDs: Set(recent.map(\.sessionID)), + validate: validateActiveSlot.map { validate in + { try validate(source.provider, $0) } + } + ) + ), + totalRecordCount: try recordCount( + database: database, + provider: source.provider + ) + ) + ) + }) + } + } + } + + /// Validates every selected provider but materializes only one global top + /// K. This keeps unfiltered CLI list reads proportional to K after the + /// mandatory fail-closed validation pass, instead of retaining K rows for + /// each configured provider. + public func globallyBoundedRecentSnapshotsImportingAdmittedLegacy( + sources: [LegacySource], + admissions: [HookLegacySourceAdmission], + maximumRecords: Int, + maximumGraphNodes: Int = 20_000, + maximumRecordBytes: Int64 = 4 * 1_024 * 1_024, + maximumProviderBytes: Int64 = 64 * 1_024 * 1_024, + maximumSelectionBytes: Int64 = 128 * 1_024 * 1_024, + projectRecord: (String, Record) throws -> HookListOrderKey, + validateActiveSlot: ((String, ActiveSlot) throws -> Void)? = nil + ) throws -> [String: BoundedRecentSnapshot] { + let sources = uniqueInspectionSources(sources) + let admitted = try validatedInspectionAdmissions( + sources: sources, + admissions: admissions + ) + let maximumRecords = max(0, maximumRecords) + return try withDatabase { database in + try ensureHookHotPathSchema(database) + return try inspectionTransaction(database, needsWrite: !admitted.isEmpty) { + try importAdmittedLegacy(database: database, admissions: admitted) + let providers = sources.map(\.provider) + try validateHookInspectionGraph( + database: database, + providers: providers, + admissions: admitted, + maximumGraphNodes: maximumGraphNodes + ) + try validateAdmittedInspectionStorage( + database: database, + providers: providers, + maximumRecordBytes: maximumRecordBytes, + maximumProviderBytes: maximumProviderBytes, + maximumSelectionBytes: maximumSelectionBytes + ) + guard !providers.isEmpty else { return [:] } + let selection = try selectGloballyBoundedListRecords( + database: database, + providers: providers, + limit: maximumRecords, + project: projectRecord + ) + let records = selection.records + let selectedSessionIDs = Dictionary( + grouping: records, + by: \.provider + ).mapValues { Set($0.map(\.sessionID)) } + let slots = try readGlobalListSlots( + database: database, + providers: providers, + selectedSessionIDs: selectedSessionIDs, + validate: validateActiveSlot + ) + let recordsByProvider = Dictionary(grouping: records, by: \.provider) + let slotsByProvider = Dictionary(grouping: slots, by: \.provider) + return Dictionary(uniqueKeysWithValues: providers.map { provider in + ( + provider, + BoundedRecentSnapshot( + snapshot: Snapshot( + records: recordsByProvider[provider] ?? [], + activeSlots: slotsByProvider[provider] ?? [] + ), + totalRecordCount: selection.counts[provider] ?? 0 + ) + ) + }) + } + } + } + + private func selectGloballyBoundedListRecords( + database: OpaquePointer, + providers: [String], + limit: Int, + project: (String, Record) throws -> HookListOrderKey + ) throws -> (records: [Record], counts: [String: Int]) { + let placeholders = selectedProviderPlaceholders(count: providers.count) + let statement = try prepare( + database, + """ + SELECT provider, session_id, updated_at, writer_generation, record_json + FROM agent_sessions + WHERE provider IN (\(placeholders)) + ORDER BY provider ASC, session_id ASC + """ + ) + defer { sqlite3_finalize(statement) } + try bindSelectedProviders(providers, to: statement) + var counts: [String: Int] = [:] + counts.reserveCapacity(providers.count) + var accumulator = HookListRecordAccumulator(limit: limit) + while try stepRow( + statement, + database: database, + operation: "project global bounded list sessions" + ) { + guard let provider = text(statement, column: 0), + let sessionID = text(statement, column: 1), + let json = data(statement, column: 4) else { + throw corruptRowError(operation: "project global bounded list sessions") + } + let record = Record( + provider: provider, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 2), + writerGeneration: Int(sqlite3_column_int64(statement, 3)), + json: json + ) + counts[provider, default: 0] += 1 + let key = try autoreleasepool { try project(provider, record) } + accumulator.insert(record: record, key: key) + } + return (accumulator.sortedRecords, counts) + } + + private func readGlobalListSlots( + database: OpaquePointer, + providers: [String], + selectedSessionIDs: [String: Set], + validate: ((String, ActiveSlot) throws -> Void)? + ) throws -> [ActiveSlot] { + let placeholders = selectedProviderPlaceholders(count: providers.count) + let statement = try prepare( + database, + """ + SELECT slot.provider, slot.scope, slot.scope_id, slot.session_id, + slot.updated_at, slot.writer_generation, slot.record_json, + json_valid(slot.record_json), + CASE WHEN json_valid(slot.record_json) + THEN json_extract(slot.record_json, '$.sessionId') END, + owner.session_id, owner.workspace_id, owner.surface_id, + CASE WHEN json_valid(owner.record_json) + THEN json_extract(owner.record_json, '$.workspaceId') END, + CASE WHEN json_valid(owner.record_json) + THEN json_extract(owner.record_json, '$.surfaceId') END, + CASE WHEN json_valid(slot.record_json) + THEN json_type(slot.record_json, '$.updatedAt') END + FROM agent_active_slots AS slot + LEFT JOIN agent_sessions AS owner + ON owner.provider = slot.provider + AND owner.session_id = slot.session_id + WHERE slot.provider IN (\(placeholders)) + ORDER BY slot.provider ASC, slot.scope ASC, slot.scope_id ASC + """ + ) + defer { sqlite3_finalize(statement) } + try bindSelectedProviders(providers, to: statement) + var slots: [ActiveSlot] = [] + while try stepRow( + statement, + database: database, + operation: "read global bounded list slots" + ) { + guard let provider = text(statement, column: 0), + let rawScope = text(statement, column: 1), + let scope = Scope(rawValue: rawScope), + let scopeID = text(statement, column: 2), + let sessionID = text(statement, column: 3), + sqlite3_column_int64(statement, 7) == 1, + text(statement, column: 8) == sessionID, + text(statement, column: 9) == sessionID, + let slotUpdatedType = text(statement, column: 14), + ["integer", "real"].contains(slotUpdatedType), + let json = data(statement, column: 6) else { + throw HookListProjectionValidationError( + provider: text(statement, column: 0) ?? "unknown" + ) + } + let ownerMatchesScope = switch scope { + case .workspace: + text(statement, column: 10) == scopeID + && text(statement, column: 12) == scopeID + case .surface: + text(statement, column: 11) == scopeID + && text(statement, column: 13) == scopeID + } + guard ownerMatchesScope else { + throw HookListProjectionValidationError(provider: provider) + } + let slot = ActiveSlot( + provider: provider, + scope: scope, + scopeID: scopeID, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 4), + writerGeneration: Int(sqlite3_column_int64(statement, 5)), + json: json + ) + if let validate { + try autoreleasepool { try validate(provider, slot) } + } + guard selectedSessionIDs[provider]?.contains(sessionID) == true else { + continue + } + slots.append(slot) + } + return slots + } + + private func selectedProviderPlaceholders(count: Int) -> String { + precondition(count > 0) + return (1...count).map { "?\($0)" }.joined(separator: ", ") + } + + private func bindSelectedProviders( + _ providers: [String], + to statement: OpaquePointer + ) throws { + for (offset, provider) in providers.enumerated() { + try bind(provider, to: Int32(offset + 1), in: statement) + } + } + + private func uniqueInspectionSources(_ sources: [LegacySource]) -> [LegacySource] { + Dictionary( + sources.map { ($0.provider, $0) }, + uniquingKeysWith: { _, latest in latest } + ).values.sorted { $0.provider < $1.provider } + } + + private func validatedInspectionAdmissions( + sources: [LegacySource], + admissions: [HookLegacySourceAdmission] + ) throws -> [HookLegacySourceAdmission] { + let sourceByProvider = Dictionary(uniqueKeysWithValues: sources.map { ($0.provider, $0) }) + var seenProviders: Set = [] + return try admissions.sorted { + $0.source.provider < $1.source.provider + }.map { admission in + guard seenProviders.insert(admission.source.provider).inserted, + admission.wasIssuedByHookLegacyScanner, + let source = sourceByProvider[admission.source.provider], + source.url.standardizedFileURL == admission.source.url.standardizedFileURL, + URL(fileURLWithPath: admission.stamp.path).standardizedFileURL + == admission.source.url.standardizedFileURL, + admission.stamp.size == Int64(admission.json.count) + else { + throw HookLegacySourceImportError(provider: admission.source.provider) + } + return admission + } + } + + /// Decode and replace one compatibility provider at a time. The admissions + /// retain all bounded source bytes, but only one Foundation object graph is + /// live in addition to those bytes. + private func importAdmittedLegacy( + database: OpaquePointer, + admissions: [HookLegacySourceAdmission] + ) throws { + for admission in admissions { + do { + try autoreleasepool { + let payload = try legacyPayload( + provider: admission.source.provider, + json: admission.json + ) + try replaceLegacy( + database: database, + provider: admission.source.provider, + stamp: admission.stamp, + payload: payload + ) + } + } catch { + throw HookLegacySourceImportError(provider: admission.source.provider) + } + } + } + + private func inspectionTransaction( + _ database: OpaquePointer, + needsWrite: Bool, + body: () throws -> T + ) throws -> T { + if needsWrite { + return try transaction(database, body: body) + } + return try readTransaction(database, body: body) + } + + private func validateAdmittedInspectionStorage( + database: OpaquePointer, + providers: [String], + maximumRecordBytes: Int64, + maximumProviderBytes: Int64, + maximumSelectionBytes: Int64 + ) throws { + let maximumRecordBytes = max(0, maximumRecordBytes) + let maximumProviderBytes = max(0, maximumProviderBytes) + let maximumSelectionBytes = max(0, maximumSelectionBytes) + var selectedBytes: Int64 = 0 + for provider in providers { + let metrics = try hookStorageMetrics(database: database, provider: provider) + guard metrics.largestRecordBytes <= maximumRecordBytes else { + throw HookInspectionStorageLimitError( + scope: .record, + provider: provider, + sessionID: metrics.largestRecordSessionID, + observed: metrics.largestRecordBytes, + maximum: maximumRecordBytes + ) + } + guard metrics.totalBytes <= maximumProviderBytes else { + throw HookInspectionStorageLimitError( + scope: .provider, + provider: provider, + observed: metrics.totalBytes, + maximum: maximumProviderBytes + ) + } + let next = selectedBytes.addingReportingOverflow(metrics.totalBytes) + selectedBytes = next.overflow ? .max : next.partialValue + guard selectedBytes <= maximumSelectionBytes else { + throw HookInspectionStorageLimitError( + scope: .selection, + provider: provider, + observed: selectedBytes, + maximum: maximumSelectionBytes + ) + } + } + } +} + +private struct HookListRecordAccumulator { + private struct Candidate { + var record: CmuxAgentSessionRegistry.Record + var key: CmuxAgentSessionRegistry.HookListOrderKey + } + + private let limit: Int + private var retained: [Candidate] = [] + + init(limit: Int) { + precondition(limit >= 0) + self.limit = limit + retained.reserveCapacity(min(limit, 1_024)) + } + + var sortedRecords: [CmuxAgentSessionRegistry.Record] { + retained.sorted { + CmuxAgentSessionRegistry.HookListOrderKey.isOrderedBefore($0.key, $1.key) + }.map(\.record) + } + + mutating func insert( + record: CmuxAgentSessionRegistry.Record, + key: CmuxAgentSessionRegistry.HookListOrderKey + ) { + guard limit > 0 else { return } + let candidate = Candidate(record: record, key: key) + guard retained.count == limit else { + retained.append(candidate) + siftUp(from: retained.count - 1) + return + } + guard let worst = retained.first, isOrderedBefore(candidate, worst) else { return } + retained[0] = candidate + siftDown(from: 0) + } + + private mutating func siftUp(from start: Int) { + var child = start + while child > 0 { + let parent = (child - 1) / 2 + guard isWorse(retained[child], than: retained[parent]) else { return } + retained.swapAt(child, parent) + child = parent + } + } + + private mutating func siftDown(from start: Int) { + var parent = start + while true { + let left = parent * 2 + 1 + guard left < retained.count else { return } + let right = left + 1 + let worseChild = right < retained.count + && isWorse(retained[right], than: retained[left]) ? right : left + guard isWorse(retained[worseChild], than: retained[parent]) else { return } + retained.swapAt(parent, worseChild) + parent = worseChild + } + } + + private func isOrderedBefore(_ lhs: Candidate, _ rhs: Candidate) -> Bool { + CmuxAgentSessionRegistry.HookListOrderKey.isOrderedBefore(lhs.key, rhs.key) + } + + private func isWorse(_ lhs: Candidate, than rhs: Candidate) -> Bool { + isOrderedBefore(rhs, lhs) + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+GraphInspection.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+GraphInspection.swift new file mode 100644 index 000000000000..87dd39f2e224 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+GraphInspection.swift @@ -0,0 +1,247 @@ +import Foundation +import SQLite3 + +extension CmuxAgentSessionRegistry { + /// Bounded graph metadata for canonical rows. + public struct HookGraphNodeMetrics: Equatable, Sendable { + public var graphNodeCount: Int + + public init(graphNodeCount: Int) { + self.graphNodeCount = graphNodeCount + } + } + + public struct HookGraphNodeInspectionLimitError: Error, Equatable, Sendable { + public var provider: String + public var observed: Int64 + public var maximum: Int64 + + public init(provider: String, observed: Int64, maximum: Int64) { + self.provider = provider + self.observed = observed + self.maximum = maximum + } + } + + public struct HookGraphNodeMalformedRecordError: Error, Equatable, Sendable { + public var provider: String + public var sessionID: String? + + public init(provider: String, sessionID: String?) { + self.provider = provider + self.sessionID = sessionID + } + } + + public struct HookInspectionGraphUnionLimitError: Error, Equatable, Sendable { + public var provider: String + public var path: String + public var observed: Int64 + public var maximum: Int64 + + public init( + provider: String, + path: String, + observed: Int64, + maximum: Int64 + ) { + self.provider = provider + self.path = path + self.observed = observed + self.maximum = maximum + } + } + + /// Streams canonical runs through SQLite JSON cursors. This does not load + /// provider record blobs into a Foundation object graph. + public func hookGraphNodeMetrics( + provider: String, + maximumGraphNodes: Int = 20_000 + ) throws -> HookGraphNodeMetrics { + let maximumGraphNodes = max(0, maximumGraphNodes) + return try withDatabase { database in + try ensureHookHotPathSchema(database) + return try readTransaction(database) { + try hookGraphNodeMetrics( + database: database, + provider: provider, + maximumGraphNodes: maximumGraphNodes + ) + } + } + } + + func validateHookInspectionGraph( + database: OpaquePointer, + providers: [String], + admissions: [HookLegacySourceAdmission], + maximumGraphNodes: Int + ) throws { + let maximumGraphNodes = max(0, maximumGraphNodes) + if admissions.isEmpty { + // Canonical rows have already passed the registry writer's storage + // boundaries. Keep the current-sidecar path independent of record JSON + // size: list projects one row per session, while tree enforces its exact + // filtered node limit while streaming decoded records. + var canonicalRecordCount = 0 + for provider in providers { + let next = canonicalRecordCount.addingReportingOverflow( + try readRecordCount(database: database, provider: provider) + ) + canonicalRecordCount = next.overflow ? .max : next.partialValue + guard canonicalRecordCount <= maximumGraphNodes else { + throw HookGraphNodeInspectionLimitError( + provider: provider, + observed: Int64(canonicalRecordCount), + maximum: Int64(maximumGraphNodes) + ) + } + } + return + } + + let admissionByProvider = Dictionary( + admissions.map { ($0.source.provider, $0) }, + uniquingKeysWith: { _, latest in latest } + ) + var graphNodeCount = 0 + for provider in providers { + let metrics: HookGraphNodeMetrics + do { + metrics = try hookGraphNodeMetrics( + database: database, + provider: provider, + maximumGraphNodes: max(0, maximumGraphNodes - graphNodeCount) + ) + } catch let error as HookGraphNodeInspectionLimitError { + let observed = Int64(graphNodeCount).addingReportingOverflow(error.observed) + let totalObserved = observed.overflow ? Int64.max : observed.partialValue + if let admission = admissionByProvider[provider] { + throw HookInspectionGraphUnionLimitError( + provider: provider, + path: admission.source.url.path, + observed: totalObserved, + maximum: Int64(maximumGraphNodes) + ) + } + throw HookGraphNodeInspectionLimitError( + provider: provider, + observed: totalObserved, + maximum: Int64(maximumGraphNodes) + ) + } + let next = graphNodeCount.addingReportingOverflow(metrics.graphNodeCount) + graphNodeCount = next.overflow ? .max : next.partialValue + } + } + + private func hookGraphNodeMetrics( + database: OpaquePointer, + provider: String, + maximumGraphNodes: Int + ) throws -> HookGraphNodeMetrics { + let statement = try prepare( + database, + """ + SELECT session.session_id, + json_type(session.record_json), + json_type(session.record_json, '$.sessionId'), + json_extract(session.record_json, '$.sessionId'), + json_type(session.record_json, '$.runs'), + CASE + WHEN json_type(session.record_json, '$.runs') = 'array' + THEN json_array_length(session.record_json, '$.runs') + ELSE NULL + END, + CASE + WHEN json_type(session.record_json, '$.runs') = 'array' + AND json_array_length(session.record_json, '$.runs') > 0 + THEN json_extract(run.value, '$.runId') + WHEN json_type(session.record_json, '$.runId') = 'text' + THEN json_extract(session.record_json, '$.runId') + ELSE 'session:' || session.provider || ':' || session.session_id + END, + CASE WHEN run.value IS NULL THEN NULL ELSE json_type(run.value) END, + CASE + WHEN run.value IS NULL THEN NULL + ELSE json_type(run.value, '$.runId') + END, + json_type(session.record_json, '$.runId') + FROM agent_sessions AS session + LEFT JOIN json_each( + CASE + WHEN json_type(session.record_json, '$.runs') = 'array' + THEN session.record_json + ELSE NULL + END, + '$.runs' + ) AS run + WHERE session.provider = ?1 + ORDER BY session.session_id ASC + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + + var graphNodeCount = 0 + var currentSessionID: String? + var currentRunIDs: Set = [] + + while try stepRow( + statement, + database: database, + operation: "inspect hook graph nodes" + ) { + guard let sessionID = text(statement, column: 0), + text(statement, column: 1) == "object", + text(statement, column: 2) == "text", + text(statement, column: 3) == sessionID, + let runID = text(statement, column: 6) + else { + throw HookGraphNodeMalformedRecordError( + provider: provider, + sessionID: text(statement, column: 0) + ) + } + let runsType = text(statement, column: 4) + guard runsType == nil || runsType == "null" || runsType == "array" else { + throw HookGraphNodeMalformedRecordError( + provider: provider, + sessionID: sessionID + ) + } + let runCount = + runsType == "array" + ? Int(sqlite3_column_int64(statement, 5)) + : 0 + let recordRunIDType = text(statement, column: 9) + guard + recordRunIDType == nil + || recordRunIDType == "null" + || recordRunIDType == "text", + runCount == 0 + || (text(statement, column: 7) == "object" + && text(statement, column: 8) == "text") + else { + throw HookGraphNodeMalformedRecordError( + provider: provider, + sessionID: sessionID + ) + } + if currentSessionID != sessionID { + currentSessionID = sessionID + currentRunIDs.removeAll(keepingCapacity: true) + } + guard currentRunIDs.insert(runID).inserted else { continue } + graphNodeCount += 1 + guard graphNodeCount <= maximumGraphNodes else { + throw HookGraphNodeInspectionLimitError( + provider: provider, + observed: Int64(graphNodeCount), + maximum: Int64(maximumGraphNodes) + ) + } + } + return HookGraphNodeMetrics(graphNodeCount: graphNodeCount) + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+HookHotPath.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+HookHotPath.swift new file mode 100644 index 000000000000..9fd92c515377 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+HookHotPath.swift @@ -0,0 +1,2534 @@ +public import Foundation +import Darwin +import SQLite3 + +extension CmuxAgentSessionRegistry { + /// Largest encoded canonical session record accepted from any writer. + public static let maximumHookRecordBytes = 4 * 1_024 * 1_024 + /// Largest encoded record-plus-slot footprint accepted for one provider. + public static let maximumHookProviderBytes = 64 * 1_024 * 1_024 + /// Largest encoded compatibility projection published for an older writer. + public static let maximumHookLegacyProjectionBytes = 64 * 1_024 * 1_024 + /// Largest compatibility projection that inspection readers can scan. + public static let maximumHookLegacyProjectionRecords = 20_000 + + /// One currently open terminal panel used to select hibernation owners. + public struct HookHibernationPanelContext: Hashable, Sendable { + public var workspaceID: String + public var surfaceID: String + + public init(workspaceID: String, surfaceID: String) { + self.workspaceID = workspaceID + self.surfaceID = surfaceID + } + } + + /// Provider-isolated hibernation projections selected through one SQLite + /// connection and one read transaction. + public struct HookHibernationSnapshotsResult: Sendable { + public var snapshots: [String: Snapshot] + public var failedProviders: Set + + public init( + snapshots: [String: Snapshot], + failedProviders: Set + ) { + self.snapshots = snapshots + self.failedProviders = failedProviders + } + } + + /// A canonical hook write exceeded a durable storage boundary. + public struct HookStorageLimitError: Error, Equatable, Sendable { + /// Storage resource that exceeded its boundary. + public enum Scope: String, Equatable, Sendable { + /// One canonical session row. + case record + /// All canonical rows for one provider. + case provider + /// The compatibility JSON projection. + case legacyProjection = "legacy_projection" + } + + /// Limit category. + public var scope: Scope + /// Provider associated with the failure. + public var provider: String + /// Session associated with a record-local failure. + public var sessionID: String? + /// Minimum observed byte count. + public var observedBytes: Int64 + /// Largest accepted byte count. + public var maximumBytes: Int64 + + /// Creates a canonical storage-limit failure. + public init( + scope: Scope, + provider: String, + sessionID: String? = nil, + observedBytes: Int64, + maximumBytes: Int64 + ) { + self.scope = scope + self.provider = provider + self.sessionID = sessionID + self.observedBytes = observedBytes + self.maximumBytes = maximumBytes + } + } + + /// A compatibility JSON file exceeded the bounded-read allocation limit. + public struct HookLegacySourceSizeError: Error, Sendable { + /// Path of the compatibility file. + public var path: String + /// File size observed through the open descriptor. + public var observedBytes: Int64 + /// Largest accepted file size. + public var maximumBytes: Int64 + + /// Creates a bounded-read failure. + public init(path: String, observedBytes: Int64, maximumBytes: Int64) { + self.path = path + self.observedBytes = observedBytes + self.maximumBytes = maximumBytes + } + } + + /// An authoritative row or active slot cannot participate in an exact list + /// projection. Callers may fall back to the compatibility source without + /// confusing corrupt canonical data with a transient SQLite failure. + public struct HookListProjectionValidationError: Error, Equatable, Sendable { + public var provider: String + + public init(provider: String) { + self.provider = provider + } + } + + /// A changed compatibility source could not be decoded or committed for one + /// provider. Canonical query failures remain their original error so callers + /// do not misreport database availability as a legacy import problem. + public struct HookLegacySourceImportError: Error, Equatable, Sendable { + public var provider: String + + public init(provider: String) { + self.provider = provider + } + } + + /// Allocation-free size metadata used before inspection commands + /// materialize a provider snapshot. + public struct HookStorageMetrics: Equatable, Sendable { + /// Number of canonical session rows for the provider. + public var recordCount: Int + /// Encoded bytes across canonical session rows. + public var recordBytes: Int64 + /// Encoded bytes across canonical active slots. + public var activeSlotBytes: Int64 + /// Session owning the largest encoded record, if any. + public var largestRecordSessionID: String? + /// Encoded size of `largestRecordSessionID`. + public var largestRecordBytes: Int64 + + /// Creates allocation metadata for one provider. + public init( + recordCount: Int, + recordBytes: Int64, + activeSlotBytes: Int64, + largestRecordSessionID: String?, + largestRecordBytes: Int64 + ) { + self.recordCount = recordCount + self.recordBytes = recordBytes + self.activeSlotBytes = activeSlotBytes + self.largestRecordSessionID = largestRecordSessionID + self.largestRecordBytes = largestRecordBytes + } + + /// Encoded bytes that a full provider snapshot would materialize. + public var totalBytes: Int64 { recordBytes + activeSlotBytes } + } + + /// A full provider snapshot exceeded a caller-supplied materialization + /// boundary. Exact `hookRecord` reads remain available when this happens. + public struct HookSnapshotLimitError: Error, Equatable, Sendable { + /// Materialized resource that exceeded its boundary. + public enum Scope: String, Equatable, Sendable { + /// Number of canonical session rows. + case records + /// Largest encoded canonical session row. + case recordBytes = "record_bytes" + /// Total encoded record-plus-slot bytes for the provider. + case providerBytes = "provider_bytes" + } + + /// Limit category. + public var scope: Scope + /// Provider associated with the failure. + public var provider: String + /// Session associated with a record-local failure. + public var sessionID: String? + /// Minimum observed count or byte size. + public var observed: Int64 + /// Largest accepted count or byte size. + public var maximum: Int64 + + /// Creates a bounded snapshot failure. + public init( + scope: Scope, + provider: String, + sessionID: String? = nil, + observed: Int64, + maximum: Int64 + ) { + self.scope = scope + self.provider = provider + self.sessionID = sessionID + self.observed = observed + self.maximum = maximum + } + } + + /// Reads provider allocation metadata without selecting any JSON blobs. + public func hookStorageMetrics(provider: String) throws -> HookStorageMetrics { + guard let metrics = try hookStorageMetrics(providers: [provider])[provider] else { + throw CocoaError(.fileReadCorruptFile) + } + return metrics + } + + /// Reads allocation metadata for every requested provider through one + /// SQLite connection and one consistent read transaction. Duplicate + /// provider IDs are collapsed; providers without rows receive zero metrics. + public func hookStorageMetrics( + providers requestedProviders: [String] + ) throws -> [String: HookStorageMetrics] { + let providers = Set(requestedProviders).sorted() + guard !providers.isEmpty else { return [:] } + return try withDatabase { database in + try ensureHookHotPathSchema(database) + return try readTransaction(database) { + var metricsByProvider: [String: HookStorageMetrics] = [:] + metricsByProvider.reserveCapacity(providers.count) + for provider in providers { + metricsByProvider[provider] = try hookStorageMetrics( + database: database, + provider: provider + ) + } + return metricsByProvider + } + } + } + + /// Reads a complete provider snapshot only after validating its row and + /// byte footprint in the same SQLite read transaction. This closes the + /// check-then-materialize race for app inspection surfaces while retaining + /// the full canonical history beyond the 256-row compatibility projection. + public func hookBoundedSnapshot( + provider: String, + maximumRecords: Int = 20_000, + maximumProviderBytes: Int64 = Int64(maximumHookProviderBytes), + maximumRecordBytes: Int64 = Int64(maximumHookRecordBytes) + ) throws -> Snapshot { + let maximumRecords = max(0, maximumRecords) + let maximumProviderBytes = max(0, maximumProviderBytes) + let maximumRecordBytes = max(0, maximumRecordBytes) + return try withDatabase { database in + try ensureHookHotPathSchema(database) + return try readTransaction(database) { + let metrics = try hookStorageMetrics(database: database, provider: provider) + guard metrics.recordCount <= maximumRecords else { + throw HookSnapshotLimitError( + scope: .records, + provider: provider, + observed: Int64(metrics.recordCount), + maximum: Int64(maximumRecords) + ) + } + guard metrics.largestRecordBytes <= maximumRecordBytes else { + throw HookSnapshotLimitError( + scope: .recordBytes, + provider: provider, + sessionID: metrics.largestRecordSessionID, + observed: metrics.largestRecordBytes, + maximum: maximumRecordBytes + ) + } + guard metrics.totalBytes <= maximumProviderBytes else { + throw HookSnapshotLimitError( + scope: .providerBytes, + provider: provider, + observed: metrics.totalBytes, + maximum: maximumProviderBytes + ) + } + return Snapshot( + records: try readRecords(database: database, provider: provider), + activeSlots: try readSlots(database: database, provider: provider) + ) + } + } + } + + /// Reads every active-slot owner plus the newest inactive history that fits + /// the caller's record and encoded-byte budgets. Active owners are never + /// silently omitted: if they alone exceed either budget, this method throws + /// `HookSnapshotLimitError`. The selection and rows share one read + /// transaction and use the provider/projection and slot-owner indexes. + public func hookBoundedRecentRecords( + provider: String, + maximumRecords: Int, + maximumBytes: Int64 = Int64(maximumHookProviderBytes) + ) throws -> [Record] { + let maximumRecords = max(0, maximumRecords) + let maximumBytes = max(0, maximumBytes) + return try withDatabase { database in + try ensureHookHotPathSchema(database) + return try readTransaction(database) { + let activeTotals = try prepare( + database, + """ + SELECT COUNT(*), COALESCE(SUM(length(session.record_json)), 0) + FROM agent_sessions AS session + WHERE session.provider = ?1 + AND EXISTS ( + SELECT 1 FROM agent_active_slots AS slot + WHERE slot.provider = session.provider + AND slot.session_id = session.session_id + ) + """ + ) + defer { sqlite3_finalize(activeTotals) } + try bind(provider, to: 1, in: activeTotals) + guard try stepRow( + activeTotals, + database: database, + operation: "read active hook record totals" + ) else { + throw corruptRowError(operation: "read active hook record totals") + } + let activeCount = Int(sqlite3_column_int64(activeTotals, 0)) + let activeBytes = sqlite3_column_int64(activeTotals, 1) + guard activeCount <= maximumRecords else { + throw HookSnapshotLimitError( + scope: .records, + provider: provider, + observed: Int64(activeCount), + maximum: Int64(maximumRecords) + ) + } + guard activeBytes <= maximumBytes else { + throw HookSnapshotLimitError( + scope: .providerBytes, + provider: provider, + observed: activeBytes, + maximum: maximumBytes + ) + } + + var records = try hookRecordsByActivity( + database: database, + provider: provider, + active: true, + limit: activeCount, + maximumBytes: activeBytes + ) + let remainingCount = maximumRecords - records.count + guard remainingCount > 0, activeBytes < maximumBytes else { + return records + } + let inactive = try hookRecordsByActivity( + database: database, + provider: provider, + active: false, + limit: remainingCount, + maximumBytes: maximumBytes - activeBytes + ) + records.reserveCapacity(records.count + inactive.count) + records.append(contentsOf: inactive) + return records + } + } + } + + /// Reads a bounded list slice without consulting a compatibility source. + /// This is the fallback used when a changed legacy projection cannot import + /// but the canonical registry remains readable. + public func hookBoundedRecentSnapshot( + provider: String, + maximumRecords: Int, + validateRecord: ((Record) throws -> Void)? = nil, + validateActiveSlot: ((ActiveSlot) throws -> Void)? = nil + ) throws -> BoundedRecentSnapshot { + let maximumRecords = max(0, maximumRecords) + return try withDatabase { database in + try ensureHookHotPathSchema(database) + return try readTransaction(database) { + if let validateRecord { + try validateListRecordPayloads( + database: database, + provider: provider, + validate: validateRecord + ) + } + let recent = try readBoundedListRecords( + database: database, + provider: provider, + limit: maximumRecords + ) + return BoundedRecentSnapshot( + snapshot: Snapshot( + records: recent, + activeSlots: try readListSlots( + database: database, + provider: provider, + selectedSessionIDs: Set(recent.map(\.sessionID)), + validate: validateActiveSlot + ) + ), + totalRecordCount: try recordCount(database: database, provider: provider) + ) + } + } + } + + func recordCount(database: OpaquePointer, provider: String) throws -> Int { + let statement = try prepare( + database, + "SELECT COUNT(*) FROM agent_sessions WHERE provider = ?1" + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + guard try stepRow(statement, database: database, operation: "count list sessions") else { + throw corruptRowError(operation: "count list sessions") + } + return Int(sqlite3_column_int64(statement, 0)) + } + + /// Orders by the same timestamp as `AgentSessionRunCanonicalizer.projectedRun`. + /// The row projection timestamp can be newer than its active run, so using + /// `agent_sessions.updated_at` alone can select the wrong global top K. + func readBoundedListRecords( + database: OpaquePointer, + provider: String, + limit: Int + ) throws -> [Record] { + try validateBoundedListRecords(database: database, provider: provider) + guard limit > 0 else { return [] } + let statement = try prepare( + database, + """ + SELECT session.session_id, session.updated_at, + session.writer_generation, session.record_json, + CASE + WHEN json_type(session.record_json, '$.runs') = 'array' + AND json_array_length(session.record_json, '$.runs') > 0 + THEN COALESCE( + ( + SELECT MAX(CAST(json_extract(run.value, '$.updatedAt') AS REAL)) + FROM json_each(session.record_json, '$.runs') AS run + WHERE json_extract(run.value, '$.runId') = + json_extract(session.record_json, '$.activeRunId') + ), + ( + SELECT MAX(CAST(json_extract(run.value, '$.updatedAt') AS REAL)) + FROM json_each(session.record_json, '$.runs') AS run + ), + CAST(json_extract(session.record_json, '$.updatedAt') AS REAL) + ) + ELSE CAST(json_extract(session.record_json, '$.updatedAt') AS REAL) + END AS list_updated_at + FROM agent_sessions AS session + WHERE session.provider = ?1 + ORDER BY list_updated_at DESC, session.session_id ASC + LIMIT ?2 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + sqlite3_bind_int64(statement, 2, sqlite3_int64(limit)) + var result: [Record] = [] + result.reserveCapacity(min(limit, 1_024)) + while try stepRow(statement, database: database, operation: "read bounded list sessions") { + guard let sessionID = text(statement, column: 0), + let json = data(statement, column: 3) else { + throw HookListProjectionValidationError(provider: provider) + } + result.append(Record( + provider: provider, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 1), + writerGeneration: Int(sqlite3_column_int64(statement, 2)), + json: json + )) + } + return result + } + + /// Validates fields that affect list ordering for every row without copying + /// omitted JSON blobs. Candidate rows still receive the complete Codable + /// validation in the CLI projection layer. + func validateBoundedListRecords( + database: OpaquePointer, + provider: String + ) throws { + let statement = try prepare( + database, + """ + SELECT session_id, + CASE + WHEN json_valid(record_json) = 0 THEN 1 + WHEN json_type(record_json) IS NOT 'object' THEN 1 + WHEN json_type(record_json, '$.sessionId') IS NOT 'text' THEN 1 + WHEN json_type(record_json, '$.workspaceId') IS NOT 'text' THEN 1 + WHEN json_type(record_json, '$.surfaceId') IS NOT 'text' THEN 1 + WHEN json_type(record_json, '$.startedAt') IS NOT 'integer' + AND json_type(record_json, '$.startedAt') IS NOT 'real' THEN 1 + WHEN json_type(record_json, '$.updatedAt') IS NOT 'integer' + AND json_type(record_json, '$.updatedAt') IS NOT 'real' THEN 1 + WHEN json_type(record_json, '$.pid') IS NOT NULL + AND json_type(record_json, '$.pid') + NOT IN ('null', 'integer') THEN 1 + WHEN json_type(record_json, '$.runId') IS NOT NULL + AND json_type(record_json, '$.runId') + NOT IN ('null', 'text') THEN 1 + WHEN json_type(record_json, '$.activeRunId') IS NOT NULL + AND json_type(record_json, '$.activeRunId') + NOT IN ('null', 'text') THEN 1 + WHEN json_type(record_json, '$.runs') IS NOT NULL + AND json_type(record_json, '$.runs') + NOT IN ('null', 'array') THEN 1 + WHEN json_type(record_json, '$.runs') = 'array' + AND EXISTS ( + SELECT 1 + FROM json_each(record_json, '$.runs') AS run + WHERE CASE + WHEN run.type != 'object' THEN 1 + WHEN json_type(run.value, '$.runId') IS NOT 'text' THEN 1 + WHEN json_type(run.value, '$.startedAt') IS NOT 'integer' + AND json_type(run.value, '$.startedAt') IS NOT 'real' THEN 1 + WHEN json_type(run.value, '$.updatedAt') IS NOT 'integer' + AND json_type(run.value, '$.updatedAt') IS NOT 'real' THEN 1 + WHEN json_type(run.value, '$.restoreAuthority') IS NOT 'true' + AND json_type(run.value, '$.restoreAuthority') IS NOT 'false' + THEN 1 + WHEN json_type(run.value, '$.pid') IS NOT NULL + AND json_type(run.value, '$.pid') + NOT IN ('null', 'integer') THEN 1 + WHEN json_type(run.value, '$.processStartedAt') IS NOT NULL + AND json_type(run.value, '$.processStartedAt') + NOT IN ('null', 'integer', 'real') THEN 1 + ELSE 0 + END = 1 + ) THEN 1 + ELSE 0 + END AS structurally_invalid, + CASE WHEN json_valid(record_json) + THEN json_extract(record_json, '$.sessionId') END + FROM agent_sessions + WHERE provider = ?1 + ORDER BY session_id ASC + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + while try stepRow( + statement, + database: database, + operation: "validate bounded list sessions" + ) { + guard let storedSessionID = text(statement, column: 0), + sqlite3_column_int64(statement, 1) == 0, + let projectedSessionID = text(statement, column: 2), + storedSessionID == projectedSessionID else { + throw HookListProjectionValidationError(provider: provider) + } + } + } + + func validateListRecordPayloads( + database: OpaquePointer, + provider: String, + validate: (Record) throws -> Void + ) throws { + let statement = try prepare( + database, + """ + SELECT session_id, updated_at, writer_generation, record_json + FROM agent_sessions + WHERE provider = ?1 + ORDER BY session_id ASC + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + while try stepRow( + statement, + database: database, + operation: "validate list session payloads" + ) { + guard let sessionID = text(statement, column: 0), + let json = data(statement, column: 3) else { + throw HookListProjectionValidationError(provider: provider) + } + let record = Record( + provider: provider, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 1), + writerGeneration: Int(sqlite3_column_int64(statement, 2)), + json: json + ) + try autoreleasepool { + try validate(record) + } + } + } + + /// Validates every provider slot but copies JSON only when the slot owner is + /// one of the retained candidates. A slot can name an older owner, so the + /// validation joins against the canonical owner row before filtering it out. + func readListSlots( + database: OpaquePointer, + provider: String, + selectedSessionIDs: Set, + validate: ((ActiveSlot) throws -> Void)? = nil + ) throws -> [ActiveSlot] { + let statement = try prepare( + database, + """ + SELECT slot.scope, slot.scope_id, slot.session_id, slot.updated_at, + slot.writer_generation, slot.record_json, + json_valid(slot.record_json), + CASE WHEN json_valid(slot.record_json) + THEN json_extract(slot.record_json, '$.sessionId') END, + owner.session_id, owner.workspace_id, owner.surface_id, + CASE WHEN json_valid(owner.record_json) + THEN json_extract(owner.record_json, '$.workspaceId') END, + CASE WHEN json_valid(owner.record_json) + THEN json_extract(owner.record_json, '$.surfaceId') END, + CASE WHEN json_valid(slot.record_json) + THEN json_type(slot.record_json, '$.updatedAt') END + FROM agent_active_slots AS slot + LEFT JOIN agent_sessions AS owner + ON owner.provider = slot.provider + AND owner.session_id = slot.session_id + WHERE slot.provider = ?1 + ORDER BY slot.scope ASC, slot.scope_id ASC + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + var result: [ActiveSlot] = [] + while try stepRow(statement, database: database, operation: "read bounded list slots") { + guard let rawScope = text(statement, column: 0), + let scope = Scope(rawValue: rawScope), + let scopeID = text(statement, column: 1), + let sessionID = text(statement, column: 2), + sqlite3_column_int64(statement, 6) == 1, + text(statement, column: 7) == sessionID, + text(statement, column: 8) == sessionID, + let slotUpdatedType = text(statement, column: 13), + ["integer", "real"].contains(slotUpdatedType) else { + throw HookListProjectionValidationError(provider: provider) + } + let ownerMatchesScope = switch scope { + case .workspace: + text(statement, column: 9) == scopeID + && text(statement, column: 11) == scopeID + case .surface: + text(statement, column: 10) == scopeID + && text(statement, column: 12) == scopeID + } + guard ownerMatchesScope else { + throw HookListProjectionValidationError(provider: provider) + } + guard let json = data(statement, column: 5) else { + throw HookListProjectionValidationError(provider: provider) + } + let slot = ActiveSlot( + provider: provider, + scope: scope, + scopeID: scopeID, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 3), + writerGeneration: Int(sqlite3_column_int64(statement, 4)), + json: json + ) + if let validate { + try autoreleasepool { + try validate(slot) + } + } + guard selectedSessionIDs.contains(sessionID) else { continue } + result.append(slot) + } + return result + } + + private func hookRecordsByActivity( + database: OpaquePointer, + provider: String, + active: Bool, + limit: Int, + maximumBytes: Int64 + ) throws -> [Record] { + guard limit > 0, maximumBytes >= 0 else { return [] } + let activityPredicate = active ? "EXISTS" : "NOT EXISTS" + let statement = try prepare( + database, + """ + SELECT session.session_id, session.updated_at, + session.writer_generation, session.record_json + FROM agent_sessions AS session + WHERE session.provider = ?1 + AND \(activityPredicate) ( + SELECT 1 FROM agent_active_slots AS slot + WHERE slot.provider = session.provider + AND slot.session_id = session.session_id + ) + ORDER BY session.updated_at DESC, session.session_id ASC + LIMIT ?2 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + sqlite3_bind_int64(statement, 2, sqlite3_int64(limit)) + var records: [Record] = [] + records.reserveCapacity(limit) + var selectedBytes: Int64 = 0 + while try stepRow(statement, database: database, operation: "read bounded hook records") { + let blobBytes = Int64(sqlite3_column_bytes(statement, 3)) + let nextBytes = selectedBytes.addingReportingOverflow(blobBytes) + guard !nextBytes.overflow, nextBytes.partialValue <= maximumBytes else { + break + } + guard let sessionID = text(statement, column: 0), + let json = data(statement, column: 3) else { + throw corruptRowError(operation: "read bounded hook records") + } + selectedBytes = nextBytes.partialValue + records.append(Record( + provider: provider, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 1), + writerGeneration: Int(sqlite3_column_int64(statement, 2)), + json: json + )) + } + return records + } + + func hookStorageMetrics( + database: OpaquePointer, + provider: String + ) throws -> HookStorageMetrics { + let totals = try prepare( + database, + """ + SELECT COUNT(*), COALESCE(SUM(length(record_json)), 0) + FROM agent_sessions WHERE provider = ?1 + """ + ) + defer { sqlite3_finalize(totals) } + try bind(provider, to: 1, in: totals) + guard try stepRow(totals, database: database, operation: "read hook storage totals") else { + throw corruptRowError(operation: "read hook storage totals") + } + let recordCount = Int(sqlite3_column_int64(totals, 0)) + let recordBytes = sqlite3_column_int64(totals, 1) + + let largest = try prepare( + database, + """ + SELECT session_id, length(record_json) + FROM agent_sessions + WHERE provider = ?1 + ORDER BY length(record_json) DESC, session_id ASC + LIMIT 1 + """ + ) + defer { sqlite3_finalize(largest) } + try bind(provider, to: 1, in: largest) + let hasLargest = try stepRow( + largest, + database: database, + operation: "read largest hook record" + ) + let largestSessionID = hasLargest ? text(largest, column: 0) : nil + let largestBytes = hasLargest ? sqlite3_column_int64(largest, 1) : 0 + + let slots = try prepare( + database, + """ + SELECT COALESCE(SUM(length(record_json)), 0) + FROM agent_active_slots WHERE provider = ?1 + """ + ) + defer { sqlite3_finalize(slots) } + try bind(provider, to: 1, in: slots) + guard try stepRow(slots, database: database, operation: "read hook slot storage") else { + throw corruptRowError(operation: "read hook slot storage") + } + return HookStorageMetrics( + recordCount: recordCount, + recordBytes: recordBytes, + activeSlotBytes: sqlite3_column_int64(slots, 0), + largestRecordSessionID: largestSessionID, + largestRecordBytes: largestBytes + ) + } + + /// Rejects an oversized caller-owned batch before opening SQLite or + /// appending its blobs to the WAL. Reconciliation handles existing history; + /// this bound prevents one API call from creating an unbounded transient + /// database even when the transaction will ultimately roll back. + func validateHookWriteBatch( + provider: String, + records: [Record], + activeSlots: [ActiveSlot] + ) throws { + for record in records where record.json.count > Self.maximumHookRecordBytes { + throw HookStorageLimitError( + scope: .record, + provider: provider, + sessionID: record.sessionID, + observedBytes: Int64(record.json.count), + maximumBytes: Int64(Self.maximumHookRecordBytes) + ) + } + var observedBytes: Int64 = 0 + func include(_ count: Int) throws { + let addition = observedBytes.addingReportingOverflow(Int64(count)) + observedBytes = addition.overflow ? .max : addition.partialValue + guard observedBytes <= Int64(Self.maximumHookProviderBytes) else { + throw HookStorageLimitError( + scope: .provider, + provider: provider, + observedBytes: observedBytes, + maximumBytes: Int64(Self.maximumHookProviderBytes) + ) + } + } + for record in records { try include(record.json.count) } + for slot in activeSlots { try include(slot.json.count) } + } + + /// Reads one session row through its provider/session primary key. + public func hookRecord(provider: String, sessionID: String) throws -> Record? { + try withDatabase { database in + try ensureHookHotPathSchema(database) + return try readTransaction(database) { + try readRecord(database: database, provider: provider, sessionID: sessionID) + } + } + } + + /// Reads the exact workspace and surface slots used by a hook decision, + /// plus the records referenced by those slots. + /// + /// - Returns: The scoped snapshot and deterministic row-read counts. + public func hookActiveContext( + provider: String, + workspaceID: String, + surfaceID: String? + ) throws -> (snapshot: Snapshot, recordsRead: Int, slotsRead: Int) { + try withDatabase { database in + try ensureHookHotPathSchema(database) + return try readTransaction(database) { + var slots: [ActiveSlot] = [] + if let slot = try readSlot( + database: database, + provider: provider, + scope: .workspace, + scopeID: workspaceID + ) { + slots.append(slot) + } + if let surfaceID, + let slot = try readSlot( + database: database, + provider: provider, + scope: .surface, + scopeID: surfaceID + ), + !slots.contains(where: { $0.scope == slot.scope && $0.scopeID == slot.scopeID }) { + slots.append(slot) + } + + let sessionIDs = Set(slots.map(\.sessionID)) + let records = try sessionIDs.compactMap { sessionID in + try readRecord(database: database, provider: provider, sessionID: sessionID) + } + return ( + Snapshot(records: records, activeSlots: slots), + records.count, + slots.count + ) + } + } + } + + /// Reads only active owners for open panels plus exact process-detected + /// sessions. History size does not affect row or blob materialization. + public func hookHibernationSnapshot( + provider: String, + panelContexts: Set, + exactSessionIDs: Set, + maximumRecords: Int, + maximumBytes: Int64 + ) throws -> Snapshot { + let maximumRecords = max(0, maximumRecords) + let maximumBytes = max(0, maximumBytes) + return try withDatabase { database in + try ensureHookHotPathSchema(database) + return try readTransaction(database) { + try hookHibernationSnapshot( + database: database, + provider: provider, + panelContexts: panelContexts, + exactSessionIDs: exactSessionIDs, + maximumRecords: maximumRecords, + maximumBytes: maximumBytes + ) + } + } + } + + /// Finds configured providers that currently own one of the requested + /// surfaces without materializing slot or record JSON. Hibernation uses + /// this indexed lookup to put known panel owners ahead of unrelated legacy + /// sources in the aggregate compatibility-read budget. + public func hookHibernationPanelOwnerProviders( + providers requestedProviders: Set, + panelContexts: Set + ) throws -> Set { + guard !requestedProviders.isEmpty, !panelContexts.isEmpty else { return [] } + return try withDatabase { database in + try ensureHookHotPathSchema(database) + return try readTransaction(database) { + Set(try hookHibernationSurfaceIDsByProvider( + database: database, + requestedProviders: requestedProviders, + panelContexts: panelContexts + ).keys) + } + } + } + + /// Finds providers that own an open surface, unions exact process-detected + /// providers, then materializes only that relevant set. Configuring many + /// unused adapters therefore has constant registry materialization cost. + public func hookHibernationSnapshots( + providers requestedProviders: Set, + panelContexts: Set, + exactSessionIDsByProvider: [String: Set], + maximumProviders: Int, + maximumRecords: Int, + maximumBytes: Int64 + ) throws -> HookHibernationSnapshotsResult { + let maximumProviders = max(0, maximumProviders) + let maximumRecords = max(0, maximumRecords) + let maximumBytes = max(0, maximumBytes) + guard !requestedProviders.isEmpty else { + return HookHibernationSnapshotsResult(snapshots: [:], failedProviders: []) + } + return try withDatabase { database in + try ensureHookHotPathSchema(database) + return try readTransaction(database) { + let surfaceIDsByProvider = try hookHibernationSurfaceIDsByProvider( + database: database, + requestedProviders: requestedProviders, + panelContexts: panelContexts + ) + + var relevantProviders = Set(surfaceIDsByProvider.keys) + for (provider, sessionIDs) in exactSessionIDsByProvider + where requestedProviders.contains(provider) && !sessionIDs.isEmpty { + relevantProviders.insert(provider) + } + let orderedRelevantProviders = relevantProviders.sorted() + let selectedProviders = Array(orderedRelevantProviders.prefix(maximumProviders)) + var failedProviders = Set(orderedRelevantProviders.dropFirst(maximumProviders)) + var snapshots: [String: Snapshot] = Dictionary( + uniqueKeysWithValues: failedProviders.map { + ($0, Snapshot(records: [], activeSlots: [])) + } + ) + var remainingRecords = maximumRecords + var remainingBytes = maximumBytes + for provider in selectedProviders { + let providerSurfaces = surfaceIDsByProvider[provider] ?? [] + let providerContexts = Set(providerSurfaces.map { + HookHibernationPanelContext(workspaceID: "", surfaceID: $0) + }) + do { + let snapshot = try hookHibernationSnapshot( + database: database, + provider: provider, + panelContexts: providerContexts, + exactSessionIDs: exactSessionIDsByProvider[provider] ?? [], + maximumRecords: remainingRecords, + maximumBytes: remainingBytes + ) + let recordBytes = snapshot.records.reduce(into: Int64(0)) { + $0 += Int64($1.json.count) + } + let slotBytes = snapshot.activeSlots.reduce(into: Int64(0)) { + $0 += Int64($1.json.count) + } + remainingRecords -= snapshot.records.count + remainingBytes -= recordBytes + slotBytes + snapshots[provider] = snapshot + } catch { + snapshots[provider] = Snapshot(records: [], activeSlots: []) + failedProviders.insert(provider) + } + } + return HookHibernationSnapshotsResult( + snapshots: snapshots, + failedProviders: failedProviders + ) + } + } + } + + private func hookHibernationSurfaceIDsByProvider( + database: OpaquePointer, + requestedProviders: Set, + panelContexts: Set + ) throws -> [String: Set] { + let providerLookup = try prepare( + database, + """ + SELECT provider FROM agent_active_slots + WHERE scope = 'surface' AND scope_id = ?1 + ORDER BY provider + """ + ) + defer { sqlite3_finalize(providerLookup) } + var surfaceIDsByProvider: [String: Set] = [:] + let surfaceIDs = Set(panelContexts.map(\.surfaceID)).sorted() + for surfaceID in surfaceIDs { + sqlite3_reset(providerLookup) + sqlite3_clear_bindings(providerLookup) + try bind(surfaceID, to: 1, in: providerLookup) + while try stepRow( + providerLookup, + database: database, + operation: "discover hibernation providers" + ) { + guard let provider = text(providerLookup, column: 0) else { + throw corruptRowError(operation: "discover hibernation providers") + } + guard requestedProviders.contains(provider) else { continue } + surfaceIDsByProvider[provider, default: []].insert(surfaceID) + } + } + return surfaceIDsByProvider + } + + private func hookHibernationSnapshot( + database: OpaquePointer, + provider: String, + panelContexts: Set, + exactSessionIDs: Set, + maximumRecords: Int, + maximumBytes: Int64 + ) throws -> Snapshot { + var materializedBytes: Int64 = 0 + func include(_ data: Data, sessionID: String? = nil) throws { + let next = materializedBytes.addingReportingOverflow(Int64(data.count)) + guard !next.overflow, next.partialValue <= maximumBytes else { + throw HookSnapshotLimitError( + scope: .providerBytes, + provider: provider, + sessionID: sessionID, + observed: next.overflow ? .max : next.partialValue, + maximum: maximumBytes + ) + } + materializedBytes = next.partialValue + } + + var slotsByKey: [ActiveSlotKey: ActiveSlot] = [:] + var sessionIDs = exactSessionIDs + let orderedContexts = panelContexts.sorted { + if $0.workspaceID != $1.workspaceID { + return $0.workspaceID < $1.workspaceID + } + return $0.surfaceID < $1.surfaceID + } + for context in orderedContexts { + let key = ActiveSlotKey(scope: .surface, scopeID: context.surfaceID) + guard slotsByKey[key] == nil, + let slot = try readSlot( + database: database, + provider: provider, + scope: key.scope, + scopeID: key.scopeID + ) else { + continue + } + guard try hookHibernationJSONHasSessionID(slot.json, sessionID: slot.sessionID) else { + throw HookListProjectionValidationError(provider: provider) + } + try include(slot.json, sessionID: slot.sessionID) + slotsByKey[key] = slot + sessionIDs.insert(slot.sessionID) + } + + guard sessionIDs.count <= maximumRecords else { + throw HookSnapshotLimitError( + scope: .records, + provider: provider, + observed: Int64(sessionIDs.count), + maximum: Int64(maximumRecords) + ) + } + var records: [Record] = [] + records.reserveCapacity(sessionIDs.count) + for sessionID in sessionIDs.sorted() { + guard let record = try readRecord( + database: database, + provider: provider, + sessionID: sessionID + ) else { + continue + } + guard try hookHibernationJSONHasSessionID(record.json, sessionID: sessionID) else { + throw HookListProjectionValidationError(provider: provider) + } + try include(record.json, sessionID: sessionID) + records.append(record) + } + records.sort { + if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt } + return $0.sessionID < $1.sessionID + } + let slots = slotsByKey.values.sorted { + if $0.scope.rawValue != $1.scope.rawValue { + return $0.scope.rawValue < $1.scope.rawValue + } + return $0.scopeID < $1.scopeID + } + return Snapshot(records: records, activeSlots: slots) + } + + private func hookHibernationJSONHasSessionID( + _ data: Data, + sessionID: String + ) throws -> Bool { + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return false + } + return object["sessionId"] as? String == sessionID + } + + /// Reads incomplete fallback candidates through the indexed panel columns. + /// A surface lookup returns at most the newest candidate; a workspace-only + /// lookup returns at most two rows so callers can reject ambiguity. + public func hookFallbackRecords( + provider: String, + workspaceID: String?, + surfaceID: String? + ) throws -> [Record] { + guard workspaceID != nil || surfaceID != nil else { return [] } + return try withDatabase { database in + try ensureHookHotPathSchema(database) + let predicates: String + let limit: Int32 + if surfaceID != nil { + predicates = "surface_id = ?2" + limit = 1 + } else { + predicates = "workspace_id = ?2" + limit = 2 + } + let statement = try prepare( + database, + """ + SELECT session_id, updated_at, writer_generation, record_json + FROM agent_sessions + WHERE provider = ?1 AND \(predicates) + AND completed_at IS NULL + AND COALESCE(json_extract(record_json, '$.sessionState'), '') != 'ended' + ORDER BY updated_at DESC, session_id ASC + LIMIT ?3 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + try bind(surfaceID ?? workspaceID, to: 2, in: statement) + sqlite3_bind_int(statement, 3, limit) + var records: [Record] = [] + while try stepRow(statement, database: database, operation: "read hook fallback sessions") { + guard let sessionID = text(statement, column: 0), + let json = data(statement, column: 3) else { + throw corruptRowError(operation: "read hook fallback sessions") + } + records.append(Record( + provider: provider, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 1), + writerGeneration: Int(sqlite3_column_int64(statement, 2)), + json: json + )) + } + return records + } + } + + /// Reads running candidates through the indexed runtime-state expression. + /// Process liveness checks intentionally happen after this SQLite read so a + /// slow or recycled PID never extends a writer transaction. + public func hookRunningRecords( + provider: String, + workspaceID: String, + surfaceID: String? + ) throws -> [Record] { + try withDatabase { database in + try ensureHookHotPathSchema(database) + let surfacePredicate = surfaceID == nil ? "" : "AND surface_id = ?3" + let statement = try prepare( + database, + """ + SELECT session_id, updated_at, writer_generation, record_json + FROM agent_sessions + WHERE provider = ?1 AND workspace_id = ?2 + \(surfacePredicate) + AND json_extract(record_json, '$.runtimeStatus') = 'running' + ORDER BY updated_at DESC, session_id ASC + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + try bind(workspaceID, to: 2, in: statement) + if let surfaceID { try bind(surfaceID, to: 3, in: statement) } + var records: [Record] = [] + while try stepRow(statement, database: database, operation: "read running hook sessions") { + guard let sessionID = text(statement, column: 0), + let json = data(statement, column: 3) else { + throw corruptRowError(operation: "read running hook sessions") + } + records.append(Record( + provider: provider, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 1), + writerGeneration: Int(sqlite3_column_int64(statement, 2)), + json: json + )) + } + return records + } + } + + /// Mutates one hook session and only its owned or explicitly addressed + /// active slots. Decoding and transformation run outside the writer + /// transaction. The commit validates only touched rows and retries a + /// conflicting generation within the registry's bounded contention policy. + /// + /// - Parameter sessionID: The only session row the closure may add, update, + /// or delete. + /// - Parameter activeSlots: Destination slots whose current owners must be + /// included in the optimistic comparison. + /// - Parameter includeOwnedSlots: Whether to load every slot currently owned + /// by `sessionID`, used by stop and consume transitions. + /// - Returns: The closure result, committed provider revision, and exact + /// operation counts for deterministic performance tests. + public func mutateHookSession( + provider: String, + sessionID: String, + activeSlots: Set = [], + includeOwnedSlots: Bool = true, + now: TimeInterval = Date().timeIntervalSince1970, + _ mutate: (inout Snapshot) throws -> T + ) throws -> ( + result: T, + revision: Int64, + recordsRead: Int, + slotsRead: Int, + recordsWritten: Int, + slotsWritten: Int + ) { + var lastContentionError: (any Error)? + for _ in 0.. Self.maximumHookRecordBytes + }) { + throw HookStorageLimitError( + scope: .record, + provider: provider, + sessionID: oversized.sessionID, + observedBytes: Int64(oversized.json.count), + maximumBytes: Int64(Self.maximumHookRecordBytes) + ) + } + let previousRecord = previous.records.first { $0.sessionID == sessionID } + let currentRecord = current.records.first { $0.sessionID == sessionID } + let recordChanged = !recordsMatch(previousRecord, currentRecord) + + let previousSlots = Dictionary(uniqueKeysWithValues: previous.activeSlots.map { + (Self.slotKey(scope: $0.scope, scopeID: $0.scopeID), $0) + }) + let currentSlots = Dictionary(uniqueKeysWithValues: current.activeSlots.map { + (Self.slotKey(scope: $0.scope, scopeID: $0.scopeID), $0) + }) + let changedSlotKeys = Set(previousSlots.keys).union(currentSlots.keys).filter { + !slotsMatch(previousSlots[$0], currentSlots[$0]) + } + + do { + let revision = try persistHookMutation( + provider: provider, + sessionID: sessionID, + previousRecord: previousRecord, + currentRecord: currentRecord, + previousSlots: previousSlots, + currentSlots: currentSlots, + changedSlotKeys: Set(changedSlotKeys), + now: now + ) + return ( + result, + revision, + previous.records.count, + previous.activeSlots.count, + recordChanged ? 1 : 0, + changedSlotKeys.count + ) + } catch { + guard isRetryableMutationError(error) else { throw error } + lastContentionError = error + } + } + throw lastContentionError ?? mutationConflictError() + } + + /// Returns a bounded compatibility projection and the exact registry + /// revision represented by it. Every active-slot owner is included, plus + /// the newest 256 inactive records. The canonical registry retains the full + /// history. The row snapshot and revision share one read transaction; JSON + /// encoding happens after that transaction is released. + public func hookLegacyProjection( + provider: String, + preservingTopLevelJSON existingJSON: Data? = nil + ) throws -> (revision: Int64, projectedRevision: Int64, json: Data) { + let captured: (snapshot: Snapshot, revision: Int64, projectedRevision: Int64) = try withDatabase { database in + try ensureHookHotPathSchema(database) + return try readTransaction(database) { + let metadata = try hookProviderRevision(database: database, provider: provider) + let footprint = try hookLegacyProjectionFootprint( + database: database, + provider: provider + ) + guard footprint.recordCount <= Self.maximumHookLegacyProjectionRecords else { + throw HookSnapshotLimitError( + scope: .records, + provider: provider, + observed: Int64(footprint.recordCount), + maximum: Int64(Self.maximumHookLegacyProjectionRecords) + ) + } + guard footprint.totalBytes <= Int64(Self.maximumHookLegacyProjectionBytes) else { + throw HookStorageLimitError( + scope: .legacyProjection, + provider: provider, + observedBytes: footprint.totalBytes, + maximumBytes: Int64(Self.maximumHookLegacyProjectionBytes) + ) + } + return ( + Snapshot( + records: try hookLegacyProjectionRecords( + database: database, + provider: provider + ), + activeSlots: try readSlots(database: database, provider: provider) + ), + metadata.revision, + metadata.projectedRevision + ) + } + } + + var root = existingJSON.flatMap { + try? JSONSerialization.jsonObject(with: $0) as? [String: Any] + } ?? [:] + root["version"] = max(root["version"] as? Int ?? 0, 2) + var sessions: [String: Any] = [:] + sessions.reserveCapacity(captured.snapshot.records.count) + for record in captured.snapshot.records { + guard let object = try JSONSerialization.jsonObject(with: record.json) as? [String: Any], + object["sessionId"] as? String == record.sessionID else { + throw corruptRowError(operation: "project hook session") + } + sessions[record.sessionID] = object + } + var workspaceSlots: [String: Any] = [:] + var surfaceSlots: [String: Any] = [:] + for slot in captured.snapshot.activeSlots { + guard let object = try JSONSerialization.jsonObject(with: slot.json) as? [String: Any], + object["sessionId"] as? String == slot.sessionID else { + throw corruptRowError(operation: "project hook active slot") + } + switch slot.scope { + case .workspace: workspaceSlots[slot.scopeID] = object + case .surface: surfaceSlots[slot.scopeID] = object + } + } + root["sessions"] = sessions + root["activeSessionsByWorkspace"] = workspaceSlots + root["activeSessionsBySurface"] = surfaceSlots + guard JSONSerialization.isValidJSONObject(root) else { + throw CocoaError(.fileWriteInapplicableStringEncoding) + } + let json = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + guard json.count <= Self.maximumHookLegacyProjectionBytes else { + throw HookStorageLimitError( + scope: .legacyProjection, + provider: provider, + observedBytes: Int64(json.count), + maximumBytes: Int64(Self.maximumHookLegacyProjectionBytes) + ) + } + return (captured.revision, captured.projectedRevision, json) + } + + /// Marks an atomically published compatibility file as representing the + /// supplied registry revision. A newer commit can advance `revision` while + /// this method runs, but `projectedRevision` never advances past the exact + /// snapshot that was written. + public func markHookLegacyProjection( + provider: String, + revision: Int64, + stamp: LegacyStamp + ) throws { + try withDatabase { database in + try ensureHookHotPathSchema(database) + try transaction(database, retryBeginContention: false) { + let statement = try prepare( + database, + """ + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at + ) VALUES (?1, 0, ?2, 0) + ON CONFLICT(provider) DO UPDATE SET + projected_revision = MAX(projected_revision, excluded.projected_revision) + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + sqlite3_bind_int64(statement, 2, revision) + try stepDone(statement, database: database, operation: "mark hook legacy projection") + try writeLegacyStamp(database: database, provider: provider, stamp: stamp) + } + } + } + + /// Publishes the complete provider snapshot under the compatibility + /// sidecar's cross-process lock and ensures `requiredRevision` is + /// represented by the file. Lock acquisition is nonblocking so a stuck + /// compatibility writer cannot stall a prompt hook indefinitely. A + /// contending writer first rechecks the published revision and otherwise + /// receives `EWOULDBLOCK`; the lock owner or a later hook converges the file. + /// + /// Cross-process `flock` is required here because Swift actors cannot + /// serialize independent cmux and hook processes. The critical section is + /// bounded to one snapshot encode, atomic rename, and revision mark. + /// + /// - Returns: The exact registry revision represented by the file. + @discardableResult + public func projectHookLegacyStore( + provider: String, + to stateURL: URL, + including requiredRevision: Int64, + fileManager: FileManager = .default + ) throws -> Int64 { + try projectHookLegacyStore( + provider: provider, + to: stateURL, + including: requiredRevision, + fileManager: fileManager, + afterPublishing: {} + ) + } + + @discardableResult + func projectHookLegacyStore( + provider: String, + to stateURL: URL, + including requiredRevision: Int64, + fileManager: FileManager = .default, + afterPublishing: () throws -> Void + ) throws -> Int64 { + let initialStatus = try hookProjectionStatus(provider: provider) + if initialStatus.projectedRevision >= requiredRevision, + let stamp = LegacyStamp.read(path: stateURL.path, fileManager: fileManager), + try legacySourceIsCurrent(provider: provider, stamp: stamp) { + return initialStatus.projectedRevision + } + try fileManager.createDirectory( + at: stateURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] + ) + let descriptor = open( + stateURL.path + ".lock", + O_CREAT | O_RDWR, + mode_t(S_IRUSR | S_IWUSR) + ) + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + defer { Darwin.close(descriptor) } + guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + defer { _ = flock(descriptor, LOCK_UN) } + + let existingData = try? readHookLegacySourceData(at: stateURL) + let existingStamp = LegacyStamp.read(path: stateURL.path, fileManager: fileManager) + let status = try hookProjectionStatus(provider: provider) + if status.projectedRevision >= requiredRevision, + let existingStamp, + try legacySourceIsCurrent(provider: provider, stamp: existingStamp) { + return status.projectedRevision + } + + let projection = try hookLegacyProjection( + provider: provider, + preservingTopLevelJSON: existingData + ) + guard projection.revision >= requiredRevision else { + throw mutationConflictError() + } + try replaceHookLegacyFile( + with: projection.json, + at: stateURL, + fileManager: fileManager + ) + try afterPublishing() + let stamp = try verifiedHookLegacyPublication( + at: stateURL, + expectedJSON: projection.json + ) + try markHookLegacyProjection( + provider: provider, + revision: projection.revision, + stamp: stamp + ) + let finalStamp = try verifiedHookLegacyPublication( + at: stateURL, + expectedJSON: projection.json + ) + guard finalStamp == stamp else { throw mutationConflictError() } + return projection.revision + } + + /// Reads the provider's current and projected compatibility revisions. + public func hookProjectionStatus(provider: String) throws -> (revision: Int64, projectedRevision: Int64) { + try withDatabase { database in + try ensureHookHotPathSchema(database) + return try hookProviderRevision(database: database, provider: provider) + } + } + + func ensureHookHotPathSchema(_ database: OpaquePointer) throws { + if try schemaVersion(database) < 4 { + try transaction(database, retryBeginContention: false) { + guard try schemaVersion(database) < 4 else { return } + try execute( + database, + sql: """ + CREATE TABLE IF NOT EXISTS agent_provider_metadata ( + provider TEXT NOT NULL PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 0, + projected_revision INTEGER NOT NULL DEFAULT 0, + last_pruned_at REAL NOT NULL DEFAULT 0 + ) WITHOUT ROWID; + INSERT OR IGNORE INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at + ) + SELECT provider, 1, 0, 0 FROM agent_sessions GROUP BY provider; + INSERT OR IGNORE INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at + ) + SELECT provider, 1, 0, 0 FROM agent_active_slots GROUP BY provider; + + CREATE INDEX IF NOT EXISTS agent_sessions_retention + ON agent_sessions(provider, updated_at ASC, session_id ASC); + CREATE INDEX IF NOT EXISTS agent_sessions_hook_projection + ON agent_sessions(provider, updated_at DESC, session_id ASC); + CREATE INDEX IF NOT EXISTS agent_sessions_hook_workspace + ON agent_sessions(provider, workspace_id, updated_at DESC, session_id ASC); + CREATE INDEX IF NOT EXISTS agent_sessions_hook_surface + ON agent_sessions(provider, surface_id, updated_at DESC, session_id ASC); + DROP INDEX IF EXISTS agent_sessions_hook_running; + CREATE INDEX agent_sessions_hook_running + ON agent_sessions( + provider, + workspace_id, + json_extract(record_json, '$.runtimeStatus'), + surface_id, + updated_at DESC, + session_id ASC + ); + CREATE INDEX IF NOT EXISTS agent_active_slots_owner + ON agent_active_slots(provider, session_id, scope, scope_id); + + CREATE TRIGGER IF NOT EXISTS agent_sessions_revision_insert + AFTER INSERT ON agent_sessions BEGIN + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at + ) VALUES (NEW.provider, 1, 0, 0) + ON CONFLICT(provider) DO UPDATE SET revision = revision + 1; + END; + CREATE TRIGGER IF NOT EXISTS agent_sessions_revision_update + AFTER UPDATE ON agent_sessions BEGIN + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at + ) VALUES (NEW.provider, 1, 0, 0) + ON CONFLICT(provider) DO UPDATE SET revision = revision + 1; + END; + CREATE TRIGGER IF NOT EXISTS agent_sessions_revision_delete + AFTER DELETE ON agent_sessions BEGIN + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at + ) VALUES (OLD.provider, 1, 0, 0) + ON CONFLICT(provider) DO UPDATE SET revision = revision + 1; + END; + CREATE TRIGGER IF NOT EXISTS agent_active_slots_revision_insert + AFTER INSERT ON agent_active_slots BEGIN + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at + ) VALUES (NEW.provider, 1, 0, 0) + ON CONFLICT(provider) DO UPDATE SET revision = revision + 1; + END; + CREATE TRIGGER IF NOT EXISTS agent_active_slots_revision_update + AFTER UPDATE ON agent_active_slots BEGIN + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at + ) VALUES (NEW.provider, 1, 0, 0) + ON CONFLICT(provider) DO UPDATE SET revision = revision + 1; + END; + CREATE TRIGGER IF NOT EXISTS agent_active_slots_revision_delete + AFTER DELETE ON agent_active_slots BEGIN + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at + ) VALUES (OLD.provider, 1, 0, 0) + ON CONFLICT(provider) DO UPDATE SET revision = revision + 1; + END; + PRAGMA user_version=4; + """ + ) + } + } + if try schemaVersion(database) < 5 { + try transaction(database, retryBeginContention: false) { + guard try schemaVersion(database) < 5 else { return } + let metadataColumns = try hookSchemaColumns( + database, + table: "agent_provider_metadata" + ) + if !metadataColumns.contains("record_bytes") { + try execute( + database, + sql: """ + ALTER TABLE agent_provider_metadata + ADD COLUMN record_bytes INTEGER NOT NULL DEFAULT 0 + """ + ) + } + if !metadataColumns.contains("slot_bytes") { + try execute( + database, + sql: """ + ALTER TABLE agent_provider_metadata + ADD COLUMN slot_bytes INTEGER NOT NULL DEFAULT 0 + """ + ) + } + try execute( + database, + sql: """ + UPDATE agent_provider_metadata SET + record_bytes = COALESCE(( + SELECT SUM(length(record_json)) FROM agent_sessions + WHERE agent_sessions.provider = agent_provider_metadata.provider + ), 0), + slot_bytes = COALESCE(( + SELECT SUM(length(record_json)) FROM agent_active_slots + WHERE agent_active_slots.provider = agent_provider_metadata.provider + ), 0); + + DROP TRIGGER IF EXISTS agent_sessions_revision_insert; + DROP TRIGGER IF EXISTS agent_sessions_revision_update; + DROP TRIGGER IF EXISTS agent_sessions_revision_delete; + DROP TRIGGER IF EXISTS agent_active_slots_revision_insert; + DROP TRIGGER IF EXISTS agent_active_slots_revision_update; + DROP TRIGGER IF EXISTS agent_active_slots_revision_delete; + + CREATE TRIGGER agent_sessions_revision_insert + AFTER INSERT ON agent_sessions BEGIN + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at, + record_bytes, slot_bytes + ) VALUES (NEW.provider, 1, 0, 0, length(NEW.record_json), 0) + ON CONFLICT(provider) DO UPDATE SET + revision = revision + 1, + record_bytes = record_bytes + length(NEW.record_json); + END; + CREATE TRIGGER agent_sessions_revision_update + AFTER UPDATE ON agent_sessions BEGIN + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at, + record_bytes, slot_bytes + ) VALUES ( + NEW.provider, 1, 0, 0, + length(NEW.record_json) - length(OLD.record_json), 0 + ) + ON CONFLICT(provider) DO UPDATE SET + revision = revision + 1, + record_bytes = record_bytes + + length(NEW.record_json) - length(OLD.record_json); + END; + CREATE TRIGGER agent_sessions_revision_delete + AFTER DELETE ON agent_sessions BEGIN + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at, + record_bytes, slot_bytes + ) VALUES (OLD.provider, 1, 0, 0, 0, 0) + ON CONFLICT(provider) DO UPDATE SET + revision = revision + 1, + record_bytes = MAX(0, record_bytes - length(OLD.record_json)); + END; + CREATE TRIGGER agent_active_slots_revision_insert + AFTER INSERT ON agent_active_slots BEGIN + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at, + record_bytes, slot_bytes + ) VALUES (NEW.provider, 1, 0, 0, 0, length(NEW.record_json)) + ON CONFLICT(provider) DO UPDATE SET + revision = revision + 1, + slot_bytes = slot_bytes + length(NEW.record_json); + END; + CREATE TRIGGER agent_active_slots_revision_update + AFTER UPDATE ON agent_active_slots BEGIN + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at, + record_bytes, slot_bytes + ) VALUES ( + NEW.provider, 1, 0, 0, 0, + length(NEW.record_json) - length(OLD.record_json) + ) + ON CONFLICT(provider) DO UPDATE SET + revision = revision + 1, + slot_bytes = slot_bytes + + length(NEW.record_json) - length(OLD.record_json); + END; + CREATE TRIGGER agent_active_slots_revision_delete + AFTER DELETE ON agent_active_slots BEGIN + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at, + record_bytes, slot_bytes + ) VALUES (OLD.provider, 1, 0, 0, 0, 0) + ON CONFLICT(provider) DO UPDATE SET + revision = revision + 1, + slot_bytes = MAX(0, slot_bytes - length(OLD.record_json)); + END; + PRAGMA user_version=5; + """ + ) + let oversized = try prepare( + database, + """ + SELECT provider FROM agent_provider_metadata + WHERE record_bytes + slot_bytes > ?1 ORDER BY provider + """ + ) + defer { sqlite3_finalize(oversized) } + sqlite3_bind_int64(oversized, 1, sqlite3_int64(Self.maximumHookProviderBytes)) + var oversizedProviders: [String] = [] + while try stepRow(oversized, database: database, operation: "read oversized providers") { + if let provider = text(oversized, column: 0) { oversizedProviders.append(provider) } + } + for provider in oversizedProviders { + try reconcileHookProviderStorageLimit( + database: database, + provider: provider, + protectedSessionIDs: [], + previousBytes: .max + ) + } + } + } + if try schemaVersion(database) < 6 { + try transaction(database, retryBeginContention: false) { + guard try schemaVersion(database) < 6 else { return } + if try !hookSchemaColumns(database, table: "agent_legacy_sources") + .contains("quarantined") { + try execute( + database, + sql: """ + ALTER TABLE agent_legacy_sources + ADD COLUMN quarantined INTEGER NOT NULL DEFAULT 0 + """ + ) + } + try execute(database, sql: "PRAGMA user_version=6") + } + } + if try schemaVersion(database) < 7 { + try transaction(database, retryBeginContention: false) { + guard try schemaVersion(database) < 7 else { return } + let legacyColumns = try hookSchemaColumns( + database, + table: "agent_legacy_sources" + ) + for column in [ + "device_id", + "inode", + "modified_seconds", + "modified_nanoseconds", + "changed_seconds", + "changed_nanoseconds", + ] where !legacyColumns.contains(column) { + try execute( + database, + sql: "ALTER TABLE agent_legacy_sources ADD COLUMN \(column) INTEGER" + ) + } + try execute(database, sql: "PRAGMA user_version=7") + } + } + if try schemaVersion(database) < 8 { + try transaction(database, retryBeginContention: false) { + guard try schemaVersion(database) < 8 else { return } + // Provider discovery ignores historical metadata rows whose + // canonical records and slots have both been removed. Open + // panel discovery reverses the active-slot primary-key order + // so it never scans every configured provider per surface. + try execute( + database, + sql: """ + CREATE INDEX IF NOT EXISTS agent_provider_metadata_active + ON agent_provider_metadata(provider) + WHERE record_bytes > 0 OR slot_bytes > 0; + CREATE INDEX IF NOT EXISTS agent_provider_metadata_active_nocase + ON agent_provider_metadata(provider COLLATE NOCASE) + WHERE record_bytes > 0 OR slot_bytes > 0; + CREATE INDEX IF NOT EXISTS agent_active_slots_hibernation_surface + ON agent_active_slots(scope, scope_id, provider); + PRAGMA user_version=8; + """ + ) + } + } + } + + private func hookSchemaColumns( + _ database: OpaquePointer, + table: String + ) throws -> Set { + let allowedTables = ["agent_provider_metadata", "agent_legacy_sources"] + guard allowedTables.contains(table) else { + throw CocoaError(.fileReadCorruptFile) + } + let statement = try prepare(database, "PRAGMA table_info(\(table))") + defer { sqlite3_finalize(statement) } + var columns = Set() + while try stepRow(statement, database: database, operation: "inspect hook schema") { + guard let name = text(statement, column: 1) else { + throw corruptRowError(operation: "inspect hook schema") + } + columns.insert(name) + } + return columns + } + + private func hookMutationContext( + provider: String, + sessionID: String, + activeSlots: Set, + includeOwnedSlots: Bool + ) throws -> Snapshot { + try withDatabase { database in + try ensureHookHotPathSchema(database) + return try readTransaction(database) { + var records: [Record] = [] + if let record = try readRecord( + database: database, + provider: provider, + sessionID: sessionID + ) { + records.append(record) + } + var slotsByKey: [String: ActiveSlot] = [:] + if includeOwnedSlots { + for slot in try hookOwnedSlots( + database: database, + provider: provider, + sessionID: sessionID + ) { + slotsByKey[Self.slotKey(scope: slot.scope, scopeID: slot.scopeID)] = slot + } + } + for key in activeSlots { + if let slot = try readSlot( + database: database, + provider: provider, + scope: key.scope, + scopeID: key.scopeID + ) { + slotsByKey[Self.slotKey(scope: key.scope, scopeID: key.scopeID)] = slot + } + } + return Snapshot(records: records, activeSlots: Array(slotsByKey.values)) + } + } + } + + private func hookOwnedSlots( + database: OpaquePointer, + provider: String, + sessionID: String + ) throws -> [ActiveSlot] { + let statement = try prepare( + database, + """ + SELECT scope, scope_id, updated_at, writer_generation, record_json + FROM agent_active_slots + WHERE provider = ?1 AND session_id = ?2 + ORDER BY scope, scope_id + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + try bind(sessionID, to: 2, in: statement) + var slots: [ActiveSlot] = [] + while try stepRow(statement, database: database, operation: "read owned hook slots") { + guard let scopeValue = text(statement, column: 0), + let scope = Scope(rawValue: scopeValue), + let scopeID = text(statement, column: 1), + let json = data(statement, column: 4) else { + throw corruptRowError(operation: "read owned hook slots") + } + slots.append(ActiveSlot( + provider: provider, + scope: scope, + scopeID: scopeID, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 2), + writerGeneration: Int(sqlite3_column_int64(statement, 3)), + json: json + )) + } + return slots + } + + private func hookLegacyProjectionRecords( + database: OpaquePointer, + provider: String + ) throws -> [Record] { + let statement = try prepare( + database, + """ + WITH recent_inactive AS ( + SELECT session.session_id + FROM agent_sessions AS session + WHERE session.provider = ?1 + AND NOT EXISTS ( + SELECT 1 + FROM agent_active_slots AS slot + WHERE slot.provider = session.provider + AND slot.session_id = session.session_id + ) + ORDER BY session.updated_at DESC, session.session_id ASC + LIMIT 256 + ) + SELECT session.session_id, + session.updated_at, + session.writer_generation, + session.record_json + FROM agent_sessions AS session + WHERE session.provider = ?1 + AND ( + EXISTS ( + SELECT 1 + FROM agent_active_slots AS slot + WHERE slot.provider = session.provider + AND slot.session_id = session.session_id + ) + OR session.session_id IN ( + SELECT session_id FROM recent_inactive + ) + ) + ORDER BY session.updated_at DESC, session.session_id ASC + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + var records: [Record] = [] + while try stepRow( + statement, + database: database, + operation: "read hook compatibility sessions" + ) { + guard let sessionID = text(statement, column: 0), + let json = data(statement, column: 3) else { + throw corruptRowError(operation: "read hook compatibility sessions") + } + records.append(Record( + provider: provider, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 1), + writerGeneration: Int(sqlite3_column_int64(statement, 2)), + json: json + )) + } + return records + } + + /// Counts the exact rows selected by `hookLegacyProjectionRecords` and all + /// active-slot blobs before either query copies JSON into Swift memory. + private func hookLegacyProjectionFootprint( + database: OpaquePointer, + provider: String + ) throws -> (recordCount: Int, totalBytes: Int64) { + let records = try prepare( + database, + """ + WITH recent_inactive AS ( + SELECT session.session_id + FROM agent_sessions AS session + WHERE session.provider = ?1 + AND NOT EXISTS ( + SELECT 1 FROM agent_active_slots AS slot + WHERE slot.provider = session.provider + AND slot.session_id = session.session_id + ) + ORDER BY session.updated_at DESC, session.session_id ASC + LIMIT 256 + ) + SELECT COUNT(*), COALESCE(SUM(length(session.record_json)), 0) + FROM agent_sessions AS session + WHERE session.provider = ?1 + AND ( + EXISTS ( + SELECT 1 FROM agent_active_slots AS slot + WHERE slot.provider = session.provider + AND slot.session_id = session.session_id + ) + OR session.session_id IN (SELECT session_id FROM recent_inactive) + ) + """ + ) + defer { sqlite3_finalize(records) } + try bind(provider, to: 1, in: records) + guard try stepRow( + records, + database: database, + operation: "read hook compatibility footprint" + ) else { + throw corruptRowError(operation: "read hook compatibility footprint") + } + let recordCount = Int(sqlite3_column_int64(records, 0)) + let recordBytes = sqlite3_column_int64(records, 1) + + let slots = try prepare( + database, + """ + SELECT COALESCE(SUM(length(record_json)), 0) + FROM agent_active_slots WHERE provider = ?1 + """ + ) + defer { sqlite3_finalize(slots) } + try bind(provider, to: 1, in: slots) + guard try stepRow( + slots, + database: database, + operation: "read hook compatibility slot footprint" + ) else { + throw corruptRowError(operation: "read hook compatibility slot footprint") + } + let slotBytes = sqlite3_column_int64(slots, 0) + let total = recordBytes.addingReportingOverflow(slotBytes) + return (recordCount, total.overflow ? .max : total.partialValue) + } + + private func persistHookMutation( + provider: String, + sessionID: String, + previousRecord: Record?, + currentRecord: Record?, + previousSlots: [String: ActiveSlot], + currentSlots: [String: ActiveSlot], + changedSlotKeys: Set, + now: TimeInterval + ) throws -> Int64 { + try withDatabase { database in + try ensureHookHotPathSchema(database) + return try transaction(database, retryBeginContention: false) { + let previousProviderBytes = try hookProviderStorageBytes( + database: database, + provider: provider + ) + guard recordsMatch( + try readRecord(database: database, provider: provider, sessionID: sessionID), + previousRecord + ) else { throw mutationConflictError() } + for key in changedSlotKeys { + guard let reference = previousSlots[key] ?? currentSlots[key], + slotsMatch( + try readSlot( + database: database, + provider: provider, + scope: reference.scope, + scopeID: reference.scopeID + ), + previousSlots[key] + ) else { throw mutationConflictError() } + } + + if !recordsMatch(previousRecord, currentRecord) { + if var currentRecord { + currentRecord.provider = provider + try upsert(currentRecord, database: database) + } else { + try deleteSession( + database: database, + provider: provider, + sessionID: sessionID, + maximumWriterGeneration: Self.currentWriterGeneration + ) + } + } + for key in changedSlotKeys { + if var slot = currentSlots[key] { + slot.provider = provider + try upsert(slot, database: database) + } else if let slot = previousSlots[key] { + try deleteSlot( + database: database, + provider: provider, + scope: slot.scope, + scopeID: slot.scopeID, + maximumWriterGeneration: Self.currentWriterGeneration + ) + } + } + try maintainHookRowsIfNeeded(database: database, provider: provider, now: now) + try reconcileHookProviderStorageLimit( + database: database, + provider: provider, + protectedSessionIDs: [sessionID], + previousBytes: previousProviderBytes + ) + return try hookProviderRevision(database: database, provider: provider).revision + } + } + } + + func hookProviderStorageBytes( + database: OpaquePointer, + provider: String + ) throws -> Int64 { + let statement = try prepare( + database, + """ + SELECT record_bytes + slot_bytes FROM agent_provider_metadata + WHERE provider = ?1 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + guard try stepRow(statement, database: database, operation: "read provider storage") else { + return 0 + } + return sqlite3_column_int64(statement, 0) + } + + /// Removes oldest inactive current-generation history before rejecting a + /// growing write. The touched session and every active/future-generation row + /// survive. An already-oversized provider may make a non-growing transition, + /// allowing stop/deactivation to recover a store migrated from an old build. + func reconcileHookProviderStorageLimit( + database: OpaquePointer, + provider: String, + protectedSessionIDs: Set, + previousBytes: Int64 + ) throws { + var currentBytes = try hookProviderStorageBytes(database: database, provider: provider) + guard currentBytes > Int64(Self.maximumHookProviderBytes) else { return } + + let candidates = try prepare( + database, + """ + SELECT session_id, length(record_json) + FROM agent_sessions AS session + WHERE provider = ?1 AND writer_generation <= ?2 + AND NOT EXISTS ( + SELECT 1 FROM agent_active_slots AS slot + WHERE slot.provider = session.provider + AND slot.session_id = session.session_id + ) + ORDER BY updated_at ASC, session_id ASC + """ + ) + defer { sqlite3_finalize(candidates) } + try bind(provider, to: 1, in: candidates) + sqlite3_bind_int64(candidates, 2, sqlite3_int64(Self.currentWriterGeneration)) + var removable: [(sessionID: String, bytes: Int64)] = [] + while try stepRow(candidates, database: database, operation: "read storage prune candidates") { + guard let sessionID = text(candidates, column: 0), + !protectedSessionIDs.contains(sessionID) else { continue } + removable.append((sessionID, sqlite3_column_int64(candidates, 1))) + } + for candidate in removable where currentBytes > Int64(Self.maximumHookProviderBytes) { + try deleteSession( + database: database, + provider: provider, + sessionID: candidate.sessionID, + maximumWriterGeneration: Self.currentWriterGeneration + ) + currentBytes = max(0, currentBytes - candidate.bytes) + } + currentBytes = try hookProviderStorageBytes(database: database, provider: provider) + guard currentBytes <= Int64(Self.maximumHookProviderBytes) + || currentBytes <= previousBytes else { + throw HookStorageLimitError( + scope: .provider, + provider: provider, + observedBytes: currentBytes, + maximumBytes: Int64(Self.maximumHookProviderBytes) + ) + } + } + + private func hookProviderRevision( + database: OpaquePointer, + provider: String + ) throws -> (revision: Int64, projectedRevision: Int64) { + let statement = try prepare( + database, + """ + SELECT revision, projected_revision FROM agent_provider_metadata + WHERE provider = ?1 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + guard try stepRow(statement, database: database, operation: "read hook provider revision") else { + return (0, 0) + } + return (sqlite3_column_int64(statement, 0), sqlite3_column_int64(statement, 1)) + } + + private func maintainHookRowsIfNeeded( + database: OpaquePointer, + provider: String, + now: TimeInterval + ) throws { + let metadata = try prepare( + database, + "SELECT last_pruned_at FROM agent_provider_metadata WHERE provider = ?1" + ) + defer { sqlite3_finalize(metadata) } + try bind(provider, to: 1, in: metadata) + let lastPrunedAt: TimeInterval + if try stepRow(metadata, database: database, operation: "read hook maintenance time") { + lastPrunedAt = sqlite3_column_double(metadata, 0) + } else { + lastPrunedAt = 0 + } + guard now - lastPrunedAt >= 60 else { return } + + let cutoff = now - (60 * 60 * 24 * 7) + let expired = try prepare( + database, + """ + DELETE FROM agent_sessions + WHERE provider = ?1 AND updated_at < ?2 + AND writer_generation <= ?3 + AND session_id NOT IN ( + SELECT session_id FROM agent_active_slots WHERE provider = ?1 + ) + """ + ) + try bind(provider, to: 1, in: expired) + sqlite3_bind_double(expired, 2, cutoff) + sqlite3_bind_int64(expired, 3, sqlite3_int64(Self.currentWriterGeneration)) + try stepDone(expired, database: database, operation: "prune expired hook sessions") + sqlite3_finalize(expired) + + let overflow = try prepare( + database, + """ + DELETE FROM agent_sessions + WHERE provider = ?1 AND session_id IN ( + SELECT session_id FROM agent_sessions + WHERE provider = ?1 + AND writer_generation <= ?2 + AND session_id NOT IN ( + SELECT session_id FROM agent_active_slots WHERE provider = ?1 + ) + ORDER BY updated_at ASC, session_id ASC + LIMIT MAX(0, ( + SELECT COUNT(*) - 10000 FROM agent_sessions WHERE provider = ?1 + )) + ) + """ + ) + try bind(provider, to: 1, in: overflow) + sqlite3_bind_int64(overflow, 2, sqlite3_int64(Self.currentWriterGeneration)) + try stepDone(overflow, database: database, operation: "cap hook sessions") + sqlite3_finalize(overflow) + + let danglingSlots = try prepare( + database, + """ + DELETE FROM agent_active_slots AS slot + WHERE slot.provider = ?1 AND ( + NOT EXISTS ( + SELECT 1 FROM agent_sessions AS session + WHERE session.provider = slot.provider + AND session.session_id = slot.session_id + ) + OR NOT EXISTS ( + SELECT 1 FROM agent_sessions AS session + WHERE session.provider = slot.provider + AND session.session_id = slot.session_id + AND CASE slot.scope + WHEN 'workspace' THEN session.workspace_id = slot.scope_id + WHEN 'surface' THEN session.surface_id = slot.scope_id + ELSE 0 + END + ) + ) AND slot.writer_generation <= ?2 + """ + ) + try bind(provider, to: 1, in: danglingSlots) + sqlite3_bind_int64(danglingSlots, 2, sqlite3_int64(Self.currentWriterGeneration)) + try stepDone(danglingSlots, database: database, operation: "prune dangling hook slots") + sqlite3_finalize(danglingSlots) + + let mark = try prepare( + database, + """ + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at + ) VALUES (?1, 0, 0, ?2) + ON CONFLICT(provider) DO UPDATE SET last_pruned_at = excluded.last_pruned_at + """ + ) + defer { sqlite3_finalize(mark) } + try bind(provider, to: 1, in: mark) + sqlite3_bind_double(mark, 2, now) + try stepDone(mark, database: database, operation: "mark hook maintenance") + } + + private func replaceHookLegacyFile( + with data: Data, + at stateURL: URL, + fileManager: FileManager + ) throws { + let parentURL = stateURL.deletingLastPathComponent() + try fileManager.createDirectory( + at: parentURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] + ) + try? fileManager.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: parentURL.path + ) + let temporaryURL = parentURL.appendingPathComponent( + ".\(stateURL.lastPathComponent).\(UUID().uuidString).tmp" + ) + guard fileManager.createFile( + atPath: temporaryURL.path, + contents: data, + attributes: [.posixPermissions: NSNumber(value: Int16(0o600))] + ) else { + throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: stateURL.path]) + } + let renameResult = temporaryURL.path.withCString { source in + stateURL.path.withCString { destination in + Darwin.rename(source, destination) + } + } + if renameResult != 0 { + let code = POSIXErrorCode(rawValue: errno) ?? .EIO + try? fileManager.removeItem(at: temporaryURL) + throw POSIXError(code) + } + try? fileManager.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o600))], + ofItemAtPath: stateURL.path + ) + } + + private func verifiedHookLegacyPublication( + at stateURL: URL, + expectedJSON: Data + ) throws -> LegacyStamp { + let descriptor = open(stateURL.path, O_RDONLY | O_CLOEXEC) + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + defer { Darwin.close(descriptor) } + + var openedStat = stat() + guard fstat(descriptor, &openedStat) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + guard openedStat.st_size == off_t(expectedJSON.count) else { + throw mutationConflictError() + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + var publishedJSON = Data() + publishedJSON.reserveCapacity(expectedJSON.count) + while publishedJSON.count <= expectedJSON.count { + let readCount = min( + 64 * 1_024, + expectedJSON.count + 1 - publishedJSON.count + ) + guard readCount > 0, + let chunk = try handle.read(upToCount: readCount), + !chunk.isEmpty else { + break + } + publishedJSON.append(chunk) + } + guard publishedJSON == expectedJSON else { throw mutationConflictError() } + + var pathStat = stat() + guard lstat(stateURL.path, &pathStat) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + guard openedStat.st_dev == pathStat.st_dev, + openedStat.st_ino == pathStat.st_ino else { + throw mutationConflictError() + } + return LegacyStamp(path: stateURL.path, metadata: openedStat) + } + + /// Reads compatibility JSON from one descriptor with a strict allocation cap. + /// The descriptor is checked before and during the read, so concurrent growth + /// cannot make the caller allocate beyond `maximumBytes + 1`. + public func readHookLegacySourceData( + at url: URL, + maximumBytes: Int64 = 64 * 1_024 * 1_024 + ) throws -> Data { + let data = try readHookLegacySourceDataUnvalidated(at: url, maximumBytes: maximumBytes) + _ = try scanHookLegacySourceData(data, path: url.path) + return data + } + + func readHookLegacySourceDataUnvalidated( + at url: URL, + maximumBytes: Int64 + ) throws -> Data { + try readHookLegacySourceRevisionUnvalidated( + at: url, + maximumBytes: maximumBytes + ).data + } + + struct HookLegacySourceDescriptorRevision { + var data: Data + var stamp: LegacyStamp + } + + /// Opens one compatibility revision and derives its stamp from that same + /// descriptor. A path replacement after `open` cannot change the bytes or + /// stamp returned to the caller. + func readHookLegacySourceRevisionUnvalidated( + at url: URL, + maximumBytes: Int64 + ) throws -> HookLegacySourceDescriptorRevision { + let maximumBytes = max(0, maximumBytes) + // O_NONBLOCK closes the race between the path check and open: if the + // path names a FIFO, opening the descriptor still cannot hang. + let descriptor = open(url.path, O_RDONLY | O_CLOEXEC | O_NONBLOCK) + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + defer { Darwin.close(descriptor) } + + var metadata = stat() + guard fstat(descriptor, &metadata) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + guard metadata.st_mode & S_IFMT == S_IFREG else { + throw POSIXError(.EFTYPE) + } + guard metadata.st_size <= maximumBytes else { + throw HookLegacySourceSizeError( + path: url.path, + observedBytes: Int64(metadata.st_size), + maximumBytes: maximumBytes + ) + } + + let maximumCount = Int(maximumBytes) + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + var data = Data() + data.reserveCapacity(min(Int(metadata.st_size), maximumCount)) + while data.count <= maximumCount { + let readCount = min(64 * 1_024, maximumCount + 1 - data.count) + guard readCount > 0, + let chunk = try handle.read(upToCount: readCount), + !chunk.isEmpty else { + break + } + data.append(chunk) + } + guard data.count <= maximumCount else { + throw HookLegacySourceSizeError( + path: url.path, + observedBytes: Int64(data.count), + maximumBytes: maximumBytes + ) + } + var finalMetadata = stat() + guard fstat(descriptor, &finalMetadata) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + guard metadata.st_dev == finalMetadata.st_dev, + metadata.st_ino == finalMetadata.st_ino, + metadata.st_size == finalMetadata.st_size, + metadata.st_mtimespec.tv_sec == finalMetadata.st_mtimespec.tv_sec, + metadata.st_mtimespec.tv_nsec == finalMetadata.st_mtimespec.tv_nsec, + metadata.st_ctimespec.tv_sec == finalMetadata.st_ctimespec.tv_sec, + metadata.st_ctimespec.tv_nsec == finalMetadata.st_ctimespec.tv_nsec, + Int64(data.count) == Int64(metadata.st_size) + else { + throw HookLegacySourceRevisionChangedError(path: url.path) + } + return HookLegacySourceDescriptorRevision( + data: data, + stamp: LegacyStamp(path: url.path, metadata: metadata) + ) + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+LegacyRefresh.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+LegacyRefresh.swift new file mode 100644 index 000000000000..87e23c401b91 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+LegacyRefresh.swift @@ -0,0 +1,73 @@ +extension CmuxAgentSessionRegistry { + /// Aggregate compatibility bytes one refresh call may open. Individual + /// sources keep the same 64 MiB ceiling, while a many-provider restore can + /// no longer retain or scan that ceiling once per configured adapter. + public static let maximumLegacyRefreshReadBytes: Int64 = 64 * 1_024 * 1_024 + + /// The exact durable owner carried by one hibernated panel in an app + /// snapshot. Session restore uses this context only when a compatibility + /// sidecar cannot be parsed; normal legacy imports remain authoritative for + /// generation-zero rows. + public struct RestoreOwnerContext: Hashable, Sendable { + public var provider: String + public var sessionID: String + public var workspaceID: String + public var surfaceID: String + + public init( + provider: String, + sessionID: String, + workspaceID: String, + surfaceID: String + ) { + self.provider = provider + self.sessionID = sessionID + self.workspaceID = workspaceID + self.surfaceID = surfaceID + } + } + + /// The provider-level outcome of refreshing compatibility JSON before restore. + public struct LegacyRefreshResult: Equatable, Sendable { + /// Providers whose changed compatibility JSON was imported successfully. + public var refreshedProviders: Set + + /// Providers whose ownership state could not be verified from JSON or SQLite. + public var failedProviders: Set + + /// Providers skipped before opening their changed compatibility source + /// because its stamped size did not fit the remaining aggregate budget. + /// These providers are also present in `failedProviders`. + public var readBudgetExceededProviders: Set + + /// A conservative upper bound on bytes opened by this refresh. Each + /// attempted revision reserves its stamped size before the descriptor + /// is opened, including malformed or concurrently replaced revisions. + public var sourceReadBudgetUsed: Int64 + + /// Exact current-generation owners that remain trustworthy even though + /// their provider's compatibility sidecar was missing or malformed. + public var verifiedCanonicalRestoreOwners: Set + + /// Creates a provider-level compatibility refresh outcome. + /// + /// - Parameters: + /// - refreshedProviders: Providers imported during this refresh. + /// - failedProviders: Providers whose ownership state was unavailable or malformed. + /// - readBudgetExceededProviders: Providers skipped by the aggregate read budget. + /// - sourceReadBudgetUsed: Stamped bytes reserved by attempted source reads. + public init( + refreshedProviders: Set, + failedProviders: Set, + readBudgetExceededProviders: Set = [], + sourceReadBudgetUsed: Int64 = 0, + verifiedCanonicalRestoreOwners: Set = [] + ) { + self.refreshedProviders = refreshedProviders + self.failedProviders = failedProviders + self.readBudgetExceededProviders = readBudgetExceededProviders + self.sourceReadBudgetUsed = sourceReadBudgetUsed + self.verifiedCanonicalRestoreOwners = verifiedCanonicalRestoreOwners + } + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+LegacySQLite.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+LegacySQLite.swift new file mode 100644 index 000000000000..2d984d6fce63 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+LegacySQLite.swift @@ -0,0 +1,235 @@ +import Foundation +import SQLite3 + +extension CmuxAgentSessionRegistry { + enum LegacySourceState: Equatable { + case changed + case imported + case quarantined + } + + func deleteLegacyRows( + database: OpaquePointer, + provider: String, + preservingSessionRows: Bool = false + ) throws { + let tables = preservingSessionRows + ? ["agent_active_slots"] + : ["agent_sessions", "agent_active_slots"] + for table in tables { + let statement = try prepare( + database, + "DELETE FROM \(table) WHERE provider = ?1 AND writer_generation = 0" + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + try stepDone(statement, database: database, operation: "delete legacy rows") + } + } + + func legacySourceIsCurrent( + database: OpaquePointer, + provider: String, + stamp: LegacyStamp + ) throws -> Bool { + try legacySourceState(database: database, provider: provider, stamp: stamp) == .imported + } + + func legacySourceCanBeSkippedForCanonicalRebind( + database: OpaquePointer, + provider: String, + stamp: LegacyStamp + ) throws -> Bool { + try legacySourceState(database: database, provider: provider, stamp: stamp) != .changed + } + + func legacySourceState( + database: OpaquePointer, + provider: String, + stamp: LegacyStamp + ) throws -> LegacySourceState { + guard let deviceID = stamp.deviceID, + let inode = stamp.inode, + let modifiedSeconds = stamp.modifiedSeconds, + let modifiedNanoseconds = stamp.modifiedNanoseconds, + let changedSeconds = stamp.changedSeconds, + let changedNanoseconds = stamp.changedNanoseconds + else { + return .changed + } + let statement = try prepare( + database, + """ + SELECT size, quarantined, + device_id, inode, + modified_seconds, modified_nanoseconds, + changed_seconds, changed_nanoseconds + FROM agent_legacy_sources + WHERE provider = ?1 AND path = ?2 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + try bind(stamp.path, to: 2, in: statement) + guard try stepRow(statement, database: database, operation: "read legacy checkpoint") else { + return .changed + } + guard sqlite3_column_int64(statement, 0) == stamp.size, + sqlite3_column_type(statement, 2) != SQLITE_NULL, + sqlite3_column_int64(statement, 2) == deviceID, + sqlite3_column_type(statement, 3) != SQLITE_NULL, + sqlite3_column_int64(statement, 3) == inode, + sqlite3_column_type(statement, 4) != SQLITE_NULL, + sqlite3_column_int64(statement, 4) == modifiedSeconds, + sqlite3_column_type(statement, 5) != SQLITE_NULL, + sqlite3_column_int64(statement, 5) == modifiedNanoseconds, + sqlite3_column_type(statement, 6) != SQLITE_NULL, + sqlite3_column_int64(statement, 6) == changedSeconds, + sqlite3_column_type(statement, 7) != SQLITE_NULL, + sqlite3_column_int64(statement, 7) == changedNanoseconds else { + return .changed + } + return sqlite3_column_int(statement, 1) == 0 ? .imported : .quarantined + } + + func replaceLegacy( + database: OpaquePointer, + provider: String, + stamp: LegacyStamp, + payload: LegacyPayload + ) throws { + try validateHookWriteBatch( + provider: provider, + records: payload.records, + activeSlots: payload.activeSlots + ) + let previousProviderBytes = try hookProviderStorageBytes( + database: database, + provider: provider + ) + // Once cmux has published a bounded compatibility projection, absence + // from legacy JSON no longer means a canonical session was deleted. + // Older writers see only active owners plus recent history and can + // safely append or update generation-zero records. Active slots remain + // a complete projection, so their removals must still propagate. + let preservesOmittedSessions = try hookProjectionHasPublished( + database: database, + provider: provider + ) + try deleteLegacyRows( + database: database, + provider: provider, + preservingSessionRows: preservesOmittedSessions + ) + for var record in payload.records { + record.provider = provider + record.writerGeneration = 0 + try upsert(record, database: database) + } + for var slot in payload.activeSlots { + slot.provider = provider + slot.writerGeneration = 0 + try upsert(slot, database: database) + } + try reconcileHookProviderStorageLimit( + database: database, + provider: provider, + protectedSessionIDs: [], + previousBytes: previousProviderBytes + ) + try writeLegacyStamp(database: database, provider: provider, stamp: stamp) + } + + private func hookProjectionHasPublished( + database: OpaquePointer, + provider: String + ) throws -> Bool { + let table = try prepare( + database, + """ + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = 'agent_provider_metadata' + """ + ) + defer { sqlite3_finalize(table) } + guard try stepRow(table, database: database, operation: "find hook projection metadata") else { + return false + } + + let statement = try prepare( + database, + """ + SELECT projected_revision > 0 + FROM agent_provider_metadata + WHERE provider = ?1 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + guard try stepRow( + statement, + database: database, + operation: "read hook projection publication" + ) else { + return false + } + return sqlite3_column_int(statement, 0) != 0 + } + + func writeLegacyStamp( + database: OpaquePointer, + provider: String, + stamp: LegacyStamp, + quarantined: Bool = false + ) throws { + let statement = try prepare( + database, + """ + INSERT INTO agent_legacy_sources ( + provider, path, size, modified_at, imported_at, quarantined, + device_id, inode, + modified_seconds, modified_nanoseconds, + changed_seconds, changed_nanoseconds + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + ON CONFLICT(provider, path) DO UPDATE SET + size = excluded.size, + modified_at = excluded.modified_at, + imported_at = excluded.imported_at, + quarantined = excluded.quarantined, + device_id = excluded.device_id, + inode = excluded.inode, + modified_seconds = excluded.modified_seconds, + modified_nanoseconds = excluded.modified_nanoseconds, + changed_seconds = excluded.changed_seconds, + changed_nanoseconds = excluded.changed_nanoseconds + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + try bind(stamp.path, to: 2, in: statement) + sqlite3_bind_int64(statement, 3, stamp.size) + sqlite3_bind_double(statement, 4, stamp.modifiedAt) + sqlite3_bind_double(statement, 5, Date().timeIntervalSince1970) + sqlite3_bind_int(statement, 6, quarantined ? 1 : 0) + bindOptionalInt64(stamp.deviceID, to: 7, in: statement) + bindOptionalInt64(stamp.inode, to: 8, in: statement) + bindOptionalInt64(stamp.modifiedSeconds, to: 9, in: statement) + bindOptionalInt64(stamp.modifiedNanoseconds, to: 10, in: statement) + bindOptionalInt64(stamp.changedSeconds, to: 11, in: statement) + bindOptionalInt64(stamp.changedNanoseconds, to: 12, in: statement) + try stepDone(statement, database: database, operation: "write legacy checkpoint") + } + + private func bindOptionalInt64( + _ value: Int64?, + to index: Int32, + in statement: OpaquePointer + ) { + if let value { + sqlite3_bind_int64(statement, index, value) + } else { + sqlite3_bind_null(statement, index) + } + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+RecordRebindBatch.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+RecordRebindBatch.swift new file mode 100644 index 000000000000..8e2d50ae4e3a --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+RecordRebindBatch.swift @@ -0,0 +1,77 @@ +public import Foundation + +extension CmuxAgentSessionRegistry { + /// A synchronous, transaction-scoped view used to restore many indexed + /// hibernated rows without reopening SQLite for every panel. + public final class RecordRebindBatch { + let registry: CmuxAgentSessionRegistry + var database: OpaquePointer? + + init(registry: CmuxAgentSessionRegistry, database: OpaquePointer) { + self.registry = registry + self.database = database + } + + func invalidate() { + database = nil + } + + /// Returns the session that owns an active slot inside this batch's transaction. + /// + /// - Parameters: + /// - provider: The provider namespace containing the slot. + /// - key: The workspace or surface slot to inspect. + /// - Returns: The owning session identifier, or `nil` when the slot is unoccupied. + public func activeSlotSessionID( + provider: String, + key: ActiveSlotKey + ) throws -> String? { + guard let database else { throw CocoaError(.fileReadUnknown) } + return try registry.readSlot( + database: database, + provider: provider, + scope: key.scope, + scopeID: key.scopeID + )?.sessionID + } + + /// Rebinds one record and its active slots atomically in this batch's transaction. + /// + /// - Parameters: + /// - provider: The provider namespace containing the record. + /// - sessionID: The durable session identifier to rebind. + /// - updatedAt: The timestamp written to the record and active slots. + /// - previousSlots: Slots that may be removed when owned by `sessionID`. + /// - activeSlots: Slots the rebound session must own after the mutation. + /// - requireExistingActiveSlots: Whether every active slot must already be owned by `sessionID`. + /// - monotonicUpdatedAt: Whether the write must preserve the greatest timestamp already owned by the record or slots. + /// - shouldMutate: A predicate that validates the current record before mutation. + /// - mutate: The record mutation applied after ownership validation. + /// - Returns: Whether the record was patched, missing, or rejected. + public func patchRecordRebindingActiveSlots( + provider: String, + sessionID: String, + updatedAt: TimeInterval, + previousSlots: [ActiveSlotKey], + activeSlots: [ActiveSlotKey], + requireExistingActiveSlots: Bool = false, + monotonicUpdatedAt: Bool = false, + shouldMutate: ([String: Any]) -> Bool = { _ in true }, + mutate: (inout [String: Any]) -> Void + ) throws -> RecordRebindResult { + guard let database else { throw CocoaError(.fileReadUnknown) } + return try registry.patchRecordRebindingActiveSlots( + database: database, + provider: provider, + sessionID: sessionID, + updatedAt: updatedAt, + previousSlots: previousSlots, + activeSlots: activeSlots, + requireExistingActiveSlots: requireExistingActiveSlots, + monotonicUpdatedAt: monotonicUpdatedAt, + shouldMutate: shouldMutate, + mutate: mutate + ) + } + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+SQLite.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+SQLite.swift new file mode 100644 index 000000000000..f22beb632fa4 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry+SQLite.swift @@ -0,0 +1,787 @@ +public import Foundation +import Darwin +import SQLite3 + +extension CmuxAgentSessionRegistry { + func withDatabase(_ body: (OpaquePointer) throws -> T) throws -> T { + let stateDirectory = url.deletingLastPathComponent() + var isDirectory: ObjCBool = false + let directoryAlreadyExists = FileManager.default.fileExists( + atPath: stateDirectory.path, + isDirectory: &isDirectory + ) + if !directoryAlreadyExists { + try FileManager.default.createDirectory( + at: stateDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] + ) + } + // `.cmuxterm` is cmux-owned and older releases may have created it + // too broadly. An explicit registry URL can live in `/tmp`, a project, + // or another shared directory, so never chmod an arbitrary existing + // parent as a side effect of opening one database file. + if !directoryAlreadyExists || stateDirectory.lastPathComponent == ".cmuxterm" { + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: stateDirectory.path + ) + } + var database: OpaquePointer? + let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX + guard sqlite3_open_v2(url.path, &database, flags, nil) == SQLITE_OK, let database else { + defer { if let database { sqlite3_close(database) } } + throw error(database, operation: "open") + } + defer { sqlite3_close(database) } + sqlite3_busy_timeout(database, busyTimeoutMilliseconds) + if try schemaVersion(database) < 1 { + try execute(database, sql: "PRAGMA journal_mode=WAL") + try transaction(database, retryBeginContention: false) { + guard try schemaVersion(database) < 1 else { return } + try migrate(database) + } + } + try ensureHookHotPathSchema(database) + try execute(database, sql: "PRAGMA synchronous=NORMAL") + for path in [url.path, url.path + "-wal", url.path + "-shm"] { + if FileManager.default.fileExists(atPath: path) { + _ = chmod(path, S_IRUSR | S_IWUSR) + } + } + return try body(database) + } + + func schemaVersion(_ database: OpaquePointer) throws -> Int { + let statement = try prepare(database, "PRAGMA user_version") + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { + throw error(database, operation: "read schema version") + } + return Int(sqlite3_column_int64(statement, 0)) + } + + func migrate(_ database: OpaquePointer) throws { + try execute( + database, + sql: """ + CREATE TABLE IF NOT EXISTS agent_sessions ( + provider TEXT NOT NULL, + session_id TEXT NOT NULL, + updated_at REAL NOT NULL, + writer_generation INTEGER NOT NULL, + workspace_id TEXT, + surface_id TEXT, + runtime_id TEXT, + completed_at REAL, + restore_authority INTEGER, + parent_session_id TEXT, + active_run_id TEXT, + record_json BLOB NOT NULL, + PRIMARY KEY (provider, session_id) + ) WITHOUT ROWID; + CREATE INDEX IF NOT EXISTS agent_sessions_runtime + ON agent_sessions(runtime_id, provider, updated_at DESC); + CREATE INDEX IF NOT EXISTS agent_sessions_panel + ON agent_sessions(workspace_id, surface_id, provider, updated_at DESC); + CREATE TABLE IF NOT EXISTS agent_active_slots ( + provider TEXT NOT NULL, + scope TEXT NOT NULL, + scope_id TEXT NOT NULL, + session_id TEXT NOT NULL, + updated_at REAL NOT NULL, + writer_generation INTEGER NOT NULL, + record_json BLOB NOT NULL, + PRIMARY KEY (provider, scope, scope_id) + ) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS agent_legacy_sources ( + provider TEXT NOT NULL, + path TEXT NOT NULL, + size INTEGER NOT NULL, + modified_at REAL NOT NULL, + imported_at REAL NOT NULL, + PRIMARY KEY (provider, path) + ) WITHOUT ROWID; + PRAGMA user_version=1; + """ + ) + } + + func readRecords(database: OpaquePointer, provider: String) throws -> [Record] { + let statement = try prepare( + database, + """ + SELECT session_id, updated_at, writer_generation, record_json + FROM agent_sessions WHERE provider = ?1 ORDER BY updated_at DESC + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + var result: [Record] = [] + while try stepRow(statement, database: database, operation: "read sessions") { + guard let sessionID = text(statement, column: 0), + let json = data(statement, column: 3) else { + throw corruptRowError(operation: "read sessions") + } + result.append(Record( + provider: provider, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 1), + writerGeneration: Int(sqlite3_column_int64(statement, 2)), + json: json + )) + } + return result + } + + func readRecord(database: OpaquePointer, provider: String, sessionID: String) throws -> Record? { + let statement = try prepare( + database, + """ + SELECT updated_at, writer_generation, record_json FROM agent_sessions + WHERE provider = ?1 AND session_id = ?2 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + try bind(sessionID, to: 2, in: statement) + guard try stepRow(statement, database: database, operation: "read session") else { return nil } + guard let json = data(statement, column: 2) else { + throw corruptRowError(operation: "read session") + } + return Record( + provider: provider, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 0), + writerGeneration: Int(sqlite3_column_int64(statement, 1)), + json: json + ) + } + + func readRecordCount(database: OpaquePointer, provider: String) throws -> Int { + let statement = try prepare( + database, + "SELECT COUNT(*) FROM agent_sessions WHERE provider = ?1" + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + guard sqlite3_step(statement) == SQLITE_ROW else { + throw error(database, operation: "count sessions") + } + return Int(sqlite3_column_int64(statement, 0)) + } + + func hasRecord(database: OpaquePointer, provider: String) throws -> Bool { + let statement = try prepare( + database, + "SELECT 1 FROM agent_sessions WHERE provider = ?1 LIMIT 1" + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + return try stepRow(statement, database: database, operation: "find session") + } + + func readSlots(database: OpaquePointer, provider: String) throws -> [ActiveSlot] { + let statement = try prepare( + database, + """ + SELECT scope, scope_id, session_id, updated_at, writer_generation, record_json + FROM agent_active_slots WHERE provider = ?1 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + var result: [ActiveSlot] = [] + while try stepRow(statement, database: database, operation: "read active slots") { + guard let scopeValue = text(statement, column: 0), + let scope = Scope(rawValue: scopeValue), + let scopeID = text(statement, column: 1), + let sessionID = text(statement, column: 2), + let json = data(statement, column: 5) else { + throw corruptRowError(operation: "read active slots") + } + result.append(ActiveSlot( + provider: provider, + scope: scope, + scopeID: scopeID, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 3), + writerGeneration: Int(sqlite3_column_int64(statement, 4)), + json: json + )) + } + return result + } + + func readSlot( + database: OpaquePointer, + provider: String, + scope: Scope, + scopeID: String + ) throws -> ActiveSlot? { + let statement = try prepare( + database, + """ + SELECT session_id, updated_at, writer_generation, record_json + FROM agent_active_slots + WHERE provider = ?1 AND scope = ?2 AND scope_id = ?3 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + try bind(scope.rawValue, to: 2, in: statement) + try bind(scopeID, to: 3, in: statement) + guard try stepRow(statement, database: database, operation: "read active slot") else { return nil } + guard let sessionID = text(statement, column: 0), + let json = data(statement, column: 3) else { + throw corruptRowError(operation: "read active slot") + } + return ActiveSlot( + provider: provider, + scope: scope, + scopeID: scopeID, + sessionID: sessionID, + updatedAt: sqlite3_column_double(statement, 1), + writerGeneration: Int(sqlite3_column_int64(statement, 2)), + json: json + ) + } + + func persistSnapshotChangesOptimistically( + provider: String, + previous: Snapshot, + current: Snapshot + ) throws { + let previousRecords = Dictionary(uniqueKeysWithValues: previous.records.map { ($0.sessionID, $0) }) + let currentRecords = Dictionary(uniqueKeysWithValues: current.records.map { ($0.sessionID, $0) }) + let previousRecordIDs = Set(previousRecords.keys) + let currentRecordIDs = Set(currentRecords.keys) + let recordMembershipChanged = previousRecordIDs != currentRecordIDs + var recordUpserts: [Record] = [] + for var record in current.records { + record.provider = provider + let old = previousRecords[record.sessionID] + if old?.updatedAt != record.updatedAt + || old?.writerGeneration != record.writerGeneration + || old?.json != record.json { + recordUpserts.append(record) + } + } + let recordDeletes = previousRecordIDs.subtracting(currentRecordIDs) + + let previousSlots = Dictionary(uniqueKeysWithValues: previous.activeSlots.map { + (Self.slotKey(scope: $0.scope, scopeID: $0.scopeID), $0) + }) + let currentSlots = Dictionary(uniqueKeysWithValues: current.activeSlots.map { + (Self.slotKey(scope: $0.scope, scopeID: $0.scopeID), $0) + }) + var slotUpserts: [ActiveSlot] = [] + for var slot in current.activeSlots { + slot.provider = provider + let key = Self.slotKey(scope: slot.scope, scopeID: slot.scopeID) + let old = previousSlots[key] + if old?.updatedAt != slot.updatedAt + || old?.writerGeneration != slot.writerGeneration + || old?.sessionID != slot.sessionID + || old?.json != slot.json { + slotUpserts.append(slot) + } + } + let slotDeleteKeys = Set(previousSlots.keys).subtracting(currentSlots.keys) + guard !recordUpserts.isEmpty || !recordDeletes.isEmpty + || !slotUpserts.isEmpty || !slotDeleteKeys.isEmpty else { return } + + try withDatabase { database in + // mutateSnapshot owns the replay budget. Avoid multiplying it by + // the generic BEGIN retry budget when another process holds the + // writer lock for the full busy timeout. + try transaction(database, retryBeginContention: false) { + if recordMembershipChanged, + try readRecordCount(database: database, provider: provider) != previousRecords.count { + throw mutationConflictError() + } + for record in recordUpserts { + guard recordsMatch( + try readRecord(database: database, provider: provider, sessionID: record.sessionID), + previousRecords[record.sessionID] + ) else { throw mutationConflictError() } + } + for sessionID in recordDeletes { + guard recordsMatch( + try readRecord(database: database, provider: provider, sessionID: sessionID), + previousRecords[sessionID] + ) else { throw mutationConflictError() } + } + for slot in slotUpserts { + let key = Self.slotKey(scope: slot.scope, scopeID: slot.scopeID) + guard slotsMatch( + try readSlot( + database: database, + provider: provider, + scope: slot.scope, + scopeID: slot.scopeID + ), + previousSlots[key] + ) else { throw mutationConflictError() } + } + for key in slotDeleteKeys { + guard let previousSlot = previousSlots[key], + slotsMatch( + try readSlot( + database: database, + provider: provider, + scope: previousSlot.scope, + scopeID: previousSlot.scopeID + ), + previousSlot + ) else { throw mutationConflictError() } + } + + for record in recordUpserts { try upsert(record, database: database) } + for sessionID in recordDeletes { + try deleteSession( + database: database, + provider: provider, + sessionID: sessionID, + maximumWriterGeneration: Self.currentWriterGeneration + ) + } + for slot in slotUpserts { try upsert(slot, database: database) } + for key in slotDeleteKeys { + guard let previousSlot = previousSlots[key] else { continue } + try deleteSlot( + database: database, + provider: provider, + scope: previousSlot.scope, + scopeID: previousSlot.scopeID, + maximumWriterGeneration: Self.currentWriterGeneration + ) + } + } + } + } + + func recordsMatch(_ lhs: Record?, _ rhs: Record?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): true + case let (lhs?, rhs?): + lhs.provider == rhs.provider + && lhs.sessionID == rhs.sessionID + && lhs.updatedAt == rhs.updatedAt + && lhs.writerGeneration == rhs.writerGeneration + && lhs.json == rhs.json + default: false + } + } + + func slotsMatch(_ lhs: ActiveSlot?, _ rhs: ActiveSlot?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): true + case let (lhs?, rhs?): + lhs.provider == rhs.provider + && lhs.scope == rhs.scope + && lhs.scopeID == rhs.scopeID + && lhs.sessionID == rhs.sessionID + && lhs.updatedAt == rhs.updatedAt + && lhs.writerGeneration == rhs.writerGeneration + && lhs.json == rhs.json + default: false + } + } + + func mutationConflictError() -> NSError { + NSError( + domain: "CmuxAgentSessionRegistry", + code: Self.optimisticConflictCode, + userInfo: [NSLocalizedDescriptionKey: "agent session snapshot changed concurrently"] + ) + } + + func isRetryableMutationError(_ error: any Error) -> Bool { + let error = error as NSError + guard error.domain == "CmuxAgentSessionRegistry" else { return false } + if error.code == Self.optimisticConflictCode { return true } + let primarySQLiteCode = Int32(error.code & 0xFF) + return primarySQLiteCode == SQLITE_BUSY || primarySQLiteCode == SQLITE_LOCKED + } + + func upsert(_ record: Record, database: OpaquePointer) throws { + guard record.json.count <= Self.maximumHookRecordBytes else { + throw HookStorageLimitError( + scope: .record, + provider: record.provider, + sessionID: record.sessionID, + observedBytes: Int64(record.json.count), + maximumBytes: Int64(Self.maximumHookRecordBytes) + ) + } + let metadata = (try? JSONSerialization.jsonObject(with: record.json) as? [String: Any]) ?? [:] + let runtime = metadata["cmuxRuntime"] as? [String: Any] + let statement = try prepare( + database, + """ + INSERT INTO agent_sessions ( + provider, session_id, updated_at, writer_generation, workspace_id, + surface_id, runtime_id, completed_at, restore_authority, + parent_session_id, active_run_id, record_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + ON CONFLICT(provider, session_id) DO UPDATE SET + updated_at = excluded.updated_at, + writer_generation = excluded.writer_generation, + workspace_id = excluded.workspace_id, + surface_id = excluded.surface_id, + runtime_id = excluded.runtime_id, + completed_at = excluded.completed_at, + restore_authority = excluded.restore_authority, + parent_session_id = excluded.parent_session_id, + active_run_id = excluded.active_run_id, + record_json = excluded.record_json + WHERE excluded.writer_generation >= agent_sessions.writer_generation + """ + ) + defer { sqlite3_finalize(statement) } + try bind(record.provider, to: 1, in: statement) + try bind(record.sessionID, to: 2, in: statement) + sqlite3_bind_double(statement, 3, record.updatedAt) + sqlite3_bind_int64(statement, 4, sqlite3_int64(record.writerGeneration)) + try bind(metadata["workspaceId"] as? String, to: 5, in: statement) + try bind(metadata["surfaceId"] as? String, to: 6, in: statement) + try bind(runtime?["id"] as? String, to: 7, in: statement) + try bind(metadata["completedAt"] as? Double, to: 8, in: statement) + try bind(metadata["restoreAuthority"] as? Bool, to: 9, in: statement) + try bind(metadata["parentSessionId"] as? String, to: 10, in: statement) + try bind(metadata["activeRunId"] as? String, to: 11, in: statement) + try bind(record.json, to: 12, in: statement) + try stepDone(statement, database: database, operation: "upsert session") + } + + func upsert(_ slot: ActiveSlot, database: OpaquePointer) throws { + let owner = try activeSlotOwnerProjection( + database: database, + provider: slot.provider, + sessionID: slot.sessionID + ) + let storedSessionID = owner?.sessionID ?? slot.sessionID + let ownerScopeID: String? + switch slot.scope { + case .workspace: + ownerScopeID = owner?.workspaceID + case .surface: + ownerScopeID = owner?.surfaceID + } + let storedScopeID = ownerScopeID == slot.scopeID ? ownerScopeID ?? slot.scopeID : slot.scopeID + let statement = try prepare( + database, + """ + INSERT INTO agent_active_slots ( + provider, scope, scope_id, session_id, updated_at, writer_generation, record_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(provider, scope, scope_id) DO UPDATE SET + session_id = excluded.session_id, + updated_at = excluded.updated_at, + writer_generation = excluded.writer_generation, + record_json = excluded.record_json + WHERE excluded.writer_generation >= agent_active_slots.writer_generation + """ + ) + defer { sqlite3_finalize(statement) } + try bind(slot.provider, to: 1, in: statement) + try bind(slot.scope.rawValue, to: 2, in: statement) + try bind(storedScopeID, to: 3, in: statement) + try bind(storedSessionID, to: 4, in: statement) + sqlite3_bind_double(statement, 5, slot.updatedAt) + sqlite3_bind_int64(statement, 6, sqlite3_int64(slot.writerGeneration)) + try bind(slot.json, to: 7, in: statement) + try stepDone(statement, database: database, operation: "upsert active slot") + } + + private func activeSlotOwnerProjection( + database: OpaquePointer, + provider: String, + sessionID: String + ) throws -> (sessionID: String, workspaceID: String?, surfaceID: String?)? { + do { + let exact = try prepare( + database, + """ + SELECT session_id, workspace_id, surface_id + FROM agent_sessions + WHERE provider = ?1 AND session_id = ?2 + """ + ) + defer { sqlite3_finalize(exact) } + try bind(provider, to: 1, in: exact) + try bind(sessionID, to: 2, in: exact) + if try stepRow(exact, database: database, operation: "read active slot owner") { + guard let storedSessionID = text(exact, column: 0) else { + throw corruptRowError(operation: "read active slot owner") + } + return ( + storedSessionID, + text(exact, column: 1), + text(exact, column: 2) + ) + } + } + + // SQLite's default TEXT comparison is bytewise, while Swift String + // identity treats canonically equivalent Unicode forms as equal. This + // exceptional scan aligns the slot to the owner's exact stored bytes so + // indexed joins and pruning remain correct after the write. + let canonical = try prepare( + database, + """ + SELECT session_id, workspace_id, surface_id + FROM agent_sessions WHERE provider = ?1 + """ + ) + defer { sqlite3_finalize(canonical) } + try bind(provider, to: 1, in: canonical) + var match: (sessionID: String, workspaceID: String?, surfaceID: String?)? + while try stepRow( + canonical, + database: database, + operation: "resolve canonical active slot owner" + ) { + guard let storedSessionID = text(canonical, column: 0) else { + throw corruptRowError(operation: "resolve canonical active slot owner") + } + guard storedSessionID == sessionID else { continue } + guard match == nil else { + throw corruptRowError(operation: "resolve canonical active slot owner") + } + match = ( + storedSessionID, + text(canonical, column: 1), + text(canonical, column: 2) + ) + } + return match + } + + func deleteSession( + database: OpaquePointer, + provider: String, + sessionID: String, + maximumWriterGeneration: Int + ) throws { + let statement = try prepare( + database, + """ + DELETE FROM agent_sessions + WHERE provider = ?1 AND session_id = ?2 AND writer_generation <= ?3 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + try bind(sessionID, to: 2, in: statement) + sqlite3_bind_int64(statement, 3, sqlite3_int64(maximumWriterGeneration)) + try stepDone(statement, database: database, operation: "delete session") + } + + func deleteSlot( + database: OpaquePointer, + provider: String, + scope: Scope, + scopeID: String, + maximumWriterGeneration: Int + ) throws { + let statement = try prepare( + database, + """ + DELETE FROM agent_active_slots + WHERE provider = ?1 AND scope = ?2 AND scope_id = ?3 AND writer_generation <= ?4 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + try bind(scope.rawValue, to: 2, in: statement) + try bind(scopeID, to: 3, in: statement) + sqlite3_bind_int64(statement, 4, sqlite3_int64(maximumWriterGeneration)) + try stepDone(statement, database: database, operation: "delete active slot") + } + + func removeActiveSlots( + database: OpaquePointer, + provider: String, + sessionID: String, + removal: ActiveSlotRemoval, + maximumWriterGeneration: Int + ) throws { + let sql: String + switch removal { + case .all: + sql = """ + DELETE FROM agent_active_slots + WHERE provider = ?1 AND session_id = ?2 AND writer_generation <= ?3 + """ + case .updatedThrough: + sql = """ + DELETE FROM agent_active_slots + WHERE provider = ?1 AND session_id = ?2 + AND writer_generation <= ?3 AND updated_at <= ?4 + """ + } + let statement = try prepare(database, sql) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + try bind(sessionID, to: 2, in: statement) + sqlite3_bind_int64(statement, 3, sqlite3_int64(maximumWriterGeneration)) + if case let .updatedThrough(updatedAt) = removal { + sqlite3_bind_double(statement, 4, updatedAt) + } + try stepDone(statement, database: database, operation: "remove session active slots") + } + + func transaction( + _ database: OpaquePointer, + retryBeginContention: Bool = true, + body: () throws -> T + ) throws -> T { + var lastContentionError: (any Error)? + let attemptCount = retryBeginContention && busyTimeoutMilliseconds > 0 + ? Self.maximumMutationAttempts + : 1 + for attempt in 0..(_ database: OpaquePointer, body: () throws -> T) throws -> T { + try execute(database, sql: "BEGIN") + do { + let result = try body() + try execute(database, sql: "COMMIT") + return result + } catch { + try? execute(database, sql: "ROLLBACK") + throw error + } + } + + func execute(_ database: OpaquePointer, sql: String) throws { + var message: UnsafeMutablePointer? + guard sqlite3_exec(database, sql, nil, nil, &message) == SQLITE_OK else { + let detail = message.map { String(cString: $0) } + sqlite3_free(message) + throw NSError( + domain: "CmuxAgentSessionRegistry", + code: Int(sqlite3_errcode(database)), + userInfo: [NSLocalizedDescriptionKey: detail ?? "SQLite execution failed"] + ) + } + } + + func prepare(_ database: OpaquePointer, _ sql: String) throws -> OpaquePointer { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw error(database, operation: "prepare") + } + return statement + } + + func stepDone(_ statement: OpaquePointer, database: OpaquePointer, operation: String) throws { + guard sqlite3_step(statement) == SQLITE_DONE else { throw error(database, operation: operation) } + } + + func stepRow( + _ statement: OpaquePointer, + database: OpaquePointer, + operation: String + ) throws -> Bool { + switch sqlite3_step(statement) { + case SQLITE_ROW: + return true + case SQLITE_DONE: + return false + default: + throw error(database, operation: operation) + } + } + + func bind(_ value: String?, to index: Int32, in statement: OpaquePointer) throws { + guard let value else { + sqlite3_bind_null(statement, index) + return + } + let result = value.withCString { pointer in + sqlite3_bind_text(statement, index, pointer, -1, Self.sqliteTransient) + } + guard result == SQLITE_OK else { throw bindingError(result) } + } + + func bind(_ value: Data, to index: Int32, in statement: OpaquePointer) throws { + let result = value.withUnsafeBytes { bytes in + sqlite3_bind_blob(statement, index, bytes.baseAddress, Int32(bytes.count), Self.sqliteTransient) + } + guard result == SQLITE_OK else { throw bindingError(result) } + } + + func bind(_ value: Double?, to index: Int32, in statement: OpaquePointer) throws { + if let value { sqlite3_bind_double(statement, index, value) } else { sqlite3_bind_null(statement, index) } + } + + func bind(_ value: Bool?, to index: Int32, in statement: OpaquePointer) throws { + if let value { sqlite3_bind_int(statement, index, value ? 1 : 0) } else { sqlite3_bind_null(statement, index) } + } + + func text(_ statement: OpaquePointer, column: Int32) -> String? { + sqlite3_column_text(statement, column).map { String(cString: $0) } + } + + func data(_ statement: OpaquePointer, column: Int32) -> Data? { + guard let bytes = sqlite3_column_blob(statement, column) else { return nil } + return Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, column))) + } + + func error(_ database: OpaquePointer?, operation: String) -> NSError { + NSError( + domain: "CmuxAgentSessionRegistry", + code: Int(database.map(sqlite3_errcode) ?? SQLITE_ERROR), + userInfo: [ + NSLocalizedDescriptionKey: "\(operation): \(database.map { String(cString: sqlite3_errmsg($0)) } ?? "SQLite unavailable")" + ] + ) + } + + func bindingError(_ code: Int32) -> NSError { + NSError( + domain: "CmuxAgentSessionRegistry", + code: Int(code), + userInfo: [NSLocalizedDescriptionKey: "SQLite bind failed"] + ) + } + + func corruptRowError(operation: String) -> NSError { + NSError( + domain: "CmuxAgentSessionRegistry", + code: Int(SQLITE_CORRUPT), + userInfo: [NSLocalizedDescriptionKey: "\(operation): malformed registry row"] + ) + } + + static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + static func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry.swift new file mode 100644 index 000000000000..f2a1d2f28e8b --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRegistry.swift @@ -0,0 +1,1543 @@ +public import Foundation +import Darwin +import SQLite3 + +/// Durable, cross-version storage for coding-agent sessions. +/// +/// Persistence flow: +/// +/// provider hook ──► one SQLite row per session/run owner +/// │ │ +/// │ ├──► `cmux agents` (root + child tree) +/// │ ├──► foreground / attention notification +/// │ ├──► restore or fork (root authority only) +/// │ └──► hibernation eligibility +/// │ │ +/// │ active workload? ──yes──► stay awake +/// │ │ no +/// │ └──► snapshot + hibernate +/// │ +/// ├── root TUI exits ──► end root run + cancel owned workloads +/// └──► legacy JSON (compatibility projection for older cmux) +/// +/// Legacy JSON remains readable by older cmux releases, but it is never allowed +/// to replace a row written by a newer registry generation. This matters when a +/// stable, nightly, and tagged build run concurrently: an older Codable model +/// rewrites its complete JSON file and necessarily drops fields it cannot see. +/// SQLite makes the mutation boundary one session, while `writerGeneration` +/// makes schema ownership monotonic. A future schema change that adds persisted +/// semantics must increment `currentWriterGeneration`; older binaries can then +/// read the row but cannot encode their smaller model over it. +/// +/// Every operation opens a short-lived connection with WAL and a bounded busy +/// timeout. Callers on the app side must use a utility task or queue. CLI hook +/// callers remain synchronous so the registry commit is durable before they +/// publish the compatibility JSON file. +public struct CmuxAgentSessionRegistry: Sendable { + public static let currentWriterGeneration = 1 + public static let filename = "agent-sessions.sqlite3" + /// Provider discovery feeds legacy sidecar paths, so identifiers are kept + /// deliberately smaller and stricter than arbitrary SQLite text values. + public static let maximumProviderIdentifierBytes = 128 + /// A hard ceiling prevents corrupt or adversarial metadata from amplifying + /// one inspection command into an unbounded number of filesystem probes. + public static let maximumProviderEnumerationCount = 256 + static let optimisticConflictCode = -7_867 + static let maximumMutationAttempts = 64 + + public struct ProviderEnumerationLimitError: Error, Equatable, Sendable { + public var maximumCount: Int + public var observedAtLeast: Int + + public init(maximumCount: Int, observedAtLeast: Int) { + self.maximumCount = maximumCount + self.observedAtLeast = observedAtLeast + } + } + + public struct UnsafeProviderIdentifierError: Error, Equatable, Sendable { + public var provider: String + + public init(provider: String) { + self.provider = provider + } + } + + public enum Scope: String, Hashable, Sendable { + case workspace + case surface + } + + public struct Record: Sendable { + public var provider: String + public var sessionID: String + public var updatedAt: TimeInterval + public var writerGeneration: Int + public var json: Data + + public init( + provider: String, + sessionID: String, + updatedAt: TimeInterval, + writerGeneration: Int = CmuxAgentSessionRegistry.currentWriterGeneration, + json: Data + ) { + self.provider = provider + self.sessionID = sessionID + self.updatedAt = updatedAt + self.writerGeneration = writerGeneration + self.json = json + } + } + + public struct ActiveSlot: Sendable { + public var provider: String + public var scope: Scope + public var scopeID: String + public var sessionID: String + public var updatedAt: TimeInterval + public var writerGeneration: Int + public var json: Data + + public init( + provider: String, + scope: Scope, + scopeID: String, + sessionID: String, + updatedAt: TimeInterval, + writerGeneration: Int = CmuxAgentSessionRegistry.currentWriterGeneration, + json: Data + ) { + self.provider = provider + self.scope = scope + self.scopeID = scopeID + self.sessionID = sessionID + self.updatedAt = updatedAt + self.writerGeneration = writerGeneration + self.json = json + } + } + + public struct ActiveSlotKey: Hashable, Sendable { + public var scope: Scope + public var scopeID: String + + public init(scope: Scope, scopeID: String) { + self.scope = scope + self.scopeID = scopeID + } + } + + public struct Snapshot: Sendable { + public var records: [Record] + public var activeSlots: [ActiveSlot] + + public init(records: [Record], activeSlots: [ActiveSlot]) { + self.records = records + self.activeSlots = activeSlots + } + } + + /// A recent provider slice plus the exact number of canonical session rows. + /// Callers can merge one bounded slice per provider without retaining every + /// encoded record merely to report a complete match count. + public struct BoundedRecentSnapshot: Sendable { + public var snapshot: Snapshot + public var totalRecordCount: Int + + public init(snapshot: Snapshot, totalRecordCount: Int) { + self.snapshot = snapshot + self.totalRecordCount = totalRecordCount + } + } + + public enum ActiveSlotRemoval: Sendable { + case all + case updatedThrough(TimeInterval) + } + + public enum RecordRebindResult: Equatable, Sendable { + case patched + case recordMissing + case rejected + } + + public struct LegacyStamp: Equatable, Sendable { + public var path: String + public var size: Int64 + public var modifiedAt: TimeInterval + public var deviceID: Int64? + public var inode: Int64? + public var modifiedSeconds: Int64? + public var modifiedNanoseconds: Int64? + public var changedSeconds: Int64? + public var changedNanoseconds: Int64? + + public init( + path: String, + size: Int64, + modifiedAt: TimeInterval, + deviceID: Int64? = nil, + inode: Int64? = nil, + modifiedSeconds: Int64? = nil, + modifiedNanoseconds: Int64? = nil, + changedSeconds: Int64? = nil, + changedNanoseconds: Int64? = nil + ) { + self.path = path + self.size = size + self.modifiedAt = modifiedAt + self.deviceID = deviceID + self.inode = inode + self.modifiedSeconds = modifiedSeconds + self.modifiedNanoseconds = modifiedNanoseconds + self.changedSeconds = changedSeconds + self.changedNanoseconds = changedNanoseconds + } + + init(path: String, metadata: stat) { + let modifiedSeconds = Int64(metadata.st_mtimespec.tv_sec) + let modifiedNanoseconds = Int64(metadata.st_mtimespec.tv_nsec) + self.init( + path: path, + size: Int64(metadata.st_size), + modifiedAt: TimeInterval(modifiedSeconds) + + TimeInterval(modifiedNanoseconds) / 1_000_000_000, + deviceID: Int64(metadata.st_dev), + inode: Int64(bitPattern: UInt64(metadata.st_ino)), + modifiedSeconds: modifiedSeconds, + modifiedNanoseconds: modifiedNanoseconds, + changedSeconds: Int64(metadata.st_ctimespec.tv_sec), + changedNanoseconds: Int64(metadata.st_ctimespec.tv_nsec) + ) + } + + var hasDurableRevisionIdentity: Bool { + deviceID != nil + && inode != nil + && modifiedSeconds != nil + && modifiedNanoseconds != nil + && changedSeconds != nil + && changedNanoseconds != nil + } + + public static func read(path: String, fileManager: FileManager = .default) -> LegacyStamp? { + _ = fileManager + var metadata = stat() + guard stat(path, &metadata) == 0, + metadata.st_mode & S_IFMT == S_IFREG else { + return nil + } + return LegacyStamp(path: path, metadata: metadata) + } + } + + public struct LegacySource: Sendable { + public var provider: String + public var url: URL + + public init(provider: String, url: URL) { + self.provider = provider + self.url = url + } + } + + public let url: URL + public let busyTimeoutMilliseconds: Int32 + + public init(url: URL, busyTimeoutMilliseconds: Int32 = 100) { + self.url = url + self.busyTimeoutMilliseconds = max(0, busyTimeoutMilliseconds) + } + + public static func defaultURL( + homeDirectory: String = NSHomeDirectory(), + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> URL { + if let explicit = normalized(environment["CMUX_AGENT_SESSION_REGISTRY_PATH"]) { + return URL(fileURLWithPath: NSString(string: explicit).expandingTildeInPath) + } + let directory: URL + if let stateDirectory = normalized(environment["CMUX_AGENT_HOOK_STATE_DIR"]) { + directory = URL( + fileURLWithPath: NSString(string: stateDirectory).expandingTildeInPath, + isDirectory: true + ) + } else { + directory = URL(fileURLWithPath: homeDirectory, isDirectory: true) + .appendingPathComponent(".cmuxterm", isDirectory: true) + } + return directory.appendingPathComponent(filename, isDirectory: false) + } + + /// Returns the registry's distinct providers in primary-key order. + /// + /// The metadata table has one row per provider and a provider primary key. + /// Reading at most one row beyond the effective limit keeps both SQLite work + /// and retained memory bounded while still reporting overflow explicitly. + public func providerIdentifiers( + maximumCount requestedMaximumCount: Int = CmuxAgentSessionRegistry.maximumProviderEnumerationCount + ) throws -> [String] { + let maximumCount = min( + max(0, requestedMaximumCount), + Self.maximumProviderEnumerationCount + ) + return try withDatabase { database in + try readTransaction(database) { + let statement = try prepare( + database, + """ + SELECT provider FROM agent_provider_metadata + WHERE record_bytes > 0 OR slot_bytes > 0 + ORDER BY provider + LIMIT ?1 + """ + ) + defer { sqlite3_finalize(statement) } + let bindResult = sqlite3_bind_int64(statement, 1, Int64(maximumCount + 1)) + guard bindResult == SQLITE_OK else { + throw bindingError(bindResult) + } + + var providers: [String] = [] + providers.reserveCapacity(maximumCount) + while try stepRow( + statement, + database: database, + operation: "enumerate agent session providers" + ) { + guard let provider = text(statement, column: 0) else { + throw corruptRowError(operation: "enumerate agent session providers") + } + guard Self.isSafeProviderIdentifier(provider) else { + throw UnsafeProviderIdentifierError(provider: provider) + } + providers.append(provider) + } + guard providers.count <= maximumCount else { + throw ProviderEnumerationLimitError( + maximumCount: maximumCount, + observedAtLeast: maximumCount + 1 + ) + } + return providers + } + } + } + + /// Performs one primary-key lookup for an exact, safe provider ID. This is + /// intentionally independent of full enumeration so a targeted inspection + /// still works when the registry contains more providers than the catalog + /// is willing to probe at once. + public func containsProviderIdentifier(_ provider: String) throws -> Bool { + guard Self.isSafeProviderIdentifier(provider) else { return false } + return try withDatabase { database in + try readTransaction(database) { + let statement = try prepare( + database, + """ + SELECT 1 FROM agent_provider_metadata + WHERE provider = ?1 AND (record_bytes > 0 OR slot_bytes > 0) + LIMIT 1 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + return try stepRow( + statement, + database: database, + operation: "find agent session provider" + ) + } + } + } + + /// Returns at most two active provider IDs that differ only by ASCII case. + /// Two rows are enough for callers to reject a sidecar-path collision. The + /// matching partial NOCASE index keeps this lookup independent of catalog + /// size, including when full enumeration exceeds its hard ceiling. + public func providerIdentifiers(caseInsensitiveTo provider: String) throws -> [String] { + guard Self.isSafeProviderIdentifier(provider) else { return [] } + return try withDatabase { database in + try readTransaction(database) { + let statement = try prepare( + database, + """ + SELECT provider FROM agent_provider_metadata + WHERE provider = ?1 COLLATE NOCASE + AND (record_bytes > 0 OR slot_bytes > 0) + ORDER BY provider + LIMIT 2 + """ + ) + defer { sqlite3_finalize(statement) } + try bind(provider, to: 1, in: statement) + var providers: [String] = [] + while try stepRow( + statement, + database: database, + operation: "find case-insensitive agent session providers" + ) { + guard let provider = text(statement, column: 0), + Self.isSafeProviderIdentifier(provider) else { + throw corruptRowError( + operation: "find case-insensitive agent session providers" + ) + } + providers.append(provider) + } + return providers + } + } + } + + public static func isSafeProviderIdentifier(_ value: String) -> Bool { + let bytes = value.utf8 + guard !bytes.isEmpty, bytes.count <= maximumProviderIdentifierBytes else { + return false + } + return bytes.allSatisfy { byte in + switch byte { + case 48...57, 65...90, 97...122, 45, 46, 95: + return true + default: + return false + } + } + } + + public func snapshot(provider: String) throws -> Snapshot { + try withDatabase { database in + try readTransaction(database) { + Snapshot( + records: try readRecords(database: database, provider: provider), + activeSlots: try readSlots(database: database, provider: provider) + ) + } + } + } + + /// Reads selected session rows through one consistent, indexed snapshot. + public func records( + provider: String, + sessionIDs: Set + ) throws -> [Record] { + guard !sessionIDs.isEmpty else { return [] } + return try withDatabase { database in + try readTransaction(database) { + try sessionIDs.compactMap { + try readRecord(database: database, provider: provider, sessionID: $0) + } + } + } + } + + /// Applies a provider snapshot mutation without holding the SQLite writer + /// lock while the caller decodes or transforms records. + /// + /// The closure may be replayed after a touched row changes concurrently, + /// so it must only derive its return value and mutations from the snapshot. + /// Each commit validates the touched rows and applies their delta inside one + /// short transaction. The busy timeout and attempt cap bound contention. + public func mutateSnapshot( + provider: String, + _ mutate: (inout Snapshot) throws -> T + ) throws -> T { + var lastContentionError: (any Error)? + for _ in 0.. Snapshot { + let snapshots = try snapshotsImportingLegacy( + sources: [LegacySource(provider: provider, url: legacyURL)], + fileManager: fileManager + ) + return snapshots[provider] ?? Snapshot(records: [], activeSlots: []) + } + + typealias HookLegacySourceAdmissionLoader = ( + _ source: LegacySource, + _ expectedStamp: LegacyStamp + ) throws -> HookLegacySourceAdmission + + private struct LegacyRefreshReadBudgetExceededError: Error {} + + private enum RetryingHookLegacySourceAdmissionResult { + case admitted(HookLegacySourceAdmission) + case stableFailure(stamp: LegacyStamp, error: any Error) + case unstable + } + + /// Pins the compatibility bytes and stamp to one descriptor, retrying once + /// when the path changes while it is being opened or read. A second change + /// is reported as unstable instead of being mistaken for a malformed exact + /// revision and quarantined under the wrong stamp. + private func retryingHookLegacySourceAdmission( + source: LegacySource, + expectedStamp initialStamp: LegacyStamp, + fileManager: FileManager, + loader: HookLegacySourceAdmissionLoader + ) -> RetryingHookLegacySourceAdmissionResult { + var expectedStamp = initialStamp + for attempt in 0..<2 { + do { + let admission = try loader(source, expectedStamp) + guard admission.source.provider == source.provider, + admission.source.url.standardizedFileURL == source.url.standardizedFileURL, + admission.stamp == expectedStamp else { + throw HookLegacySourceRevisionChangedError(path: source.url.path) + } + return .admitted(admission) + } catch { + let observedStamp = LegacyStamp.read( + path: source.url.path, + fileManager: fileManager + ) + let revisionChanged = error is HookLegacySourceRevisionChangedError + || observedStamp != Optional(expectedStamp) + guard revisionChanged else { + return .stableFailure(stamp: expectedStamp, error: error) + } + guard attempt == 0, let observedStamp else { return .unstable } + expectedStamp = observedStamp + } + } + return .unstable + } + + /// Public counterpart used by app restore preflight. The returned bytes and + /// stamp always describe the same descriptor revision. + public func hookLegacySourceAdmissionRetryingOneReplacement( + source: LegacySource, + expectedStamp: LegacyStamp, + fileManager: FileManager = .default + ) throws -> HookLegacySourceAdmission { + switch retryingHookLegacySourceAdmission( + source: source, + expectedStamp: expectedStamp, + fileManager: fileManager, + loader: { source, stamp in + try hookLegacySourceAdmission( + source: source, + expectedStamp: stamp, + fileManager: fileManager + ) + } + ) { + case let .admitted(admission): + return admission + case let .stableFailure(_, error): + throw error + case .unstable: + throw HookLegacySourceRevisionChangedError(path: source.url.path) + } + } + + /// Imports changed compatibility files without materializing provider + /// snapshots. Session restore uses this bounded preflight before it adopts + /// hibernated rows. A malformed or read-budget-limited provider is isolated + /// from valid peers, while database failures still abort the preflight so + /// callers can fail closed. Priority providers consume the aggregate source + /// budget first, in caller order, followed by deterministic provider order. + public func refreshLegacySources( + _ sources: [LegacySource], + preservingCanonicalRestoreOwners restoreOwners: Set = [], + prioritizingProviders priorityProviders: [String] = [], + maximumReadBytes: Int64 = CmuxAgentSessionRegistry.maximumLegacyRefreshReadBytes, + fileManager: FileManager = .default + ) throws -> LegacyRefreshResult { + try refreshLegacySources( + sources, + preservingCanonicalRestoreOwners: restoreOwners, + prioritizingProviders: priorityProviders, + maximumReadBytes: maximumReadBytes, + fileManager: fileManager, + legacyAdmissionLoader: { source, stamp in + try hookLegacySourceAdmission( + source: source, + expectedStamp: stamp, + fileManager: fileManager, + maximumBytes: min( + stamp.size, + CmuxAgentSessionRegistry.maximumLegacyRefreshReadBytes + ) + ) + } + ) + } + + func refreshLegacySources( + _ sources: [LegacySource], + preservingCanonicalRestoreOwners restoreOwners: Set = [], + prioritizingProviders priorityProviders: [String] = [], + maximumReadBytes: Int64 = CmuxAgentSessionRegistry.maximumLegacyRefreshReadBytes, + fileManager: FileManager = .default, + legacyAdmissionLoader: HookLegacySourceAdmissionLoader + ) throws -> LegacyRefreshResult { + let priorityRanks = Dictionary( + priorityProviders.enumerated().map { ($0.element, $0.offset) }, + uniquingKeysWith: { min($0, $1) } + ) + let uniqueSources = Dictionary( + sources.map { ($0.provider, $0) }, + uniquingKeysWith: { _, latest in latest } + ).values.sorted { + let leftRank = priorityRanks[$0.provider] + let rightRank = priorityRanks[$1.provider] + switch (leftRank, rightRank) { + case let (.some(left), .some(right)) where left != right: + return left < right + case (.some(_), .none): + return true + case (.none, .some(_)): + return false + default: + return $0.provider < $1.provider + } + } + let restoreOwnersByProvider = Dictionary( + grouping: restoreOwners, + by: \.provider + ) + let maximumReadBytes = max(0, maximumReadBytes) + return try withDatabase { database in + var changed: [(source: LegacySource, stamp: LegacyStamp, payload: LegacyPayload)] = [] + var malformed: [( + source: LegacySource, + stamp: LegacyStamp, + needsQuarantineWrite: Bool + )] = [] + var failedProviders = Set() + var readBudgetExceededProviders = Set() + var sourceReadBudgetUsed: Int64 = 0 + var verifiedCanonicalRestoreOwners = Set() + let budgetedAdmissionLoader: HookLegacySourceAdmissionLoader = { source, stamp in + let next = sourceReadBudgetUsed.addingReportingOverflow(stamp.size) + guard stamp.size >= 0, + !next.overflow, + next.partialValue <= maximumReadBytes else { + throw LegacyRefreshReadBudgetExceededError() + } + // Reserve before opening. A malformed read or first revision + // replaced during admission still consumed I/O and cannot give + // its budget back to a later provider. + sourceReadBudgetUsed = next.partialValue + return try legacyAdmissionLoader(source, stamp) + } + for source in uniqueSources { + guard let stamp = LegacyStamp.read(path: source.url.path, fileManager: fileManager) else { + // SQLite is canonical after migration. A removed + // compatibility projection is harmless when this provider + // already has durable rows; first-time restore with neither + // source remains unavailable and is sanitized by the caller. + let providerRestoreOwners = Set(restoreOwnersByProvider[source.provider] ?? []) + if !providerRestoreOwners.isEmpty { + let verified = try verifyCanonicalRestoreOwners( + database: database, + provider: source.provider, + candidates: providerRestoreOwners + ) + verifiedCanonicalRestoreOwners.formUnion(verified) + if verified.count != providerRestoreOwners.count { + failedProviders.insert(source.provider) + } + } else if try !hasRecord(database: database, provider: source.provider) { + failedProviders.insert(source.provider) + } + continue + } + let sourceState = try legacySourceState( + database: database, + provider: source.provider, + stamp: stamp + ) + if sourceState == .imported { continue } + if sourceState == .quarantined { + failedProviders.insert(source.provider) + malformed.append((source, stamp, false)) + continue + } + switch retryingHookLegacySourceAdmission( + source: source, + expectedStamp: stamp, + fileManager: fileManager, + loader: budgetedAdmissionLoader + ) { + case let .admitted(admission): + let admittedState = try legacySourceState( + database: database, + provider: source.provider, + stamp: admission.stamp + ) + if admittedState == .imported { continue } + if admittedState == .quarantined { + failedProviders.insert(source.provider) + malformed.append((source, admission.stamp, false)) + continue + } + do { + changed.append(( + source, + admission.stamp, + try legacyPayload(provider: source.provider, json: admission.json) + )) + } catch { + failedProviders.insert(source.provider) + malformed.append((source, admission.stamp, true)) + } + case let .stableFailure(failedStamp, error): + failedProviders.insert(source.provider) + if error is LegacyRefreshReadBudgetExceededError { + readBudgetExceededProviders.insert(source.provider) + // Budget exhaustion is transient and must not quarantine + // this exact, otherwise valid, sidecar revision. + malformed.append((source, failedStamp, false)) + } else { + malformed.append((source, failedStamp, true)) + } + case .unstable: + failedProviders.insert(source.provider) + malformed.append((source, stamp, false)) + } + } + func verifyMalformedOwners( + _ items: [(source: LegacySource, stamp: LegacyStamp, needsQuarantineWrite: Bool)] + ) throws { + for item in items { + let candidates = Set(restoreOwnersByProvider[item.source.provider] ?? []) + guard !candidates.isEmpty else { continue } + let verified = try verifyCanonicalRestoreOwners( + database: database, + provider: item.source.provider, + candidates: candidates + ) + guard !verified.isEmpty else { continue } + verifiedCanonicalRestoreOwners.formUnion(verified) + guard item.needsQuarantineWrite else { continue } + // Quarantine only this malformed revision. Restore can now + // adopt the independently verified canonical rows; any + // later compatibility rewrite has a different stamp and is + // imported normally on the next refresh. + try writeLegacyStamp( + database: database, + provider: item.source.provider, + stamp: item.stamp, + quarantined: true + ) + } + } + let needsWriteTransaction = !changed.isEmpty || malformed.contains { + $0.needsQuarantineWrite + } + if needsWriteTransaction { + // This API is used synchronously immediately before panel + // restore. One busy timeout is its complete lock-wait budget. + try transaction(database, retryBeginContention: false) { + for item in changed { + try replaceLegacy( + database: database, + provider: item.source.provider, + stamp: item.stamp, + payload: item.payload + ) + } + try verifyMalformedOwners(malformed) + } + } else if !malformed.isEmpty { + try readTransaction(database) { + try verifyMalformedOwners(malformed) + } + } + return LegacyRefreshResult( + refreshedProviders: Set(changed.map { $0.source.provider }), + failedProviders: failedProviders, + readBudgetExceededProviders: readBudgetExceededProviders, + sourceReadBudgetUsed: sourceReadBudgetUsed, + verifiedCanonicalRestoreOwners: verifiedCanonicalRestoreOwners + ) + } + } + + /// Verifies only the requested session row and its singular surface lease. + /// Both reads use primary keys, so corrupt-sidecar recovery scales with the + /// bounded restore snapshot instead of provider history. + private func verifyCanonicalRestoreOwners( + database: OpaquePointer, + provider: String, + candidates: Set + ) throws -> Set { + var verified = Set() + verified.reserveCapacity(candidates.count) + for candidate in candidates where candidate.provider == provider { + guard let record = try readRecord( + database: database, + provider: provider, + sessionID: candidate.sessionID + ), + record.writerGeneration >= Self.currentWriterGeneration, + let surfaceSlot = try readSlot( + database: database, + provider: provider, + scope: .surface, + scopeID: candidate.surfaceID + ), + surfaceSlot.writerGeneration >= Self.currentWriterGeneration, + surfaceSlot.sessionID == candidate.sessionID, + let recordObject = try? JSONSerialization.jsonObject(with: record.json) as? [String: Any], + recordObject["sessionId"] as? String == candidate.sessionID, + identifiersEqual(recordObject["workspaceId"] as? String, candidate.workspaceID), + identifiersEqual(recordObject["surfaceId"] as? String, candidate.surfaceID), + recordObject["sessionState"] as? String == "hibernated", + CmuxAgentSessionRunAuthorityProjection() + .projectedRestoreAuthority(recordJSON: record.json) == true, + recordObject["updatedAt"] is TimeInterval, + !hasCompletion(recordObject), + let slotObject = try? JSONSerialization.jsonObject(with: surfaceSlot.json) as? [String: Any], + slotObject["sessionId"] as? String == candidate.sessionID, + slotObject["updatedAt"] is TimeInterval else { + continue + } + verified.insert(candidate) + } + return verified + } + + private func identifiersEqual(_ value: String?, _ expected: String) -> Bool { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return false } + return value.caseInsensitiveCompare(expected) == .orderedSame + } + + private func hasCompletion(_ record: [String: Any]) -> Bool { + guard let completedAt = record["completedAt"] else { return false } + return !(completedAt is NSNull) + } + + /// Refreshes all changed compatibility files and reads every requested + /// provider through one SQLite connection. This is the tree/restore path, + /// so its cost does not grow by one connection per supported adapter. + public func snapshotsImportingLegacy( + sources: [LegacySource], + fileManager: FileManager = .default + ) throws -> [String: Snapshot] { + let uniqueSources = Dictionary( + sources.map { ($0.provider, $0) }, + uniquingKeysWith: { _, latest in latest } + ).values.sorted { $0.provider < $1.provider } + return try withDatabase { database in + var changed: [(source: LegacySource, stamp: LegacyStamp, payload: LegacyPayload)] = [] + for source in uniqueSources { + guard let stamp = LegacyStamp.read(path: source.url.path, fileManager: fileManager), + try !legacySourceIsCurrent( + database: database, + provider: source.provider, + stamp: stamp + ) else { continue } + let admission = try hookLegacySourceAdmissionRetryingOneReplacement( + source: source, + expectedStamp: stamp, + fileManager: fileManager + ) + guard try !legacySourceIsCurrent( + database: database, + provider: source.provider, + stamp: admission.stamp + ) else { continue } + changed.append(( + source, + admission.stamp, + try legacyPayload(provider: source.provider, json: admission.json) + )) + } + if !changed.isEmpty { + try transaction(database) { + for item in changed { + try replaceLegacy( + database: database, + provider: item.source.provider, + stamp: item.stamp, + payload: item.payload + ) + } + } + } + return try readTransaction(database) { + try Dictionary(uniqueKeysWithValues: uniqueSources.map { source in + (source.provider, Snapshot( + records: try readRecords(database: database, provider: source.provider), + activeSlots: try readSlots(database: database, provider: source.provider) + )) + }) + } + } + } + + /// Refreshes changed compatibility sources, then reads only each provider's + /// newest list candidates and the active slots that annotate those rows. + /// All providers share one SQLite connection and one read transaction. + public func boundedRecentSnapshotsImportingLegacy( + sources: [LegacySource], + maximumRecordsPerProvider: Int, + fileManager: FileManager = .default, + validateRecord: ((String, Record) throws -> Void)? = nil, + validateActiveSlot: ((String, ActiveSlot) throws -> Void)? = nil + ) throws -> [String: BoundedRecentSnapshot] { + let uniqueSources = Dictionary( + sources.map { ($0.provider, $0) }, + uniquingKeysWith: { _, latest in latest } + ).values.sorted { $0.provider < $1.provider } + let maximumRecordsPerProvider = max(0, maximumRecordsPerProvider) + return try withDatabase { database in + var changed: [(source: LegacySource, stamp: LegacyStamp, payload: LegacyPayload)] = [] + for source in uniqueSources { + guard let stamp = LegacyStamp.read(path: source.url.path, fileManager: fileManager), + try !legacySourceIsCurrent( + database: database, + provider: source.provider, + stamp: stamp + ) else { continue } + do { + let admission = try hookLegacySourceAdmissionRetryingOneReplacement( + source: source, + expectedStamp: stamp, + fileManager: fileManager + ) + guard try !legacySourceIsCurrent( + database: database, + provider: source.provider, + stamp: admission.stamp + ) else { continue } + changed.append(( + source, + admission.stamp, + try legacyPayload(provider: source.provider, json: admission.json) + )) + } catch { + throw HookLegacySourceImportError(provider: source.provider) + } + } + if !changed.isEmpty { + try transaction(database) { + for item in changed { + try replaceLegacy( + database: database, + provider: item.source.provider, + stamp: item.stamp, + payload: item.payload + ) + } + } + } + return try readTransaction(database) { + try Dictionary(uniqueKeysWithValues: uniqueSources.map { source in + if let validateRecord { + try validateListRecordPayloads( + database: database, + provider: source.provider, + validate: { try validateRecord(source.provider, $0) } + ) + } + let recent = try readBoundedListRecords( + database: database, + provider: source.provider, + limit: maximumRecordsPerProvider + ) + return (source.provider, BoundedRecentSnapshot( + snapshot: Snapshot( + records: recent, + activeSlots: try readListSlots( + database: database, + provider: source.provider, + selectedSessionIDs: Set(recent.map(\.sessionID)), + validate: validateActiveSlot.map { validate in + { try validate(source.provider, $0) } + } + ) + ), + totalRecordCount: try recordCount( + database: database, + provider: source.provider + ) + )) + }) + } + } + } + + public func legacySourceIsCurrent(provider: String, stamp: LegacyStamp) throws -> Bool { + try withDatabase { database in + try legacySourceIsCurrent(database: database, provider: provider, stamp: stamp) + } + } + + /// Returns whether an exact compatibility revision has already been + /// imported or quarantined after canonical restore-owner verification. + /// Canonical rebinds may skip either state; projection readers still use + /// `legacySourceIsCurrent` so a quarantined sidecar is repaired normally. + public func canonicalRebindCanSkipLegacySource( + provider: String, + stamp: LegacyStamp + ) throws -> Bool { + try withDatabase { database in + try legacySourceCanBeSkippedForCanonicalRebind( + database: database, + provider: provider, + stamp: stamp + ) + } + } + + /// Imports a complete compatibility snapshot. Generation-zero rows may + /// replace only other generation-zero rows. Current or future rows win. + public func importLegacy( + provider: String, + stamp: LegacyStamp, + records: [Record], + activeSlots: [ActiveSlot] + ) throws { + try withDatabase { database in + try transaction(database) { + try replaceLegacy( + database: database, + provider: provider, + stamp: stamp, + payload: LegacyPayload(records: records, activeSlots: activeSlots) + ) + } + } + } + + /// Imports the raw compatibility store without decoding it through the + /// caller's model. This preserves keys introduced by another cmux version. + public func importLegacyStoreJSON( + provider: String, + stamp: LegacyStamp, + json: Data + ) throws { + let payload = try legacyPayload(provider: provider, json: json) + try importLegacy( + provider: provider, + stamp: stamp, + records: payload.records, + activeSlots: payload.activeSlots + ) + } + + struct LegacyPayload { + var records: [Record] + var activeSlots: [ActiveSlot] + } + + func legacyPayload(provider: String, json: Data) throws -> LegacyPayload { + guard let root = try JSONSerialization.jsonObject(with: json) as? [String: Any], + let sessions = root["sessions"] as? [String: Any] else { + throw CocoaError(.fileReadCorruptFile) + } + var records: [Record] = [] + records.reserveCapacity(sessions.count) + for (sessionID, value) in sessions { + guard let object = value as? [String: Any], + let embeddedSessionID = object["sessionId"] as? String, + embeddedSessionID == sessionID, + JSONSerialization.isValidJSONObject(object), + let updatedAt = object["updatedAt"] as? TimeInterval, + let recordJSON = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) else { + throw CocoaError(.fileReadCorruptFile) + } + records.append(Record( + provider: provider, + sessionID: sessionID, + updatedAt: updatedAt, + writerGeneration: 0, + json: recordJSON + )) + } + var activeSlots: [ActiveSlot] = [] + for (key, scope) in [ + ("activeSessionsByWorkspace", Scope.workspace), + ("activeSessionsBySurface", Scope.surface), + ] { + let slots: [String: Any] + if let value = root[key] { + guard let decoded = value as? [String: Any] else { + throw CocoaError(.fileReadCorruptFile) + } + slots = decoded + } else { + slots = [:] + } + for (scopeID, value) in slots { + guard let object = value as? [String: Any], + let sessionID = object["sessionId"] as? String, + let updatedAt = object["updatedAt"] as? TimeInterval, + JSONSerialization.isValidJSONObject(object), + let recordJSON = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) else { + throw CocoaError(.fileReadCorruptFile) + } + activeSlots.append(ActiveSlot( + provider: provider, + scope: scope, + scopeID: scopeID, + sessionID: sessionID, + updatedAt: updatedAt, + writerGeneration: 0, + json: recordJSON + )) + } + } + return LegacyPayload(records: records, activeSlots: activeSlots) + } + + /// Applies only rows changed by the current hook event. The SQL conflict + /// clause rejects attempts to replace a row owned by a newer generation. + public func apply( + provider: String, + records: [Record], + deletedSessionIDs: Set = [], + activeSlots: [ActiveSlot] = [], + deletedSlots: Set = [] + ) throws { + try validateHookWriteBatch( + provider: provider, + records: records, + activeSlots: activeSlots + ) + try withDatabase { database in + try transaction(database) { + let previousProviderBytes = try hookProviderStorageBytes( + database: database, + provider: provider + ) + for var record in records { + record.provider = provider + try upsert(record, database: database) + } + for sessionID in deletedSessionIDs { + try deleteSession( + database: database, + provider: provider, + sessionID: sessionID, + maximumWriterGeneration: Self.currentWriterGeneration + ) + } + for var slot in activeSlots { + slot.provider = provider + try upsert(slot, database: database) + } + for compoundKey in deletedSlots { + let components = compoundKey.split(separator: "\u{0}", maxSplits: 1).map(String.init) + guard components.count == 2, let scope = Scope(rawValue: components[0]) else { continue } + try deleteSlot( + database: database, + provider: provider, + scope: scope, + scopeID: components[1], + maximumWriterGeneration: Self.currentWriterGeneration + ) + } + try reconcileHookProviderStorageLimit( + database: database, + provider: provider, + protectedSessionIDs: Set(records.map(\.sessionID)), + previousBytes: previousProviderBytes + ) + } + } + } + + public func markLegacySource(provider: String, stamp: LegacyStamp) throws { + try withDatabase { database in + try writeLegacyStamp(database: database, provider: provider, stamp: stamp) + } + } + + /// Patches a single registry row without decoding it into the caller's + /// older Codable type. Unknown keys and a newer writer generation survive. + public func patchRecord( + provider: String, + sessionID: String, + updatedAt: TimeInterval, + activeSlotRemoval: ActiveSlotRemoval? = nil, + shouldMutate: ([String: Any]) -> Bool = { _ in true }, + mutate: (inout [String: Any]) -> Void + ) throws -> Bool { + try withDatabase { database in + try transaction(database) { + let previousProviderBytes = try hookProviderStorageBytes( + database: database, + provider: provider + ) + guard let existing = try readRecord( + database: database, + provider: provider, + sessionID: sessionID + ), + var object = try JSONSerialization.jsonObject(with: existing.json) as? [String: Any] else { + return false + } + guard shouldMutate(object) else { return false } + mutate(&object) + guard JSONSerialization.isValidJSONObject(object) else { return false } + let json = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + try upsert( + Record( + provider: provider, + sessionID: sessionID, + updatedAt: updatedAt, + writerGeneration: max(existing.writerGeneration, Self.currentWriterGeneration), + json: json + ), + database: database + ) + if let activeSlotRemoval { + try removeActiveSlots( + database: database, + provider: provider, + sessionID: sessionID, + removal: activeSlotRemoval, + maximumWriterGeneration: Self.currentWriterGeneration + ) + } + try reconcileHookProviderStorageLimit( + database: database, + provider: provider, + protectedSessionIDs: [sessionID], + previousBytes: previousProviderBytes + ) + return true + } + } + } + + /// Patches one session row and moves its known active slots in one + /// transaction. Every lookup is backed by a primary key, so restore cost + /// does not grow with the number of sessions owned by the provider. + /// + /// A destination owned by another session rejects the complete mutation. + /// Callers can also require every destination to exist already, turning the + /// operation into an ownership claim rather than a slot creation. + /// A previous slot that has already changed owners is left untouched. Slot + /// JSON merges the owned sources from oldest to newest so unknown keys and + /// a newer writer generation survive the move. + public func patchRecordRebindingActiveSlots( + provider: String, + sessionID: String, + updatedAt: TimeInterval, + previousSlots: [ActiveSlotKey], + activeSlots: [ActiveSlotKey], + requireExistingActiveSlots: Bool = false, + monotonicUpdatedAt: Bool = false, + shouldMutate: ([String: Any]) -> Bool = { _ in true }, + mutate: (inout [String: Any]) -> Void + ) throws -> RecordRebindResult { + return try withDatabase { database in + // Session restore runs on the main actor. One SQLite busy timeout + // is the complete lock-wait budget; the caller may retry off-main. + try transaction(database, retryBeginContention: false) { + try patchRecordRebindingActiveSlots( + database: database, + provider: provider, + sessionID: sessionID, + updatedAt: updatedAt, + previousSlots: previousSlots, + activeSlots: activeSlots, + requireExistingActiveSlots: requireExistingActiveSlots, + monotonicUpdatedAt: monotonicUpdatedAt, + shouldMutate: shouldMutate, + mutate: mutate + ) + } + } + } + + /// Refreshes one compatibility source and adopts its hibernated row through + /// one SQLite connection and one writer transaction. Restore callers hold + /// the provider sidecar's shared lock across this call, preventing an older + /// JSON writer from changing the source between import and rebind. + public func refreshLegacySourceAndPatchRecordRebindingActiveSlots( + provider: String, + legacyURL: URL, + fileManager: FileManager = .default, + sessionID: String, + updatedAt: TimeInterval, + previousSlots: [ActiveSlotKey], + activeSlots: [ActiveSlotKey], + requireExistingActiveSlots: Bool = false, + monotonicUpdatedAt: Bool = false, + shouldMutate: ([String: Any]) -> Bool = { _ in true }, + mutate: (inout [String: Any]) -> Void + ) throws -> RecordRebindResult { + try withLegacySourceRebindBatch(provider: provider, legacyURL: legacyURL, fileManager: fileManager) { batch in + try batch.patchRecordRebindingActiveSlots( + provider: provider, + sessionID: sessionID, + updatedAt: updatedAt, + previousSlots: previousSlots, + activeSlots: activeSlots, + requireExistingActiveSlots: requireExistingActiveSlots, + monotonicUpdatedAt: monotonicUpdatedAt, + shouldMutate: shouldMutate, + mutate: mutate + ) + } + } + + /// Performs indexed record/slot mutations through one SQLite connection + /// and one writer transaction. The complete batch consumes at most one + /// busy-timeout budget, independent of its record count. + public func withRecordRebindBatch( + _ body: (RecordRebindBatch) throws -> T + ) throws -> T { + try withDatabase { database in + try transaction(database, retryBeginContention: false) { + let batch = RecordRebindBatch(registry: self, database: database) + defer { batch.invalidate() } + return try body(batch) + } + } + } + + /// Imports one provider and performs every requested indexed rebind in one + /// writer transaction. Lock contention consumes one busy timeout for the + /// complete batch, independent of the number of restored panels. + public func withLegacySourceRebindBatch( + provider: String, + legacyURL: URL, + fileManager: FileManager = .default, + _ body: (RecordRebindBatch) throws -> T + ) throws -> T { + return try withDatabase { database in + let changedLegacy: (stamp: LegacyStamp, payload: LegacyPayload)? + if let stamp = LegacyStamp.read(path: legacyURL.path, fileManager: fileManager), + try !legacySourceCanBeSkippedForCanonicalRebind( + database: database, + provider: provider, + stamp: stamp + ) { + let source = LegacySource(provider: provider, url: legacyURL) + let admission = try hookLegacySourceAdmissionRetryingOneReplacement( + source: source, + expectedStamp: stamp, + fileManager: fileManager + ) + if try legacySourceCanBeSkippedForCanonicalRebind( + database: database, + provider: provider, + stamp: admission.stamp + ) { + changedLegacy = nil + } else { + changedLegacy = ( + admission.stamp, + try legacyPayload(provider: provider, json: admission.json) + ) + } + } else { + changedLegacy = nil + } + // Import and ownership transfer are indivisible. A concurrent + // registry writer either precedes the complete restore mutation or + // observes it after commit. + return try transaction(database, retryBeginContention: false) { + if let changedLegacy { + try replaceLegacy( + database: database, + provider: provider, + stamp: changedLegacy.stamp, + payload: changedLegacy.payload + ) + } + let batch = RecordRebindBatch(registry: self, database: database) + defer { batch.invalidate() } + return try body(batch) + } + } + } + + func patchRecordRebindingActiveSlots( + database: OpaquePointer, + provider: String, + sessionID: String, + updatedAt: TimeInterval, + previousSlots: [ActiveSlotKey], + activeSlots: [ActiveSlotKey], + requireExistingActiveSlots: Bool, + monotonicUpdatedAt: Bool, + shouldMutate: ([String: Any]) -> Bool, + mutate: (inout [String: Any]) -> Void + ) throws -> RecordRebindResult { + let previousProviderBytes = try hookProviderStorageBytes( + database: database, + provider: provider + ) + let previousKeys = Set(previousSlots) + let activeKeys = Set(activeSlots) + let keys = previousKeys.union(activeKeys) + guard let existingRecord = try readRecord( + database: database, + provider: provider, + sessionID: sessionID + ) else { + return .recordMissing + } + guard let decodedRecord = try? JSONSerialization.jsonObject(with: existingRecord.json), + var object = decodedRecord as? [String: Any] else { + return .rejected + } + guard shouldMutate(object) else { return .rejected } + + var storedSlots: [ActiveSlotKey: ActiveSlot] = [:] + storedSlots.reserveCapacity(keys.count) + for key in keys { + if let slot = try readSlot( + database: database, + provider: provider, + scope: key.scope, + scopeID: key.scopeID + ) { + storedSlots[key] = slot + } + } + guard activeKeys.allSatisfy({ key in + guard let slot = storedSlots[key] else { + return !requireExistingActiveSlots + } + return slot.sessionID == sessionID + }) else { + return .rejected + } + let ownedSlots = storedSlots.values + .filter { $0.sessionID == sessionID } + .sorted { + if $0.updatedAt != $1.updatedAt { return $0.updatedAt < $1.updatedAt } + if $0.scope.rawValue != $1.scope.rawValue { + return $0.scope.rawValue < $1.scope.rawValue + } + return $0.scopeID < $1.scopeID + } + var effectiveUpdatedAt = monotonicUpdatedAt + ? max( + updatedAt, + existingRecord.updatedAt, + ownedSlots.map(\.updatedAt).max() ?? -.infinity + ) + : updatedAt + var slotObject: [String: Any] = [:] + for ownedSlot in ownedSlots { + guard let decodedJSON = try? JSONSerialization.jsonObject(with: ownedSlot.json), + let decoded = decodedJSON as? [String: Any] else { + return .rejected + } + slotObject.merge(decoded) { _, new in new } + } + slotObject["sessionId"] = sessionID + let slotWriterGeneration = max( + Self.currentWriterGeneration, + ownedSlots.map(\.writerGeneration).max() ?? 0 + ) + + mutate(&object) + if monotonicUpdatedAt { + effectiveUpdatedAt = max( + effectiveUpdatedAt, + object["updatedAt"] as? TimeInterval ?? -.infinity + ) + object["updatedAt"] = effectiveUpdatedAt + } + slotObject["updatedAt"] = effectiveUpdatedAt + guard JSONSerialization.isValidJSONObject(slotObject), + let slotJSON = try? JSONSerialization.data( + withJSONObject: slotObject, + options: [.sortedKeys] + ) else { + return .rejected + } + guard JSONSerialization.isValidJSONObject(object), + let recordJSON = try? JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys] + ) else { return .rejected } + try upsert( + Record( + provider: provider, + sessionID: sessionID, + updatedAt: effectiveUpdatedAt, + writerGeneration: max( + existingRecord.writerGeneration, + Self.currentWriterGeneration + ), + json: recordJSON + ), + database: database + ) + + for key in previousKeys.subtracting(activeKeys) { + guard let storedSlot = storedSlots[key], + storedSlot.sessionID == sessionID else { continue } + try deleteSlot( + database: database, + provider: provider, + scope: key.scope, + scopeID: key.scopeID, + maximumWriterGeneration: max( + storedSlot.writerGeneration, + Self.currentWriterGeneration + ) + ) + } + + for key in activeKeys { + try upsert( + ActiveSlot( + provider: provider, + scope: key.scope, + scopeID: key.scopeID, + sessionID: sessionID, + updatedAt: effectiveUpdatedAt, + writerGeneration: slotWriterGeneration, + json: slotJSON + ), + database: database + ) + } + try reconcileHookProviderStorageLimit( + database: database, + provider: provider, + protectedSessionIDs: [sessionID], + previousBytes: previousProviderBytes + ) + return .patched + } + + public static func slotKey(scope: Scope, scopeID: String) -> String { + "\(scope.rawValue)\u{0}\(scopeID)" + } + +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRunAuthorityProjection.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRunAuthorityProjection.swift new file mode 100644 index 000000000000..4c9c4d60bed0 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentSessionRunAuthorityProjection.swift @@ -0,0 +1,495 @@ +public import Foundation + +/// Projects restore ownership from one logical session's canonical process run. +/// +/// Compatibility fields on the logical record can lag run history during a +/// fork, resume, or concurrent registry write. Every restore consumer uses this +/// projector so stale record-level authority cannot promote a child or demote a +/// new root. The ordering and duplicate merge intentionally match the CLI's +/// `AgentSessionRunCanonicalizer`. +public struct CmuxAgentSessionRunAuthorityProjection: Sendable { + public struct Runtime: Codable, Equatable, Sendable { + public var id: String + public var socketPath: String? + public var bundleIdentifier: String? + public var processId: Int? + public var processStartSeconds: Int64? + public var processStartMicroseconds: Int64? + + public init( + id: String, + socketPath: String? = nil, + bundleIdentifier: String? = nil, + processId: Int? = nil, + processStartSeconds: Int64? = nil, + processStartMicroseconds: Int64? = nil + ) { + self.id = id + self.socketPath = socketPath + self.bundleIdentifier = bundleIdentifier + self.processId = processId + self.processStartSeconds = processStartSeconds + self.processStartMicroseconds = processStartMicroseconds + } + } + + public struct Projection: Equatable, Sendable { + public var restoreAuthority: Bool + public var run: Run? + + public init(restoreAuthority: Bool, run: Run?) { + self.restoreAuthority = restoreAuthority + self.run = run + } + } + + public enum Relationship: String, Codable, Sendable { + case spawned + case forked + case resumed + } + + public enum AuthorityEvidence: String, Codable, Equatable, Sendable { + case verifiedForkRoot = "verified_fork_root" + case managedChild = "managed_child" + case explicitSpawnedChild = "explicit_spawned_child" + case verifiedAncestorChild = "verified_ancestor_child" + case provisionalAmbiguousChild = "provisional_ambiguous_child" + case legacyChild = "legacy_child" + + fileprivate var isDurableChild: Bool { + switch self { + case .managedChild, .explicitSpawnedChild, .verifiedAncestorChild, .legacyChild: + true + case .verifiedForkRoot, .provisionalAmbiguousChild: + false + } + } + + fileprivate var prohibitsRestore: Bool { + self != .verifiedForkRoot + } + } + + public struct Run: Codable, Equatable, Sendable { + public var runId: String + public var pid: Int? + public var processStartedAt: TimeInterval? + public var cmuxRuntime: Runtime? + public var parentRunId: String? + public var parentSessionId: String? + public var relationship: Relationship? + public var restoreAuthority: Bool + public var authorityEvidence: AuthorityEvidence? + public var cmuxHibernationResumeAttemptId: String? + public var startedAt: TimeInterval + public var updatedAt: TimeInterval + public var endedAt: TimeInterval? + public var identityConflict: Bool? + + public init( + runId: String, + pid: Int? = nil, + processStartedAt: TimeInterval? = nil, + cmuxRuntime: Runtime? = nil, + parentRunId: String? = nil, + parentSessionId: String? = nil, + relationship: Relationship? = nil, + restoreAuthority: Bool, + authorityEvidence: AuthorityEvidence? = nil, + cmuxHibernationResumeAttemptId: String? = nil, + startedAt: TimeInterval, + updatedAt: TimeInterval, + endedAt: TimeInterval? = nil, + identityConflict: Bool? = nil + ) { + self.runId = runId + self.pid = pid + self.processStartedAt = processStartedAt + self.cmuxRuntime = cmuxRuntime + self.parentRunId = parentRunId + self.parentSessionId = parentSessionId + self.relationship = relationship + self.restoreAuthority = restoreAuthority + self.authorityEvidence = authorityEvidence + self.cmuxHibernationResumeAttemptId = cmuxHibernationResumeAttemptId + self.startedAt = startedAt + self.updatedAt = updatedAt + self.endedAt = endedAt + self.identityConflict = identityConflict + } + } + + private struct RecordEnvelope: Decodable { + var restoreAuthority: Bool? + var relationship: Relationship? + var authorityEvidence: AuthorityEvidence? + var completedAt: TimeInterval? + var runs: [Run]? + var activeRunId: String? + } + + public init() {} + + /// Returns `nil` when a nonempty run projection cannot be decoded safely. + /// Callers must treat `nil` as non-authoritative. + public func projectedRestoreAuthority(recordJSON: Data) -> Bool? { + projection(recordJSON: recordJSON)?.restoreAuthority + } + + /// Projects authority and runtime from the same canonical run so lifecycle + /// consumers cannot combine a run's authority with stale root ownership. + public func projection(recordJSON: Data) -> Projection? { + guard let record = try? JSONDecoder().decode(RecordEnvelope.self, from: recordJSON) else { + return nil + } + return projection( + recordRestoreAuthority: record.restoreAuthority, + runs: record.runs, + activeRunId: record.activeRunId, + recordRelationship: record.relationship, + recordAuthorityEvidence: record.authorityEvidence, + recordCompletedAt: record.completedAt + ) + } + + /// Empty or absent run history preserves the legacy record-level behavior. + /// Once a nonempty run array exists, it is authoritative. + public func projectedRestoreAuthority( + recordRestoreAuthority: Bool?, + runs: [Run]?, + activeRunId: String?, + recordRelationship: Relationship? = nil, + recordAuthorityEvidence: AuthorityEvidence? = nil, + recordCompletedAt: TimeInterval? = nil + ) -> Bool { + projection( + recordRestoreAuthority: recordRestoreAuthority, + runs: runs, + activeRunId: activeRunId, + recordRelationship: recordRelationship, + recordAuthorityEvidence: recordAuthorityEvidence, + recordCompletedAt: recordCompletedAt + ).restoreAuthority + } + + public func projection( + recordRestoreAuthority: Bool?, + runs: [Run]?, + activeRunId: String?, + recordRelationship: Relationship? = nil, + recordAuthorityEvidence: AuthorityEvidence? = nil, + recordCompletedAt: TimeInterval? = nil + ) -> Projection { + guard let runs, !runs.isEmpty else { + let evidence = persistedAuthorityEvidence( + relationship: recordRelationship, + authorityEvidence: recordAuthorityEvidence + ) + let restoreAuthority = recordRestoreAuthority != false + && recordCompletedAt == nil + && recordRelationship != .spawned + && evidence?.prohibitsRestore != true + return Projection(restoreAuthority: restoreAuthority, run: nil) + } + let run = projectedRun(runs: runs, activeRunId: activeRunId) + return Projection(restoreAuthority: run?.restoreAuthority ?? false, run: run) + } + + public func projectedRun(runs: [Run], activeRunId: String?) -> Run? { + guard !runs.isEmpty else { return nil } + let canonicalRuns = canonicalRuns(runs) + if let activeRunId, + let active = canonicalRuns.first(where: { $0.runId == activeRunId }) { + return active + } + return canonicalRuns.dropFirst().reduce(canonicalRuns[0]) { newest, candidate in + isNewer(candidate, than: newest) ? candidate : newest + } + } + + private func canonicalRuns(_ runs: [Run]) -> [Run] { + var newestByRunID: [String: Run] = [:] + newestByRunID.reserveCapacity(runs.count) + for run in runs { + if let current = newestByRunID[run.runId] { + newestByRunID[run.runId] = normalizedAuthority( + canonicalDuplicate(run, current) + ) + } else { + newestByRunID[run.runId] = normalizedAuthority(run) + } + } + return newestByRunID.values.sorted { $0.runId < $1.runId } + } + + private func isNewer(_ candidate: Run, than current: Run) -> Bool { + if candidate.updatedAt != current.updatedAt { return candidate.updatedAt > current.updatedAt } + if candidate.startedAt != current.startedAt { return candidate.startedAt > current.startedAt } + if (candidate.endedAt == nil) != (current.endedAt == nil) { return candidate.endedAt == nil } + if candidate.endedAt != current.endedAt { + return (candidate.endedAt ?? -.infinity) > (current.endedAt ?? -.infinity) + } + if candidate.processStartedAt != current.processStartedAt { + return (candidate.processStartedAt ?? -.infinity) > (current.processStartedAt ?? -.infinity) + } + if candidate.pid != current.pid { return (candidate.pid ?? -1) > (current.pid ?? -1) } + if let result = optionalStringPrecedes(candidate.cmuxRuntime?.id, current.cmuxRuntime?.id) { + return result + } + if let result = optionalStringPrecedes( + candidate.cmuxRuntime?.socketPath, + current.cmuxRuntime?.socketPath + ) { + return result + } + if let result = optionalStringPrecedes( + candidate.cmuxRuntime?.bundleIdentifier, + current.cmuxRuntime?.bundleIdentifier + ) { + return result + } + if let result = optionalStringPrecedes(candidate.parentRunId, current.parentRunId) { + return result + } + if let result = optionalStringPrecedes(candidate.parentSessionId, current.parentSessionId) { + return result + } + if let result = optionalStringPrecedes( + candidate.relationship?.rawValue, + current.relationship?.rawValue + ) { + return result + } + if let result = optionalStringPrecedes( + candidate.authorityEvidence?.rawValue, + current.authorityEvidence?.rawValue + ) { + return result + } + if let result = optionalStringPrecedes( + candidate.cmuxHibernationResumeAttemptId, + current.cmuxHibernationResumeAttemptId + ) { + return result + } + if candidate.restoreAuthority != current.restoreAuthority { return !candidate.restoreAuthority } + return false + } + + private func canonicalDuplicate(_ candidate: Run, _ current: Run) -> Run { + guard candidate.updatedAt == current.updatedAt, + candidate.startedAt == current.startedAt else { + return canonicalNonEqualDuplicate(candidate, current) + } + + let preferred = isNewer(candidate, than: current) ? candidate : current + let alternate = preferred == candidate ? current : candidate + var merged = preferred + let processIdentityConflict = conflictingProcessIdentity(candidate, current) + let runtimeIdentityConflict = conflictingRuntimeIdentity(candidate.cmuxRuntime, current.cmuxRuntime) + let resumeProofConflict = if let candidateAttempt = candidate.cmuxHibernationResumeAttemptId, + let currentAttempt = current.cmuxHibernationResumeAttemptId { + candidateAttempt != currentAttempt + } else { + false + } + let identityConflict = candidate.identityConflict == true + || current.identityConflict == true + || processIdentityConflict + || runtimeIdentityConflict + merged.identityConflict = identityConflict ? true : nil + merged.restoreAuthority = candidate.restoreAuthority + && current.restoreAuthority + && !identityConflict + if identityConflict { + merged.pid = nil + merged.processStartedAt = nil + merged.cmuxRuntime = nil + } else { + if preferred.pid != nil || preferred.processStartedAt != nil { + merged.pid = preferred.pid + merged.processStartedAt = preferred.processStartedAt + } else { + merged.pid = alternate.pid + merged.processStartedAt = alternate.processStartedAt + } + merged.cmuxRuntime = preferredRuntime(candidate.cmuxRuntime, current.cmuxRuntime) + } + merged.parentRunId = preferred.parentRunId ?? alternate.parentRunId + merged.parentSessionId = preferred.parentSessionId ?? alternate.parentSessionId + if candidate.relationship == .spawned || current.relationship == .spawned { + merged.relationship = .spawned + } else { + merged.relationship = preferred.relationship ?? alternate.relationship + } + merged.authorityEvidence = preferredAuthorityEvidence( + candidate.authorityEvidence, + current.authorityEvidence + ) + if resumeProofConflict { + merged.cmuxHibernationResumeAttemptId = nil + merged.restoreAuthority = false + } else { + merged.cmuxHibernationResumeAttemptId = candidate.cmuxHibernationResumeAttemptId + ?? current.cmuxHibernationResumeAttemptId + } + if let candidateEndedAt = candidate.endedAt, let currentEndedAt = current.endedAt { + merged.endedAt = max(candidateEndedAt, currentEndedAt) + } else { + merged.endedAt = candidate.endedAt ?? current.endedAt + } + return normalizedAuthority(merged) + } + + /// Persisted compatibility booleans are untrusted when structural run + /// evidence proves the process generation cannot own restoration. + private func normalizedAuthority(_ source: Run) -> Run { + var run = source + run.authorityEvidence = persistedAuthorityEvidence( + relationship: run.relationship, + authorityEvidence: run.authorityEvidence + ) + if run.relationship == .spawned + || run.authorityEvidence?.prohibitsRestore == true + || run.endedAt != nil + || run.identityConflict == true { + run.restoreAuthority = false + } + return run + } + + private func canonicalNonEqualDuplicate(_ candidate: Run, _ current: Run) -> Run { + let newer = isNewer(candidate, than: current) ? candidate : current + let older = newer == candidate ? current : candidate + let newerEvidence = persistedAuthorityEvidence( + relationship: newer.relationship, + authorityEvidence: newer.authorityEvidence + ) + let olderEvidence = persistedAuthorityEvidence( + relationship: older.relationship, + authorityEvidence: older.authorityEvidence + ) + if let durableEvidence = preferredAuthorityEvidence( + newerEvidence?.isDurableChild == true ? newerEvidence : nil, + olderEvidence?.isDurableChild == true ? olderEvidence : nil + ) { + var merged = newer + merged.parentRunId = newer.parentRunId ?? older.parentRunId + merged.parentSessionId = newer.parentSessionId ?? older.parentSessionId + merged.relationship = .spawned + merged.restoreAuthority = false + merged.authorityEvidence = durableEvidence + return normalizedAuthority(merged) + } + if olderEvidence == .provisionalAmbiguousChild { + if newerEvidence == .verifiedForkRoot, + newer.relationship == .forked, + newer.restoreAuthority { + return normalizedAuthority(newer) + } + return provisionalChildProjection(newer: newer, older: older) + } + if newerEvidence == .provisionalAmbiguousChild { + return provisionalChildProjection(newer: newer, older: older) + } + return normalizedAuthority(newer) + } + + private func provisionalChildProjection(newer: Run, older: Run) -> Run { + var merged = newer + merged.parentRunId = newer.parentRunId ?? older.parentRunId + merged.parentSessionId = newer.parentSessionId ?? older.parentSessionId + merged.relationship = .spawned + merged.restoreAuthority = false + merged.authorityEvidence = .provisionalAmbiguousChild + return normalizedAuthority(merged) + } + + private func persistedAuthorityEvidence( + relationship: Relationship?, + authorityEvidence: AuthorityEvidence? + ) -> AuthorityEvidence? { + guard relationship == .spawned else { return authorityEvidence } + if authorityEvidence == .provisionalAmbiguousChild { return authorityEvidence } + if authorityEvidence?.isDurableChild == true { return authorityEvidence } + return .legacyChild + } + + private func conflictingProcessIdentity(_ lhs: Run, _ rhs: Run) -> Bool { + if let lhsPID = lhs.pid, let rhsPID = rhs.pid, lhsPID != rhsPID { return true } + if let lhsStartedAt = lhs.processStartedAt, + let rhsStartedAt = rhs.processStartedAt, + abs(lhsStartedAt - rhsStartedAt) > 0.001 { + return true + } + return false + } + + private func conflictingRuntimeIdentity(_ lhs: Runtime?, _ rhs: Runtime?) -> Bool { + guard let lhs, let rhs else { return false } + return lhs.id != rhs.id + || optionalValuesConflict(lhs.socketPath, rhs.socketPath) + || optionalValuesConflict(lhs.bundleIdentifier, rhs.bundleIdentifier) + || optionalValuesConflict(lhs.processId, rhs.processId) + || optionalValuesConflict(lhs.processStartSeconds, rhs.processStartSeconds) + || optionalValuesConflict(lhs.processStartMicroseconds, rhs.processStartMicroseconds) + } + + private func optionalValuesConflict(_ lhs: T?, _ rhs: T?) -> Bool { + guard let lhs, let rhs else { return false } + return lhs != rhs + } + + private func preferredRuntime(_ lhs: Runtime?, _ rhs: Runtime?) -> Runtime? { + guard let lhs else { return rhs } + guard let rhs else { return lhs } + guard lhs.id == rhs.id else { return nil } + return Runtime( + id: lhs.id, + socketPath: mergedRuntimeField(lhs.socketPath, rhs.socketPath), + bundleIdentifier: mergedRuntimeField(lhs.bundleIdentifier, rhs.bundleIdentifier), + processId: mergedRuntimeField(lhs.processId, rhs.processId), + processStartSeconds: mergedRuntimeField( + lhs.processStartSeconds, + rhs.processStartSeconds + ), + processStartMicroseconds: mergedRuntimeField( + lhs.processStartMicroseconds, + rhs.processStartMicroseconds + ) + ) + } + + private func mergedRuntimeField(_ lhs: String?, _ rhs: String?) -> String? { + guard let lhs else { return rhs } + guard let rhs else { return lhs } + return lhs == rhs ? lhs : nil + } + + private func mergedRuntimeField(_ lhs: T?, _ rhs: T?) -> T? { + guard let lhs else { return rhs } + guard let rhs else { return lhs } + return lhs == rhs ? lhs : nil + } + + private func preferredAuthorityEvidence( + _ lhs: AuthorityEvidence?, + _ rhs: AuthorityEvidence? + ) -> AuthorityEvidence? { + let candidates = [lhs, rhs].compactMap { $0 } + return candidates.sorted { first, second in + if first.isDurableChild != second.isDurableChild { return first.isDurableChild } + if first == .provisionalAmbiguousChild && second != .provisionalAmbiguousChild { return true } + if second == .provisionalAmbiguousChild && first != .provisionalAmbiguousChild { return false } + return first.rawValue < second.rawValue + }.first + } + + private func optionalStringPrecedes(_ lhs: String?, _ rhs: String?) -> Bool? { + if lhs == rhs { return nil } + guard let lhs else { return false } + guard let rhs else { return true } + return lhs < rhs + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentTerminalObservation.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentTerminalObservation.swift new file mode 100644 index 000000000000..f5e17f268bc7 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/AgentSessions/CmuxAgentTerminalObservation.swift @@ -0,0 +1,87 @@ +public import Foundation + +/// A bounded, content-free snapshot of one live coding-agent terminal. +/// +/// The app updates these values when its deferred detector publishes a new +/// effective state. Socket and CLI consumers only copy this cached metadata; +/// they never trigger terminal capture or classification. +public struct CmuxAgentTerminalObservation: Codable, Sendable, Equatable { + /// The cmux app process that owns the terminal. + public let runtimeID: String + /// The owning workspace UUID. + public let workspaceID: UUID + /// The stable cmux surface UUID. + public let surfaceID: UUID + /// The native terminal lifetime that produced this observation. + public let surfaceGeneration: UInt64 + /// The terminal dirty-signal revision that was classified. + public let revision: UInt64 + /// The detector catalog family identifier. + public let familyID: String + /// The canonical hook/session provider identifier used by `cmux agents`. + public let sessionProviderID: String + /// Whether complete lifecycle hooks take precedence over terminal evidence. + public let lifecycleAuthoritative: Bool + /// The observed terminal interaction state. + public let state: CmuxAgentObservedState + /// The foreground process identifier. + public let pid: Int32 + /// Kernel process start-time seconds. + public let processStartSeconds: Int64 + /// Kernel process start-time microseconds. + public let processStartMicroseconds: Int64 + /// The terminal's requested working directory, when available. + public let cwd: String? + /// Wall-clock time when cmux published this cached observation. + public let publishedAt: TimeInterval + + /// Creates one live terminal observation. + public init( + runtimeID: String, + workspaceID: UUID, + surfaceID: UUID, + surfaceGeneration: UInt64, + revision: UInt64, + familyID: String, + sessionProviderID: String, + lifecycleAuthoritative: Bool, + state: CmuxAgentObservedState, + pid: Int32, + processStartSeconds: Int64, + processStartMicroseconds: Int64, + cwd: String?, + publishedAt: TimeInterval + ) { + self.runtimeID = runtimeID + self.workspaceID = workspaceID + self.surfaceID = surfaceID + self.surfaceGeneration = surfaceGeneration + self.revision = revision + self.familyID = familyID + self.sessionProviderID = sessionProviderID + self.lifecycleAuthoritative = lifecycleAuthoritative + self.state = state + self.pid = pid + self.processStartSeconds = processStartSeconds + self.processStartMicroseconds = processStartMicroseconds + self.cwd = cwd + self.publishedAt = publishedAt + } + + enum CodingKeys: String, CodingKey { + case runtimeID = "runtime_id" + case workspaceID = "workspace_id" + case surfaceID = "surface_id" + case surfaceGeneration = "surface_generation" + case revision + case familyID = "family_id" + case sessionProviderID = "session_provider_id" + case lifecycleAuthoritative = "lifecycle_authoritative" + case state + case pid + case processStartSeconds = "process_start_seconds" + case processStartMicroseconds = "process_start_microseconds" + case cwd + case publishedAt = "published_at" + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAgentSessionRegistryHookHotPathTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAgentSessionRegistryHookHotPathTests.swift new file mode 100644 index 000000000000..449c192a2aad --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAgentSessionRegistryHookHotPathTests.swift @@ -0,0 +1,2690 @@ +import Foundation +import Darwin +import SQLite3 +import Testing +@testable import CmuxFoundation + +@Suite("Agent session registry hook hot path", .serialized) +struct CmuxAgentSessionRegistryHookHotPathTests { + private final class Fixture: @unchecked Sendable { + let registry: CmuxAgentSessionRegistry + let directory: URL + let legacyURL: URL + + init(directory: URL) { + self.directory = directory + registry = CmuxAgentSessionRegistry( + url: directory.appendingPathComponent(CmuxAgentSessionRegistry.filename), + busyTimeoutMilliseconds: 250 + ) + legacyURL = directory.appendingPathComponent("agent-hook-sessions.json") + } + + deinit { + try? FileManager.default.removeItem(at: directory) + } + } + + @Test("exact hook mutation reads one record and at most four slots at ten thousand rows") + func exactMutationHasConstantRowCost() throws { + let fixture = try makeFixture() + let now = Date().timeIntervalSince1970 + let records = try (0..<10_000).map { index in + try record( + provider: "codex", + sessionID: String(format: "session-%05d", index), + workspaceID: "workspace-\(index)", + surfaceID: "surface-\(index)", + updatedAt: now + ) + } + try fixture.registry.apply(provider: "codex", records: records) + let targetID = "session-05000" + let targetSlots = try slots( + provider: "codex", + sessionID: targetID, + workspaceID: "workspace-5000", + surfaceID: "surface-5000", + updatedAt: now + ) + let otherSlots = try slots( + provider: "codex", + sessionID: "session-05001", + workspaceID: "destination-workspace", + surfaceID: "destination-surface", + updatedAt: now + ) + try fixture.registry.apply( + provider: "codex", + records: [], + activeSlots: targetSlots + otherSlots + ) + + let result = try fixture.registry.mutateHookSession( + provider: "codex", + sessionID: targetID, + activeSlots: [ + .init(scope: .workspace, scopeID: "destination-workspace"), + .init(scope: .surface, scopeID: "destination-surface"), + ], + now: now + ) { snapshot in + var stored = try #require(snapshot.records.first) + var object = try #require( + JSONSerialization.jsonObject(with: stored.json) as? [String: Any] + ) + object["counter"] = 1 + stored.updatedAt += 1 + object["updatedAt"] = stored.updatedAt + stored.json = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + snapshot.records = [stored] + } + + #expect(result.recordsRead == 1) + #expect(result.slotsRead == 4) + #expect(result.recordsWritten == 1) + #expect(result.slotsWritten == 0) + #expect(try fixture.registry.snapshot(provider: "codex").records.count == 10_000) + } + + @Test("running lookup skips ten thousand inactive rows in one workspace") + func runningLookupUsesRuntimeStateIndex() throws { + let fixture = try makeFixture() + let records = try (0..<10_000).map { index in + try record( + provider: "claude", + sessionID: String(format: "inactive-%05d", index), + workspaceID: "shared-workspace", + surfaceID: "surface-\(index)", + updatedAt: TimeInterval(index), + extra: ["runtimeStatus": "idle"] + ) + } + try fixture.registry.apply(provider: "claude", records: records) + try fixture.registry.apply( + provider: "claude", + records: [try record( + provider: "claude", + sessionID: "running", + workspaceID: "shared-workspace", + surfaceID: "running-surface", + updatedAt: 10_001 + )] + ) + + let running = try fixture.registry.hookRunningRecords( + provider: "claude", + workspaceID: "shared-workspace", + surfaceID: nil + ) + #expect(running.map(\.sessionID) == ["running"]) + } + + @Test("storage preflight reports encoded lengths without loading snapshots") + func storagePreflightReportsExactLengths() throws { + let fixture = try makeFixture() + let small = try record( + provider: "pi", + sessionID: "small", + workspaceID: "workspace-small", + surfaceID: "surface-small", + updatedAt: 1 + ) + let large = try record( + provider: "pi", + sessionID: "large", + workspaceID: "workspace-large", + surfaceID: "surface-large", + updatedAt: 2, + extra: ["payload": String(repeating: "x", count: 4_096)] + ) + let activeSlots = try slots( + provider: "pi", + sessionID: "large", + workspaceID: "workspace-large", + surfaceID: "surface-large", + updatedAt: 2 + ) + try fixture.registry.apply( + provider: "pi", + records: [small, large], + activeSlots: activeSlots + ) + + let metrics = try fixture.registry.hookStorageMetrics(provider: "pi") + #expect(metrics.recordCount == 2) + #expect(metrics.recordBytes == Int64(small.json.count + large.json.count)) + #expect(metrics.activeSlotBytes == Int64(activeSlots.reduce(0) { $0 + $1.json.count })) + #expect(metrics.largestRecordSessionID == "large") + #expect(metrics.largestRecordBytes == Int64(large.json.count)) + #expect(metrics.totalBytes == metrics.recordBytes + metrics.activeSlotBytes) + } + + @Test("bounded recent reads retain active owners before newest inactive history") + func boundedRecentRecordsRetainActiveOwners() throws { + let fixture = try makeFixture() + let provider = "recent" + var records: [CmuxAgentSessionRegistry.Record] = [] + var activeSlots: [CmuxAgentSessionRegistry.ActiveSlot] = [] + for index in 0..<2 { + let sessionID = "active-old-\(index)" + records.append(try record( + provider: provider, + sessionID: sessionID, + workspaceID: "workspace-\(sessionID)", + surfaceID: "surface-\(sessionID)", + updatedAt: TimeInterval(index) + )) + activeSlots.append(contentsOf: try slots( + provider: provider, + sessionID: sessionID, + workspaceID: "workspace-\(sessionID)", + surfaceID: "surface-\(sessionID)", + updatedAt: TimeInterval(index) + )) + } + for index in 2..<10 { + records.append(try record( + provider: provider, + sessionID: "inactive-\(index)", + workspaceID: "workspace-inactive-\(index)", + surfaceID: "surface-inactive-\(index)", + updatedAt: TimeInterval(index) + )) + } + try fixture.registry.apply( + provider: provider, + records: records, + activeSlots: activeSlots + ) + + let selected = try fixture.registry.hookBoundedRecentRecords( + provider: provider, + maximumRecords: 3 + ) + #expect(selected.map(\.sessionID) == [ + "active-old-1", + "active-old-0", + "inactive-9", + ]) + #expect( + try fixture.registry.hookBoundedRecentRecords( + provider: provider, + maximumRecords: 3 + ).map(\.sessionID) == selected.map(\.sessionID) + ) + + var countFailure: CmuxAgentSessionRegistry.HookSnapshotLimitError? + do { + _ = try fixture.registry.hookBoundedRecentRecords( + provider: provider, + maximumRecords: 1 + ) + } catch let error as CmuxAgentSessionRegistry.HookSnapshotLimitError { + countFailure = error + } + let countError = try #require(countFailure) + #expect(countError.scope == .records) + #expect(countError.observed == 2) + + let activeBytes = records + .filter { $0.sessionID.hasPrefix("active-old-") } + .reduce(Int64(0)) { $0 + Int64($1.json.count) } + let newestInactive = try #require( + records.first { $0.sessionID == "inactive-9" } + ) + let exactBudget = activeBytes + Int64(newestInactive.json.count) + #expect( + try fixture.registry.hookBoundedRecentRecords( + provider: provider, + maximumRecords: 3, + maximumBytes: exactBudget + ).map(\.sessionID) == selected.map(\.sessionID) + ) + #expect( + try fixture.registry.hookBoundedRecentRecords( + provider: provider, + maximumRecords: 3, + maximumBytes: exactBudget - 1 + ).map(\.sessionID) == ["active-old-1", "active-old-0"] + ) + + var byteFailure: CmuxAgentSessionRegistry.HookSnapshotLimitError? + do { + _ = try fixture.registry.hookBoundedRecentRecords( + provider: provider, + maximumRecords: 3, + maximumBytes: activeBytes - 1 + ) + } catch let error as CmuxAgentSessionRegistry.HookSnapshotLimitError { + byteFailure = error + } + #expect(try #require(byteFailure).scope == .providerBytes) + + let largeProvider = "recent-large" + let payload = String(repeating: "x", count: 256 * 1_024) + let largeRecords = try (0..<32).map { index in + let sessionID = String(format: "inactive-%02d", index) + let object: [String: Any] = [ + "sessionId": sessionID, + "updatedAt": TimeInterval(index), + "payload": payload, + ] + return CmuxAgentSessionRegistry.Record( + provider: largeProvider, + sessionID: sessionID, + updatedAt: TimeInterval(index), + json: try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + ) + } + try fixture.registry.apply( + provider: largeProvider, + records: largeRecords, + activeSlots: [] + ) + let newestLargeRecord = try #require(largeRecords.last) + #expect( + try fixture.registry.hookBoundedRecentRecords( + provider: largeProvider, + maximumRecords: largeRecords.count, + maximumBytes: Int64(newestLargeRecord.json.count) + ).map(\.sessionID) == [newestLargeRecord.sessionID] + ) + } + + @Test("bounded list snapshots return exact per-provider counts and top K") + func boundedListSnapshotsAreExactAcrossProviders() throws { + let fixture = try makeFixture() + try fixture.registry.apply( + provider: "codex", + records: try (0..<5).map { index in + try record( + provider: "codex", + sessionID: "codex-\(index)", + workspaceID: "codex-workspace-\(index)", + surfaceID: "codex-surface-\(index)", + updatedAt: TimeInterval(index) + ) + } + ) + try fixture.registry.apply( + provider: "claude", + records: try (0..<3).map { index in + try record( + provider: "claude", + sessionID: "claude-\(index)", + workspaceID: "claude-workspace-\(index)", + surfaceID: "claude-surface-\(index)", + updatedAt: TimeInterval(index) + ) + } + ) + + let snapshots = try fixture.registry.boundedRecentSnapshotsImportingLegacy( + sources: [ + .init(provider: "codex", url: fixture.directory.appendingPathComponent("codex.json")), + .init(provider: "claude", url: fixture.directory.appendingPathComponent("claude.json")), + ], + maximumRecordsPerProvider: 2 + ) + + #expect(snapshots["codex"]?.totalRecordCount == 5) + #expect(snapshots["codex"]?.snapshot.records.map(\.sessionID) == ["codex-4", "codex-3"]) + #expect(snapshots["claude"]?.totalRecordCount == 3) + #expect(snapshots["claude"]?.snapshot.records.map(\.sessionID) == ["claude-2", "claude-1"]) + } + + @Test("global bounded list materializes one shared top K across providers") + func globallyBoundedListSnapshotsShareOneCandidateBudget() throws { + let fixture = try makeFixture() + let providers = ["alpha", "beta", "gamma", "delta"] + for (providerIndex, provider) in providers.enumerated() { + try fixture.registry.apply( + provider: provider, + records: try (0..<5).map { recordIndex in + try record( + provider: provider, + sessionID: "\(provider)-\(recordIndex)", + workspaceID: "\(provider)-workspace-\(recordIndex)", + surfaceID: "\(provider)-surface-\(recordIndex)", + updatedAt: TimeInterval(recordIndex * providers.count + providerIndex) + ) + } + ) + } + + let snapshots = try fixture.registry + .globallyBoundedRecentSnapshotsImportingAdmittedLegacy( + sources: providers.map { + .init( + provider: $0, + url: fixture.directory.appendingPathComponent("\($0).json") + ) + }, + admissions: [], + maximumRecords: 3, + projectRecord: { try listOrderKey(provider: $0, record: $1) } + ) + + #expect(snapshots.values.reduce(0) { $0 + $1.snapshot.records.count } == 3) + #expect(snapshots.values.reduce(0) { $0 + $1.totalRecordCount } == 20) + #expect(snapshots.mapValues(\.totalRecordCount) == Dictionary( + uniqueKeysWithValues: providers.map { ($0, 5) } + )) + #expect(Set(snapshots.flatMap { provider, snapshot in + snapshot.snapshot.records.map { "\(provider):\($0.sessionID)" } + }) == Set([ + "beta:beta-4", + "gamma:gamma-4", + "delta:delta-4", + ])) + } + + @Test("global bounded list ordering matches projected run and CLI tie rules") + func globallyBoundedListOrderingCoversDuplicateAndMissingActiveRuns() throws { + let fixture = try makeFixture() + let recordsByProvider: [String: [CmuxAgentSessionRegistry.Record]] = [ + "zeta": [try record( + provider: "zeta", + sessionID: "duplicate-active-run", + workspaceID: "workspace-duplicate", + surfaceID: "surface-duplicate", + updatedAt: 1, + extra: [ + "activeRunId": "active", + "runs": [ + [ + "runId": "active", + "restoreAuthority": true, + "startedAt": 1.0, + "updatedAt": 11.0, + ], + [ + "runId": "active", + "restoreAuthority": true, + "startedAt": 2.0, + "updatedAt": 20.0, + ], + ], + ] + )], + "gamma": [try record( + provider: "gamma", + sessionID: "missing-active-run", + workspaceID: "workspace-missing", + surfaceID: "surface-missing", + updatedAt: 100, + extra: [ + "runs": [ + [ + "runId": "old", + "restoreAuthority": true, + "startedAt": 1.0, + "updatedAt": 3.0, + ], + [ + "runId": "new", + "restoreAuthority": true, + "startedAt": 2.0, + "updatedAt": 19.0, + ], + ], + ] + )], + "delta": [try record( + provider: "delta", + sessionID: "invalid-active-run-reference", + workspaceID: "workspace-invalid", + surfaceID: "surface-invalid", + updatedAt: 200, + extra: [ + "activeRunId": "not-present", + "runs": [ + [ + "runId": "fallback", + "restoreAuthority": true, + "startedAt": 1.0, + "updatedAt": 18.0, + ], + ], + ] + )], + "alpha": [try record( + provider: "alpha", + sessionID: "equal-session", + workspaceID: "workspace-alpha", + surfaceID: "surface-alpha", + updatedAt: 17 + )], + "beta": [try record( + provider: "beta", + sessionID: "equal-session", + workspaceID: "workspace-beta", + surfaceID: "surface-beta", + updatedAt: 17 + )], + ] + for (provider, records) in recordsByProvider { + try fixture.registry.apply(provider: provider, records: records) + } + + let providers = recordsByProvider.keys.sorted() + let snapshots = try fixture.registry + .globallyBoundedRecentSnapshotsImportingAdmittedLegacy( + sources: providers.map { + .init( + provider: $0, + url: fixture.directory.appendingPathComponent("\($0).json") + ) + }, + admissions: [], + maximumRecords: 4, + projectRecord: { try listOrderKey(provider: $0, record: $1) } + ) + let selected = Set(snapshots.flatMap { provider, snapshot in + snapshot.snapshot.records.map { "\(provider):\($0.sessionID)" } + }) + + #expect(selected == Set([ + "zeta:duplicate-active-run", + "gamma:missing-active-run", + "delta:invalid-active-run-reference", + "alpha:equal-session", + ])) + #expect(snapshots["beta"]?.snapshot.records.isEmpty == true) + } + + @Test("global bounded list resolves canonical Unicode ties within K") + func globallyBoundedListUsesSwiftCanonicalUnicodeOrdering() throws { + let fixture = try makeFixture() + let decomposedSessionID = "e\u{301}" + let precomposedSessionID = "\u{e9}" + #expect(decomposedSessionID == precomposedSessionID) + try fixture.registry.apply(provider: "zeta", records: [try record( + provider: "zeta", + sessionID: decomposedSessionID, + workspaceID: "workspace-zeta", + surfaceID: "surface-zeta", + updatedAt: 100 + )]) + try fixture.registry.apply(provider: "alpha", records: [try record( + provider: "alpha", + sessionID: precomposedSessionID, + workspaceID: "workspace-alpha", + surfaceID: "surface-alpha", + updatedAt: 100 + )]) + + let snapshots = try fixture.registry + .globallyBoundedRecentSnapshotsImportingAdmittedLegacy( + sources: ["alpha", "zeta"].map { + .init( + provider: $0, + url: fixture.directory.appendingPathComponent("\($0).json") + ) + }, + admissions: [], + maximumRecords: 1, + projectRecord: { try listOrderKey(provider: $0, record: $1) } + ) + + #expect(snapshots.values.reduce(0) { $0 + $1.snapshot.records.count } == 1) + #expect(snapshots["alpha"]?.snapshot.records.first?.sessionID == precomposedSessionID) + #expect(snapshots["zeta"]?.snapshot.records.isEmpty == true) + } + + @Test("bounded validation selection and count share one WAL snapshot") + func boundedListValidationAndSelectionAreAtomic() throws { + let fixture = try makeFixture() + let provider = "atomic-bounded-list" + try fixture.registry.apply(provider: provider, records: [ + try record( + provider: provider, + sessionID: "original-old", + workspaceID: "workspace-old", + surfaceID: "surface-old", + updatedAt: 1 + ), + try record( + provider: provider, + sessionID: "original-new", + workspaceID: "workspace-new", + surfaceID: "surface-new", + updatedAt: 2 + ), + ]) + var insertedConcurrentRow = false + + let snapshots = try fixture.registry.boundedRecentSnapshotsImportingLegacy( + sources: [.init( + provider: provider, + url: fixture.directory.appendingPathComponent("atomic.json") + )], + maximumRecordsPerProvider: 1, + validateRecord: { _, _ in + guard !insertedConcurrentRow else { return } + insertedConcurrentRow = true + try fixture.registry.apply(provider: provider, records: [try record( + provider: provider, + sessionID: "concurrent-newest", + workspaceID: "workspace-concurrent", + surfaceID: "surface-concurrent", + updatedAt: 3 + )]) + } + ) + + #expect(insertedConcurrentRow) + #expect(snapshots[provider]?.totalRecordCount == 2) + #expect(snapshots[provider]?.snapshot.records.map(\.sessionID) == ["original-new"]) + #expect(try fixture.registry.snapshot(provider: provider).records.count == 3) + } + + @Test("bounded list ordering uses the projected active run timestamp") + func boundedListOrderingUsesProjectedRun() throws { + let fixture = try makeFixture() + let provider = "projected-run" + let staleActiveRun = try record( + provider: provider, + sessionID: "row-newer-run-older", + workspaceID: "workspace-stale", + surfaceID: "surface-stale", + updatedAt: 100, + extra: [ + "activeRunId": "active-stale", + "runs": [[ + "runId": "active-stale", + "restoreAuthority": true, + "startedAt": 1.0, + "updatedAt": 1.0, + ]], + ] + ) + let recentRun = try record( + provider: provider, + sessionID: "row-older-run-newer", + workspaceID: "workspace-recent", + surfaceID: "surface-recent", + updatedAt: 10, + extra: [ + "activeRunId": "active-recent", + "runs": [[ + "runId": "active-recent", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 10.0, + ]], + ] + ) + try fixture.registry.apply(provider: provider, records: [staleActiveRun, recentRun]) + + let bounded = try fixture.registry.hookBoundedRecentSnapshot( + provider: provider, + maximumRecords: 1 + ) + + #expect(bounded.totalRecordCount == 2) + #expect(bounded.snapshot.records.map(\.sessionID) == ["row-older-run-newer"]) + } + + @Test("bounded list slots never attach an omitted owner to a retained session") + func boundedListSlotsRequireSelectedOwner() throws { + let fixture = try makeFixture() + let provider = "displaced-owner" + let older = try record( + provider: provider, + sessionID: "older-owner", + workspaceID: "shared-workspace", + surfaceID: "older-surface", + updatedAt: 1 + ) + let newer = try record( + provider: provider, + sessionID: "newer-session", + workspaceID: "shared-workspace", + surfaceID: "newer-surface", + updatedAt: 2 + ) + let workspaceSlot = try #require(try slots( + provider: provider, + sessionID: older.sessionID, + workspaceID: "shared-workspace", + surfaceID: "older-surface", + updatedAt: 1 + ).first { $0.scope == .workspace }) + try fixture.registry.apply( + provider: provider, + records: [older, newer], + activeSlots: [workspaceSlot] + ) + + let one = try fixture.registry.hookBoundedRecentSnapshot( + provider: provider, + maximumRecords: 1 + ) + #expect(one.snapshot.records.map(\.sessionID) == ["newer-session"]) + #expect(one.snapshot.activeSlots.isEmpty) + + let two = try fixture.registry.hookBoundedRecentSnapshot( + provider: provider, + maximumRecords: 2 + ) + #expect(two.snapshot.activeSlots.map(\.sessionID) == ["older-owner"]) + } + + @Test("bounded list slots reject canonical metadata and JSON scope disagreement") + func boundedListSlotsValidateCanonicalProjectionMetadata() throws { + let fixture = try makeFixture() + let provider = "slot-metadata" + let sessionID = "session" + let stored = try record( + provider: provider, + sessionID: sessionID, + workspaceID: "workspace", + surfaceID: "surface", + updatedAt: 1 + ) + try fixture.registry.apply( + provider: provider, + records: [stored], + activeSlots: try slots( + provider: provider, + sessionID: sessionID, + workspaceID: "workspace", + surfaceID: "surface", + updatedAt: 1 + ) + ) + try overwriteWorkspaceProjectionMetadata( + registryURL: fixture.registry.url, + provider: provider, + sessionID: sessionID, + workspaceID: "different-workspace" + ) + + var failure: CmuxAgentSessionRegistry.HookListProjectionValidationError? + do { + _ = try fixture.registry.hookBoundedRecentSnapshot( + provider: provider, + maximumRecords: 1 + ) + } catch let error as CmuxAgentSessionRegistry.HookListProjectionValidationError { + failure = error + } + + let error = try #require(failure) + #expect(error.provider == provider) + } + + @Test("bounded list identity validation matches Swift canonical equivalence") + func boundedListIdentityUsesCanonicalUnicodeEquivalence() throws { + let fixture = try makeFixture() + let provider = "unicode-identity" + let storedSessionID = "session-\u{00E9}" + let projectedSessionID = "session-e\u{0301}" + let storedWorkspaceID = "workspace-\u{00E9}" + let projectedWorkspaceID = "workspace-e\u{0301}" + let storedSurfaceID = "surface-\u{00E9}" + let projectedSurfaceID = "surface-e\u{0301}" + #expect(storedSessionID == projectedSessionID) + #expect(storedWorkspaceID == projectedWorkspaceID) + #expect(storedSurfaceID == projectedSurfaceID) + + let record = CmuxAgentSessionRegistry.Record( + provider: provider, + sessionID: storedSessionID, + updatedAt: 100, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": projectedSessionID, + "workspaceId": projectedWorkspaceID, + "surfaceId": projectedSurfaceID, + "startedAt": 100.0, + "updatedAt": 100.0, + ], options: [.sortedKeys]) + ) + let slotJSON = try JSONSerialization.data(withJSONObject: [ + "sessionId": projectedSessionID, + "updatedAt": 100.0, + ], options: [.sortedKeys]) + try fixture.registry.apply( + provider: provider, + records: [record], + activeSlots: [ + .init( + provider: provider, + scope: .workspace, + scopeID: storedWorkspaceID, + sessionID: storedSessionID, + updatedAt: 100, + json: slotJSON + ), + .init( + provider: provider, + scope: .surface, + scopeID: storedSurfaceID, + sessionID: storedSessionID, + updatedAt: 100, + json: slotJSON + ), + ] + ) + + let complete = try fixture.registry.snapshot(provider: provider) + #expect(complete.records.count == 1) + #expect(complete.activeSlots.count == 2) + let bounded = try fixture.registry.hookBoundedRecentSnapshot( + provider: provider, + maximumRecords: 1 + ) + #expect(bounded.totalRecordCount == 1) + #expect(bounded.snapshot.records.map(\.sessionID) == [storedSessionID]) + #expect(Set(bounded.snapshot.activeSlots.map(\.scopeID)) == [ + storedWorkspaceID, storedSurfaceID, + ]) + } + + @Test("active slot writes align canonically equivalent owner identities") + func boundedListJoinsCanonicalUnicodeSlotOwner() throws { + let fixture = try makeFixture() + let provider = "unicode-slot-owner" + let recordSessionID = "session-\u{00E9}" + let slotSessionID = "session-e\u{0301}" + #expect(recordSessionID == slotSessionID) + let record = try record( + provider: provider, + sessionID: recordSessionID, + workspaceID: "workspace", + surfaceID: "surface", + updatedAt: 100 + ) + let slotJSON = try JSONSerialization.data(withJSONObject: [ + "sessionId": slotSessionID, + "updatedAt": 100.0, + ], options: [.sortedKeys]) + try fixture.registry.apply( + provider: provider, + records: [record], + activeSlots: [.init( + provider: provider, + scope: .workspace, + scopeID: "workspace", + sessionID: slotSessionID, + updatedAt: 100, + json: slotJSON + )] + ) + + let storedSlot = try #require( + fixture.registry.snapshot(provider: provider).activeSlots.first + ) + #expect(storedSlot.sessionID.utf8.elementsEqual(recordSessionID.utf8)) + + let bounded = try fixture.registry.hookBoundedRecentSnapshot( + provider: provider, + maximumRecords: 1 + ) + + #expect(bounded.snapshot.records.map(\.sessionID) == [recordSessionID]) + #expect(bounded.snapshot.activeSlots.count == 1) + #expect(bounded.snapshot.activeSlots.first?.sessionID == recordSessionID) + } + + @Test("bounded legacy import preserves canonical storage failures") + func boundedLegacyImportPreservesCanonicalStorageFailure() throws { + let fixture = try makeFixture() + let provider = "legacy-storage-failure" + _ = try fixture.registry.snapshot(provider: provider) + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + "legacy-session": [ + "sessionId": "legacy-session", + "workspaceId": "workspace", + "surfaceId": "surface", + "updatedAt": 100.0, + ], + ], + "activeSessionsByWorkspace": [:], + "activeSessionsBySurface": [:], + ], options: [.sortedKeys]).write(to: fixture.legacyURL, options: .atomic) + try executeRegistrySQL( + at: fixture.directory.appendingPathComponent(CmuxAgentSessionRegistry.filename), + sql: """ + CREATE TRIGGER reject_legacy_session_insert + BEFORE INSERT ON agent_sessions BEGIN + SELECT RAISE(ABORT, 'forced canonical storage failure'); + END; + """ + ) + + var caught: (any Error)? + do { + _ = try fixture.registry.boundedRecentSnapshotsImportingLegacy( + sources: [.init(provider: provider, url: fixture.legacyURL)], + maximumRecordsPerProvider: 1 + ) + } catch { + caught = error + } + + #expect(caught != nil) + #expect(!(caught is CmuxAgentSessionRegistry.HookLegacySourceImportError)) + } + + @Test("bounded list rejects a corrupt authoritative row outside top K") + func boundedListValidatesOmittedRows() throws { + let fixture = try makeFixture() + let provider = "corrupt-omitted" + var corrupt = try record( + provider: provider, + sessionID: "embedded-identity", + workspaceID: "old-workspace", + surfaceID: "old-surface", + updatedAt: 1 + ) + corrupt.sessionID = "canonical-identity" + let valid = try record( + provider: provider, + sessionID: "newest-valid", + workspaceID: "new-workspace", + surfaceID: "new-surface", + updatedAt: 2 + ) + try fixture.registry.apply(provider: provider, records: [corrupt, valid]) + + var failure: CmuxAgentSessionRegistry.HookListProjectionValidationError? + do { + _ = try fixture.registry.hookBoundedRecentSnapshot( + provider: provider, + maximumRecords: 1 + ) + } catch let error as CmuxAgentSessionRegistry.HookListProjectionValidationError { + failure = error + } + + let error = try #require(failure) + #expect(error.provider == provider) + } + + @Test("global top K validates twenty thousand equal-time rows with bounded retention") + func globalBoundedListFullValidationStreamsEveryEqualTimeRecord() throws { + let fixture = try makeFixture() + let provider = "stream-validation" + let records = try (0..<20_000).map { index in + try record( + provider: provider, + sessionID: String(format: "session-%05d", index), + workspaceID: "workspace-\(index % 100)", + surfaceID: "surface-\(index)", + updatedAt: 100 + ) + } + try fixture.registry.apply(provider: provider, records: records) + + var validatedRecords = 0 + let startedAt = Date().timeIntervalSinceReferenceDate + let snapshots = try fixture.registry.globallyBoundedRecentSnapshotsImportingAdmittedLegacy( + sources: [.init( + provider: provider, + url: fixture.directory.appendingPathComponent("stream-validation.json") + )], + admissions: [], + maximumRecords: 100, + projectRecord: { validatedProvider, record in + #expect(validatedProvider == provider) + validatedRecords += 1 + return try listOrderKey(provider: validatedProvider, record: record) + } + ) + let elapsed = Date().timeIntervalSinceReferenceDate - startedAt + print("global bounded list heap 20000 equal-time rows elapsed: \(elapsed) seconds") + + #expect(validatedRecords == 20_000) + #expect(snapshots[provider]?.totalRecordCount == 20_000) + #expect(snapshots[provider]?.snapshot.records.count == 100) + #expect(snapshots[provider]?.snapshot.records.first?.sessionID == "session-00000") + #expect(snapshots[provider]?.snapshot.records.last?.sessionID == "session-00099") + } + + @Test("hibernation projection reads only active owners and exact detected sessions") + func hibernationProjectionIsIndependentOfProviderHistory() throws { + let fixture = try makeFixture() + let provider = "hibernation-projection" + let records = try (0..<20_000).map { index in + try record( + provider: provider, + sessionID: String(format: "session-%05d", index), + workspaceID: "workspace-\(index)", + surfaceID: "surface-\(index)", + updatedAt: TimeInterval(index) + ) + } + try fixture.registry.apply( + provider: provider, + records: records, + activeSlots: try slots( + provider: provider, + sessionID: "session-10000", + workspaceID: "workspace-10000", + surfaceID: "surface-10000", + updatedAt: 10_000 + ) + ) + + let snapshot = try fixture.registry.hookHibernationSnapshot( + provider: provider, + panelContexts: [.init( + workspaceID: "workspace-10000", + surfaceID: "surface-10000" + )], + exactSessionIDs: ["session-15000"], + maximumRecords: 3, + maximumBytes: Int64(CmuxAgentSessionRegistry.maximumHookProviderBytes) + ) + #expect(Set(snapshot.records.map(\.sessionID)) == ["session-10000", "session-15000"]) + #expect(snapshot.activeSlots.count == 1) + } + + @Test("hibernation projection fails closed at row and byte limits") + func hibernationProjectionEnforcesMaterializationLimits() throws { + let fixture = try makeFixture() + let provider = "hibernation-limits" + let first = try record( + provider: provider, + sessionID: "first", + workspaceID: "workspace-first", + surfaceID: "surface-first", + updatedAt: 1 + ) + let second = try record( + provider: provider, + sessionID: "second", + workspaceID: "workspace-second", + surfaceID: "surface-second", + updatedAt: 2 + ) + try fixture.registry.apply(provider: provider, records: [first, second]) + + #expect(throws: CmuxAgentSessionRegistry.HookSnapshotLimitError.self) { + try fixture.registry.hookHibernationSnapshot( + provider: provider, + panelContexts: [], + exactSessionIDs: ["first", "second"], + maximumRecords: 1, + maximumBytes: .max + ) + } + #expect(throws: CmuxAgentSessionRegistry.HookSnapshotLimitError.self) { + try fixture.registry.hookHibernationSnapshot( + provider: provider, + panelContexts: [], + exactSessionIDs: ["first"], + maximumRecords: 1, + maximumBytes: Int64(first.json.count - 1) + ) + } + } + + @Test("batched hibernation ignores irrelevant providers and isolates malformed peers") + func batchedHibernationSelectsRelevantProviders() throws { + let fixture = try makeFixture() + let provider = "provider-255" + let sessionID = "active-owner" + let activeRecord = try record( + provider: provider, + sessionID: sessionID, + workspaceID: "workspace", + surfaceID: "surface", + updatedAt: 1 + ) + try fixture.registry.apply( + provider: provider, + records: [activeRecord], + activeSlots: try slots( + provider: provider, + sessionID: sessionID, + workspaceID: "workspace", + surfaceID: "surface", + updatedAt: 1 + ) + ) + try fixture.registry.apply(provider: "malformed", records: [.init( + provider: "malformed", + sessionID: "broken", + updatedAt: 2, + json: Data("{}".utf8) + )]) + let configuredProviders = Set((0..<256).map { "provider-\($0)" }) + .union(["malformed"]) + + let result = try fixture.registry.hookHibernationSnapshots( + providers: configuredProviders, + panelContexts: [.init(workspaceID: "workspace", surfaceID: "surface")], + exactSessionIDsByProvider: ["malformed": ["broken"]], + maximumProviders: 64, + maximumRecords: 64, + maximumBytes: 1_024 * 1_024 + ) + + #expect(result.snapshots[provider]?.records.map(\.sessionID) == [sessionID]) + #expect(result.snapshots[provider]?.activeSlots.map(\.sessionID) == [sessionID]) + #expect(result.failedProviders == ["malformed"]) + #expect(result.snapshots["malformed"]?.records.isEmpty == true) + #expect(result.snapshots.count == 2) + } + + @Test("legacy reads reject oversized files before allocating their payload") + func legacyReadHasDescriptorBound() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = FileManager.default.createFile(atPath: fixture.legacyURL.path, contents: Data()) + let handle = try FileHandle(forWritingTo: fixture.legacyURL) + try handle.truncate(atOffset: UInt64(64 * 1_024 * 1_024 + 1)) + try handle.close() + + var failure: CmuxAgentSessionRegistry.HookLegacySourceSizeError? + do { + _ = try fixture.registry.readHookLegacySourceData(at: fixture.legacyURL) + } catch let error as CmuxAgentSessionRegistry.HookLegacySourceSizeError { + failure = error + } + let error = try #require(failure) + #expect(error.observedBytes == 64 * 1_024 * 1_024 + 1) + #expect(error.maximumBytes == 64 * 1_024 * 1_024) + } + + @Test("legacy reads reject non-regular files before reading") + func legacyReadRejectsNonRegularFile() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + try FileManager.default.createDirectory(at: fixture.legacyURL, withIntermediateDirectories: false) + + var failure: POSIXError? + do { + _ = try fixture.registry.readHookLegacySourceData(at: fixture.legacyURL) + } catch let error as POSIXError { + failure = error + } + #expect(try #require(failure).code == .EFTYPE) + } + + @Test("thirty-two disjoint hook mutations do not lose rows") + func disjointConcurrentMutationsDoNotLoseRows() async throws { + let fixture = try makeFixture() + _ = try fixture.registry.hookProjectionStatus(provider: "claude") + let now = Date().timeIntervalSince1970 + try await withThrowingTaskGroup(of: Void.self) { group in + for index in 0..<32 { + group.addTask { + let sessionID = "disjoint-\(index)" + _ = try fixture.registry.mutateHookSession( + provider: "claude", + sessionID: sessionID + ) { snapshot in + snapshot.records = [try self.record( + provider: "claude", + sessionID: sessionID, + workspaceID: "workspace-\(index)", + surfaceID: "surface-\(index)", + updatedAt: now + TimeInterval(index) + )] + } + } + } + try await group.waitForAll() + } + let snapshot = try fixture.registry.snapshot(provider: "claude") + #expect(Set(snapshot.records.map(\.sessionID)) == Set((0..<32).map { "disjoint-\($0)" })) + } + + @Test("independent first-use writers migrate a fresh registry without dropping hooks") + func concurrentColdStartMigrationDoesNotDropHooks() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hook-cold-start-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let registryURL = directory.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let now = Date().timeIntervalSince1970 + + try await withThrowingTaskGroup(of: Void.self) { group in + for index in 0..<32 { + group.addTask { + let registry = CmuxAgentSessionRegistry( + url: registryURL, + busyTimeoutMilliseconds: 250 + ) + let sessionID = "cold-\(index)" + _ = try registry.mutateHookSession( + provider: "codex", + sessionID: sessionID, + now: now + ) { snapshot in + snapshot.records = [try self.record( + provider: "codex", + sessionID: sessionID, + workspaceID: "workspace-\(index)", + surfaceID: "surface-\(index)", + updatedAt: now + TimeInterval(index) + )] + } + } + } + try await group.waitForAll() + } + + let registry = CmuxAgentSessionRegistry(url: registryURL) + let ids = Set(try registry.snapshot(provider: "codex").records.map(\.sessionID)) + #expect(ids == Set((0..<32).map { "cold-\($0)" })) + #expect(try registry.hookProjectionStatus(provider: "codex").revision >= 32) + } + + @Test("thirty-two same-session hook mutations replay without lost increments") + func sameSessionConcurrentMutationsDoNotLoseUpdates() async throws { + let fixture = try makeFixture() + let initial = try record( + provider: "opencode", + sessionID: "shared", + workspaceID: "workspace", + surfaceID: "surface", + updatedAt: 1, + extra: ["counter": 0] + ) + try fixture.registry.apply(provider: "opencode", records: [initial]) + _ = try fixture.registry.hookProjectionStatus(provider: "opencode") + + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<32 { + group.addTask { + _ = try fixture.registry.mutateHookSession( + provider: "opencode", + sessionID: "shared", + now: 100 + ) { snapshot in + var stored = try #require(snapshot.records.first) + var object = try #require( + JSONSerialization.jsonObject(with: stored.json) as? [String: Any] + ) + let counter = object["counter"] as? Int ?? 0 + object["counter"] = counter + 1 + stored.updatedAt = TimeInterval(counter + 2) + object["updatedAt"] = stored.updatedAt + stored.json = try JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys] + ) + snapshot.records = [stored] + } + } + } + try await group.waitForAll() + } + + let stored = try #require( + try fixture.registry.hookRecord(provider: "opencode", sessionID: "shared") + ) + let object = try #require( + JSONSerialization.jsonObject(with: stored.json) as? [String: Any] + ) + #expect(object["counter"] as? Int == 32) + } + + @Test("canonical projection marks only its captured revision and preserves unknown fields") + func projectionRevisionIsExactAndLossless() throws { + let fixture = try makeFixture() + _ = try fixture.registry.hookProjectionStatus(provider: "pi") + let stored = try record( + provider: "pi", + sessionID: "session", + workspaceID: "workspace", + surfaceID: "surface", + updatedAt: 1, + extra: ["futureField": ["nested": true]] + ) + try fixture.registry.apply( + provider: "pi", + records: [stored], + activeSlots: try slots( + provider: "pi", + sessionID: "session", + workspaceID: "workspace", + surfaceID: "surface", + updatedAt: 1 + ) + ) + let existing = try JSONSerialization.data(withJSONObject: ["futureTopLevel": "kept"]) + let projection = try fixture.registry.hookLegacyProjection( + provider: "pi", + preservingTopLevelJSON: existing + ) + try projection.json.write(to: fixture.legacyURL, options: .atomic) + let stamp = try #require(CmuxAgentSessionRegistry.LegacyStamp.read(path: fixture.legacyURL.path)) + try fixture.registry.markHookLegacyProjection( + provider: "pi", + revision: projection.revision, + stamp: stamp + ) + + let status = try fixture.registry.hookProjectionStatus(provider: "pi") + #expect(status.revision == projection.revision) + #expect(status.projectedRevision == projection.revision) + let root = try #require( + JSONSerialization.jsonObject(with: projection.json) as? [String: Any] + ) + #expect(root["futureTopLevel"] as? String == "kept") + let sessions = try #require(root["sessions"] as? [String: Any]) + let session = try #require(sessions["session"] as? [String: Any]) + let future = try #require(session["futureField"] as? [String: Any]) + #expect(future["nested"] as? Bool == true) + } + + @Test("compatibility projection keeps active owners and the newest 256 inactive records") + func compatibilityProjectionIsBoundedAndDeterministic() throws { + let fixture = try makeFixture() + _ = try fixture.registry.hookProjectionStatus(provider: "claude") + let records = try (0..<300).map { index in + try record( + provider: "claude", + sessionID: String(format: "session-%03d", index), + workspaceID: "workspace-\(index)", + surfaceID: "surface-\(index)", + updatedAt: TimeInterval(index), + extra: index == 0 || index == 299 + ? ["futureField": "kept-\(index)"] + : [:] + ) + } + try fixture.registry.apply( + provider: "claude", + records: records, + activeSlots: try slots( + provider: "claude", + sessionID: "session-000", + workspaceID: "workspace-0", + surfaceID: "surface-0", + updatedAt: 0 + ) + ) + + let canonicalStatus = try fixture.registry.hookProjectionStatus(provider: "claude") + let projection = try fixture.registry.hookLegacyProjection(provider: "claude") + let root = try #require( + JSONSerialization.jsonObject(with: projection.json) as? [String: Any] + ) + let sessions = try #require(root["sessions"] as? [String: Any]) + #expect(sessions.count == 257) + #expect(sessions["session-000"] != nil) + #expect(sessions["session-043"] == nil) + #expect(sessions["session-044"] != nil) + #expect(sessions["session-299"] != nil) + #expect( + (sessions["session-000"] as? [String: Any])?["futureField"] as? String + == "kept-0" + ) + #expect( + (sessions["session-299"] as? [String: Any])?["futureField"] as? String + == "kept-299" + ) + #expect(projection.revision == canonicalStatus.revision) + + try projection.json.write(to: fixture.legacyURL, options: .atomic) + let stamp = try #require( + CmuxAgentSessionRegistry.LegacyStamp.read(path: fixture.legacyURL.path) + ) + try fixture.registry.markHookLegacyProjection( + provider: "claude", + revision: projection.revision, + stamp: stamp + ) + let projectedStatus = try fixture.registry.hookProjectionStatus(provider: "claude") + #expect(projectedStatus.revision == projection.revision) + #expect(projectedStatus.projectedRevision == projection.revision) + + let canonical = try fixture.registry.snapshot(provider: "claude") + #expect(canonical.records.count == 300) + #expect(canonical.records.contains { $0.sessionID == "session-043" }) + #expect( + try fixture.registry.hookRecord(provider: "claude", sessionID: "session-043") + != nil + ) + let boundedCanonical = try fixture.registry.hookBoundedSnapshot( + provider: "claude", + maximumRecords: 300 + ) + #expect(boundedCanonical.records.count == 300) + #expect(boundedCanonical.records.contains { $0.sessionID == "session-043" }) + + var countFailure: CmuxAgentSessionRegistry.HookSnapshotLimitError? + do { + _ = try fixture.registry.hookBoundedSnapshot( + provider: "claude", + maximumRecords: 299 + ) + } catch let error as CmuxAgentSessionRegistry.HookSnapshotLimitError { + countFailure = error + } + let countError = try #require(countFailure) + #expect(countError.scope == .records) + #expect(countError.observed == 300) + #expect(countError.maximum == 299) + } + + @Test("compatibility projection rejects active-owner overflow before copying records") + func compatibilityProjectionPreflightsActiveOwnerCount() throws { + let fixture = try makeFixture() + let provider = "projection-owner-overflow" + let count = CmuxAgentSessionRegistry.maximumHookLegacyProjectionRecords + 1 + var records: [CmuxAgentSessionRegistry.Record] = [] + var activeSlots: [CmuxAgentSessionRegistry.ActiveSlot] = [] + records.reserveCapacity(count) + activeSlots.reserveCapacity(count) + for index in 0..= revision + ) + } + + @Test("a held compatibility lock cannot block a hook projection") + func compatibilityLockContentionFailsImmediatelyAndRecovers() throws { + let fixture = try makeFixture() + _ = try fixture.registry.hookProjectionStatus(provider: "codex") + try fixture.registry.apply( + provider: "codex", + records: [try record( + provider: "codex", + sessionID: "session", + workspaceID: "workspace", + surfaceID: "surface", + updatedAt: 1 + )] + ) + let revision = try fixture.registry.hookProjectionStatus(provider: "codex").revision + let descriptor = open( + fixture.legacyURL.path + ".lock", + O_CREAT | O_RDWR, + mode_t(S_IRUSR | S_IWUSR) + ) + #expect(descriptor >= 0) + guard descriptor >= 0 else { return } + defer { Darwin.close(descriptor) } + #expect(flock(descriptor, LOCK_EX | LOCK_NB) == 0) + + var contentionCode: POSIXErrorCode? + do { + try fixture.registry.projectHookLegacyStore( + provider: "codex", + to: fixture.legacyURL, + including: revision + ) + } catch let error as POSIXError { + contentionCode = error.code + } + #expect(contentionCode == .EWOULDBLOCK || contentionCode == .EAGAIN) + #expect( + try fixture.registry.hookProjectionStatus(provider: "codex").projectedRevision + < revision + ) + #expect( + try fixture.registry.hookRecord(provider: "codex", sessionID: "session") != nil + ) + + #expect(flock(descriptor, LOCK_UN) == 0) + // This second call represents the next hook/app lifecycle event. It + // observes the committed canonical row and converges compatibility. + try fixture.registry.projectHookLegacyStore( + provider: "codex", + to: fixture.legacyURL, + including: revision + ) + #expect( + try fixture.registry.hookProjectionStatus(provider: "codex").projectedRevision + >= revision + ) + } + + @Test("an absent compatibility file projects a versioned store that imports losslessly") + func absentCompatibilityFileProjectionRoundTrips() throws { + let source = try makeFixture() + _ = try source.registry.hookProjectionStatus(provider: "cursor") + let stored = try record( + provider: "cursor", + sessionID: "fresh", + workspaceID: "workspace", + surfaceID: "surface", + updatedAt: 10, + extra: ["futureField": "kept"] + ) + try source.registry.apply(provider: "cursor", records: [stored]) + let projection = try source.registry.hookLegacyProjection(provider: "cursor") + let root = try #require( + JSONSerialization.jsonObject(with: projection.json) as? [String: Any] + ) + #expect(root["version"] as? Int == 2) + + let destination = try makeFixture() + try destination.registry.importLegacyStoreJSON( + provider: "cursor", + stamp: .init(path: "memory", size: Int64(projection.json.count), modifiedAt: 1), + json: projection.json + ) + let roundTripped = try #require( + try destination.registry.hookRecord(provider: "cursor", sessionID: "fresh") + ) + let object = try #require( + JSONSerialization.jsonObject(with: roundTripped.json) as? [String: Any] + ) + #expect(object["futureField"] as? String == "kept") + } + + @Test("maintenance is deterministic and preserves active and future-generation rows") + func deterministicMaintenancePreservesAuthorities() throws { + let fixture = try makeFixture() + _ = try fixture.registry.hookProjectionStatus(provider: "gemini") + var records = try (0..<10_002).map { index in + try record( + provider: "gemini", + sessionID: String(format: "session-%05d", index), + workspaceID: "workspace-\(index)", + surfaceID: "surface-\(index)", + updatedAt: 100 + ) + } + records[1].writerGeneration = CmuxAgentSessionRegistry.currentWriterGeneration + 1 + let active = try slots( + provider: "gemini", + sessionID: "session-00000", + workspaceID: "workspace-0", + surfaceID: "surface-0", + updatedAt: 100 + ) + let dangling = try slots( + provider: "gemini", + sessionID: "missing", + workspaceID: "dangling-workspace", + surfaceID: "dangling-surface", + updatedAt: 100 + ) + try fixture.registry.apply( + provider: "gemini", + records: records, + activeSlots: active + dangling + ) + + _ = try fixture.registry.mutateHookSession( + provider: "gemini", + sessionID: "session-10001", + now: 100 + ) { _ in } + let snapshot = try fixture.registry.snapshot(provider: "gemini") + let ids = Set(snapshot.records.map(\.sessionID)) + #expect(ids.count == 10_000) + #expect(ids.contains("session-00000")) + #expect(ids.contains("session-00001")) + #expect(!ids.contains("session-00002")) + #expect(!ids.contains("session-00003")) + #expect(!snapshot.activeSlots.contains { $0.sessionID == "missing" }) + } + + @Test("maintenance expires only inactive current-generation rows older than seven days") + func maintenanceExpiresOldInactiveRows() throws { + let fixture = try makeFixture() + _ = try fixture.registry.hookProjectionStatus(provider: "amp") + let now = 8 * 24 * 60 * 60.0 + var future = try record( + provider: "amp", + sessionID: "future", + workspaceID: "workspace-future", + surfaceID: "surface-future", + updatedAt: 0 + ) + future.writerGeneration = CmuxAgentSessionRegistry.currentWriterGeneration + 1 + try fixture.registry.apply( + provider: "amp", + records: [ + try record( + provider: "amp", + sessionID: "expired", + workspaceID: "workspace-expired", + surfaceID: "surface-expired", + updatedAt: 0 + ), + try record( + provider: "amp", + sessionID: "active-old", + workspaceID: "workspace-active", + surfaceID: "surface-active", + updatedAt: 0 + ), + try record( + provider: "amp", + sessionID: "recent", + workspaceID: "workspace-recent", + surfaceID: "surface-recent", + updatedAt: now + ), + future, + ], + activeSlots: try slots( + provider: "amp", + sessionID: "active-old", + workspaceID: "workspace-active", + surfaceID: "surface-active", + updatedAt: 0 + ) + ) + + _ = try fixture.registry.mutateHookSession( + provider: "amp", + sessionID: "recent", + now: now + ) { _ in } + let ids = Set(try fixture.registry.snapshot(provider: "amp").records.map(\.sessionID)) + #expect(!ids.contains("expired")) + #expect(ids.contains("active-old")) + #expect(ids.contains("recent")) + #expect(ids.contains("future")) + } + + @Test("a near-cap insert prunes the oldest inactive history") + func nearCapInsertPrunesInactiveHistory() throws { + let fixture = try makeFixture() + let provider = "near-cap" + let payloadBytes = CmuxAgentSessionRegistry.maximumHookRecordBytes - 2_048 + let records = try (0..<16).map { index in + try largeRecord( + provider: provider, + sessionID: String(format: "old-%02d", index), + updatedAt: TimeInterval(index), + payloadBytes: payloadBytes + ) + } + try fixture.registry.apply(provider: provider, records: records) + let before = try fixture.registry.hookStorageMetrics(provider: provider) + #expect(before.recordCount == 16) + #expect(before.totalBytes < Int64(CmuxAgentSessionRegistry.maximumHookProviderBytes)) + + let inserted = try largeRecord( + provider: provider, + sessionID: "new", + updatedAt: 100, + payloadBytes: 128 * 1_024 + ) + try fixture.registry.apply(provider: provider, records: [inserted]) + + let after = try fixture.registry.hookStorageMetrics(provider: provider) + #expect(after.totalBytes <= Int64(CmuxAgentSessionRegistry.maximumHookProviderBytes)) + let snapshot = try fixture.registry.hookBoundedSnapshot(provider: provider) + #expect(snapshot.records.contains { $0.sessionID == "new" }) + #expect(!snapshot.records.contains { $0.sessionID == "old-00" }) + } + + @Test("an oversized input batch is rejected before SQLite is opened") + func oversizedBatchIsRejectedBeforeDatabaseWrite() throws { + let fixture = try makeFixture() + let provider = "oversized-batch" + let payloadBytes = CmuxAgentSessionRegistry.maximumHookRecordBytes - 2_048 + let records = try (0..<17).map { index in + try largeRecord( + provider: provider, + sessionID: "batch-\(index)", + updatedAt: TimeInterval(index), + payloadBytes: payloadBytes + ) + } + + var failure: CmuxAgentSessionRegistry.HookStorageLimitError? + do { + try fixture.registry.apply(provider: provider, records: records) + } catch let error as CmuxAgentSessionRegistry.HookStorageLimitError { + failure = error + } + let error = try #require(failure) + #expect(error.scope == .provider) + #expect(error.observedBytes > error.maximumBytes) + #expect(!FileManager.default.fileExists(atPath: fixture.registry.url.path)) + } + + @Test("a growing mutation rolls back when only protected or active rows remain") + func providerCapRejectsUnprunableGrowingMutation() throws { + let fixture = try makeFixture() + let provider = "active-cap" + let payloadBytes = CmuxAgentSessionRegistry.maximumHookRecordBytes - 2_048 + var records: [CmuxAgentSessionRegistry.Record] = [] + var activeSlots: [CmuxAgentSessionRegistry.ActiveSlot] = [] + for index in 0..<16 { + let sessionID = String(format: "active-%02d", index) + records.append(try largeRecord( + provider: provider, + sessionID: sessionID, + updatedAt: TimeInterval(index), + payloadBytes: payloadBytes + )) + activeSlots.append(contentsOf: try slots( + provider: provider, + sessionID: sessionID, + workspaceID: "workspace-\(sessionID)", + surfaceID: "surface-\(sessionID)", + updatedAt: TimeInterval(index) + )) + } + try fixture.registry.apply( + provider: provider, + records: records, + activeSlots: activeSlots + ) + let before = try fixture.registry.hookStorageMetrics(provider: provider) + + var limitFailure: CmuxAgentSessionRegistry.HookStorageLimitError? + do { + _ = try fixture.registry.mutateHookSession( + provider: provider, + sessionID: "protected-new", + now: 200 + ) { snapshot in + snapshot.records = [try largeRecord( + provider: provider, + sessionID: "protected-new", + updatedAt: 200, + payloadBytes: payloadBytes + )] + } + } catch let error as CmuxAgentSessionRegistry.HookStorageLimitError { + limitFailure = error + } + let error = try #require(limitFailure) + #expect(error.scope == .provider) + #expect( + try fixture.registry.hookRecord(provider: provider, sessionID: "protected-new") + == nil + ) + #expect(try fixture.registry.hookStorageMetrics(provider: provider) == before) + } + + @Test("an existing oversized v5 provider accepts a non-growing update") + func oversizedProviderIsNotWedgedForNonGrowingUpdate() throws { + let fixture = try makeFixture() + let provider = "oversized-v5" + _ = try fixture.registry.hookProjectionStatus(provider: provider) + let payloadBytes = CmuxAgentSessionRegistry.maximumHookRecordBytes - 2_048 + var records: [CmuxAgentSessionRegistry.Record] = [] + for index in 0..<17 { + records.append(try largeRecord( + provider: provider, + sessionID: String(format: "active-%02d", index), + updatedAt: TimeInterval(index), + payloadBytes: payloadBytes + )) + } + try insertRawRecords( + records, + provider: provider, + registryURL: fixture.registry.url, + createActiveSlots: true + ) + let before = try fixture.registry.hookStorageMetrics(provider: provider) + #expect(before.totalBytes > Int64(CmuxAgentSessionRegistry.maximumHookProviderBytes)) + + let patched = try fixture.registry.patchRecord( + provider: provider, + sessionID: "active-00", + updatedAt: 1_000 + ) { _ in } + #expect(patched) + let after = try fixture.registry.hookStorageMetrics(provider: provider) + #expect(after.totalBytes == before.totalBytes) + #expect( + try fixture.registry.hookRecord(provider: provider, sessionID: "active-00")?.updatedAt + == 1_000 + ) + } + + @Test("v4 migration prunes oversized inactive history before returning") + func migrationRecoversOversizedProvider() throws { + let fixture = try makeFixture() + let provider = "oversized-v4" + let payloadBytes = CmuxAgentSessionRegistry.maximumHookRecordBytes - 2_048 + let records = try (0..<17).map { index in + try largeRecord( + provider: provider, + sessionID: String(format: "old-%02d", index), + updatedAt: TimeInterval(index), + payloadBytes: payloadBytes + ) + } + try createV4Registry(at: fixture.registry.url, records: records) + + let metrics = try fixture.registry.hookStorageMetrics(provider: provider) + #expect(metrics.totalBytes <= Int64(CmuxAgentSessionRegistry.maximumHookProviderBytes)) + #expect(metrics.recordCount == 16) + #expect(try fixture.registry.hookRecord(provider: provider, sessionID: "old-00") == nil) + #expect(try fixture.registry.hookRecord(provider: provider, sessionID: "old-16") != nil) + } + + @Test("concurrent readers migrate one existing v4 registry to v5") + func concurrentExistingRegistryMigrationIsSerialized() async throws { + let fixture = try makeFixture() + let provider = "concurrent-v4" + let stored = try record( + provider: provider, + sessionID: "existing", + workspaceID: "workspace", + surfaceID: "surface", + updatedAt: 1 + ) + try createV4Registry(at: fixture.registry.url, records: [stored]) + + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<32 { + group.addTask { + let registry = CmuxAgentSessionRegistry( + url: fixture.registry.url, + busyTimeoutMilliseconds: 2_000 + ) + let read = try registry.hookRecord( + provider: provider, + sessionID: "existing" + ) + #expect(read != nil) + } + } + try await group.waitForAll() + } + #expect(try fixture.registry.hookStorageMetrics(provider: provider).recordCount == 1) + } + + @Test("legacy scanner decodes escaped keys and counts only direct unique runs") + func legacyScannerHandlesEscapesDuplicatesAndNesting() throws { + let fixture = try makeFixture() + let data = Data(#""" + { + "nested": {"sessions": {"ignored": {"runs": [{"runId": "ignored"}]}}}, + "sess\u0069ons": { + "alpha": { + "runs": [ + {"runId": "same"}, + {"runId": "same"}, + {"run\u0049d": "second", "nested": {"runId": "ignored"}} + ], + "nested": {"runs": [{"runId": "ignored"}]} + }, + "beta": {"runs": []} + } + } + """#.utf8) + + let metrics = try fixture.registry.scanHookLegacySourceData( + data, + path: fixture.legacyURL.path + ) + #expect(metrics.sessionCount == 2) + #expect(metrics.graphNodeCount == 3) + #expect(metrics.largestRecordSessionID == "alpha") + } + + @Test("admitted inspection imports the scanned sidecar revision, not a later rewrite") + func admittedInspectionPinsScannedLegacyBytes() throws { + let fixture = try makeFixture() + let source = CmuxAgentSessionRegistry.LegacySource( + provider: "codex", + url: fixture.legacyURL + ) + func sidecar(sessionID: String) throws -> Data { + try JSONSerialization.data( + withJSONObject: [ + "version": 2, + "sessions": [ + sessionID: [ + "sessionId": sessionID, + "workspaceId": "workspace-\(sessionID)", + "surfaceId": "surface-\(sessionID)", + "startedAt": 1.0, + "updatedAt": 1.0, + "runs": [["runId": "run-\(sessionID)"]], + ] + ], + ], options: [.sortedKeys]) + } + + try sidecar(sessionID: "admitted").write(to: source.url, options: .atomic) + let stamp = try #require(CmuxAgentSessionRegistry.LegacyStamp.read(path: source.url.path)) + let admission = try fixture.registry.hookLegacySourceAdmission( + source: source, + expectedStamp: stamp + ) + #expect(admission.wasIssuedByHookLegacyScanner) + try sidecar(sessionID: "later").write(to: source.url, options: .atomic) + + let snapshot = try #require( + fixture.registry.snapshotsImportingAdmittedLegacy( + sources: [source], + admissions: [admission], + maximumGraphNodes: 1 + )[source.provider]) + #expect(snapshot.records.map(\.sessionID) == ["admitted"]) + let laterStamp = try #require( + CmuxAgentSessionRegistry.LegacyStamp.read(path: source.url.path)) + #expect( + try !fixture.registry.legacySourceIsCurrent( + provider: source.provider, + stamp: laterStamp + )) + } + + @Test( + "admitted inspection validates the graph after replacing an unpublished legacy generation") + func admittedInspectionCountsPostReplacementGraph() throws { + let fixture = try makeFixture() + let source = CmuxAgentSessionRegistry.LegacySource( + provider: "codex", + url: fixture.legacyURL + ) + func sidecar(sessionID: String) throws -> Data { + try JSONSerialization.data( + withJSONObject: [ + "version": 2, + "sessions": [ + sessionID: [ + "sessionId": sessionID, + "workspaceId": "workspace-\(sessionID)", + "surfaceId": "surface-\(sessionID)", + "startedAt": 1.0, + "updatedAt": 1.0, + "runs": [["runId": "run-\(sessionID)"]], + ] + ], + ], options: [.sortedKeys]) + } + + let previous = try sidecar(sessionID: "previous") + try previous.write(to: source.url, options: .atomic) + let previousStamp = try #require( + CmuxAgentSessionRegistry.LegacyStamp.read( + path: source.url.path + )) + try fixture.registry.importLegacyStoreJSON( + provider: source.provider, + stamp: previousStamp, + json: previous + ) + + let replacement = try sidecar(sessionID: "replacement") + try replacement.write(to: source.url, options: .atomic) + let replacementStamp = try #require( + CmuxAgentSessionRegistry.LegacyStamp.read( + path: source.url.path + )) + let admission = try fixture.registry.hookLegacySourceAdmission( + source: source, + expectedStamp: replacementStamp + ) + let snapshot = try #require( + fixture.registry.snapshotsImportingAdmittedLegacy( + sources: [source], + admissions: [admission], + maximumGraphNodes: 1 + )[source.provider]) + + #expect(snapshot.records.map(\.sessionID) == ["replacement"]) + } + + @Test("published projections preserve omitted sessions during admitted graph validation") + func admittedInspectionPreservesPublishedProjectionOmissions() throws { + let fixture = try makeFixture() + let source = CmuxAgentSessionRegistry.LegacySource( + provider: "codex", + url: fixture.legacyURL + ) + func sidecar(sessionID: String) throws -> Data { + try JSONSerialization.data( + withJSONObject: [ + "version": 2, + "sessions": [ + sessionID: [ + "sessionId": sessionID, + "workspaceId": "workspace-\(sessionID)", + "surfaceId": "surface-\(sessionID)", + "startedAt": 1.0, + "updatedAt": 1.0, + "runs": [["runId": "run-\(sessionID)"]], + ] + ], + ], options: [.sortedKeys]) + } + + let published = try sidecar(sessionID: "published") + try published.write(to: source.url, options: .atomic) + let publishedStamp = try #require( + CmuxAgentSessionRegistry.LegacyStamp.read( + path: source.url.path + )) + try fixture.registry.importLegacyStoreJSON( + provider: source.provider, + stamp: publishedStamp, + json: published + ) + try fixture.registry.markHookLegacyProjection( + provider: source.provider, + revision: 1, + stamp: publishedStamp + ) + + let partial = try sidecar(sessionID: "partial") + try partial.write(to: source.url, options: .atomic) + let partialStamp = try #require( + CmuxAgentSessionRegistry.LegacyStamp.read( + path: source.url.path + )) + let admission = try fixture.registry.hookLegacySourceAdmission( + source: source, + expectedStamp: partialStamp + ) + + #expect(throws: CmuxAgentSessionRegistry.HookInspectionGraphUnionLimitError.self) { + try fixture.registry.snapshotsImportingAdmittedLegacy( + sources: [source], + admissions: [admission], + maximumGraphNodes: 1 + ) + } + #expect( + try fixture.registry.snapshot(provider: source.provider).records.map(\.sessionID) + == ["published"]) + } + + @Test("admitted inspection validates against canonical rows committed after admission") + func admittedInspectionUsesLatestCanonicalGraphSnapshot() throws { + let fixture = try makeFixture() + let source = CmuxAgentSessionRegistry.LegacySource( + provider: "codex", + url: fixture.legacyURL + ) + let legacy = try JSONSerialization.data( + withJSONObject: [ + "version": 2, + "sessions": [ + "legacy": [ + "sessionId": "legacy", + "workspaceId": "workspace-legacy", + "surfaceId": "surface-legacy", + "startedAt": 1.0, + "updatedAt": 1.0, + "runs": [["runId": "legacy-run"]], + ] + ], + ], options: [.sortedKeys]) + try legacy.write(to: source.url, options: .atomic) + let stamp = try #require(CmuxAgentSessionRegistry.LegacyStamp.read(path: source.url.path)) + let admission = try fixture.registry.hookLegacySourceAdmission( + source: source, + expectedStamp: stamp + ) + try fixture.registry.apply( + provider: source.provider, + records: [ + try record( + provider: source.provider, + sessionID: "canonical-a", + workspaceID: "workspace-a", + surfaceID: "surface-a", + updatedAt: 2, + extra: ["runs": [["runId": "canonical-run-a"]]] + ), + try record( + provider: source.provider, + sessionID: "canonical-b", + workspaceID: "workspace-b", + surfaceID: "surface-b", + updatedAt: 2, + extra: ["runs": [["runId": "canonical-run-b"]]] + ), + ]) + + #expect(throws: CmuxAgentSessionRegistry.HookInspectionGraphUnionLimitError.self) { + try fixture.registry.snapshotsImportingAdmittedLegacy( + sources: [source], + admissions: [admission], + maximumGraphNodes: 2 + ) + } + #expect(try fixture.registry.snapshot(provider: source.provider).records.count == 2) + } + + @Test("graph admission rejects a legacy map key and embedded session mismatch") + func legacyGraphAdmissionRejectsEmbeddedSessionMismatch() throws { + let fixture = try makeFixture() + let source = CmuxAgentSessionRegistry.LegacySource( + provider: "codex", + url: fixture.legacyURL + ) + try Data(#"{"sessions":{"outer":{"sessionId":"inner","runs":[]}}}"#.utf8) + .write(to: source.url, options: .atomic) + let stamp = try #require(CmuxAgentSessionRegistry.LegacyStamp.read(path: source.url.path)) + #expect(throws: CmuxAgentSessionRegistry.HookLegacySourceMalformedError.self) { + try fixture.registry.hookLegacySourceAdmission( + source: source, + expectedStamp: stamp + ) + } + } + + @Test("bounded legacy reads accept the older flat session-map layout") + func legacyScannerAcceptsFlatSessionMaps() throws { + let fixture = try makeFixture() + let data = Data(#""" + { + "version": 1, + "alpha": { + "sessionId": "alpha", + "runs": [{"runId": "first"}, {"runId": "second"}] + }, + "beta": {"sessionId": "beta", "runs": []} + } + """#.utf8) + try data.write(to: fixture.legacyURL) + + let read = try fixture.registry.readHookLegacySourceData(at: fixture.legacyURL) + #expect(read == data) + let metrics = try fixture.registry.hookLegacySourceMetrics(at: fixture.legacyURL) + #expect(metrics.sessionCount == 2) + #expect(metrics.graphNodeCount == 3) + #expect(metrics.largestRecordSessionID == "alpha") + } + + @Test("graph-node overflow wins a tied session limit in wrapped and flat layouts") + func legacyScannerTiedLimitsPreferGraphDiagnostics() throws { + let fixture = try makeFixture() + let sources = [ + Data(#"{"sessions":{"a":{},"b":{}}}"#.utf8), + Data(#"{"a":{},"b":{}}"#.utf8), + ] + for data in sources { + var failure: CmuxAgentSessionRegistry.HookLegacySourceInspectionLimitError? + do { + _ = try fixture.registry.scanHookLegacySourceData( + data, + path: fixture.legacyURL.path, + maximumSessions: 1, + maximumGraphNodes: 1 + ) + } catch let error as CmuxAgentSessionRegistry.HookLegacySourceInspectionLimitError { + failure = error + } + #expect(try #require(failure).scope == .graphNodes) + } + + var scalarFailure: CmuxAgentSessionRegistry.HookLegacySourceInspectionLimitError? + do { + _ = try fixture.registry.scanHookLegacySourceData( + Data(#"{"metadata-a":1,"metadata-b":2}"#.utf8), + path: fixture.legacyURL.path, + maximumSessions: 1, + maximumGraphNodes: 1 + ) + } catch let error as CmuxAgentSessionRegistry.HookLegacySourceInspectionLimitError { + scalarFailure = error + } + #expect(try #require(scalarFailure).scope == .sessions) + } + + @Test("legacy scanner rejects malformed strings and truncated nested values") + func legacyScannerRejectsMalformedJSON() throws { + let fixture = try makeFixture() + for malformed in [ + #"{"sessions":{"bad":{"runs":[{"runId":"\q"}]}}}"#, + #"{"sessions":{"bad":{"nested":[1,2}}}"#, + #"{"sess\u00ZZions":{}}"#, + ] { + #expect(throws: CmuxAgentSessionRegistry.HookLegacySourceMalformedError.self) { + try fixture.registry.scanHookLegacySourceData( + Data(malformed.utf8), + path: fixture.legacyURL.path + ) + } + } + } + + @Test("legacy scanner accepts ten thousand nodes and rejects fifty thousand before decode") + func legacyScannerEnforcesGraphBudgetBeforeDecode() throws { + let fixture = try makeFixture() + func source(sessionCount: Int) -> Data { + let sessions = (0.. Fixture { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hook-hot-path-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return Fixture(directory: directory) + } + + private func listOrderKey( + provider: String, + record: CmuxAgentSessionRegistry.Record + ) throws -> CmuxAgentSessionRegistry.HookListOrderKey { + let decoded = try JSONDecoder().decode(StreamingListRecord.self, from: record.json) + guard decoded.sessionId == record.sessionID else { + throw CmuxAgentSessionRegistry.HookListProjectionValidationError(provider: provider) + } + let run = decoded.runs.flatMap { + CmuxAgentSessionRunAuthorityProjection().projectedRun( + runs: $0, + activeRunId: decoded.activeRunId + ) + } + return .init( + updatedAt: run?.updatedAt ?? decoded.updatedAt, + sortValues: .init( + sessionID: decoded.sessionId, + agent: provider, + runID: run?.runId ?? decoded.runId ?? "session:\(provider):\(decoded.sessionId)", + workspaceID: decoded.workspaceId, + surfaceID: decoded.surfaceId, + identitySource: "hook_session", + pid: run?.pid ?? decoded.pid, + processStartedAt: run?.processStartedAt + ) + ) + } + + private func record( + provider: String, + sessionID: String, + workspaceID: String, + surfaceID: String, + updatedAt: TimeInterval, + extra: [String: Any] = [:] + ) throws -> CmuxAgentSessionRegistry.Record { + var object: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": workspaceID, + "surfaceId": surfaceID, + "startedAt": updatedAt, + "updatedAt": updatedAt, + "runtimeStatus": "running", + ] + object.merge(extra) { _, new in new } + return CmuxAgentSessionRegistry.Record( + provider: provider, + sessionID: sessionID, + updatedAt: updatedAt, + json: try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + ) + } + + private func largeRecord( + provider: String, + sessionID: String, + updatedAt: TimeInterval, + payloadBytes: Int + ) throws -> CmuxAgentSessionRegistry.Record { + try record( + provider: provider, + sessionID: sessionID, + workspaceID: "workspace-\(sessionID)", + surfaceID: "surface-\(sessionID)", + updatedAt: updatedAt, + extra: ["payload": String(repeating: "x", count: payloadBytes)] + ) + } + + private func createV4Registry( + at url: URL, + records: [CmuxAgentSessionRegistry.Record] + ) throws { + var database: OpaquePointer? + guard sqlite3_open_v2( + url.path, + &database, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, + nil + ) == SQLITE_OK, let database else { + throw CocoaError(.fileWriteUnknown) + } + defer { sqlite3_close(database) } + let schema = """ + CREATE TABLE agent_sessions ( + provider TEXT NOT NULL, + session_id TEXT NOT NULL, + updated_at REAL NOT NULL, + writer_generation INTEGER NOT NULL, + workspace_id TEXT, + surface_id TEXT, + runtime_id TEXT, + completed_at REAL, + restore_authority INTEGER, + parent_session_id TEXT, + active_run_id TEXT, + record_json BLOB NOT NULL, + PRIMARY KEY (provider, session_id) + ) WITHOUT ROWID; + CREATE TABLE agent_active_slots ( + provider TEXT NOT NULL, + scope TEXT NOT NULL, + scope_id TEXT NOT NULL, + session_id TEXT NOT NULL, + updated_at REAL NOT NULL, + writer_generation INTEGER NOT NULL, + record_json BLOB NOT NULL, + PRIMARY KEY (provider, scope, scope_id) + ) WITHOUT ROWID; + CREATE TABLE agent_legacy_sources ( + provider TEXT NOT NULL, + path TEXT NOT NULL, + size INTEGER NOT NULL, + modified_at REAL NOT NULL, + imported_at REAL NOT NULL, + PRIMARY KEY (provider, path) + ) WITHOUT ROWID; + CREATE TABLE agent_provider_metadata ( + provider TEXT NOT NULL PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 0, + projected_revision INTEGER NOT NULL DEFAULT 0, + last_pruned_at REAL NOT NULL DEFAULT 0 + ) WITHOUT ROWID; + PRAGMA user_version=4; + """ + guard sqlite3_exec(database, schema, nil, nil, nil) == SQLITE_OK else { + throw sqliteTestError(database) + } + try insertRawRecords(records, provider: records.first?.provider ?? "", database: database) + guard sqlite3_exec( + database, + """ + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at + ) SELECT provider, 1, 0, 0 FROM agent_sessions GROUP BY provider + """, + nil, + nil, + nil + ) == SQLITE_OK else { + throw sqliteTestError(database) + } + } + + private func insertRawRecords( + _ records: [CmuxAgentSessionRegistry.Record], + provider: String, + registryURL: URL, + createActiveSlots: Bool + ) throws { + var database: OpaquePointer? + guard sqlite3_open_v2( + registryURL.path, + &database, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, + nil + ) == SQLITE_OK, let database else { + throw CocoaError(.fileWriteUnknown) + } + defer { sqlite3_close(database) } + try insertRawRecords(records, provider: provider, database: database) + guard createActiveSlots else { return } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + var statement: OpaquePointer? + let sql = """ + INSERT INTO agent_active_slots ( + provider, scope, scope_id, session_id, updated_at, + writer_generation, record_json + ) VALUES (?1, 'surface', ?2, ?3, ?4, ?5, ?6) + """ + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, + let statement else { + throw sqliteTestError(database) + } + defer { sqlite3_finalize(statement) } + for record in records { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + sqlite3_bind_text(statement, 1, provider, -1, transient) + sqlite3_bind_text(statement, 2, "slot-\(record.sessionID)", -1, transient) + sqlite3_bind_text(statement, 3, record.sessionID, -1, transient) + sqlite3_bind_double(statement, 4, record.updatedAt) + sqlite3_bind_int64(statement, 5, sqlite3_int64(record.writerGeneration)) + let slotJSON = Data("{\"sessionId\":\"\(record.sessionID)\"}".utf8) + let result = slotJSON.withUnsafeBytes { bytes in + sqlite3_bind_blob(statement, 6, bytes.baseAddress, Int32(bytes.count), transient) + } + guard result == SQLITE_OK, sqlite3_step(statement) == SQLITE_DONE else { + throw sqliteTestError(database) + } + } + } + + private func insertRawRecords( + _ records: [CmuxAgentSessionRegistry.Record], + provider: String, + database: OpaquePointer + ) throws { + guard !records.isEmpty else { return } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + var statement: OpaquePointer? + let sql = """ + INSERT INTO agent_sessions ( + provider, session_id, updated_at, writer_generation, record_json + ) VALUES (?1, ?2, ?3, ?4, ?5) + """ + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, + let statement else { + throw sqliteTestError(database) + } + defer { sqlite3_finalize(statement) } + for record in records { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + sqlite3_bind_text(statement, 1, provider, -1, transient) + sqlite3_bind_text(statement, 2, record.sessionID, -1, transient) + sqlite3_bind_double(statement, 3, record.updatedAt) + sqlite3_bind_int64(statement, 4, sqlite3_int64(record.writerGeneration)) + let result = record.json.withUnsafeBytes { bytes in + sqlite3_bind_blob(statement, 5, bytes.baseAddress, Int32(bytes.count), transient) + } + guard result == SQLITE_OK, sqlite3_step(statement) == SQLITE_DONE else { + throw sqliteTestError(database) + } + } + } + + private func sqliteTestError(_ database: OpaquePointer) -> NSError { + NSError( + domain: "CmuxAgentSessionRegistryHookHotPathTests", + code: Int(sqlite3_errcode(database)), + userInfo: [NSLocalizedDescriptionKey: String(cString: sqlite3_errmsg(database))] + ) + } + + private func overwriteWorkspaceProjectionMetadata( + registryURL: URL, + provider: String, + sessionID: String, + workspaceID: String + ) throws { + var database: OpaquePointer? + guard sqlite3_open_v2( + registryURL.path, + &database, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, + nil + ) == SQLITE_OK, let database else { + throw CocoaError(.fileWriteUnknown) + } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + database, + """ + UPDATE agent_sessions SET workspace_id = ?1 + WHERE provider = ?2 AND session_id = ?3 + """, + -1, + &statement, + nil + ) == SQLITE_OK, let statement else { + throw sqliteTestError(database) + } + defer { sqlite3_finalize(statement) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, workspaceID, -1, transient) + sqlite3_bind_text(statement, 2, provider, -1, transient) + sqlite3_bind_text(statement, 3, sessionID, -1, transient) + guard sqlite3_step(statement) == SQLITE_DONE else { + throw sqliteTestError(database) + } + } + + private func executeRegistrySQL(at url: URL, sql: String) throws { + var database: OpaquePointer? + guard sqlite3_open_v2( + url.path, + &database, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, + nil + ) == SQLITE_OK, let database else { + throw CocoaError(.fileWriteUnknown) + } + defer { sqlite3_close(database) } + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw sqliteTestError(database) + } + } + + private func slots( + provider: String, + sessionID: String, + workspaceID: String, + surfaceID: String, + updatedAt: TimeInterval + ) throws -> [CmuxAgentSessionRegistry.ActiveSlot] { + let json = try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "updatedAt": updatedAt, + ], options: [.sortedKeys]) + return [ + .init( + provider: provider, + scope: .workspace, + scopeID: workspaceID, + sessionID: sessionID, + updatedAt: updatedAt, + json: json + ), + .init( + provider: provider, + scope: .surface, + scopeID: surfaceID, + sessionID: sessionID, + updatedAt: updatedAt, + json: json + ), + ] + } +} + +private struct StreamingListRecord: Decodable { + var sessionId: String + var workspaceId: String + var surfaceId: String + var runId: String? + var pid: Int? + var startedAt: TimeInterval + var updatedAt: TimeInterval + var runs: [CmuxAgentSessionRunAuthorityProjection.Run]? + var activeRunId: String? +} + +private struct StreamingListSlot: Decodable { + var sessionId: String + var turnId: String? + var allowsNewSessionReplacement: Bool? + var updatedAt: TimeInterval +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAgentSessionRegistryPartialMigrationTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAgentSessionRegistryPartialMigrationTests.swift new file mode 100644 index 000000000000..737268bcdda6 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAgentSessionRegistryPartialMigrationTests.swift @@ -0,0 +1,266 @@ +import Foundation +import SQLite3 +import Testing +@testable import CmuxFoundation + +@Suite("Agent session registry partial migrations", .serialized) +struct CmuxAgentSessionRegistryPartialMigrationTests { + @Test("v4 byte-accounting migration repairs every partial column state") + func repairsPartialV5Migration() throws { + for columns in [ + ["record_bytes"], + ["slot_bytes"], + ["record_bytes", "slot_bytes"], + ] { + let fixture = try MigrationFixture( + version: 4, + metadataColumns: columns, + legacyColumns: [], + installLegacyRecordTrigger: true + ) + defer { fixture.remove() } + + let metrics = try fixture.registry.hookStorageMetrics(provider: "codex") + + #expect(metrics.recordBytes == 2) + #expect(metrics.activeSlotBytes == 2) + #expect(try fixture.userVersion() == 8) + #expect(try fixture.columnCount(table: "agent_provider_metadata", named: "record_bytes") == 1) + #expect(try fixture.columnCount(table: "agent_provider_metadata", named: "slot_bytes") == 1) + let triggers = try fixture.revisionTriggers() + #expect(triggers.count == 6) + #expect(triggers["agent_sessions_revision_insert"]?.contains("record_bytes") == true) + #expect(triggers["agent_active_slots_revision_insert"]?.contains("slot_bytes") == true) + } + } + + @Test("v5 quarantine migration accepts an already-added column") + func repairsPartialV6Migration() throws { + let fixture = try MigrationFixture( + version: 5, + metadataColumns: ["record_bytes", "slot_bytes"], + legacyColumns: ["quarantined"] + ) + defer { fixture.remove() } + + _ = try fixture.registry.hookStorageMetrics(provider: "codex") + + #expect(try fixture.userVersion() == 8) + #expect(try fixture.columnCount(table: "agent_legacy_sources", named: "quarantined") == 1) + } + + @Test("v6 revision-identity migration repairs partial and complete column states") + func repairsPartialV7Migration() throws { + let identityColumns = [ + "device_id", + "inode", + "modified_seconds", + "modified_nanoseconds", + "changed_seconds", + "changed_nanoseconds", + ] + for columns in [Array(identityColumns.prefix(3)), identityColumns] { + let fixture = try MigrationFixture( + version: 6, + metadataColumns: ["record_bytes", "slot_bytes"], + legacyColumns: ["quarantined"] + columns + ) + defer { fixture.remove() } + + _ = try fixture.registry.hookStorageMetrics(provider: "codex") + + #expect(try fixture.userVersion() == 8) + for column in identityColumns { + #expect(try fixture.columnCount(table: "agent_legacy_sources", named: column) == 1) + } + } + } +} + +private final class MigrationFixture { + let directory: URL + let registry: CmuxAgentSessionRegistry + + init( + version: Int, + metadataColumns: [String], + legacyColumns: [String], + installLegacyRecordTrigger: Bool = false + ) throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-partial-migration-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent(CmuxAgentSessionRegistry.filename) + registry = CmuxAgentSessionRegistry(url: url, busyTimeoutMilliseconds: 250) + + let metadataExtras = metadataColumns.map { + ", \($0) INTEGER NOT NULL DEFAULT 0" + }.joined() + let legacyExtras = legacyColumns.map { + if $0 == "quarantined" { + return ", quarantined INTEGER NOT NULL DEFAULT 0" + } + return ", \($0) INTEGER" + }.joined() + var database: OpaquePointer? + guard sqlite3_open_v2( + url.path, + &database, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, + nil + ) == SQLITE_OK, let database else { + throw CocoaError(.fileWriteUnknown) + } + defer { sqlite3_close(database) } + let schema = """ + CREATE TABLE agent_sessions ( + provider TEXT NOT NULL, + session_id TEXT NOT NULL, + updated_at REAL NOT NULL, + writer_generation INTEGER NOT NULL, + workspace_id TEXT, + surface_id TEXT, + runtime_id TEXT, + completed_at REAL, + restore_authority INTEGER, + parent_session_id TEXT, + active_run_id TEXT, + record_json BLOB NOT NULL, + PRIMARY KEY (provider, session_id) + ) WITHOUT ROWID; + CREATE TABLE agent_active_slots ( + provider TEXT NOT NULL, + scope TEXT NOT NULL, + scope_id TEXT NOT NULL, + session_id TEXT NOT NULL, + updated_at REAL NOT NULL, + writer_generation INTEGER NOT NULL, + record_json BLOB NOT NULL, + PRIMARY KEY (provider, scope, scope_id) + ) WITHOUT ROWID; + CREATE TABLE agent_legacy_sources ( + provider TEXT NOT NULL, + path TEXT NOT NULL, + size INTEGER NOT NULL, + modified_at REAL NOT NULL, + imported_at REAL NOT NULL + \(legacyExtras), + PRIMARY KEY (provider, path) + ) WITHOUT ROWID; + CREATE TABLE agent_provider_metadata ( + provider TEXT NOT NULL PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 0, + projected_revision INTEGER NOT NULL DEFAULT 0, + last_pruned_at REAL NOT NULL DEFAULT 0 + \(metadataExtras) + ) WITHOUT ROWID; + INSERT INTO agent_sessions ( + provider, session_id, updated_at, writer_generation, record_json + ) VALUES ('codex', 'session', 1, 1, x'7b7d'); + INSERT INTO agent_active_slots ( + provider, scope, scope_id, session_id, updated_at, writer_generation, record_json + ) VALUES ('codex', 'surface', 'surface', 'session', 1, 1, x'7b7d'); + INSERT INTO agent_provider_metadata ( + provider, revision, projected_revision, last_pruned_at + ) VALUES ('codex', 1, 0, 0); + PRAGMA user_version=\(version); + """ + try Self.execute(schema, database: database) + if installLegacyRecordTrigger { + try Self.execute( + """ + CREATE TRIGGER agent_sessions_revision_insert + AFTER INSERT ON agent_sessions BEGIN + UPDATE agent_provider_metadata SET revision = revision + 1 + WHERE provider = NEW.provider; + END; + """, + database: database + ) + } + let assignments = metadataColumns.map { "\($0) = 999" }.joined(separator: ", ") + if !assignments.isEmpty { + try Self.execute( + "UPDATE agent_provider_metadata SET \(assignments)", + database: database + ) + } + } + + func remove() { + try? FileManager.default.removeItem(at: directory) + } + + func userVersion() throws -> Int { + try withDatabase { database in + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, "PRAGMA user_version", -1, &statement, nil) == SQLITE_OK, + let statement else { throw Self.error(database) } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { throw Self.error(database) } + return Int(sqlite3_column_int64(statement, 0)) + } + } + + func columnCount(table: String, named name: String) throws -> Int { + try withDatabase { database in + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, "PRAGMA table_info(\(table))", -1, &statement, nil) == SQLITE_OK, + let statement else { throw Self.error(database) } + defer { sqlite3_finalize(statement) } + var count = 0 + while sqlite3_step(statement) == SQLITE_ROW { + guard let rawName = sqlite3_column_text(statement, 1) else { continue } + if String(cString: rawName) == name { count += 1 } + } + return count + } + } + + func revisionTriggers() throws -> [String: String] { + try withDatabase { database in + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + database, + "SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND name LIKE '%_revision_%'", + -1, + &statement, + nil + ) == SQLITE_OK, let statement else { throw Self.error(database) } + defer { sqlite3_finalize(statement) } + var result: [String: String] = [:] + while sqlite3_step(statement) == SQLITE_ROW { + guard let rawName = sqlite3_column_text(statement, 0), + let rawSQL = sqlite3_column_text(statement, 1) else { continue } + result[String(cString: rawName)] = String(cString: rawSQL) + } + return result + } + } + + private func withDatabase(_ body: (OpaquePointer) throws -> T) throws -> T { + var database: OpaquePointer? + guard sqlite3_open_v2( + registry.url.path, + &database, + SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, + nil + ) == SQLITE_OK, let database else { throw CocoaError(.fileReadUnknown) } + defer { sqlite3_close(database) } + return try body(database) + } + + private static func execute(_ sql: String, database: OpaquePointer) throws { + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw error(database) + } + } + + private static func error(_ database: OpaquePointer) -> NSError { + NSError( + domain: "CmuxAgentSessionRegistryPartialMigrationTests", + code: Int(sqlite3_errcode(database)), + userInfo: [NSLocalizedDescriptionKey: String(cString: sqlite3_errmsg(database))] + ) + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAgentSessionRegistryTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAgentSessionRegistryTests.swift new file mode 100644 index 000000000000..550a0f863ca9 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAgentSessionRegistryTests.swift @@ -0,0 +1,1865 @@ +import Darwin +import Foundation +import SQLite3 +import Testing +@testable import CmuxFoundation + +@Suite("Agent session registry", .serialized) +struct CmuxAgentSessionRegistryTests { + @Test("batched provider storage metrics preserve single-provider semantics") + func batchedProviderStorageMetricsMatchSingleReads() throws { + let fixture = try Fixture() + try fixture.registry.apply( + provider: "alpha", + records: [ + try fixture.record(sessionID: "alpha-small", updatedAt: 1, generation: 1), + try fixture.record( + sessionID: "alpha-large", + updatedAt: 2, + generation: 1, + extra: ["padding": String(repeating: "x", count: 512)] + ), + ], + activeSlots: [ + try fixture.slot( + provider: "alpha", + scope: .surface, + scopeID: "alpha-surface", + sessionID: "alpha-large", + updatedAt: 2 + ), + ] + ) + try fixture.registry.apply(provider: "beta", records: [ + try fixture.record(sessionID: "beta-only", updatedAt: 3, generation: 1), + ]) + + let providers = ["beta", "missing", "alpha"] + let expected = try Dictionary(uniqueKeysWithValues: providers.map { + ($0, try fixture.registry.hookStorageMetrics(provider: $0)) + }) + + #expect( + try fixture.registry.hookStorageMetrics( + providers: ["beta", "missing", "alpha", "alpha"] + ) == expected + ) + } + + @Test("provider identifiers enforce the path-safe byte boundary") + func providerIdentifierValidationIsPathSafeAndByteBounded() { + #expect(CmuxAgentSessionRegistry.isSafeProviderIdentifier(String(repeating: "a", count: 128))) + #expect(!CmuxAgentSessionRegistry.isSafeProviderIdentifier(String(repeating: "a", count: 129))) + #expect(CmuxAgentSessionRegistry.isSafeProviderIdentifier("custom.agent_v2-beta")) + #expect(!CmuxAgentSessionRegistry.isSafeProviderIdentifier("../custom")) + #expect(!CmuxAgentSessionRegistry.isSafeProviderIdentifier("custom/agent")) + #expect(!CmuxAgentSessionRegistry.isSafeProviderIdentifier("café")) + } + + @Test("provider enumeration is sorted, bounded, and rejects unsafe active IDs") + func providerEnumerationIsSortedBoundedAndSafe() throws { + let fixture = try Fixture() + for provider in ["z-provider", "a-provider", "m-provider"] { + try fixture.registry.apply(provider: provider, records: [ + try fixture.record(sessionID: "session-\(provider)", updatedAt: 1, generation: 1), + ]) + } + #expect(try fixture.registry.providerIdentifiers() == [ + "a-provider", "m-provider", "z-provider", + ]) + #expect(throws: CmuxAgentSessionRegistry.ProviderEnumerationLimitError.self) { + try fixture.registry.providerIdentifiers(maximumCount: 2) + } + + let collisionFixture = try Fixture() + for provider in ["Custom", "custom"] { + try collisionFixture.registry.apply(provider: provider, records: [ + try collisionFixture.record( + sessionID: "session-\(provider)", + updatedAt: 1, + generation: 1 + ), + ]) + } + #expect( + Set(try collisionFixture.registry.providerIdentifiers(caseInsensitiveTo: "CUSTOM")) + == Set(["Custom", "custom"]) + ) + + let unsafeFixture = try Fixture() + try unsafeFixture.registry.apply(provider: "../escape", records: [ + try unsafeFixture.record(sessionID: "unsafe", updatedAt: 1, generation: 1), + ]) + #expect(throws: CmuxAgentSessionRegistry.UnsafeProviderIdentifierError.self) { + try unsafeFixture.registry.providerIdentifiers() + } + #expect(try !unsafeFixture.registry.containsProviderIdentifier("../escape")) + } + + @Test("provider enumeration admits 256 current providers and rejects the 257th") + func providerEnumerationBoundaryIsExplicit() throws { + let fixture = try Fixture() + try fixture.registry.withDatabase { database in + try fixture.registry.transaction(database) { + for index in 0.. relevantData.count) + var sources: [CmuxAgentSessionRegistry.LegacySource] = [] + var noiseProviders = Set() + for index in 0..<64 { + let provider = String(format: "noise-%02d", index) + let url = fixture.directory.appendingPathComponent("\(provider)-hook-sessions.json") + try noiseData.write(to: url, options: .atomic) + sources.append(.init(provider: provider, url: url)) + noiseProviders.insert(provider) + } + sources.append(.init(provider: relevantProvider, url: relevantURL)) + + var openedProviders: [String] = [] + let result = try fixture.registry.refreshLegacySources( + sources, + maximumReadBytes: Int64(relevantData.count), + legacyAdmissionLoader: { source, stamp in + openedProviders.append(source.provider) + return try fixture.registry.hookLegacySourceAdmission( + source: source, + expectedStamp: stamp, + maximumBytes: stamp.size + ) + } + ) + + #expect(openedProviders == [relevantProvider]) + #expect(result.sourceReadBudgetUsed == Int64(relevantData.count)) + #expect(result.readBudgetExceededProviders == noiseProviders) + #expect(result.failedProviders == noiseProviders) + #expect(result.refreshedProviders == [relevantProvider]) + #expect( + try fixture.registry.snapshot(provider: relevantProvider).records.map(\.sessionID) + == [relevantSessionID] + ) + + let unchanged = try fixture.registry.refreshLegacySources( + [.init(provider: relevantProvider, url: relevantURL)], + maximumReadBytes: 0 + ) + #expect(unchanged.failedProviders.isEmpty) + #expect(unchanged.sourceReadBudgetUsed == 0) + + let firstNoiseSource = try #require(sources.first) + let retried = try fixture.registry.refreshLegacySources( + [firstNoiseSource], + maximumReadBytes: Int64(noiseData.count) + ) + #expect(retried.refreshedProviders == [firstNoiseSource.provider]) + #expect(retried.failedProviders.isEmpty) + #expect(retried.readBudgetExceededProviders.isEmpty) + } + + @Test("restore preflight retries one exact sidecar replacement") + func restorePreflightRetriesOneExactSidecarReplacement() throws { + let fixture = try Fixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let legacyURL = fixture.directory.appendingPathComponent("codex-hook-sessions.json") + let source = CmuxAgentSessionRegistry.LegacySource(provider: "codex", url: legacyURL) + let original = try fixture.legacyStore( + sessions: ["before": fixture.object(sessionID: "before", updatedAt: 1)] + ) + let replacement = try fixture.legacyStore( + sessions: ["latest": fixture.object(sessionID: "latest", updatedAt: 2)] + ) + #expect(original.count == replacement.count) + try original.write(to: legacyURL, options: .atomic) + let originalStamp = try #require( + CmuxAgentSessionRegistry.LegacyStamp.read(path: legacyURL.path) + ) + var admissionAttempts = 0 + + let result = try fixture.registry.refreshLegacySources( + [source], + preservingCanonicalRestoreOwners: [], + legacyAdmissionLoader: { requestedSource, expectedStamp in + admissionAttempts += 1 + if admissionAttempts == 1 { + try replacement.write(to: legacyURL, options: .atomic) + } + return try fixture.registry.hookLegacySourceAdmission( + source: requestedSource, + expectedStamp: expectedStamp + ) + } + ) + + let replacementStamp = try #require( + CmuxAgentSessionRegistry.LegacyStamp.read(path: legacyURL.path) + ) + #expect(admissionAttempts == 2) + #expect(result.refreshedProviders == ["codex"]) + #expect(result.failedProviders.isEmpty) + #expect(try fixture.registry.snapshot(provider: "codex").records.map(\.sessionID) == ["latest"]) + #expect(originalStamp != replacementStamp) + #expect(try fixture.registry.legacySourceIsCurrent( + provider: "codex", + stamp: replacementStamp + )) + } + + @Test("canonical rebind skips only the exact quarantined legacy revision") + func canonicalRebindSkipsExactQuarantinedRevision() throws { + let fixture = try Fixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let legacyURL = fixture.directory.appendingPathComponent("codex-hook-sessions.json") + let sessionID = "canonical-hibernation" + let workspaceID = "11111111-1111-1111-1111-111111111111" + let surfaceID = "22222222-2222-2222-2222-222222222222" + let context = CmuxAgentSessionRegistry.RestoreOwnerContext( + provider: "codex", + sessionID: sessionID, + workspaceID: workspaceID, + surfaceID: surfaceID + ) + try fixture.registry.apply( + provider: "codex", + records: [try fixture.record( + sessionID: sessionID, + updatedAt: 20, + generation: CmuxAgentSessionRegistry.currentWriterGeneration, + extra: [ + "sessionState": "hibernated", + "restoreAuthority": true, + ] + )], + activeSlots: [try fixture.slot( + provider: "codex", + scope: .surface, + scopeID: surfaceID, + sessionID: sessionID, + updatedAt: 20 + )] + ) + try Data("{broken".utf8).write(to: legacyURL, options: .atomic) + let quarantinedStamp = try #require( + CmuxAgentSessionRegistry.LegacyStamp.read(path: legacyURL.path) + ) + + let result = try fixture.registry.refreshLegacySources( + [.init(provider: "codex", url: legacyURL)], + preservingCanonicalRestoreOwners: [context] + ) + + #expect(result.failedProviders == ["codex"]) + #expect(result.verifiedCanonicalRestoreOwners == [context]) + #expect(try !fixture.registry.legacySourceIsCurrent( + provider: "codex", + stamp: quarantinedStamp + )) + #expect(try fixture.registry.canonicalRebindCanSkipLegacySource( + provider: "codex", + stamp: quarantinedStamp + )) + + try fixture.legacyStore(sessions: [ + sessionID: fixture.object(sessionID: sessionID, updatedAt: 21), + ]).write(to: legacyURL, options: .atomic) + let changedStamp = try #require( + CmuxAgentSessionRegistry.LegacyStamp.read(path: legacyURL.path) + ) + #expect(try !fixture.registry.canonicalRebindCanSkipLegacySource( + provider: "codex", + stamp: changedStamp + )) + } + + @Test("canonical restore-owner verification follows run authority") + func canonicalRestoreOwnerVerificationFollowsRunAuthority() throws { + for malformedSidecar in [false, true] { + for (name, recordAuthority, runAuthority) in [ + ("stale-promote", true, false), + ("stale-demote", false, true), + ] { + let fixture = try Fixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let legacyURL = fixture.directory.appendingPathComponent("codex-hook-sessions.json") + let sessionID = "\(name)-\(malformedSidecar ? "malformed" : "missing")" + let workspaceID = "11111111-1111-1111-1111-111111111111" + let surfaceID = "22222222-2222-2222-2222-222222222222" + let context = CmuxAgentSessionRegistry.RestoreOwnerContext( + provider: "codex", + sessionID: sessionID, + workspaceID: workspaceID, + surfaceID: surfaceID + ) + try fixture.registry.apply( + provider: "codex", + records: [try fixture.record( + sessionID: sessionID, + updatedAt: 20, + generation: CmuxAgentSessionRegistry.currentWriterGeneration, + extra: [ + "sessionState": "hibernated", + "restoreAuthority": recordAuthority, + "activeRunId": "canonical-run", + "runs": [[ + "runId": "canonical-run", + "restoreAuthority": runAuthority, + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + ] + )], + activeSlots: [try fixture.slot( + provider: "codex", + scope: .surface, + scopeID: surfaceID, + sessionID: sessionID, + updatedAt: 20 + )] + ) + if malformedSidecar { + try Data("{broken".utf8).write(to: legacyURL, options: .atomic) + } + + let result = try fixture.registry.refreshLegacySources( + [.init(provider: "codex", url: legacyURL)], + preservingCanonicalRestoreOwners: [context] + ) + + #expect( + result.verifiedCanonicalRestoreOwners.contains(context) == runAuthority, + Comment(rawValue: "\(name) must use run authority with a \(malformedSidecar ? "malformed" : "missing") sidecar") + ) + #expect(result.failedProviders.contains("codex") == (malformedSidecar || !runAuthority)) + } + } + } + + @Test("three-way runtime identity conflicts fail closed in every order") + func threeWayRuntimeIdentityConflictsFailClosedInEveryOrder() throws { + let first = CmuxAgentSessionRunAuthorityProjection.Run( + runId: "shared-run", + pid: 42, + processStartedAt: 100, + cmuxRuntime: .init( + id: "shared-runtime", + socketPath: "/tmp/runtime-a.sock", + bundleIdentifier: "com.cmux.runtime", + processId: 101, + processStartSeconds: 10, + processStartMicroseconds: 20 + ), + restoreAuthority: true, + startedAt: 100, + updatedAt: 200 + ) + var conflicting = first + conflicting.cmuxRuntime = .init( + id: "shared-runtime", + socketPath: "/tmp/runtime-b.sock", + bundleIdentifier: "com.cmux.runtime", + processId: 202, + processStartSeconds: 30, + processStartMicroseconds: 40 + ) + let permutations = [ + [first, conflicting, first], + [first, first, conflicting], + [conflicting, first, first], + ] + + for (index, runs) in permutations.enumerated() { + let projection = CmuxAgentSessionRunAuthorityProjection().projection( + recordRestoreAuthority: true, + runs: runs, + activeRunId: "shared-run" + ) + let run = try #require(projection.run) + #expect(!projection.restoreAuthority, Comment(rawValue: "order \(index)")) + #expect(run.identityConflict == true, Comment(rawValue: "order \(index)")) + #expect(run.cmuxRuntime == nil, Comment(rawValue: "order \(index)")) + } + } + + @Test("restore preflight imports ten thousand legacy rows within a bounded interval") + func restorePreflightPerformance() throws { + let fixture = try Fixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let legacyURL = fixture.directory.appendingPathComponent("codex-hook-sessions.json") + let sessions = Dictionary(uniqueKeysWithValues: (0..<10_000).map { index in + let sessionID = "session-\(index)" + return (sessionID, fixture.object(sessionID: sessionID, updatedAt: Double(index))) + }) + try fixture.legacyStore(sessions: sessions).write(to: legacyURL, options: .atomic) + + let clock = ContinuousClock() + let elapsed = try clock.measure { + let result = try fixture.registry.refreshLegacySources([ + .init(provider: "codex", url: legacyURL), + ]) + #expect(result.refreshedProviders == ["codex"]) + #expect(result.failedProviders.isEmpty) + } + + print("restore preflight 10000-row elapsed: \(elapsed)") + #expect(elapsed < .seconds(5)) + #expect(try fixture.registry.snapshot(provider: "codex").records.count == 10_000) + + let unchangedElapsed = try clock.measure { + let result = try fixture.registry.refreshLegacySources([ + .init(provider: "codex", url: legacyURL), + ]) + #expect(result.refreshedProviders.isEmpty) + #expect(result.failedProviders.isEmpty) + } + print("restore preflight unchanged 10000-row elapsed: \(unchangedElapsed)") + #expect(unchangedElapsed < .seconds(1)) + } + + @Test("restore preflight spends one busy timeout waiting for a writer") + func restorePreflightContentionIsBounded() throws { + let fixture = try Fixture(busyTimeoutMilliseconds: 5) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try fixture.registry.snapshot(provider: "codex") + let legacyURL = fixture.directory.appendingPathComponent("codex-hook-sessions.json") + try fixture.legacyStore( + sessions: ["blocked": fixture.object(sessionID: "blocked", updatedAt: 1)] + ).write(to: legacyURL, options: .atomic) + var database: OpaquePointer? + #expect(sqlite3_open(fixture.registry.url.path, &database) == SQLITE_OK) + let writer = try #require(database) + defer { sqlite3_close(writer) } + #expect(sqlite3_exec(writer, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK) + defer { sqlite3_exec(writer, "ROLLBACK", nil, nil, nil) } + + let clock = ContinuousClock() + let elapsed = clock.measure { + #expect(throws: (any Error).self) { + try fixture.registry.refreshLegacySources([ + .init(provider: "codex", url: legacyURL), + ]) + } + } + + #expect(elapsed < .seconds(1)) + } + + @Test("missing legacy sources fail restore preflight closed") + func restorePreflightRejectsMissingLegacySource() throws { + let fixture = try Fixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let result = try fixture.registry.refreshLegacySources([ + .init( + provider: "codex", + url: fixture.directory.appendingPathComponent("missing-codex-hook-sessions.json") + ), + ]) + + #expect(result.refreshedProviders.isEmpty) + #expect(result.failedProviders == ["codex"]) + } + + @Test("missing compatibility JSON does not discard a canonical registry row") + func canonicalRegistryRestoresWithoutLegacySource() throws { + let fixture = try Fixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let missingURL = fixture.directory.appendingPathComponent("missing-codex-hook-sessions.json") + try fixture.registry.apply(provider: "codex", records: [ + try fixture.record( + sessionID: "canonical", + updatedAt: 1, + generation: 1, + extra: ["sessionState": "hibernated"] + ), + ]) + + let preflight = try fixture.registry.refreshLegacySources([ + .init(provider: "codex", url: missingURL), + ]) + #expect(preflight.refreshedProviders.isEmpty) + #expect(preflight.failedProviders.isEmpty) + let result = try fixture.registry.withLegacySourceRebindBatch( + provider: "codex", + legacyURL: missingURL + ) { batch in + try batch.patchRecordRebindingActiveSlots( + provider: "codex", + sessionID: "canonical", + updatedAt: 2, + previousSlots: [], + activeSlots: [], + shouldMutate: { $0["sessionState"] as? String == "hibernated" } + ) { object in + object["workspaceId"] = "restored-workspace" + object["updatedAt"] = 2.0 + } + } + + #expect(result == .patched) + let record = try #require(fixture.registry.snapshot(provider: "codex").records.first) + let object = try #require(JSONSerialization.jsonObject(with: record.json) as? [String: Any]) + #expect(object["workspaceId"] as? String == "restored-workspace") + } + + @Test("missing compatibility lookup stays bounded with ten thousand canonical rows") + func missingLegacyCanonicalLookupPerformance() throws { + let fixture = try Fixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let records = try (0..<10_000).map { index in + try fixture.record( + sessionID: "canonical-\(index)", + updatedAt: Double(index), + generation: 1 + ) + } + try fixture.registry.apply(provider: "codex", records: records) + let missingURL = fixture.directory.appendingPathComponent("missing-codex-hook-sessions.json") + + let elapsed = try ContinuousClock().measure { + let result = try fixture.registry.refreshLegacySources([ + .init(provider: "codex", url: missingURL), + ]) + #expect(result.failedProviders.isEmpty) + } + + #expect(elapsed < .seconds(1)) + } + + @Test("lifecycle patches preserve unknown future keys") + func patchPreservesUnknownKeys() throws { + let fixture = try Fixture() + try fixture.registry.apply(provider: "opencode", records: [ + try fixture.record( + sessionID: "patched", + updatedAt: 10, + generation: 2, + extra: ["futureWorkload": ["monitor": true]] + ), + ]) + + let patched = try fixture.registry.patchRecord( + provider: "opencode", + sessionID: "patched", + updatedAt: 50 + ) { object in + object["sessionState"] = "hibernated" + object["updatedAt"] = 50 + } + + #expect(patched) + let stored = try #require(fixture.registry.snapshot(provider: "opencode").records.first) + let object = try #require(JSONSerialization.jsonObject(with: stored.json) as? [String: Any]) + #expect((object["futureWorkload"] as? [String: Any])?["monitor"] as? Bool == true) + #expect(object["sessionState"] as? String == "hibernated") + #expect(stored.writerGeneration == 2) + } + + @Test("lifecycle patches promote imported legacy rows") + func patchPromotesLegacyWriterGeneration() throws { + let fixture = try Fixture() + try fixture.registry.apply(provider: "codex", records: [ + try fixture.record(sessionID: "legacy", updatedAt: 10, generation: 0), + ]) + + let patched = try fixture.registry.patchRecord( + provider: "codex", + sessionID: "legacy", + updatedAt: 20 + ) { object in + object["sessionState"] = "hibernated" + object["updatedAt"] = 20 + } + + #expect(patched) + let stored = try #require(fixture.registry.snapshot(provider: "codex").records.first) + #expect(stored.writerGeneration == CmuxAgentSessionRegistry.currentWriterGeneration) + } + + @Test("record and active panel bindings rebind atomically") + func recordAndActivePanelBindingsRebindAtomically() throws { + let fixture = try Fixture() + let oldWorkspace = "11111111-1111-1111-1111-111111111111" + let oldSurface = "22222222-2222-2222-2222-222222222222" + let newWorkspace = "33333333-3333-3333-3333-333333333333" + let newSurface = "44444444-4444-4444-4444-444444444444" + try fixture.registry.apply( + provider: "codex", + records: [try fixture.record( + sessionID: "restored", + updatedAt: 10, + generation: 2, + extra: ["futureRecordKey": "preserved"] + )], + activeSlots: [ + try fixture.slot( + provider: "codex", + scope: .workspace, + scopeID: oldWorkspace, + sessionID: "restored", + updatedAt: 10, + generation: 2, + extra: ["futureSlotKey": "preserved"] + ), + try fixture.slot( + provider: "codex", + scope: .surface, + scopeID: oldSurface, + sessionID: "restored", + updatedAt: 10, + generation: 2 + ), + ] + ) + + let patched = try fixture.registry.patchRecordRebindingActiveSlots( + provider: "codex", + sessionID: "restored", + updatedAt: 20, + previousSlots: [ + .init(scope: .workspace, scopeID: oldWorkspace), + .init(scope: .surface, scopeID: oldSurface), + ], + activeSlots: [ + .init(scope: .workspace, scopeID: newWorkspace), + .init(scope: .surface, scopeID: newSurface), + ], + shouldMutate: { + $0["workspaceId"] as? String == oldWorkspace + && $0["surfaceId"] as? String == oldSurface + } + ) { object in + object["workspaceId"] = newWorkspace + object["surfaceId"] = newSurface + object["updatedAt"] = 20 + object["sessionState"] = "hibernated" + } + + #expect(patched == .patched) + let snapshot = try fixture.registry.snapshot(provider: "codex") + let record = try #require(snapshot.records.first) + let object = try #require(JSONSerialization.jsonObject(with: record.json) as? [String: Any]) + #expect(object["workspaceId"] as? String == newWorkspace) + #expect(object["surfaceId"] as? String == newSurface) + #expect(object["futureRecordKey"] as? String == "preserved") + #expect(record.writerGeneration == 2) + #expect(Set(snapshot.activeSlots.map(\.scopeID)) == [newWorkspace, newSurface]) + for slot in snapshot.activeSlots { + let slotObject = try #require(JSONSerialization.jsonObject(with: slot.json) as? [String: Any]) + #expect(slot.sessionID == "restored") + #expect(slot.writerGeneration == 2) + #expect(slotObject["futureSlotKey"] as? String == "preserved") + } + } + + @Test("active slot collisions reject the complete rebind") + func activeSlotCollisionRejectsCompleteRebind() throws { + let fixture = try Fixture() + let oldWorkspace = "11111111-1111-1111-1111-111111111111" + let oldSurface = "22222222-2222-2222-2222-222222222222" + let occupiedWorkspace = "33333333-3333-3333-3333-333333333333" + let newSurface = "44444444-4444-4444-4444-444444444444" + try fixture.registry.apply( + provider: "codex", + records: [ + try fixture.record(sessionID: "restored", updatedAt: 10, generation: 1), + try fixture.record(sessionID: "occupant", updatedAt: 11, generation: 1), + ], + activeSlots: [ + try fixture.slot( + provider: "codex", + scope: .workspace, + scopeID: oldWorkspace, + sessionID: "restored", + updatedAt: 10 + ), + try fixture.slot( + provider: "codex", + scope: .surface, + scopeID: oldSurface, + sessionID: "restored", + updatedAt: 10 + ), + try fixture.slot( + provider: "codex", + scope: .workspace, + scopeID: occupiedWorkspace, + sessionID: "occupant", + updatedAt: 11 + ), + ] + ) + + let patched = try fixture.registry.patchRecordRebindingActiveSlots( + provider: "codex", + sessionID: "restored", + updatedAt: 20, + previousSlots: [ + .init(scope: .workspace, scopeID: oldWorkspace), + .init(scope: .surface, scopeID: oldSurface), + ], + activeSlots: [ + .init(scope: .workspace, scopeID: occupiedWorkspace), + .init(scope: .surface, scopeID: newSurface), + ] + ) { object in + object["workspaceId"] = occupiedWorkspace + object["surfaceId"] = newSurface + object["updatedAt"] = 20 + } + + #expect(patched == .rejected) + let snapshot = try fixture.registry.snapshot(provider: "codex") + let restored = try #require(snapshot.records.first(where: { $0.sessionID == "restored" })) + let object = try #require(JSONSerialization.jsonObject(with: restored.json) as? [String: Any]) + #expect(object["workspaceId"] as? String == oldWorkspace) + #expect(object["surfaceId"] as? String == oldSurface) + #expect(Set(snapshot.activeSlots.map(\.scopeID)) == [oldWorkspace, oldSurface, occupiedWorkspace]) + #expect(!snapshot.activeSlots.contains(where: { $0.scopeID == newSurface })) + } + + @Test("resume claims require the current surface slot to still exist") + func resumeClaimRejectsMissingCurrentSurfaceSlot() throws { + let fixture = try Fixture() + let workspace = "11111111-1111-1111-1111-111111111111" + let surface = "22222222-2222-2222-2222-222222222222" + try fixture.registry.apply(provider: "codex", records: [ + try fixture.record( + sessionID: "hibernated", + updatedAt: 10, + generation: 1, + extra: [ + "workspaceId": workspace, + "surfaceId": surface, + "sessionState": "hibernated", + "restoreAuthority": true, + ] + ), + ]) + + let result = try fixture.registry.patchRecordRebindingActiveSlots( + provider: "codex", + sessionID: "hibernated", + updatedAt: 20, + previousSlots: [], + activeSlots: [.init(scope: .surface, scopeID: surface)], + requireExistingActiveSlots: true, + shouldMutate: { $0["sessionState"] as? String == "hibernated" } + ) { object in + object["sessionState"] = "restoring" + object["updatedAt"] = 20.0 + } + + #expect(result == .rejected) + let snapshot = try fixture.registry.snapshot(provider: "codex") + let record = try #require(snapshot.records.first) + let object = try #require(JSONSerialization.jsonObject(with: record.json) as? [String: Any]) + #expect(object["sessionState"] as? String == "hibernated") + #expect(record.updatedAt == 10) + #expect(snapshot.activeSlots.isEmpty) + } + + @Test("resume claims preserve owned timestamps across wall-clock rollback") + func resumeClaimUsesMonotonicOwnedTimestamp() throws { + let fixture = try Fixture() + let surface = "22222222-2222-2222-2222-222222222222" + try fixture.registry.apply( + provider: "codex", + records: [try fixture.record( + sessionID: "hibernated", + updatedAt: 500, + generation: 1, + extra: [ + "surfaceId": surface, + "sessionState": "hibernated", + "updatedAt": 500.0, + ] + )], + activeSlots: [try fixture.slot( + provider: "codex", + scope: .surface, + scopeID: surface, + sessionID: "hibernated", + updatedAt: 500 + )] + ) + + let result = try fixture.registry.patchRecordRebindingActiveSlots( + provider: "codex", + sessionID: "hibernated", + updatedAt: 100, + previousSlots: [], + activeSlots: [.init(scope: .surface, scopeID: surface)], + requireExistingActiveSlots: true, + monotonicUpdatedAt: true, + shouldMutate: { $0["sessionState"] as? String == "hibernated" } + ) { object in + object["sessionState"] = "restoring" + object["updatedAt"] = 100.0 + } + + #expect(result == .patched) + let snapshot = try fixture.registry.snapshot(provider: "codex") + let record = try #require(snapshot.records.first) + let recordObject = try #require( + JSONSerialization.jsonObject(with: record.json) as? [String: Any] + ) + let slot = try #require(snapshot.activeSlots.first) + let slotObject = try #require( + JSONSerialization.jsonObject(with: slot.json) as? [String: Any] + ) + #expect(record.updatedAt == 500) + #expect(recordObject["updatedAt"] as? TimeInterval == 500) + #expect(recordObject["sessionState"] as? String == "restoring") + #expect(slot.updatedAt == 500) + #expect(slotObject["updatedAt"] as? TimeInterval == 500) + } + + @Test("invalid siblings do not roll back valid members of a restore batch") + func invalidRecordsAreIsolatedWithinRestoreBatch() throws { + let fixture = try Fixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let legacyURL = fixture.directory.appendingPathComponent("codex-hook-sessions.json") + let sessions = Dictionary(uniqueKeysWithValues: ["corrupt", "collision", "valid"].map { sessionID in + (sessionID, fixture.object(sessionID: sessionID, updatedAt: 1).merging([ + "sessionState": "hibernated", + ]) { _, new in new }) + }) + try fixture.legacyStore(sessions: sessions).write(to: legacyURL, options: .atomic) + _ = try fixture.registry.refreshLegacySources([ + .init(provider: "codex", url: legacyURL), + ]) + var database: OpaquePointer? + #expect(sqlite3_open(fixture.registry.url.path, &database) == SQLITE_OK) + let writer = try #require(database) + defer { sqlite3_close(writer) } + #expect(sqlite3_exec( + writer, + // Keep the payload valid JSON so SQLite's JSON expression index + // accepts the fixture, while making it the wrong top-level shape + // for a registry session record. + "UPDATE agent_sessions SET record_json = X'5B5D' WHERE provider = 'codex' AND session_id = 'corrupt'", + nil, + nil, + nil + ) == SQLITE_OK) + try fixture.registry.apply( + provider: "codex", + records: [], + activeSlots: [try fixture.slot( + provider: "codex", + scope: .workspace, + scopeID: "occupied-workspace", + sessionID: "occupant", + updatedAt: 2 + )] + ) + + var results: [CmuxAgentSessionRegistry.RecordRebindResult] = [] + try fixture.registry.withLegacySourceRebindBatch( + provider: "codex", + legacyURL: legacyURL + ) { batch in + for sessionID in ["corrupt", "missing", "collision", "valid"] { + results.append(try batch.patchRecordRebindingActiveSlots( + provider: "codex", + sessionID: sessionID, + updatedAt: 2, + previousSlots: [], + activeSlots: sessionID == "collision" + ? [.init(scope: .workspace, scopeID: "occupied-workspace")] + : [], + shouldMutate: { $0["sessionState"] as? String == "hibernated" } + ) { object in + object["workspaceId"] = "restored-workspace" + object["updatedAt"] = 2.0 + }) + } + } + + #expect(results == [.rejected, .recordMissing, .rejected, .patched]) + let snapshot = try fixture.registry.snapshot(provider: "codex") + let valid = try #require(snapshot.records.first { $0.sessionID == "valid" }) + let validObject = try #require(JSONSerialization.jsonObject(with: valid.json) as? [String: Any]) + #expect(validObject["workspaceId"] as? String == "restored-workspace") + } + + @Test("a failed provider transaction rolls back without affecting another provider") + func restoreBatchFailureIsProviderScoped() throws { + enum ExpectedFailure: Error { case rollback } + let fixture = try Fixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let codexURL = fixture.directory.appendingPathComponent("codex-hook-sessions.json") + let claudeURL = fixture.directory.appendingPathComponent("claude-hook-sessions.json") + try fixture.legacyStore(sessions: [ + "codex-session": fixture.object(sessionID: "codex-session", updatedAt: 1), + ]).write(to: codexURL, options: .atomic) + try fixture.legacyStore(sessions: [ + "claude-session": fixture.object(sessionID: "claude-session", updatedAt: 1), + ]).write(to: claudeURL, options: .atomic) + _ = try fixture.registry.refreshLegacySources([ + .init(provider: "codex", url: codexURL), + .init(provider: "claude", url: claudeURL), + ]) + + #expect(throws: ExpectedFailure.self) { + try fixture.registry.withLegacySourceRebindBatch( + provider: "codex", + legacyURL: codexURL + ) { batch -> Void in + let result = try batch.patchRecordRebindingActiveSlots( + provider: "codex", + sessionID: "codex-session", + updatedAt: 2, + previousSlots: [], + activeSlots: [] + ) { $0["workspaceId"] = "rolled-back-workspace" } + #expect(result == .patched) + throw ExpectedFailure.rollback + } + } + try fixture.registry.withLegacySourceRebindBatch( + provider: "claude", + legacyURL: claudeURL + ) { batch in + let result = try batch.patchRecordRebindingActiveSlots( + provider: "claude", + sessionID: "claude-session", + updatedAt: 2, + previousSlots: [], + activeSlots: [] + ) { $0["workspaceId"] = "committed-workspace" } + #expect(result == .patched) + } + + let codex = try #require(fixture.registry.snapshot(provider: "codex").records.first) + let codexObject = try #require(JSONSerialization.jsonObject(with: codex.json) as? [String: Any]) + #expect(codexObject["workspaceId"] as? String == "11111111-1111-1111-1111-111111111111") + let claude = try #require(fixture.registry.snapshot(provider: "claude").records.first) + let claudeObject = try #require(JSONSerialization.jsonObject(with: claude.json) as? [String: Any]) + #expect(claudeObject["workspaceId"] as? String == "committed-workspace") + } + + @Test("stale previous slots owned by another session survive rebind") + func stalePreviousSlotOwnedByAnotherSessionSurvivesRebind() throws { + let fixture = try Fixture() + let oldWorkspace = "11111111-1111-1111-1111-111111111111" + let oldSurface = "22222222-2222-2222-2222-222222222222" + let newWorkspace = "33333333-3333-3333-3333-333333333333" + let newSurface = "44444444-4444-4444-4444-444444444444" + try fixture.registry.apply( + provider: "codex", + records: [try fixture.record(sessionID: "restored", updatedAt: 10, generation: 1)], + activeSlots: [ + try fixture.slot( + provider: "codex", + scope: .workspace, + scopeID: oldWorkspace, + sessionID: "new-owner", + updatedAt: 15 + ), + try fixture.slot( + provider: "codex", + scope: .surface, + scopeID: oldSurface, + sessionID: "restored", + updatedAt: 10 + ), + ] + ) + + let patched = try fixture.registry.patchRecordRebindingActiveSlots( + provider: "codex", + sessionID: "restored", + updatedAt: 20, + previousSlots: [ + .init(scope: .workspace, scopeID: oldWorkspace), + .init(scope: .surface, scopeID: oldSurface), + ], + activeSlots: [ + .init(scope: .workspace, scopeID: newWorkspace), + .init(scope: .surface, scopeID: newSurface), + ] + ) { object in + object["workspaceId"] = newWorkspace + object["surfaceId"] = newSurface + object["updatedAt"] = 20 + } + + #expect(patched == .patched) + let snapshot = try fixture.registry.snapshot(provider: "codex") + #expect(snapshot.activeSlots.first(where: { $0.scopeID == oldWorkspace })?.sessionID == "new-owner") + #expect(!snapshot.activeSlots.contains(where: { $0.scopeID == oldSurface })) + #expect(snapshot.activeSlots.first(where: { $0.scopeID == newWorkspace })?.sessionID == "restored") + #expect(snapshot.activeSlots.first(where: { $0.scopeID == newSurface })?.sessionID == "restored") + } + + @Test("concurrent stale rebinds serialize through the record fence") + func concurrentStaleRebindsSerializeThroughRecordFence() async throws { + let fixture = try Fixture(busyTimeoutMilliseconds: 25) + let oldWorkspace = "11111111-1111-1111-1111-111111111111" + let oldSurface = "22222222-2222-2222-2222-222222222222" + try fixture.registry.apply( + provider: "codex", + records: [try fixture.record(sessionID: "restored", updatedAt: 10, generation: 1)], + activeSlots: [ + try fixture.slot( + provider: "codex", + scope: .workspace, + scopeID: oldWorkspace, + sessionID: "restored", + updatedAt: 10 + ), + try fixture.slot( + provider: "codex", + scope: .surface, + scopeID: oldSurface, + sessionID: "restored", + updatedAt: 10 + ), + ] + ) + let registry = fixture.registry + let targets = [ + ("33333333-3333-3333-3333-333333333333", "44444444-4444-4444-4444-444444444444"), + ("55555555-5555-5555-5555-555555555555", "66666666-6666-6666-6666-666666666666"), + ] + + let results = await withTaskGroup(of: Bool.self, returning: [Bool].self) { group in + for (workspace, surface) in targets { + group.addTask { + (try? registry.patchRecordRebindingActiveSlots( + provider: "codex", + sessionID: "restored", + updatedAt: 20, + previousSlots: [ + .init(scope: .workspace, scopeID: oldWorkspace), + .init(scope: .surface, scopeID: oldSurface), + ], + activeSlots: [ + .init(scope: .workspace, scopeID: workspace), + .init(scope: .surface, scopeID: surface), + ], + shouldMutate: { + $0["workspaceId"] as? String == oldWorkspace + && $0["surfaceId"] as? String == oldSurface + } + ) { object in + object["workspaceId"] = workspace + object["surfaceId"] = surface + object["updatedAt"] = 20 + }) == .patched + } + } + var values: [Bool] = [] + for await value in group { values.append(value) } + return values + } + + #expect(results.filter { $0 }.count == 1) + let snapshot = try fixture.registry.snapshot(provider: "codex") + let record = try #require(snapshot.records.first) + let object = try #require(JSONSerialization.jsonObject(with: record.json) as? [String: Any]) + let workspace = try #require(object["workspaceId"] as? String) + let surface = try #require(object["surfaceId"] as? String) + #expect(Set(snapshot.activeSlots.map(\.scopeID)) == [workspace, surface]) + #expect(snapshot.activeSlots.allSatisfy { $0.sessionID == "restored" }) + } + + @Test("targeted rebind stays bounded with ten thousand other sessions") + func targetedRebindPerformance() throws { + let fixture = try Fixture() + let records = try (0..<10_000).map { index in + try fixture.record(sessionID: "session-\(index)", updatedAt: Double(index), generation: 1) + } + try fixture.registry.apply(provider: "codex", records: records) + + let clock = ContinuousClock() + let elapsed = try clock.measure { + let patched = try fixture.registry.patchRecordRebindingActiveSlots( + provider: "codex", + sessionID: "session-5000", + updatedAt: 20_000, + previousSlots: [], + activeSlots: [ + .init(scope: .workspace, scopeID: "new-workspace"), + .init(scope: .surface, scopeID: "new-surface"), + ] + ) { object in + object["workspaceId"] = "new-workspace" + object["surfaceId"] = "new-surface" + object["updatedAt"] = 20_000 + } + #expect(patched == .patched) + } + #expect(elapsed < .seconds(1)) + } + + @Test("one thousand pre-imported hibernated rows adopt sequentially within a bounded interval") + func restoredHibernationBatchAdoptionPerformance() throws { + let fixture = try Fixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let legacyURL = fixture.directory.appendingPathComponent("codex-hook-sessions.json") + var sessions: [String: Any] = [:] + var workspaceSlots: [String: Any] = [:] + var surfaceSlots: [String: Any] = [:] + for index in 0..<1_000 { + let sessionID = "session-\(index)" + let oldWorkspace = "old-workspace-\(index)" + let oldSurface = "old-surface-\(index)" + sessions[sessionID] = [ + "sessionId": sessionID, + "workspaceId": oldWorkspace, + "surfaceId": oldSurface, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 1.0, + "updatedAt": 2.0, + ] + let slot: [String: Any] = ["sessionId": sessionID, "updatedAt": 2.0] + workspaceSlots[oldWorkspace] = slot + surfaceSlots[oldSurface] = slot + } + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": sessions, + "activeSessionsByWorkspace": workspaceSlots, + "activeSessionsBySurface": surfaceSlots, + ], options: [.sortedKeys]).write(to: legacyURL, options: .atomic) + let preflight = try fixture.registry.refreshLegacySources([ + .init(provider: "codex", url: legacyURL), + ]) + #expect(preflight.refreshedProviders == ["codex"]) + + let elapsed = try ContinuousClock().measure { + try fixture.registry.withLegacySourceRebindBatch( + provider: "codex", + legacyURL: legacyURL + ) { batch in + for index in 0..<1_000 { + let sessionID = "session-\(index)" + let oldWorkspace = "old-workspace-\(index)" + let oldSurface = "old-surface-\(index)" + let newWorkspace = "new-workspace-\(index)" + let newSurface = "new-surface-\(index)" + let result = try batch.patchRecordRebindingActiveSlots( + provider: "codex", + sessionID: sessionID, + updatedAt: 3.0, + previousSlots: [ + .init(scope: .workspace, scopeID: oldWorkspace), + .init(scope: .surface, scopeID: oldSurface), + ], + activeSlots: [ + .init(scope: .workspace, scopeID: newWorkspace), + .init(scope: .surface, scopeID: newSurface), + ], + shouldMutate: { + $0["sessionState"] as? String == "hibernated" + && $0["workspaceId"] as? String == oldWorkspace + && $0["surfaceId"] as? String == oldSurface + } + ) { object in + object["workspaceId"] = newWorkspace + object["surfaceId"] = newSurface + object["updatedAt"] = 3.0 + } + #expect(result == .patched) + } + } + } + + print("restore sequential 1000-row adoption elapsed: \(elapsed)") + #expect(elapsed < .seconds(1)) + let snapshot = try fixture.registry.snapshot(provider: "codex") + #expect(snapshot.records.count == 1_000) + #expect(snapshot.activeSlots.count == 2_000) + #expect(snapshot.activeSlots.allSatisfy { $0.scopeID.hasPrefix("new-") }) + } + + @Test("targeted rebind spends only one busy timeout waiting for a writer") + func targetedRebindContentionIsBounded() throws { + let fixture = try Fixture(busyTimeoutMilliseconds: 5) + try fixture.registry.apply(provider: "codex", records: [ + try fixture.record(sessionID: "blocked", updatedAt: 10, generation: 1), + ]) + var database: OpaquePointer? + #expect(sqlite3_open(fixture.registry.url.path, &database) == SQLITE_OK) + let writer = try #require(database) + defer { sqlite3_close(writer) } + #expect(sqlite3_exec(writer, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK) + defer { sqlite3_exec(writer, "ROLLBACK", nil, nil, nil) } + + let clock = ContinuousClock() + let elapsed = clock.measure { + #expect(throws: (any Error).self) { + try fixture.registry.patchRecordRebindingActiveSlots( + provider: "codex", + sessionID: "blocked", + updatedAt: 20, + previousSlots: [], + activeSlots: [] + ) { object in + object["updatedAt"] = 20 + } + } + } + + #expect(elapsed < .seconds(1)) + } + + @Test("one thousand indexed session rows load within a bounded interval") + func indexedSnapshotPerformance() throws { + let fixture = try Fixture() + let records = try (0..<1_000).map { index in + try fixture.record(sessionID: "session-\(index)", updatedAt: Double(index), generation: 1) + } + try fixture.registry.apply(provider: "codex", records: records) + + let clock = ContinuousClock() + let elapsed = try clock.measure { + let snapshot = try fixture.registry.snapshot(provider: "codex") + #expect(snapshot.records.count == 1_000) + } + #expect(elapsed < .seconds(1)) + } + + @Test("disjoint hook mutations do not lose writes at the retention boundary") + func disjointMutationsAtRetentionBoundary() async throws { + let fixture = try Fixture(busyTimeoutMilliseconds: 25) + let records = try (0..<10_000).map { index in + try fixture.record(sessionID: "session-\(index)", updatedAt: Double(index), generation: 1) + } + try fixture.registry.apply(provider: "codex", records: records) + let registry = fixture.registry + + let successes = await withTaskGroup(of: Bool.self, returning: Int.self) { group in + for index in 0..<16 { + group.addTask { + do { + return try registry.mutateSnapshot(provider: "codex") { snapshot in + guard let recordIndex = snapshot.records.firstIndex(where: { + $0.sessionID == "session-\(index)" + }) else { return false } + snapshot.records[recordIndex].updatedAt = 20_000 + Double(index) + return true + } + } catch { + return false + } + } + } + var count = 0 + for await success in group where success { count += 1 } + return count + } + + #expect(successes == 16) + let stored = try fixture.registry.snapshot(provider: "codex") + let updated = stored.records.filter { $0.updatedAt >= 20_000 } + #expect(updated.count == 16) + } + + @Test("concurrent inserts replay retention decisions from the latest membership") + func concurrentInsertsReplayRetentionDecision() async throws { + let fixture = try Fixture(busyTimeoutMilliseconds: 25) + let records = try (0..<9_999).map { index in + try fixture.record(sessionID: "session-\(index)", updatedAt: Double(index), generation: 1) + } + try fixture.registry.apply(provider: "codex", records: records) + let additions = try (0..<2).map { index in + try fixture.record( + sessionID: "concurrent-\(index)", + updatedAt: 20_000 + Double(index), + generation: 1 + ) + } + let registry = fixture.registry + let rendezvous = FirstMutationRendezvous(participantCount: additions.count) + + let successes = await withTaskGroup(of: Bool.self, returning: Int.self) { group in + for addition in additions { + group.addTask { + var isFirstAttempt = true + do { + return try registry.mutateSnapshot(provider: "codex") { snapshot in + if isFirstAttempt { + isFirstAttempt = false + rendezvous.wait() + } + snapshot.records.append(addition) + if snapshot.records.count > 10_000, + let oldest = snapshot.records.min(by: { $0.updatedAt < $1.updatedAt }) { + snapshot.records.removeAll { $0.sessionID == oldest.sessionID } + } + return true + } + } catch { + return false + } + } + } + var count = 0 + for await success in group where success { count += 1 } + return count + } + + #expect(successes == additions.count) + let stored = try fixture.registry.snapshot(provider: "codex") + #expect(stored.records.count == 10_000) + #expect(Set(stored.records.map(\.sessionID)).isSuperset(of: additions.map(\.sessionID))) + } + + @Test("same-session mutations replay without losing increments") + func sameSessionMutationsReplay() async throws { + let fixture = try Fixture(busyTimeoutMilliseconds: 25) + try fixture.registry.apply(provider: "codex", records: [ + try fixture.record(sessionID: "shared", updatedAt: 0, generation: 1), + ]) + let registry = fixture.registry + let mutationCount = 8 + let rendezvous = FirstMutationRendezvous(participantCount: mutationCount) + + let successes = await withTaskGroup(of: Bool.self, returning: Int.self) { group in + for _ in 0.. [String: Any] { + [ + "sessionId": sessionID, + "workspaceId": "11111111-1111-1111-1111-111111111111", + "surfaceId": "22222222-2222-2222-2222-222222222222", + "startedAt": 1, + "updatedAt": updatedAt, + ] + } + + func record( + sessionID: String, + updatedAt: TimeInterval, + generation: Int, + extra: [String: Any] = [:] + ) throws -> CmuxAgentSessionRegistry.Record { + let object = object(sessionID: sessionID, updatedAt: updatedAt).merging(extra) { _, new in new } + return CmuxAgentSessionRegistry.Record( + provider: "test", + sessionID: sessionID, + updatedAt: updatedAt, + writerGeneration: generation, + json: try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + ) + } + + func slot( + provider: String, + scope: CmuxAgentSessionRegistry.Scope, + scopeID: String, + sessionID: String, + updatedAt: TimeInterval, + generation: Int = CmuxAgentSessionRegistry.currentWriterGeneration, + extra: [String: Any] = [:] + ) throws -> CmuxAgentSessionRegistry.ActiveSlot { + let object: [String: Any] = [ + "sessionId": sessionID, + "updatedAt": updatedAt, + ].merging(extra) { _, new in new } + return CmuxAgentSessionRegistry.ActiveSlot( + provider: provider, + scope: scope, + scopeID: scopeID, + sessionID: sessionID, + updatedAt: updatedAt, + writerGeneration: generation, + json: try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + ) + } + + func legacyStore(sessions: [String: [String: Any]]) throws -> Data { + try JSONSerialization.data(withJSONObject: ["version": 2, "sessions": sessions], options: [.sortedKeys]) + } + } + + private final class FirstMutationRendezvous: @unchecked Sendable { + private let condition = NSCondition() + private var remaining: Int + + init(participantCount: Int) { + remaining = participantCount + } + + func wait() { + condition.lock() + remaining -= 1 + if remaining == 0 { + condition.broadcast() + } else { + let deadline = Date(timeIntervalSinceNow: 1) + while remaining > 0, condition.wait(until: deadline) {} + } + condition.unlock() + } + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAgentSessionRunAuthorityProjectionTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAgentSessionRunAuthorityProjectionTests.swift new file mode 100644 index 000000000000..bd5af8610bcf --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAgentSessionRunAuthorityProjectionTests.swift @@ -0,0 +1,192 @@ +import Foundation +import Testing +@testable import CmuxFoundation + +@Suite("Agent session run authority projection") +struct CmuxAgentSessionRunAuthorityProjectionTests { + @Test("spawned runs cannot retain restore authority") + func spawnedRunCannotRetainRestoreAuthority() throws { + let spawned = run(relationship: .spawned) + + let projection = CmuxAgentSessionRunAuthorityProjection().projection( + recordRestoreAuthority: true, + runs: [spawned], + activeRunId: spawned.runId + ) + + #expect(!projection.restoreAuthority) + #expect(try #require(projection.run).restoreAuthority == false) + } + + @Test("all child evidence prevents restore authority") + func childEvidencePreventsRestoreAuthority() throws { + let childEvidence: [CmuxAgentSessionRunAuthorityProjection.AuthorityEvidence] = [ + .managedChild, + .explicitSpawnedChild, + .verifiedAncestorChild, + .provisionalAmbiguousChild, + .legacyChild, + ] + + for evidence in childEvidence { + let child = run(authorityEvidence: evidence) + let projection = CmuxAgentSessionRunAuthorityProjection().projection( + recordRestoreAuthority: true, + runs: [child], + activeRunId: child.runId + ) + #expect(!projection.restoreAuthority, Comment(rawValue: evidence.rawValue)) + #expect( + try #require(projection.run).restoreAuthority == false, + Comment(rawValue: evidence.rawValue) + ) + } + } + + @Test("equal-time spawned evidence demotes either duplicate order") + func spawnedDuplicateDemotesEitherOrder() throws { + let root = run() + let spawned = run(relationship: .spawned, authorityEvidence: .managedChild) + + for runs in [[root, spawned], [spawned, root]] { + let projection = CmuxAgentSessionRunAuthorityProjection().projection( + recordRestoreAuthority: true, + runs: runs, + activeRunId: root.runId + ) + #expect(!projection.restoreAuthority) + #expect(try #require(projection.run).restoreAuthority == false) + } + } + + @Test("verified fork roots retain explicit restore authority") + func verifiedForkRootRetainsAuthority() { + let root = run(relationship: .forked, authorityEvidence: .verifiedForkRoot) + + let projection = CmuxAgentSessionRunAuthorityProjection().projection( + recordRestoreAuthority: false, + runs: [root], + activeRunId: root.runId + ) + + #expect(projection.restoreAuthority) + } + + @Test("later duplicate cannot revive durable child authority") + func laterDuplicateCannotReviveDurableChildAuthority() throws { + var laterRoot = run(relationship: .forked, authorityEvidence: .verifiedForkRoot) + laterRoot.updatedAt = 300 + + for (evidence, expectedEvidence): ( + CmuxAgentSessionRunAuthorityProjection.AuthorityEvidence?, + CmuxAgentSessionRunAuthorityProjection.AuthorityEvidence + ) in [(.managedChild, .managedChild), (nil, .legacyChild)] { + var child = run(relationship: .spawned, authorityEvidence: evidence) + child.restoreAuthority = false + child.updatedAt = 200 + for runs in [[child, laterRoot], [laterRoot, child]] { + let projection = CmuxAgentSessionRunAuthorityProjection().projection( + recordRestoreAuthority: true, + runs: runs, + activeRunId: child.runId + ) + let projectedRun = try #require(projection.run) + #expect(!projection.restoreAuthority) + #expect(projectedRun.relationship == .spawned) + #expect(projectedRun.authorityEvidence == expectedEvidence) + } + } + } + + @Test("later verified fork root recovers provisional child authority") + func laterVerifiedForkRootRecoversProvisionalAuthority() throws { + var provisional = run( + relationship: .spawned, + authorityEvidence: .provisionalAmbiguousChild + ) + provisional.restoreAuthority = false + provisional.updatedAt = 200 + var verifiedRoot = run(relationship: .forked, authorityEvidence: .verifiedForkRoot) + verifiedRoot.updatedAt = 300 + + let projection = CmuxAgentSessionRunAuthorityProjection().projection( + recordRestoreAuthority: false, + runs: [verifiedRoot, provisional], + activeRunId: verifiedRoot.runId + ) + let projectedRun = try #require(projection.run) + #expect(projection.restoreAuthority) + #expect(projectedRun.relationship == .forked) + #expect(projectedRun.authorityEvidence == .verifiedForkRoot) + } + + @Test("provisional child authority needs complete fork-root proof to recover") + func provisionalAuthorityNeedsCompleteForkRootProof() throws { + var provisional = run( + relationship: .spawned, + authorityEvidence: .provisionalAmbiguousChild + ) + provisional.restoreAuthority = false + provisional.updatedAt = 200 + + for (relationship, evidence): ( + CmuxAgentSessionRunAuthorityProjection.Relationship?, + CmuxAgentSessionRunAuthorityProjection.AuthorityEvidence? + ) in [(nil, nil), (.forked, nil), (nil, .verifiedForkRoot)] { + var incompleteRoot = run( + relationship: relationship, + authorityEvidence: evidence + ) + incompleteRoot.updatedAt = 300 + let projection = CmuxAgentSessionRunAuthorityProjection().projection( + recordRestoreAuthority: true, + runs: [provisional, incompleteRoot], + activeRunId: provisional.runId + ) + let projectedRun = try #require(projection.run) + #expect(!projection.restoreAuthority) + #expect(projectedRun.relationship == .spawned) + #expect(projectedRun.authorityEvidence == .provisionalAmbiguousChild) + } + } + + @Test("legacy records project top-level child evidence") + func legacyRecordProjectsTopLevelChildEvidence() throws { + for fields: [String: Any] in [ + ["restoreAuthority": true, "relationship": "spawned"], + ["restoreAuthority": true, "authorityEvidence": "managed_child"], + ["restoreAuthority": true, "authorityEvidence": "provisional_ambiguous_child"], + ["restoreAuthority": true, "completedAt": 200], + ] { + let data = try JSONSerialization.data(withJSONObject: fields) + #expect( + CmuxAgentSessionRunAuthorityProjection() + .projection(recordJSON: data)?.restoreAuthority == false + ) + } + + let verifiedRoot = try JSONSerialization.data(withJSONObject: [ + "restoreAuthority": true, + "relationship": "forked", + "authorityEvidence": "verified_fork_root", + ]) + #expect( + CmuxAgentSessionRunAuthorityProjection() + .projection(recordJSON: verifiedRoot)?.restoreAuthority == true + ) + } + + private func run( + relationship: CmuxAgentSessionRunAuthorityProjection.Relationship? = nil, + authorityEvidence: CmuxAgentSessionRunAuthorityProjection.AuthorityEvidence? = nil + ) -> CmuxAgentSessionRunAuthorityProjection.Run { + .init( + runId: "run", + relationship: relationship, + restoreAuthority: true, + authorityEvidence: authorityEvidence, + startedAt: 100, + updatedAt: 200 + ) + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index 024a955b692f..03e8f7d5350a 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -22,6 +22,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { private var pendingReasonsById: [UUID: String] = [:] #endif private var queuedRequests: [TerminalSurfaceRuntimeTeardownRequest] = [] + private var queuedRequestReadIndex = 0 private var isWorkerRunning = false /// Creates the process's teardown coordinator. @@ -37,6 +38,12 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { request.read() } + /// Reads plain active-screen evidence on the same serialized native lane as + /// surface teardown, so the borrowed pointer cannot be freed mid-read. + func readActiveScreenTailText(_ request: TerminalSurfaceRuntimeVisibleTextRequest) -> String? { + request.read() + } + /// Queues a native-surface free from any isolation (the surface model's /// `deinit` is nonisolated and cannot await). /// @@ -72,6 +79,28 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { ) } + /// Conditionally frees a hibernating runtime surface on the serialized + /// native teardown lane. + /// + /// The final validation runs after every earlier queued teardown and + /// immediately before the native free. When validation rejects, the + /// coordinator neither frees the surface nor releases its callback + /// userdata, allowing the caller to restore the exact live runtime. + /// + /// - Parameter request: The temporary native-runtime ownership transfer, + /// including its final validation and free operation. + /// - Returns: `true` only after the native free and all userdata releases + /// have completed; `false` when final validation rejected the request. + func freeRuntimeSurfaceForAgentHibernation( + _ request: TerminalSurfaceRuntimeTeardownRequest + ) async -> TerminalSurfaceRuntimeHibernationTeardownResult { + await withCheckedContinuation { continuation in + var request = request + request.completion = continuation + enqueue(request) + } + } + /// Queues a native-surface free that also transports the surface's other /// retained callback userdata. /// @@ -134,29 +163,63 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { Task { await self.observeTimeout(id: request.id) } - await Self.free(request) + let didFree = await Self.free(request) await self.complete(id: request.id) + request.completion?.resume(returning: didFree) } } } } private func nextRequestForWorker() -> TerminalSurfaceRuntimeTeardownRequest? { - guard !queuedRequests.isEmpty else { + guard queuedRequestReadIndex < queuedRequests.count else { + queuedRequests.removeAll(keepingCapacity: true) + queuedRequestReadIndex = 0 isWorkerRunning = false return nil } - return queuedRequests.removeFirst() + let request = queuedRequests[queuedRequestReadIndex] + queuedRequestReadIndex += 1 + if queuedRequestReadIndex == queuedRequests.count { + queuedRequests.removeAll(keepingCapacity: true) + queuedRequestReadIndex = 0 + } + return request } - private nonisolated static func free(_ request: TerminalSurfaceRuntimeTeardownRequest) async { + private nonisolated static func free( + _ request: TerminalSurfaceRuntimeTeardownRequest + ) async -> TerminalSurfaceRuntimeHibernationTeardownResult { + if let finalValidation = request.finalValidation { + let isValid = await finalValidation() + guard isValid else { return .rejected(finalizer: nil) } + } + let finalizer: (@Sendable () -> Void)? + if let finalTeardownPreparation = request.finalTeardownPreparation { + guard let preparedFinalizer = finalTeardownPreparation() else { + return .rejected(finalizer: nil) + } + finalizer = preparedFinalizer + } else { + finalizer = nil + } + if let finalCommit = request.finalCommit, !finalCommit() { + // Do not run the finalizer here. The surface and callback ownership + // are still provisional; the main-actor owner must restore all of + // them and flush queued traffic before relinquishing authority. + return .rejected(finalizer: finalizer) + } #if DEBUG logDebugEvent( "surface.lifecycle.nativeFree.begin surface=\(request.surfaceToken) " + "workspace=\(request.workspaceToken) reason=\(request.reason)" ) #endif - request.freeSurface(request.surface) + // Keep last-mile authority continuously held from its synchronous + // preparation through native free. The helper returns only after the + // finalizer runs, so no `await` can suspend while that authority is + // active (the app uses it to SIGSTOP/SIGCONT the exact shell generation). + freeAndFinalize(request, finalizer: finalizer) if request.callbackContext != nil || request.manualIOContext != nil || request.byteTeeLease != nil { // The request is the @unchecked Sendable transport for the // Unmanaged contexts; release through the request so the @Sendable @@ -177,6 +240,15 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { "workspace=\(request.workspaceToken) reason=\(request.reason)" ) #endif + return .freed + } + + private nonisolated static func freeAndFinalize( + _ request: TerminalSurfaceRuntimeTeardownRequest, + finalizer: (@Sendable () -> Void)? + ) { + defer { finalizer?() } + request.freeSurface(request.surface) } private func complete(id: UUID) { diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift index 3395648179c5..9819d2ce6f49 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift @@ -2,14 +2,21 @@ public import Foundation public import GhosttyKit public import CmuxTerminalCore -/// A one-shot native-surface free queued on the teardown coordinator. +enum TerminalSurfaceRuntimeHibernationTeardownResult: @unchecked Sendable { + case freed + case rejected(finalizer: (@Sendable () -> Void)?) +} + +/// A native-surface teardown queued on the teardown coordinator. /// /// The native pointer has been removed from all main-thread owner state /// before this request is created; this wrapper only transports the one-shot /// free. It is `@unchecked Sendable` for exactly that reason: the surface /// pointer, the `Unmanaged` callback contexts, and the byte-tee lease are -/// exclusively owned by the request from creation until the coordinator -/// consumes them. +/// exclusively owned by an unconditional request from creation until the +/// coordinator consumes them. Agent-hibernation requests temporarily transfer +/// the same resources to the coordinator; failed final validation returns that +/// ownership to the live ``TerminalSurface`` without releasing anything. /// /// The transported callback userdata (`callbackContext`, `manualIOContext`, /// `byteTeeLease`) is released only after `freeSurface` returns: the native @@ -24,6 +31,17 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { let callbackContext: Unmanaged? let manualIOContext: Unmanaged? let byteTeeLease: (any TerminalByteTeeLease)? + let finalValidation: (@Sendable () async -> Bool)? + /// Synchronous last-mile preparation run after async validation and + /// immediately before native free. A successful preparation returns a + /// one-shot finalizer that the coordinator invokes synchronously after + /// `freeSurface` and before its next suspension point. + let finalTeardownPreparation: (@Sendable () -> (@Sendable () -> Void)?)? + /// Synchronous durable authority commit performed after last-mile + /// preparation. Rejection returns the prepared finalizer to the main-actor + /// owner so it can restore native ownership before relinquishing authority. + let finalCommit: (@Sendable () -> Bool)? + var completion: CheckedContinuation? let freeSurface: @Sendable (ghostty_surface_t) -> Void #if DEBUG let surfaceToken: String @@ -38,6 +56,10 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { callbackContext: Unmanaged?, manualIOContext: Unmanaged?, byteTeeLease: (any TerminalByteTeeLease)?, + finalValidation: (@Sendable () async -> Bool)? = nil, + finalTeardownPreparation: (@Sendable () -> (@Sendable () -> Void)?)? = nil, + finalCommit: (@Sendable () -> Bool)? = nil, + completion: CheckedContinuation? = nil, freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void ) { self.id = id @@ -47,6 +69,10 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { self.callbackContext = callbackContext self.manualIOContext = manualIOContext self.byteTeeLease = byteTeeLease + self.finalValidation = finalValidation + self.finalTeardownPreparation = finalTeardownPreparation + self.finalCommit = finalCommit + self.completion = completion self.freeSurface = freeSurface #if DEBUG self.surfaceToken = String(id.uuidString.prefix(5)) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeVisibleTextRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeVisibleTextRequest.swift new file mode 100644 index 000000000000..2bb8e623683c --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeVisibleTextRequest.swift @@ -0,0 +1,40 @@ +internal import GhosttyKit + +/// A row-bounded plain-text read serialized with native surface teardown. +/// +/// The request selects only the current active-screen suffix. Scrollback and VT +/// styling never cross this boundary. `@unchecked Sendable` is limited to +/// transporting the borrowed surface pointer onto the teardown coordinator. +struct TerminalSurfaceRuntimeVisibleTextRequest: @unchecked Sendable { + let surface: ghostty_surface_t + let startRow: UInt32 + let maxBytes: Int + + func read() -> String? { + let selection = ghostty_selection_s( + top_left: ghostty_point_s( + tag: GHOSTTY_POINT_ACTIVE, + coord: GHOSTTY_POINT_COORD_EXACT, + x: 0, + y: startRow + ), + bottom_right: ghostty_point_s( + tag: GHOSTTY_POINT_ACTIVE, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0 + ), + rectangle: false + ) + var text = ghostty_text_s() + guard ghostty_surface_read_text(surface, selection, &text) else { return nil } + defer { ghostty_surface_free_text(surface, &text) } + + guard let byteCount = Int(exactly: text.text_len), byteCount <= maxBytes else { return nil } + guard byteCount > 0, let bytes = text.text else { return "" } + return String( + decoding: UnsafeRawBufferPointer(start: bytes, count: byteCount), + as: UTF8.self + ) + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalByteTeeBinding.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalByteTeeBinding.swift index 462120d92f41..30fe76ece42f 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalByteTeeBinding.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalByteTeeBinding.swift @@ -22,17 +22,21 @@ public protocol TerminalByteTeeBinding: AnyObject, Sendable { /// - surface: The live runtime surface. /// - workspaceID: The workspace that owns the surface. /// - surfaceID: The owning surface id used to key tee state. + /// - surfaceGeneration: The native runtime lifetime being observed. /// - Returns: The retained lease the caller releases on teardown. @MainActor func installTee( on surface: ghostty_surface_t, workspaceID: UUID, - surfaceID: UUID + surfaceID: UUID, + surfaceGeneration: UInt64 ) -> any TerminalByteTeeLease /// Drops all tee/replay state keyed by a surface id. /// - /// - Parameter surfaceID: The surface id being torn down. + /// - Parameters: + /// - surfaceID: The surface id being torn down. + /// - surfaceGeneration: The native runtime lifetime being torn down. @MainActor - func dropSurface(surfaceID: UUID) + func dropSurface(surfaceID: UUID, surfaceGeneration: UInt64) } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift index bfb2fe568e8b..3f06ea43df8a 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift @@ -19,6 +19,10 @@ extension TerminalSurface { /// The managed `COLORTERM` value exported to spawned shells. public static let managedColorTerm = "truecolor" + /// One opaque identifier per cmux app process. Agent hooks persist it so + /// default CLI queries can exclude history from other running builds. + public static let managedCmuxRuntimeId = UUID().uuidString + private static let inheritedClaudeAuthSelectionEnvironmentKeys: Set = [ "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", @@ -47,13 +51,16 @@ extension TerminalSurface { to environment: inout [String: String], protectedKeys: inout Set ) { - let values = [ + var values = [ "CMUX_SURFACE_ID": context.surfaceId.uuidString, "CMUX_WORKSPACE_ID": context.workspaceId.uuidString, "CMUX_PANEL_ID": context.surfaceId.uuidString, "CMUX_TAB_ID": context.workspaceId.uuidString, "CMUX_SOCKET_PATH": context.socketPath ] + if let runtimeId = context.runtimeId, !runtimeId.isEmpty { + values["CMUX_RUNTIME_ID"] = runtimeId + } for (key, value) in values { environment[key] = value diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift index 5ca080191c3e..b89b702b1cb3 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift @@ -9,9 +9,15 @@ internal import CMUXDebugLog // MARK: - Socket/API input: send paths, pending queues, parsing extension TerminalSurface { + @MainActor + private var shouldQueueInputForAgentHibernationTransition: Bool { + runtimeSurfaceHibernationTeardownInFlight || runtimeSurfaceHibernationOwnerCommitPending + } + /// Notifies the pane host that user-initiated terminal input is about to be sent. @MainActor public func didReceiveExplicitInput() { + invalidateProvisionalAgentHibernation() paneHost.terminalSurfaceDidReceiveExplicitInput() } @@ -51,6 +57,13 @@ extension TerminalSurface { guard let data = text.data(using: .utf8), !data.isEmpty else { return true } didReceiveExplicitInput() guard surface != nil else { + if shouldQueueInputForAgentHibernationTransition { + let queued = enqueuePendingSocketInput(.pasteText(data)) + if queued { + hibernationRecorder.recordTerminalInput(workspaceId: tabId, panelId: id) + } + return queued + } guard allowsRuntimeSurfaceCreation() else { return false } let queued = enqueuePendingSocketInput(.pasteText(data)) if queued { @@ -76,6 +89,15 @@ extension TerminalSurface { public func sendKeyText(_ text: String) -> Bool { guard !text.isEmpty else { return true } didReceiveExplicitInput() + if shouldQueueInputForAgentHibernationTransition, + let data = text.data(using: .utf8), + !data.isEmpty { + let queued = enqueuePendingSocketInput(.inputText(data)) + if queued { + hibernationRecorder.recordTerminalInput(workspaceId: tabId, panelId: id) + } + return queued + } guard let liveSurface = liveSurfaceForSocketWrite(reason: "socket.sendKeyText") else { return false } @@ -102,6 +124,11 @@ extension TerminalSurface { guard let event = pendingKeyEvent(for: keyName) else { return .unknownKey } didReceiveExplicitInput() guard surface != nil else { + if shouldQueueInputForAgentHibernationTransition { + guard enqueuePendingSocketInput(.key(event)) else { return .inputQueueFull } + hibernationRecorder.recordTerminalInput(workspaceId: tabId, panelId: id) + return .queued + } guard allowsRuntimeSurfaceCreation() else { return .surfaceUnavailable } guard enqueuePendingSocketInput(.key(event)) else { return .inputQueueFull } hibernationRecorder.recordTerminalInput(workspaceId: tabId, panelId: id) @@ -161,6 +188,13 @@ extension TerminalSurface { guard !text.isEmpty else { return .sent } didReceiveExplicitInput() guard surface != nil else { + if shouldQueueInputForAgentHibernationTransition { + let queued = enqueuePendingSocketInput(text) + if queued { + hibernationRecorder.recordTerminalInput(workspaceId: tabId, panelId: id) + } + return queued ? .queued : .inputQueueFull + } guard allowsRuntimeSurfaceCreation() else { return .surfaceUnavailable } let queued = enqueuePendingSocketInput(text) if queued { @@ -762,6 +796,13 @@ extension TerminalSurface { pendingSocketInputBytes = 0 guard !queued.isEmpty else { return } +#if DEBUG + if let pendingSocketInputFlushOverrideForTesting { + pendingSocketInputFlushOverrideForTesting(queued.count, queuedBytes) + return + } +#endif + var queuedKeys = 0 for item in queued { switch item { diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift index 0881649c125b..98e33fed5669 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -172,7 +172,9 @@ extension TerminalSurface { } func allowsRuntimeSurfaceCreation() -> Bool { - portalLifecycleState == .live && !runtimeSurfaceSuspendedForAgentHibernation + portalLifecycleState == .live && + !runtimeSurfaceSuspendedForAgentHibernation && + !runtimeSurfaceHibernationTeardownInFlight } private var hasDeferredStartupWork: Bool { @@ -231,17 +233,24 @@ extension TerminalSurface { public func teardownSurface() { recordTeardownRequest(reason: "surface.teardown") markPortalLifecycleClosed(reason: "teardown") + runtimeSurfaceHibernationOwnerCommitPending = false backgroundSurfaceStartSource = .normal cancelClaudeCommandShimInstallLifecycle() closeHeadlessStartupWindowIfNeeded() + // The async hibernation path has exclusive ownership of the native + // pointer and callback userdata until its coordinator request resolves. + // Sealing the portal is enough here; that path observes the closed + // lifecycle and completes the native free instead of restoring it. + guard !runtimeSurfaceHibernationTeardownInFlight else { return } + let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil let manualIOContext = manualIOContext self.manualIOContext = nil let teeLease = mobileByteTeeLease mobileByteTeeLease = nil - byteTee.dropSurface(surfaceID: id) + byteTee.dropSurface(surfaceID: id, surfaceGeneration: runtimeSurfaceGeneration) let surfaceToFree = surface if let surfaceToFree { @@ -295,42 +304,61 @@ extension TerminalSurface { } } - /// Frees the runtime surface while keeping the model alive for an - /// agent-hibernation resume. + /// Atomically frees the runtime surface while keeping the model alive for + /// an agent-hibernation resume. + /// + /// The native pointer and its callback userdata are provisionally removed + /// from the model while `finalValidation` runs on the serialized teardown + /// lane. Rejected validation restores the exact pointer and userdata with + /// the same runtime generation. A portal close during validation instead + /// completes the free and returns `false`, so a closing surface is never + /// resurrected. + /// + /// - Parameters: + /// - reason: The teardown reason, for diagnostics. + /// - finalValidation: Async safety work completed before the non-suspending + /// native-free handoff. + /// - finalTeardownPreparation: Synchronous last safety gate. Success + /// returns a finalizer kept alive through native free and invoked before + /// the coordinator can suspend again. + /// - finalCommit: Durable commit performed only after local invalidation + /// has been atomically closed and last-mile preparation succeeds. A + /// rejection runs the finalizer and restores the live runtime. + /// - Returns: `true` only after native free and callback-userdata release + /// complete and the model commits its suspended state. @MainActor - public func suspendRuntimeSurfaceForAgentHibernation(reason: String) { - runtimeSurfaceSuspendedForAgentHibernation = true - backgroundSurfaceStartQueued = false - backgroundSurfaceStartSource = .normal - cancelClaudeCommandShimInstallLifecycle() - closeHeadlessStartupWindowIfNeeded() + public func suspendRuntimeSurfaceForAgentHibernation( + reason: String, + finalValidation: @escaping @Sendable () async -> Bool, + finalTeardownPreparation: @escaping @Sendable () -> (@Sendable () -> Void)? = { {} }, + finalCommit: @escaping @Sendable () -> Bool = { true } + ) async -> Bool { + guard portalLifecycleState == .live, + !runtimeSurfaceHibernationTeardownInFlight, + let surfaceToFree = surface else { + return false + } + + runtimeSurfaceHibernationOwnerCommitPending = false + let validationGate = TerminalSurfaceHibernationValidationGate() + runtimeSurfaceHibernationValidationGate = validationGate + runtimeSurfaceHibernationTeardownInFlight = true + let transferredGeneration = runtimeSurfaceGeneration let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil let manualIOContext = manualIOContext self.manualIOContext = nil let teeLease = mobileByteTeeLease mobileByteTeeLease = nil - byteTee.dropSurface(surfaceID: id) - - let surfaceToFree = surface - if let surfaceToFree { - registry.unregisterRuntimeSurface(surfaceToFree, ownerId: id) - } - surface = nil - activePortalHostLease = nil - portalHostAuthority = nil + registry.unregisterRuntimeSurface(surfaceToFree, ownerId: id) + let transferredSurface = transferRuntimeSurfaceForAgentHibernation() + precondition(transferredSurface == surfaceToFree) + // A queued vacancy retry may otherwise re-bind this model while its + // native pointer is provisionally owned by the teardown lane. Advancing + // the portal generation makes every pre-hibernation retry stale before + // final validation can suspend. clearPortalHostVacancyRetries() portalLifecycleGeneration &+= 1 - pendingSocketInputQueue.removeAll(keepingCapacity: false) - pendingSocketInputBytes = 0 - desiredFocusState = false - - guard let surfaceToFree else { - callbackContext?.release() - manualIOContext?.release() - teeLease?.release() - return - } #if DEBUG logDebugEvent( @@ -340,11 +368,17 @@ extension TerminalSurface { #endif #if DEBUG - if let freeSurface = Self.runtimeSurfaceFreeOverrideForTesting { - // Transport manualIOContext and teeLease through the request too: - // the coordinator releases all callback userdata only after the - // native free, which is what joins ghostty's IO threads. - runtimeTeardown.enqueueRuntimeTeardown( + let freeSurface = Self.runtimeSurfaceFreeOverrideForTesting ?? { surface in + ghostty_surface_free(surface) + } +#else + let freeSurface: @Sendable (ghostty_surface_t) -> Void = { surface in + ghostty_surface_free(surface) + } +#endif + + let teardownResult = await runtimeTeardown.freeRuntimeSurfaceForAgentHibernation( + TerminalSurfaceRuntimeTeardownRequest( id: id, workspaceId: tabId, reason: reason, @@ -352,28 +386,166 @@ extension TerminalSurface { callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease, + finalValidation: finalValidation, + finalTeardownPreparation: { + // Close package-local invalidation first. Input after this + // claim is queued; the app's last-mile preparation still + // performs its own lifecycle CAS after freezing the shell. + guard validationGate.claim(), + let finalizer = finalTeardownPreparation() else { + return nil + } + return finalizer + }, + finalCommit: finalCommit, freeSurface: freeSurface ) - return + ) + let didFree: Bool + let rejectedFinalizer: (@Sendable () -> Void)? + switch teardownResult { + case .freed: + didFree = true + rejectedFinalizer = nil + case .rejected(let finalizer): + didFree = false + rejectedFinalizer = finalizer } -#endif - Task { @MainActor in - ghostty_surface_free(surfaceToFree) - callbackContext?.release() - manualIOContext?.release() - teeLease?.release() + if !didFree, portalLifecycleState == .live { + restoreRuntimeSurfaceAfterRejectedAgentHibernation(surfaceToFree) + surfaceCallbackContext = callbackContext + self.manualIOContext = manualIOContext + mobileByteTeeLease = teeLease + registry.registerRuntimeSurface(surfaceToFree, ownerId: id) + runtimeSurfaceHibernationTeardownInFlight = false + runtimeSurfaceHibernationOwnerCommitPending = false + runtimeSurfaceHibernationValidationGate = nil + flushPendingRemoteOutput(to: surfaceToFree) + if pendingSocketInputBytes > 0 { + flushPendingSocketInputIfNeeded() + } + // The exact runtime, callback contexts, registry ownership, and + // queued traffic are live again before the shell can produce more + // output. The coordinator intentionally transferred this finalizer + // back instead of running it at durable-commit rejection. + rejectedFinalizer?() + return false + } + + if !didFree { + // Close won the race with final validation. The provisional + // resources still belong to this method, so send them back through + // the same serialized lane and await their unconditional free. + _ = await runtimeTeardown.freeRuntimeSurfaceForAgentHibernation( + TerminalSurfaceRuntimeTeardownRequest( + id: id, + workspaceId: tabId, + reason: "\(reason).portalClosed", + surface: surfaceToFree, + callbackContext: callbackContext, + manualIOContext: manualIOContext, + byteTeeLease: teeLease, + finalValidation: { true }, + finalTeardownPreparation: { + if let rejectedFinalizer { return rejectedFinalizer } + return {} + }, + freeSurface: freeSurface + ) + ) + } + + commitTransferredRuntimeSurfaceTeardown() + runtimeSurfaceHibernationTeardownInFlight = false + runtimeSurfaceHibernationValidationGate = nil + byteTee.dropSurface(surfaceID: id, surfaceGeneration: transferredGeneration) + backgroundSurfaceStartQueued = false + backgroundSurfaceStartSource = .normal + cancelClaudeCommandShimInstallLifecycle() + closeHeadlessStartupWindowIfNeeded() + activePortalHostLease = nil + portalHostAuthority = nil + if portalLifecycleState != .live { + pendingSocketInputQueue.removeAll(keepingCapacity: false) + pendingSocketInputBytes = 0 + } + desiredFocusState = false + + guard portalLifecycleState == .live, didFree else { + runtimeSurfaceSuspendedForAgentHibernation = false + runtimeSurfaceHibernationOwnerCommitPending = false + return false } + runtimeSurfaceSuspendedForAgentHibernation = true + runtimeSurfaceHibernationOwnerCommitPending = true + return true + } + + /// Revokes a provisional native hibernation transfer. + /// + /// Workspace shell, agent lifecycle, and tracked-process mutations call + /// this synchronously on the main actor. The internal one-shot gate remains + /// claimable until immediately before native free, so a mutation that lands + /// after the controller's outer token was claimed still rejects teardown. + @MainActor + public func invalidateProvisionalAgentHibernation() { + runtimeSurfaceHibernationValidationGate?.invalidate() } /// Marks the resume side of agent hibernation and primes the next runtime /// spawn's initial input. + @MainActor + public var canPrepareAgentHibernationResume: Bool { + portalLifecycleState == .live + && runtimeSurfaceSuspendedForAgentHibernation + && !runtimeSurfaceHibernationTeardownInFlight + } + @MainActor public func prepareAgentHibernationResume(initialInput: String?) { + guard canPrepareAgentHibernationResume else { return } runtimeSurfaceSuspendedForAgentHibernation = false + runtimeSurfaceHibernationOwnerCommitPending = false prepareNextRuntimeInitialInput(initialInput) } + /// Completes the handoff from native teardown to the pane owner's durable + /// hibernation state. Direct surface input must no longer queue after this + /// point because only the pane owner can resume the suspended runtime. + @MainActor + public func commitAgentHibernationOwnerState() { + runtimeSurfaceHibernationOwnerCommitPending = false + } + + /// Applies a persisted hibernation marker to a newly restored surface + /// model that has no native runtime to tear down. + /// + /// Session restore uses this state-only entry point instead of the live + /// hibernation teardown API. It fails closed if a native runtime already + /// exists or a live teardown owns one provisionally. + /// + /// - Returns: `true` when the model entered restored hibernation, or + /// `false` when its lifecycle was not safe for a state-only transition. + @MainActor + public func markRuntimeSurfaceSuspendedForRestoredAgentHibernation() -> Bool { + guard portalLifecycleState == .live, + surface == nil, + !runtimeSurfaceHibernationTeardownInFlight else { + return false + } + runtimeSurfaceSuspendedForAgentHibernation = true + runtimeSurfaceHibernationOwnerCommitPending = false + return true + } + + /// Whether explicit input arrived after the native hibernation commit + /// point and must trigger an immediate agent resume. + @MainActor + public var hasPendingInputForAgentHibernationResume: Bool { + pendingSocketInputBytes > 0 + } + /// Primes the initial input for the next runtime spawn only. public func prepareNextRuntimeInitialInput(_ input: String?) { let trimmedInput = input?.isEmpty == false ? input : nil @@ -570,7 +742,12 @@ extension TerminalSurface { // grid parity by construction. The lease is released alongside // `surfaceCallbackContext` when the surface tears down. mobileByteTeeLease?.release() - mobileByteTeeLease = byteTee.installTee(on: createdSurface, workspaceID: tabId, surfaceID: id) + mobileByteTeeLease = byteTee.installTee( + on: createdSurface, + workspaceID: tabId, + surfaceID: id, + surfaceGeneration: runtimeSurfaceGeneration + ) if runtimeInitialInput != nil { nextRuntimeInitialInput = nil } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift index c1c9f7aebb01..dc986d3c7ddc 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift @@ -1,4 +1,28 @@ extension TerminalSurface { + /// Reads plain rendered text from the newest active-screen rows. + /// + /// This deliberately excludes scrollback and terminal styling. Consumers + /// receive Ghostty's cell-model text rather than a VT serialization, so row + /// boundaries and control sequences cannot affect semantic matching. + @MainActor + public func boundedActiveScreenTailText(maxRows: Int, maxBytes: Int) async -> String? { + guard maxRows > 0, + maxBytes > 0, + let totalRows = rawSizingSample()?.rows, + totalRows > 0, + let surface = liveSurfaceForGhosttyAccess(reason: "boundedActiveScreenTailText"), + let startRow = UInt32(exactly: max(0, totalRows - min(totalRows, maxRows))) else { + return nil + } + return await runtimeTeardown.readActiveScreenTailText( + TerminalSurfaceRuntimeVisibleTextRequest( + surface: surface, + startRow: startRow, + maxBytes: maxBytes + ) + ) + } + /// Reads a byte-bounded VT reconstruction of the newest physical terminal rows. /// /// Ghostty selects the history suffix and formats it into a fixed-size buffer diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index ee3b17bab5df..a9735be18b13 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -7,6 +7,34 @@ public import CmuxTerminalCore internal import CMUXDebugLog #endif +/// One-shot arbitration between explicit input and a provisional native +/// hibernation free. The lock protects only the three-state handoff shared by +/// the main-actor input path and the utility teardown worker. +final class TerminalSurfaceHibernationValidationGate: @unchecked Sendable { + private enum State { + case valid + case invalidated + case claimed + } + + private let lock = NSLock() + private var state = State.valid + + func invalidate() { + lock.lock() + if state == .valid { state = .invalidated } + lock.unlock() + } + + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + guard state == .valid else { return false } + state = .claimed + return true + } +} + /// The owner of one `ghostty_surface_t` lifecycle: spawn inputs, runtime /// creation/teardown, pending input queues, portal-host leases, and renderer /// reclamation state. @@ -68,6 +96,30 @@ public final class TerminalSurface: Identifiable, ObservableObject { runtimeSurfaceGeneration &+= 1 } } + + /// Temporarily transfers the native pointer without ending its lifetime. + /// + /// Agent hibernation must be able to restore a rejected teardown with the + /// same generation, so its provisional detach cannot use ``surface``'s + /// lifetime-ending setter. + func transferRuntimeSurfaceForAgentHibernation() -> ghostty_surface_t? { + let transferredSurface = runtimeSurface + runtimeSurface = nil + return transferredSurface + } + + /// Restores a provisionally transferred native pointer without changing + /// its lifetime generation. + func restoreRuntimeSurfaceAfterRejectedAgentHibernation(_ restoredSurface: ghostty_surface_t) { + precondition(runtimeSurface == nil) + runtimeSurface = restoredSurface + } + + /// Commits the end of a provisionally transferred native lifetime. + func commitTransferredRuntimeSurfaceTeardown() { + precondition(runtimeSurface == nil) + runtimeSurfaceGeneration &+= 1 + } /// Monotonic lifetime identity for the native Ghostty surface. /// /// The generation advances whenever the runtime handle is installed or @@ -259,6 +311,9 @@ public final class TerminalSurface: Identifiable, ObservableObject { var restoredRuntimeSurfaceStartQueued = false var requiresRestoreSpawnPacing = false var runtimeSurfaceSuspendedForAgentHibernation = false + var runtimeSurfaceHibernationTeardownInFlight = false + var runtimeSurfaceHibernationOwnerCommitPending = false + var runtimeSurfaceHibernationValidationGate: TerminalSurfaceHibernationValidationGate? var headlessStartupWindow: NSWindow? var surfaceCallbackContext: Unmanaged? var claudeCommandShim: ClaudeCommandShim? @@ -294,6 +349,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { public internal(set) var clipboardReadGeneration = 0 #if DEBUG var needsConfirmCloseOverrideForTesting: Bool? + var pendingSocketInputFlushOverrideForTesting: ((Int, Int) -> Void)? var runtimeSurfaceFreedOutOfBandForTesting = false var runtimeSurfaceCreateAttemptCountForTesting = 0 // Same off-isolation-reader carve-out as debugMetadataLock. @@ -415,7 +471,8 @@ public final class TerminalSurface: Identifiable, ObservableObject { CmuxContextEnvironment( workspaceId: workspaceId, surfaceId: surfaceId, - socketPath: socketPath + socketPath: socketPath, + runtimeId: managedCmuxRuntimeId ) } @@ -605,8 +662,14 @@ public final class TerminalSurface: Identifiable, ObservableObject { // Dropping by id only clears the registry/replay state; releasing // `teeLease` on each exit path frees the userdata independently. let teeSurfaceID = id + let teeSurfaceGeneration = runtimeSurfaceGeneration let teeBinding = byteTee - Task { @MainActor in teeBinding.dropSurface(surfaceID: teeSurfaceID) } + Task { @MainActor in + teeBinding.dropSurface( + surfaceID: teeSurfaceID, + surfaceGeneration: teeSurfaceGeneration + ) + } // Nil out the surface pointer so any in-flight closures (e.g. geometry // reconcile dispatched via DispatchQueue.main.async) that read self.surface diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalByteTee.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalByteTee.swift index b0d444acd2c9..a4b671588461 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalByteTee.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalByteTee.swift @@ -7,11 +7,12 @@ final class FakeTerminalByteTee: TerminalByteTeeBinding { func installTee( on surface: ghostty_surface_t, workspaceID: UUID, - surfaceID: UUID + surfaceID: UUID, + surfaceGeneration: UInt64 ) -> any TerminalByteTeeLease { FakeTerminalByteTeeLease() } @MainActor - func dropSurface(surfaceID: UUID) {} + func dropSurface(surfaceID: UUID, surfaceGeneration: UInt64) {} } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TeardownOrderRecorder.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TeardownOrderRecorder.swift index 918f4e238fac..0e6e5fc16f0d 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TeardownOrderRecorder.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TeardownOrderRecorder.swift @@ -9,7 +9,12 @@ import Foundation /// carve-out for off-isolation compare-and-set). final class TeardownOrderRecorder: @unchecked Sendable { enum Event: Equatable, Sendable { + case finalValidation + case shellStop + case durableCommitRejected + case pendingInputFlush case nativeFree + case shellContinue case teeLeaseRelease } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfacePortalHostVacancyTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfacePortalHostVacancyTests.swift index b9053c781f96..b05a6bae2062 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfacePortalHostVacancyTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfacePortalHostVacancyTests.swift @@ -116,7 +116,11 @@ import Testing @Test func hibernationInvalidatesQueuedVacancyRetriesBeforeRunLoopDrain() async { let surface = makeSurface() - defer { surface.releaseSurfaceForTesting() } + surface.installRuntimeSurfaceForTesting(fakeRuntimeSurface()) + defer { + surface.runtimeSurfaceFreedOutOfBandForTesting = true + surface.releaseSurfaceForTesting() + } let paneId = PaneID() let owner = NSView(frame: NSRect(x: 0, y: 0, width: 80, height: 24)) @@ -146,7 +150,11 @@ import Testing ) #expect(surface.portalHostVacancyWakeScheduled) - surface.suspendRuntimeSurfaceForAgentHibernation(reason: "test.hibernate") + let didSuspend = await surface.suspendRuntimeSurfaceForAgentHibernation( + reason: "test.hibernate", + finalValidation: { false } + ) + #expect(!didSuspend) #expect(surface.portalHostVacancyRetries.isEmpty) #expect(!surface.portalHostVacancyWakeScheduled) #expect(!surface.canAcceptPortalBinding( @@ -250,4 +258,8 @@ import Testing ) ) } + + private func fakeRuntimeSurface() -> ghostty_surface_t { + UnsafeMutableRawPointer(bitPattern: 0x7542)! + } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift index e82833142d3b..79097908e71e 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift @@ -59,6 +59,72 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec } @Suite struct TerminalSurfaceRuntimeTeardownCoordinatorTests { + @Test func hibernationFreeValidatesImmediatelyBeforeFreeAndAwaitsUserdataRelease() async { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + let recorder = TeardownLifetimeRecorder() + let lease = LifetimeRecordingByteTeeLease(recorder: recorder) + let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { surface.deallocate() } + + let result = await coordinator.freeRuntimeSurfaceForAgentHibernation( + TerminalSurfaceRuntimeTeardownRequest( + id: UUID(), + workspaceId: UUID(), + reason: "test.hibernate", + surface: surface, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: lease, + finalValidation: { + recorder.record("validation") + return true + }, + freeSurface: { _ in + recorder.record("surface.free") + } + ) + ) + + guard case .freed = result else { + Issue.record("Expected the hibernation request to free") + return + } + #expect(recorder.snapshot() == ["validation", "surface.free", "tee.release"]) + } + + @Test func rejectedHibernationFreeKeepsNativeSurfaceAndUserdataOwnedByCaller() async { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + let recorder = TeardownLifetimeRecorder() + let lease = LifetimeRecordingByteTeeLease(recorder: recorder) + let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { surface.deallocate() } + + let result = await coordinator.freeRuntimeSurfaceForAgentHibernation( + TerminalSurfaceRuntimeTeardownRequest( + id: UUID(), + workspaceId: UUID(), + reason: "test.hibernate.rejected", + surface: surface, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: lease, + finalValidation: { + recorder.record("validation") + return false + }, + freeSurface: { _ in + recorder.record("surface.free") + } + ) + ) + + guard case .rejected(finalizer: nil) = result else { + Issue.record("Expected validation rejection without a finalizer") + return + } + #expect(recorder.snapshot() == ["validation"]) + } + @Test func enqueuedTeardownInvokesInjectedFreeWithTheSamePointer() async { let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() let recorder = FreedSurfaceRecorder() diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift index 7c01181f85a8..553f929b371f 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift @@ -1,9 +1,78 @@ import AppKit +import CmuxTerminalCore import Foundation import GhosttyKit import Testing @testable import CmuxTerminal +private actor HibernationValidationGate { + private var validationStarted = false + private var validationStartWaiters: [CheckedContinuation] = [] + private var validationResultContinuation: CheckedContinuation? + + func validate() async -> Bool { + validationStarted = true + let waiters = validationStartWaiters + validationStartWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + return await withCheckedContinuation { continuation in + validationResultContinuation = continuation + } + } + + func waitUntilValidationStarts() async { + guard !validationStarted else { return } + await withCheckedContinuation { continuation in + validationStartWaiters.append(continuation) + } + } + + func resolveValidation(_ result: Bool) { + validationResultContinuation?.resume(returning: result) + validationResultContinuation = nil + } +} + +private final class HibernationSurfaceInvalidator: @unchecked Sendable { + private weak var surface: TerminalSurface? + + @MainActor + init(surface: TerminalSurface) { + self.surface = surface + } + + func invalidate() async { + await MainActor.run { + self.surface?.invalidateProvisionalAgentHibernation() + } + } +} + +private final class HibernationSurfaceRegistry: TerminalSurfaceRegistering, @unchecked Sendable { + private var runtimeOwners: [UInt: UUID] = [:] + + var topologyGeneration: UInt64 { 0 } + func register(_ surface: any TerminalSurfacing) {} + func unregister(_ surface: any TerminalSurfacing) {} + func registerRuntimeSurface(_ surface: ghostty_surface_t, ownerId: UUID) { + runtimeOwners[UInt(bitPattern: surface)] = ownerId + } + func unregisterRuntimeSurface(_ surface: ghostty_surface_t, ownerId: UUID) { + let key = UInt(bitPattern: surface) + guard runtimeOwners[key] == ownerId else { return } + runtimeOwners.removeValue(forKey: key) + } + func runtimeSurfaceOwnerId(_ surface: ghostty_surface_t) -> UUID? { + runtimeOwners[UInt(bitPattern: surface)] + } + func surface(id: UUID) -> (any TerminalSurfacing)? { nil } + func isRightSidebarDockSurface(id: UUID) -> Bool { false } + func updateFocusPlacement(id: UUID, _ placement: TerminalSurfaceFocusPlacement) {} + func allSurfaces() -> [any TerminalSurfacing] { [] } +} + /// The ghostty PTY tee callback and the MANUAL-mode `io_write_cb` fire on /// ghostty's IO threads until `ghostty_surface_free` joins those threads. The /// retained callback userdata (the byte-tee lease's context and the manual IO @@ -37,25 +106,364 @@ import Testing #expect(recorder.events == [.nativeFree, .teeLeaseRelease]) } - @Test func agentHibernationSuspendKeepsTeeLeaseUntilNativeFree() async { + @Test func agentHibernationSuspendReturnsOnlyAfterNativeFreeAndUserdataRelease() async { let recorder = TeardownOrderRecorder() let surface = makeSurface() surface.installRuntimeSurfaceForTesting(fakeRuntimeSurface()) + let generation = surface.runtimeSurfaceGeneration surface.mobileByteTeeLease = RecordingTerminalByteTeeLease(recorder: recorder) TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in recorder.record(.nativeFree) } defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } - surface.suspendRuntimeSurfaceForAgentHibernation(reason: "test.hibernate") + let didSuspend = await surface.suspendRuntimeSurfaceForAgentHibernation( + reason: "test.hibernate", + finalValidation: { + recorder.record(.finalValidation) + return true + } + ) - #expect( - !recorder.events.contains(.teeLeaseRelease), - "tee lease was released before the native free; the IO reader thread can still fire the tee callback" + #expect(didSuspend) + #expect(recorder.events == [.finalValidation, .nativeFree, .teeLeaseRelease]) + #expect(surface.surface == nil) + #expect(surface.runtimeSurfaceGeneration == generation &+ 1) + #expect(surface.runtimeSurfaceSuspendedForAgentHibernation) + } + + @Test func rejectedAgentHibernationRestoresExactRuntimeOwnershipWithoutChangingGeneration() async { + let recorder = TeardownOrderRecorder() + let surface = makeSurface() + let runtimeSurface = fakeRuntimeSurface() + surface.installRuntimeSurfaceForTesting(runtimeSurface) + let generation = surface.runtimeSurfaceGeneration + let callbackContext = Unmanaged.passRetained( + GhosttySurfaceCallbackContext(surfaceHost: surface.surfaceView, surfaceController: surface) + ) + let manualIOContext = Unmanaged.passRetained(TerminalManualIOWriteBox(onWrite: { _ in })) + let teeLease = RecordingTerminalByteTeeLease(recorder: recorder) + surface.surfaceCallbackContext = callbackContext + surface.manualIOContext = manualIOContext + surface.mobileByteTeeLease = teeLease + #expect(surface.portalLifecycleState == .live) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + recorder.record(.nativeFree) + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + let didSuspend = await surface.suspendRuntimeSurfaceForAgentHibernation( + reason: "test.hibernate.rejected", + finalValidation: { + recorder.record(.finalValidation) + return false + } + ) + + #expect(!didSuspend) + #expect(surface.portalLifecycleState == .live) + #expect(surface.teardownRequestReason == nil) + #expect(surface.surface == runtimeSurface) + #expect(surface.runtimeSurfaceGeneration == generation) + #expect(surface.surfaceCallbackContext?.toOpaque() == callbackContext.toOpaque()) + #expect(surface.manualIOContext?.toOpaque() == manualIOContext.toOpaque()) + #expect(surface.mobileByteTeeLease === teeLease) + #expect(!surface.runtimeSurfaceSuspendedForAgentHibernation) + #expect(recorder.events == [.finalValidation]) + + surface.teardownSurface() + await recorder.waitForEventCount(3) + #expect(recorder.events == [.finalValidation, .nativeFree, .teeLeaseRelease]) + } + + @Test func durableCommitRejectionRestoresRuntimeAndQueuedInputBeforeShellResume() async { + let recorder = TeardownOrderRecorder() + let registry = HibernationSurfaceRegistry() + let surface = makeSurface(registry: registry) + let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { runtimeSurface.deallocate() } + surface.installRuntimeSurfaceForTesting(runtimeSurface) + registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) + #expect(surface.enqueuePendingSocketInputs([.inputText(Data("preserved".utf8))])) + surface.pendingSocketInputFlushOverrideForTesting = { items, bytes in + #expect(items == 1) + #expect(bytes > 0) + recorder.record(.pendingInputFlush) + } + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + recorder.record(.nativeFree) + } + defer { + surface.pendingSocketInputFlushOverrideForTesting = nil + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil + } + + let didSuspend = await surface.suspendRuntimeSurfaceForAgentHibernation( + reason: "test.hibernate.commitRejected", + finalValidation: { + recorder.record(.finalValidation) + return true + }, + finalTeardownPreparation: { + recorder.record(.shellStop) + return { recorder.record(.shellContinue) } + }, + finalCommit: { + recorder.record(.durableCommitRejected) + return false + } + ) + + #expect(!didSuspend) + #expect(surface.surface == runtimeSurface) + #expect(registry.runtimeSurfaceOwnerId(runtimeSurface) == surface.id) + #expect(surface.debugPendingSocketInputForTesting().items == 0) + #expect(recorder.events == [ + .finalValidation, + .shellStop, + .durableCommitRejected, + .pendingInputFlush, + .shellContinue, + ]) + + surface.runtimeSurfaceFreedOutOfBandForTesting = true + surface.teardownSurface() + } + + @Test func portalCloseDuringRejectedValidationFreesTransferredRuntimeInsteadOfRestoringIt() async { + let recorder = TeardownOrderRecorder() + let validationGate = HibernationValidationGate() + let surface = makeSurface() + surface.installRuntimeSurfaceForTesting(fakeRuntimeSurface()) + let generation = surface.runtimeSurfaceGeneration + surface.mobileByteTeeLease = RecordingTerminalByteTeeLease(recorder: recorder) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + recorder.record(.nativeFree) + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + let suspension = Task { @MainActor in + await surface.suspendRuntimeSurfaceForAgentHibernation( + reason: "test.hibernate.closeRace", + finalValidation: { + recorder.record(.finalValidation) + return await validationGate.validate() + } + ) + } + await validationGate.waitUntilValidationStarts() + + surface.teardownSurface() + await validationGate.resolveValidation(false) + let didSuspend = await suspension.value + + #expect(!didSuspend) + #expect(surface.surface == nil) + #expect(surface.runtimeSurfaceGeneration == generation &+ 1) + #expect(!surface.runtimeSurfaceSuspendedForAgentHibernation) + #expect(recorder.events == [.finalValidation, .nativeFree, .teeLeaseRelease]) + } + + @Test func explicitInputDuringProvisionalHibernationRejectsAndFlushesToRestoredRuntime() async { + let recorder = TeardownOrderRecorder() + let validationGate = HibernationValidationGate() + let registry = HibernationSurfaceRegistry() + let surface = makeSurface(registry: registry) + let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { runtimeSurface.deallocate() } + surface.installRuntimeSurfaceForTesting(runtimeSurface) + registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) + let generation = surface.runtimeSurfaceGeneration + surface.pendingSocketInputFlushOverrideForTesting = { items, bytes in + #expect(items > 0) + #expect(bytes > 0) + recorder.record(.pendingInputFlush) + } + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + recorder.record(.nativeFree) + } + defer { + surface.pendingSocketInputFlushOverrideForTesting = nil + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil + } + + let suspension = Task { @MainActor in + await surface.suspendRuntimeSurfaceForAgentHibernation( + reason: "test.hibernate.inputRace", + finalValidation: { + recorder.record(.finalValidation) + return await validationGate.validate() + } + ) + } + await validationGate.waitUntilValidationStarts() + + #expect(surface.portalLifecycleState == .live) + #expect(surface.sendInputResult("echo preserved\r") == .queued) + #expect(surface.portalLifecycleState == .live) + #expect(surface.debugPendingSocketInputForTesting().items > 0) + await validationGate.resolveValidation(true) + let didSuspend = await suspension.value + #expect(surface.portalLifecycleState == .live) + #expect(surface.teardownRequestReason == nil) + + #expect(!didSuspend) + #expect(surface.surface == runtimeSurface) + #expect(surface.runtimeSurfaceGeneration == generation) + #expect(surface.debugPendingSocketInputForTesting().items == 0) + #expect(recorder.events == [.finalValidation, .pendingInputFlush]) + + surface.runtimeSurfaceFreedOutOfBandForTesting = true + surface.teardownSurface() + } + + @Test func lifecycleInvalidationAfterOuterClaimStillRejectsNativeHibernation() async { + let recorder = TeardownOrderRecorder() + let registry = HibernationSurfaceRegistry() + let surface = makeSurface(registry: registry) + let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { runtimeSurface.deallocate() } + surface.installRuntimeSurfaceForTesting(runtimeSurface) + registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) + let generation = surface.runtimeSurfaceGeneration + let invalidator = HibernationSurfaceInvalidator(surface: surface) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + recorder.record(.nativeFree) + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + let didSuspend = await surface.suspendRuntimeSurfaceForAgentHibernation( + reason: "test.hibernate.lifecycleAfterOuterClaim", + finalValidation: { + // Model the controller token having been claimed, followed by + // a Workspace shell/lifecycle/PID mutation before the package's + // last native-free gate. + recorder.record(.finalValidation) + await invalidator.invalidate() + return true + } ) + #expect(!didSuspend) + #expect(surface.surface == runtimeSurface) + #expect(surface.runtimeSurfaceGeneration == generation) + #expect(recorder.events == [.finalValidation]) + + surface.runtimeSurfaceFreedOutOfBandForTesting = true + surface.teardownSurface() + } + + @Test func explicitInputAfterCommitPointSurvivesForImmediateResume() async { + let recorder = TeardownOrderRecorder() + let releaseNativeFree = DispatchSemaphore(value: 0) + let surface = makeSurface() + surface.installRuntimeSurfaceForTesting(fakeRuntimeSurface()) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + recorder.record(.nativeFree) + releaseNativeFree.wait() + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + let suspension = Task { @MainActor in + await surface.suspendRuntimeSurfaceForAgentHibernation( + reason: "test.hibernate.inputAfterCommit", + finalValidation: { + recorder.record(.finalValidation) + return true + } + ) + } await recorder.waitForEventCount(2) - #expect(recorder.events == [.nativeFree, .teeLeaseRelease]) + + #expect(surface.sendNamedKey("enter") == .queued) + #expect(surface.debugPendingSocketInputForTesting().items == 1) + releaseNativeFree.signal() + + #expect(await suspension.value) + #expect(surface.hasPendingInputForAgentHibernationResume) + #expect(surface.debugPendingSocketInputForTesting().items == 1) + #expect(recorder.events == [.finalValidation, .nativeFree]) + } + + @Test func explicitInputAfterSuspendReturnsQueuesBeforeOwnerCommit() async { + let recorder = TeardownOrderRecorder() + let surface = makeSurface() + surface.installRuntimeSurfaceForTesting(fakeRuntimeSurface()) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + recorder.record(.nativeFree) + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + #expect(await surface.suspendRuntimeSurfaceForAgentHibernation( + reason: "test.hibernate.inputBeforeOwnerCommit", + finalValidation: { true } + )) + + #expect(surface.sendInputResult("echo handoff-preserved\r") == .queued) + #expect(surface.hasPendingInputForAgentHibernationResume) + #expect(surface.debugPendingSocketInputForTesting().items > 0) + #expect(recorder.events == [.nativeFree]) + } + + @Test func resumeCannotReopenRuntimeUntilHibernationFreeCompletes() async { + let recorder = TeardownOrderRecorder() + let validationGate = HibernationValidationGate() + let surface = makeSurface() + surface.installRuntimeSurfaceForTesting(fakeRuntimeSurface()) + surface.mobileByteTeeLease = RecordingTerminalByteTeeLease(recorder: recorder) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + recorder.record(.nativeFree) + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + let suspension = Task { @MainActor in + await surface.suspendRuntimeSurfaceForAgentHibernation( + reason: "test.hibernate.resumeRace", + finalValidation: { + recorder.record(.finalValidation) + return await validationGate.validate() + } + ) + } + await validationGate.waitUntilValidationStarts() + + surface.prepareAgentHibernationResume(initialInput: "too early") + #expect(surface.nextRuntimeInitialInput == nil) + + await validationGate.resolveValidation(true) + #expect(await suspension.value) + #expect(surface.runtimeSurfaceSuspendedForAgentHibernation) + + surface.prepareAgentHibernationResume(initialInput: "after free") + #expect(!surface.runtimeSurfaceSuspendedForAgentHibernation) + #expect(surface.nextRuntimeInitialInput == "after free") + #expect(recorder.events == [.finalValidation, .nativeFree, .teeLeaseRelease]) + } + + @Test func restoredHibernationMarkerRequiresLiveModelWithoutNativeRuntime() { + let restoredSurface = makeSurface() + + #expect(restoredSurface.markRuntimeSurfaceSuspendedForRestoredAgentHibernation()) + #expect(restoredSurface.runtimeSurfaceSuspendedForAgentHibernation) + + let liveSurface = makeSurface() + liveSurface.installRuntimeSurfaceForTesting(fakeRuntimeSurface()) + + #expect(!liveSurface.markRuntimeSurfaceSuspendedForRestoredAgentHibernation()) + #expect(!liveSurface.runtimeSurfaceSuspendedForAgentHibernation) + + liveSurface.runtimeSurfaceFreedOutOfBandForTesting = true + liveSurface.teardownSurface() + } + + @Test func directInputRejectsSuspendedHibernatedSurfaceInsteadOfStrandingBytes() { + let surface = makeSurface() + #expect(surface.markRuntimeSurfaceSuspendedForRestoredAgentHibernation()) + + #expect(surface.sendInputResult("pwd\r") == .surfaceUnavailable) + let pending = surface.debugPendingSocketInputForTesting() + #expect(pending.items == 0) + #expect(pending.bytes == 0) } @Test func deinitKeepsTeeLeaseUntilCoordinatorFree() async { @@ -138,7 +546,9 @@ import Testing #expect(recorder.events == [.nativeFree, .teeLeaseRelease]) } - private func makeSurface() -> TerminalSurface { + private func makeSurface( + registry: any TerminalSurfaceRegistering = FakeSurfaceRegistry() + ) -> TerminalSurface { let nativeView = FakeTerminalSurfaceNativeView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) let paneHost = FakeTerminalSurfacePaneHost(surfaceView: nativeView) return TerminalSurface( @@ -146,7 +556,7 @@ import Testing context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: nil, dependencies: TerminalSurfaceRuntimeDependencies( - registry: FakeSurfaceRegistry(), + registry: registry, engine: FakeTerminalEngine(), viewProvider: FakeTerminalSurfaceViewProvider(surfaceView: nativeView, paneHost: paneHost), spawnPolicy: FakeSpawnPolicyProvider(), diff --git a/Packages/macOS/CmuxTerminalCore/CLEAN_ROOM.md b/Packages/macOS/CmuxTerminalCore/CLEAN_ROOM.md new file mode 100644 index 000000000000..59dbb3e4043d --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/CLEAN_ROOM.md @@ -0,0 +1,3 @@ +# Clean-room provenance + +Research and implementation were performed by separate roles. The implementation used only a sanitized behavioral specification and independently captured tmux behavior from locally installed agents. No Herdr source, code identifiers, or matching expressions were exposed to the implementation role or copied into cmux. diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalAuthorityResolver.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalAuthorityResolver.swift new file mode 100644 index 000000000000..5e9c28d7775d --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalAuthorityResolver.swift @@ -0,0 +1,19 @@ +/// Resolves lifecycle authority without allowing screen evidence to compete. +public struct AgentTerminalAuthorityResolver: Sendable { + /// Creates a stateless authority resolver. + public init() {} + + /// Resolves one family's lifecycle and screen reports. + /// + /// Complete integrations own state whenever present. Session-only + /// integrations remain a fallback because their events do not describe + /// every terminal interaction state. + public func resolve( + authoritative: AgentTerminalSemanticState?, + screen: AgentTerminalSemanticState?, + lifecycleAuthoritative: Bool = true + ) -> AgentTerminalSemanticState { + if lifecycleAuthoritative { return authoritative ?? screen ?? .unknown } + return screen ?? authoritative ?? .unknown + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalDetectionClock.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalDetectionClock.swift new file mode 100644 index 000000000000..a0076f0863ae --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalDetectionClock.swift @@ -0,0 +1,34 @@ +/// Injected monotonic time and cancellable delay operations for the detector scheduler. +public struct AgentTerminalDetectionClock: Sendable { + private let nowOperation: @Sendable () async -> Duration + private let sleepOperation: @Sendable (Duration) async throws -> Void + + /// Creates a clock seam from monotonic operations. + public init( + now: @escaping @Sendable () async -> Duration, + sleep: @escaping @Sendable (Duration) async throws -> Void + ) { + nowOperation = now + sleepOperation = sleep + } + + /// Returns elapsed monotonic time from the clock's private origin. + public func now() async -> Duration { + await nowOperation() + } + + /// Suspends for an intended debounce/deadline delay. + public func sleep(for duration: Duration) async throws { + try await sleepOperation(duration) + } + + /// A production clock backed by ``ContinuousClock``. + public static func continuous() -> AgentTerminalDetectionClock { + let clock = ContinuousClock() + let origin = clock.now + return AgentTerminalDetectionClock( + now: { origin.duration(to: clock.now) }, + sleep: { duration in try await clock.sleep(for: duration) } + ) + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalDetectionConfiguration.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalDetectionConfiguration.swift new file mode 100644 index 000000000000..794f0e0305f5 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalDetectionConfiguration.swift @@ -0,0 +1,13 @@ +/// Timing bounds for coalesced terminal-state evaluation. +public struct AgentTerminalDetectionConfiguration: Sendable, Equatable { + /// Intended quiet period after the newest output invalidation. + public let quietWindow: Duration + /// Maximum time a sustained burst may defer evaluation. + public let maximumLatency: Duration + + /// Creates bounded scheduler timing. + public init(quietWindow: Duration = .milliseconds(90), maximumLatency: Duration = .milliseconds(350)) { + self.quietWindow = quietWindow + self.maximumLatency = maximumLatency + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalDetectionUpdate.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalDetectionUpdate.swift new file mode 100644 index 000000000000..2d04abb39bd1 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalDetectionUpdate.swift @@ -0,0 +1,18 @@ +public import Foundation + +/// One effective state change published by the bounded scheduler. +public struct AgentTerminalDetectionUpdate: Sendable, Equatable { + /// The terminal surface receiving the change. + public let surfaceID: UUID + /// The terminal evidence revision that was classified. + public let revision: UInt64 + /// The generation-safe semantic classification. + public let classification: AgentTerminalStateClassification + + /// Creates an effective scheduler update. + public init(surfaceID: UUID, revision: UInt64, classification: AgentTerminalStateClassification) { + self.surfaceID = surfaceID + self.revision = revision + self.classification = classification + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalDirtySignal.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalDirtySignal.swift new file mode 100644 index 000000000000..2b047f0d1ea1 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalDirtySignal.swift @@ -0,0 +1,37 @@ +import CmuxFoundation + +/// A synchronous, thread-safe invalidation signal for a terminal output callback. +/// +/// The raw callback only advances an atomic revision and yields into a +/// buffering-newest stream. It never captures a screen, classifies text, or +/// creates a task. +public final class AgentTerminalDirtySignal: Sendable { + private let generation = AtomicUInt64Generation() + private let continuation: AsyncStream.Continuation + + /// The single-consumer stream of coalesced newest revisions. + public let revisions: AsyncStream + + /// Creates an initially clean signal. + public init() { + let pair = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + revisions = pair.stream + continuation = pair.continuation + } + + deinit { + continuation.finish() + } + + /// Marks newer terminal evidence from a synchronous PTY callback. + @inline(__always) + public func markDirty() { + continuation.yield(generation.advanceRelaxed()) + } + + /// Returns the newest invalidation revision without an actor hop. + @inline(__always) + public func currentRevision() -> UInt64 { + generation.loadRelaxed() + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalFamilyProfile.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalFamilyProfile.swift new file mode 100644 index 000000000000..e9b85d373745 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalFamilyProfile.swift @@ -0,0 +1,63 @@ +/// Validated, data-driven identity and terminal evidence for one agent family. +public struct AgentTerminalFamilyProfile: Sendable, Equatable { + /// Canonical detector identifier. + public let id: String + /// Existing cmux lifecycle/status key used by sidebar integration. + public let statusKey: String + /// Canonical hook/session provider identifier used by `cmux agents`. + public let sessionProviderID: String + /// Human-readable family name for diagnostics. + public let displayName: String + /// Whether a complete official lifecycle integration may own semantic state. + public let lifecycleAuthoritative: Bool + /// Executable basenames that directly identify this family. + public let executableBasenames: Set + /// Command-line fragments that reveal this family behind a generic runtime. + public let argumentNeedles: [String] + /// Accepted values of cmux-owned wrapper hints. + public let hintAliases: Set + /// Current live-bottom fragments that strongly indicate an idle composer. + public let idleNeedles: [String] + /// Current live-bottom evidence groups that indicate active work. + /// + /// Every fragment in one inner group must match. Any complete group is + /// sufficient. This keeps generic words from becoming standalone signals. + public let workingEvidenceGroups: [[String]] + /// Strict current-interaction evidence groups that require human input. + public let blockedEvidenceGroups: [[String]] + /// High-confidence prompts that require human input when they occupy a rendered line. + public let blockedExactLines: [String] + /// Agent-owned history/transcript fragments that suppress new classification. + public let historyViewNeedles: [String] + + /// Creates one profile. Catalog validation rejects empty or duplicate identity data. + public init( + id: String, + statusKey: String, + sessionProviderID: String? = nil, + displayName: String, + lifecycleAuthoritative: Bool = false, + executableBasenames: Set, + argumentNeedles: [String] = [], + hintAliases: Set = [], + idleNeedles: [String] = [], + workingEvidenceGroups: [[String]] = [], + blockedEvidenceGroups: [[String]] = [], + blockedExactLines: [String] = [], + historyViewNeedles: [String] = [] + ) { + self.id = id + self.statusKey = statusKey + self.sessionProviderID = sessionProviderID ?? statusKey + self.displayName = displayName + self.lifecycleAuthoritative = lifecycleAuthoritative + self.executableBasenames = executableBasenames + self.argumentNeedles = argumentNeedles + self.hintAliases = hintAliases + self.idleNeedles = idleNeedles + self.workingEvidenceGroups = workingEvidenceGroups + self.blockedEvidenceGroups = blockedEvidenceGroups + self.blockedExactLines = blockedExactLines + self.historyViewNeedles = historyViewNeedles + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalProcessIdentity.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalProcessIdentity.swift new file mode 100644 index 000000000000..cf6780163055 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalProcessIdentity.swift @@ -0,0 +1,19 @@ +/// Stable identity for one foreground process and terminal-runtime generation. +public struct AgentTerminalProcessIdentity: Sendable, Equatable, Hashable { + /// The operating-system process identifier. + public let pid: Int32 + /// Process start time seconds from the kernel process record. + public let startSeconds: Int64 + /// Process start time microseconds from the kernel process record. + public let startMicroseconds: Int64 + /// The native terminal runtime generation that observed the process. + public let runtimeGeneration: UInt64 + + /// Creates a generation-safe foreground process identity. + public init(pid: Int32, startSeconds: Int64, startMicroseconds: Int64, runtimeGeneration: UInt64) { + self.pid = pid + self.startSeconds = startSeconds + self.startMicroseconds = startMicroseconds + self.runtimeGeneration = runtimeGeneration + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalProcessInspector.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalProcessInspector.swift new file mode 100644 index 000000000000..3983292f8249 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalProcessInspector.swift @@ -0,0 +1,113 @@ +import Darwin +import Foundation + +/// Reads foreground-process identity and launch metadata outside the app target. +public struct AgentTerminalProcessInspector: Sendable { + /// Creates a stateless process inspector. + public init() {} + + /// Captures one process generation with its executable, arguments, and environment. + @concurrent + public func snapshot(pid: Int32, runtimeGeneration: UInt64) async -> AgentTerminalProcessSnapshot? { + guard let identity = processIdentity(pid: pid, runtimeGeneration: runtimeGeneration) else { return nil } + let command = processArgumentsAndEnvironment(pid: pid) + return AgentTerminalProcessSnapshot( + identity: identity, + executablePath: executablePath(pid: pid), + arguments: command?.arguments ?? [], + environment: command?.environment ?? [:] + ) + } + + /// Re-reads only the stable process identity for post-capture validation. + @concurrent + public func identity(pid: Int32, runtimeGeneration: UInt64) async -> AgentTerminalProcessIdentity? { + processIdentity(pid: pid, runtimeGeneration: runtimeGeneration) + } + + private func processIdentity(pid: Int32, runtimeGeneration: UInt64) -> AgentTerminalProcessIdentity? { + guard pid > 0 else { return nil } + var info = proc_bsdinfo() + let expectedSize = MemoryLayout.stride + let size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(expectedSize)) + guard size == expectedSize else { return nil } + return AgentTerminalProcessIdentity( + pid: pid, + startSeconds: Int64(info.pbi_start_tvsec), + startMicroseconds: Int64(info.pbi_start_tvusec), + runtimeGeneration: runtimeGeneration + ) + } + + private func executablePath(pid: Int32) -> String? { + var buffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN)) + let count = proc_pidpath(pid, &buffer, UInt32(buffer.count)) + guard count > 0 else { return nil } + let pathBytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + return String(decoding: pathBytes, as: UTF8.self) + } + + private func processArgumentsAndEnvironment(pid: Int32) -> (arguments: [String], environment: [String: String])? { + guard let bytes = kernProcArgsBytes(pid: pid), bytes.count > MemoryLayout.size else { return nil } + var argcRaw: Int32 = 0 + withUnsafeMutableBytes(of: &argcRaw) { destination in + destination.copyBytes(from: bytes.prefix(MemoryLayout.size)) + } + let argumentCount = Int(Int32(littleEndian: argcRaw)) + guard argumentCount > 0 else { return nil } + + var index = MemoryLayout.size + skipString(in: bytes, index: &index) + skipNulls(in: bytes, index: &index) + var arguments: [String] = [] + for _ in 0.. AgentTerminalFamilyProfile? { + let id = normalized(raw.id) + let statusKey = raw.statusKey.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = raw.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + let executables = Set(raw.executableBasenames.map(normalizedNeedle)) + guard !id.isEmpty, !statusKey.isEmpty, !displayName.isEmpty, + !executables.isEmpty, !executables.contains("") else { return nil } + guard let arguments = normalizedNeedles(raw.argumentNeedles), + let aliases = normalizedAliases(raw.hintAliases), + let idle = normalizedNeedles(raw.idleNeedles), + let working = normalizedEvidenceGroups(raw.workingEvidenceGroups), + let blocked = normalizedEvidenceGroups(raw.blockedEvidenceGroups, minimumCount: 2), + let blockedExactLines = normalizedNeedles(raw.blockedExactLines), + let history = normalizedNeedles(raw.historyViewNeedles) else { return nil } + return AgentTerminalFamilyProfile( + id: id, + statusKey: statusKey, + sessionProviderID: raw.sessionProviderID, + displayName: displayName, + lifecycleAuthoritative: raw.lifecycleAuthoritative, + executableBasenames: executables, + argumentNeedles: arguments, + hintAliases: aliases, + idleNeedles: idle, + workingEvidenceGroups: working, + blockedEvidenceGroups: blocked, + blockedExactLines: blockedExactLines, + historyViewNeedles: history + ) + } + + private static func normalizedNeedle(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private static func normalizedNeedles(_ raw: [String]) -> [String]? { + let normalized = raw.map(normalizedNeedle) + return normalized.contains("") ? nil : normalized + } + + private static func normalizedAliases(_ raw: Set) -> Set? { + let normalized = Set(raw.map(normalized)) + return normalized.contains("") ? nil : normalized + } + + private static func normalizedEvidenceGroups(_ raw: [[String]], minimumCount: Int = 1) -> [[String]]? { + var result: [[String]] = [] + for group in raw { + guard group.count >= minimumCount, let normalized = normalizedNeedles(group) else { return nil } + result.append(normalized) + } + return result + } + + private static let commonHistoryNeedles = ["conversation history", "transcript viewer", "session history"] + private static let commonBlockedEvidenceGroups = [ + ["requires approval", "yes", "no"], ["approval required", "approve"], + ["would you like to run the following command", "yes", "no"], + ["do you want to proceed", "yes", "no"], + ["session may have expired", "/login"], + ] + private static let commonBlockedExactLines = [ + "waiting for approval", + "enter your api key", + "api key required", + "authentication required", + "type /login to re-authenticate", + ] + + private static let builtInProfiles: [AgentTerminalFamilyProfile] = [ + profile("pi", "Pi", authoritative: true, executables: ["pi", "pi-coding-agent"], arguments: ["pi-coding-agent"], idle: ["pi", "context"], working: [["working..."], ["working…"]]), + profile("omp", "OMP", authoritative: true, executables: ["omp"], arguments: ["oh-my-pi"], idle: ["context", "reasoning"], working: [["working..."], ["working…"]]), + profile("copilot", "GitHub Copilot CLI", executables: ["copilot", "github-copilot-cli"], arguments: ["github copilot", "@github/copilot"], idle: ["what would you like"], working: [["esc to interrupt"]]), + profile("devin", "Devin CLI", executables: ["devin", "devin-cli"], arguments: ["devin-cli"], idle: ["ask devin"], working: [["esc to interrupt"]]), + profile("kimi", "Kimi Code CLI", authoritative: true, executables: ["kimi", "kimi-cli", "kimi-code"], arguments: ["kimi-code", "kimi code"], idle: ["input"], working: [["esc to interrupt"]], blocked: [["[enter]", "upgrade now", "[q]", "not now"]]), + profile("hermes-agent", "Hermes Agent", authoritative: true, executables: ["hermes", "hermes-agent"], arguments: ["hermes-agent"], idle: ["hermes"], working: [["executing tool"]]), + profile("qoder", "Qoder CLI", executables: ["qoder", "qodercli"], arguments: ["qodercli"], idle: ["ask qoder"], working: [["esc to interrupt"]]), + profile("droid", "Droid", statusKey: "factory", executables: ["droid"], arguments: ["factory.ai", "factory-cli"], idle: ["ask droid"], working: [["esc to interrupt"]]), + profile("opencode", "OpenCode", authoritative: true, executables: ["opencode", "opencode-ai", "open-code"], arguments: ["opencode"], idle: ["ask anything"], working: [["esc interrupt"]]), + profile("kilo", "Kilo Code CLI", authoritative: true, executables: ["kilo", "kilo-code"], arguments: ["kilo-code"], idle: ["ask anything"], working: [["esc to interrupt"]]), + profile("mastracode", "MastraCode", authoritative: true, executables: ["mastracode", "mastra-code"], arguments: ["mastracode", "mastra-code"], idle: ["mastra"], working: [["esc to interrupt"]]), + profile("claude-code", "Claude Code", statusKey: "claude_code", sessionProviderID: "claude", executables: ["claude", "claude-code", "claude_code"], arguments: ["@anthropic-ai/claude-code", "claude-code", "/claude/versions/"], aliases: ["claude"], idle: ["try \"", "claude code"], working: [["esc to interrupt"]]), + profile("codex", "Codex", executables: ["codex"], arguments: ["@openai/codex"], idle: ["ask codex", "write tests for"], working: [["working ("], ["esc to interrupt"]]), + profile("cursor-agent", "Cursor Agent CLI", statusKey: "cursor", executables: ["cursor-agent"], arguments: ["cursor-agent"], aliases: ["cursor"], idle: ["ask cursor", "cursor agent"], working: [["running", "tokens"]]), + profile("amp", "Amp", executables: ["amp"], arguments: ["@ampcode"], idle: ["ask amp"], working: [["esc to interrupt"]]), + profile("grok", "Grok CLI", executables: ["grok", "grok-macos-aarch64", "grok-macos-aarch"], arguments: ["@xai/grok", "grok-build"], idle: ["grok", "model"], working: [["starting session"], ["queued task"]]), + profile("antigravity", "Antigravity CLI", executables: ["antigravity", "agy"], arguments: ["antigravity-cli"], aliases: ["agy"], idle: ["ask anything"], working: [["esc to interrupt"]]), + profile("kiro", "Kiro CLI", executables: ["kiro", "kiro-cli"], arguments: ["kiro-cli"], idle: ["ask kiro"], working: [["esc to interrupt"]]), + profile("maki", "Maki", executables: ["maki"], arguments: ["maki-cli"], idle: ["ask maki"], working: [["esc to interrupt"]]), + profile( + "gemini", + "Gemini CLI", + executables: ["gemini"], + arguments: ["@google/gemini-cli", "gemini-cli"], + idle: ["type your message", "gemini"], + working: [["esc to cancel"]], + blocked: [["enter gemini api key", "paste your api key here"]] + ), + profile("cline", "Cline", executables: ["cline", "cline-cli"], arguments: ["cline-cli"], idle: ["ask cline"], working: [["esc to interrupt"]]), + // Existing cmux-only families remain recognized. + profile("campfire", "Campfire", authoritative: true, executables: ["campfire"], arguments: ["session/bin/campfire", "session/dist/campfire"], idle: ["campfire"], working: [["esc to interrupt"]]), + profile("rovodev", "Rovo Dev", authoritative: true, executables: ["rovodev"], arguments: ["rovodev"], aliases: ["rovo"], idle: ["ask rovo"], working: [["esc to interrupt"]]), + profile("codebuddy", "CodeBuddy", authoritative: true, executables: ["codebuddy"], arguments: ["codebuddy"], idle: ["codebuddy"], working: [["esc to interrupt"]]), + profile("factory", "Factory", authoritative: true, executables: ["factory"], arguments: ["factory"], idle: ["factory"], working: [["esc to interrupt"]]), + profile("ollama", "Ollama", authoritative: true, executables: ["ollama"], arguments: ["ollama run"], idle: [">>>", "send a message"], working: [["thinking"], ["generating"]]), + ] + + private static func profile( + _ id: String, + _ displayName: String, + statusKey: String? = nil, + sessionProviderID: String? = nil, + authoritative: Bool = false, + executables: Set, + arguments: [String], + aliases: Set = [], + idle: [String], + working: [[String]], + blocked: [[String]] = [] + ) -> AgentTerminalFamilyProfile { + AgentTerminalFamilyProfile( + id: id, + statusKey: statusKey ?? id, + sessionProviderID: sessionProviderID ?? statusKey ?? id, + displayName: displayName, + lifecycleAuthoritative: authoritative, + executableBasenames: executables, + argumentNeedles: arguments, + hintAliases: aliases, + idleNeedles: idle, + workingEvidenceGroups: working, + blockedEvidenceGroups: commonBlockedEvidenceGroups + blocked, + blockedExactLines: commonBlockedExactLines, + historyViewNeedles: commonHistoryNeedles + ) + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalRecognitionCache.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalRecognitionCache.swift new file mode 100644 index 000000000000..a59caa47b262 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalRecognitionCache.swift @@ -0,0 +1,24 @@ +/// Caches only successful agent-family recognition for one process generation. +/// +/// Some agents replace their process title after launch. An unresolved generation +/// must therefore remain eligible for another snapshot when terminal output changes. +public struct AgentTerminalRecognitionCache: Sendable { + private var identity: AgentTerminalProcessIdentity? + private var familyID: String? + + public init() {} + + public func requiresSnapshot(for identity: AgentTerminalProcessIdentity) -> Bool { + self.identity != identity || familyID == nil + } + + public func familyID(for identity: AgentTerminalProcessIdentity) -> String? { + guard self.identity == identity else { return nil } + return familyID + } + + public mutating func store(identity: AgentTerminalProcessIdentity, familyID: String?) { + self.identity = identity + self.familyID = familyID + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalScreenSnapshot.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalScreenSnapshot.swift new file mode 100644 index 000000000000..dcc13293a10e --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalScreenSnapshot.swift @@ -0,0 +1,24 @@ +/// An immutable, live-bottom terminal snapshot for one process generation. +public struct AgentTerminalScreenSnapshot: Sendable, Equatable { + /// The stable foreground process and runtime identity. + public let processIdentity: AgentTerminalProcessIdentity + /// The recognized profile identifier, or `nil` for an unsupported process. + public let familyID: String? + /// Plain rendered text from the bounded active-screen bottom. + public let liveBottomText: String + /// The last reliable state, used only while an agent-owned history view is current. + public let previousReliableState: AgentTerminalSemanticState? + + /// Creates immutable classification evidence. + public init( + processIdentity: AgentTerminalProcessIdentity, + familyID: String?, + liveBottomText: String, + previousReliableState: AgentTerminalSemanticState? = nil + ) { + self.processIdentity = processIdentity + self.familyID = familyID + self.liveBottomText = liveBottomText + self.previousReliableState = previousReliableState + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalSemanticState.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalSemanticState.swift new file mode 100644 index 000000000000..8de06f3324b3 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalSemanticState.swift @@ -0,0 +1,11 @@ +/// A coding agent's semantic state inferred from current terminal evidence. +public enum AgentTerminalSemanticState: String, Sendable, Codable, CaseIterable, Equatable { + /// No supported foreground agent or no safe classification. + case unknown + /// A supported agent is ready for another turn. + case idle + /// A supported agent is executing a turn or tool. + case working + /// A supported agent requires a human decision or credential. + case blocked +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalStateClassification.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalStateClassification.swift new file mode 100644 index 000000000000..960fbea2703b --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalStateClassification.swift @@ -0,0 +1,32 @@ +/// One effective screen classification tied to a foreground process generation. +public struct AgentTerminalStateClassification: Sendable, Equatable { + /// The recognized profile, or `nil` for an unsupported process. + public let familyID: String? + /// Existing cmux status key for lifecycle/sidebar integration. + public let statusKey: String? + /// Canonical hook/session provider identifier, when recognized. + public let sessionProviderID: String? + /// Whether complete lifecycle hooks take precedence over screen evidence. + public let lifecycleAuthoritative: Bool + /// The inferred semantic state. + public let state: AgentTerminalSemanticState + /// The process generation that produced the result. + public let processIdentity: AgentTerminalProcessIdentity + + /// Creates a classification result. + public init( + familyID: String?, + statusKey: String?, + sessionProviderID: String? = nil, + lifecycleAuthoritative: Bool = false, + state: AgentTerminalSemanticState, + processIdentity: AgentTerminalProcessIdentity + ) { + self.familyID = familyID + self.statusKey = statusKey + self.sessionProviderID = sessionProviderID + self.lifecycleAuthoritative = lifecycleAuthoritative + self.state = state + self.processIdentity = processIdentity + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalStateClassifier.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalStateClassifier.swift new file mode 100644 index 000000000000..d16976f273d1 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalStateClassifier.swift @@ -0,0 +1,230 @@ +import Foundation + +/// Pure recognition and live-bottom classification using a prevalidated catalog. +public struct AgentTerminalStateClassifier: Sendable { + private let catalog: AgentTerminalProfileCatalog + private static let argumentWrapperBasenames: Set = [ + "acli", "bun", "deno", "node", "npm", "npx", "pnpm", "python", "python3", + "sandbox-exec", "ts-node", "tsx", "uv", "uvx", "yarn", + ] + private static let wrapperFlagOptionsByBasename: [String: Set] = [ + "npx": [ + "-q", "--ignore-existing", "--no-install", "--offline", + "--prefer-offline", "--quiet", "--yes", "-y", + ], + "uvx": ["--isolated", "--no-cache", "--offline"], + ] + private static let wrapperValueOptionsByBasename: [String: Set] = [ + "npx": ["--cache", "--package", "--prefix", "--registry", "--userconfig"], + "uvx": ["--from", "--python", "--with"], + ] + private static let wrapperSubcommandsByBasename: [String: Set] = [ + "bun": ["x"], + "npm": ["exec", "x"], + "pnpm": ["dlx", "exec", "x"], + "yarn": ["dlx", "exec"], + ] + private static let shellBasenames: Set = ["bash", "fish", "nu", "sh", "zsh"] + + /// Creates a classifier that reuses a catalog across evaluations. + public init(catalog: AgentTerminalProfileCatalog = .builtIn) { + self.catalog = catalog + } + + /// Recognizes the foreground process, including generic runtime wrappers and cmux hints. + public func recognize(_ process: AgentTerminalProcessSnapshot) -> AgentTerminalFamilyProfile? { + let normalizedPath = process.executablePath?.lowercased() + let executable = normalizedPath.map { URL(fileURLWithPath: $0).lastPathComponent.lowercased() } + if executable == "tmux" || executable.map(Self.shellBasenames.contains) == true { return nil } + if let executable, + let direct = catalog.profile(executableBasename: executable) { + return direct + } + if let normalizedPath, + let pathMatch = catalog.profiles.first(where: { profile in + profile.argumentNeedles.contains { normalizedPath.contains($0.lowercased()) } + }) { + return pathMatch + } + guard let executable, Self.isArgumentWrapperBasename(executable) else { return nil } + if let profile = wrappedExecutableProfile( + wrapperExecutable: executable, + arguments: process.arguments + ) { + return profile + } + for key in ["CMUX_AGENT", "CMUX_AGENT_LAUNCH_KIND"] { + if let hint = process.environment[key], let profile = catalog.profile(hint: hint) { + return profile + } + } + return nil + } + + /// Generic runtimes often remain the foreground process while their launch + /// target names the real agent executable. Inspect that one path only. + /// Later arguments may be subcommands, file paths, or user prompt text. + private func wrappedExecutableProfile( + wrapperExecutable: String, + arguments: [String], + depth: Int = 0 + ) -> AgentTerminalFamilyProfile? { + guard depth < 4 else { return nil } + guard let argvZero = arguments.first?.trimmingCharacters(in: .whitespacesAndNewlines), + !argvZero.isEmpty else { return nil } + + // Some Python agents replace argv[0] with a process title such as + // "Kimi Code". That title is the launch identity, not a prompt. + let rawTarget: String + let remainingArguments: ArraySlice + if URL(fileURLWithPath: argvZero).lastPathComponent.lowercased() != wrapperExecutable { + rawTarget = argvZero + remainingArguments = arguments.dropFirst() + } else { + let launcherArguments = arguments.dropFirst() + guard let targetIndex = Self.wrapperTargetIndex( + wrapperExecutable: wrapperExecutable, + in: launcherArguments + ) else { return nil } + rawTarget = launcherArguments[targetIndex] + remainingArguments = launcherArguments[launcherArguments.index(after: targetIndex)...] + } + let target = rawTarget.trimmingCharacters(in: .whitespacesAndNewlines) + guard !target.isEmpty else { return nil } + let targetBasename = URL(fileURLWithPath: target).lastPathComponent.lowercased() + if let exact = catalog.profile( + executableBasename: targetBasename + ) { + return exact + } + let normalizedTarget = target.lowercased() + if let argumentMatch = catalog.profiles.first(where: { profile in + profile.argumentNeedles.contains { normalizedTarget.contains($0.lowercased()) } + }) { + return argumentMatch + } + guard Self.isArgumentWrapperBasename(targetBasename) else { return nil } + return wrappedExecutableProfile( + wrapperExecutable: targetBasename, + arguments: [target] + Array(remainingArguments), + depth: depth + 1 + ) + } + + private static func wrapperTargetIndex( + wrapperExecutable: String, + in arguments: ArraySlice + ) -> ArraySlice.Index? { + let flagOptions = wrapperFlagOptionsByBasename[wrapperExecutable] ?? [] + let valueOptions = wrapperValueOptionsByBasename[wrapperExecutable] ?? [] + let wrapperSubcommands = wrapperSubcommandsByBasename[wrapperExecutable] ?? [] + var skippedWrapperSubcommand = false + var index = arguments.startIndex + while index < arguments.endIndex { + let argument = arguments[index] + if argument == "--" { + let targetIndex = arguments.index(after: index) + return targetIndex < arguments.endIndex ? targetIndex : nil + } + if flagOptions.contains(argument) { + index = arguments.index(after: index) + continue + } + if valueOptions.contains(argument) { + index = arguments.index(after: index) + guard index < arguments.endIndex else { return nil } + index = arguments.index(after: index) + continue + } + if valueOptions.contains(where: { argument.hasPrefix("\($0)=") }) { + index = arguments.index(after: index) + continue + } + if !skippedWrapperSubcommand, wrapperSubcommands.contains(argument) { + skippedWrapperSubcommand = true + index = arguments.index(after: index) + continue + } + return argument.hasPrefix("-") ? nil : index + } + return nil + } + + private static func isArgumentWrapperBasename(_ executable: String) -> Bool { + if argumentWrapperBasenames.contains(executable) { return true } + guard executable.hasPrefix("python") else { return false } + let version = executable.dropFirst("python".count) + guard !version.isEmpty else { return false } + return version.contains(where: \.isNumber) + && version.allSatisfy { $0.isNumber || $0 == "." } + } + + /// Classifies bounded plain-text evidence for one recognized generation. + public func classify(_ snapshot: AgentTerminalScreenSnapshot) -> AgentTerminalStateClassification { + guard let familyID = snapshot.familyID, let profile = catalog.profile(id: familyID) else { + return AgentTerminalStateClassification( + familyID: nil, + statusKey: nil, + sessionProviderID: nil, + state: .unknown, + processIdentity: snapshot.processIdentity + ) + } + let liveEvidence = snapshot.liveBottomText.lowercased() + let workingEvidence = Self.bottomRows(liveEvidence, maximumRows: 12) + let state: AgentTerminalSemanticState + if profile.historyViewNeedles.contains(where: liveEvidence.contains) { + state = snapshot.previousReliableState ?? .unknown + } else if Self.matchesAnyEvidenceGroup(profile.blockedEvidenceGroups, in: liveEvidence) + || Self.matchesAnyExactLine(profile.blockedExactLines, in: liveEvidence) { + state = .blocked + } else if Self.matchesAnyEvidenceGroup(profile.workingEvidenceGroups, in: workingEvidence) { + state = .working + } else { + // An explicit idle marker and a known-agent fallback have the same + // semantic result. Keeping both in profile data lets diagnostics + // distinguish strong evidence later without changing the contract. + state = .idle + } + return AgentTerminalStateClassification( + familyID: profile.id, + statusKey: profile.statusKey, + sessionProviderID: profile.sessionProviderID, + lifecycleAuthoritative: profile.lifecycleAuthoritative, + state: state, + processIdentity: snapshot.processIdentity + ) + } + + private static func matchesAnyEvidenceGroup(_ groups: [[String]], in evidence: String) -> Bool { + groups.contains { group in + !group.isEmpty && group.allSatisfy { evidence.contains($0.lowercased()) } + } + } + + private static func matchesAnyExactLine(_ needles: [String], in evidence: String) -> Bool { + guard !needles.isEmpty else { return false } + let lines = evidence.split(whereSeparator: \Character.isNewline).map { + $0.trimmingCharacters(in: .whitespaces) + } + return needles.contains { needle in lines.contains(needle) } + } + + private static func bottomRows(_ evidence: String, maximumRows: Int) -> String { + guard maximumRows > 0 else { return "" } + var rowStart = evidence.endIndex + var newlineCount = 0 + while rowStart > evidence.startIndex { + let previous = evidence.index(before: rowStart) + if evidence[previous] == "\n" { + newlineCount += 1 + if newlineCount == maximumRows { + return String(evidence[rowStart...]) + } + } + rowStart = previous + } + return evidence + } + +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalStateDetectionScheduler.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalStateDetectionScheduler.swift new file mode 100644 index 000000000000..26cee8a52235 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/AgentState/AgentTerminalStateDetectionScheduler.swift @@ -0,0 +1,115 @@ +public import Foundation + +/// Schedules coalesced, cancellable, bounded terminal-state evaluations. +/// +/// One persistent consumer task exists per registered surface. Rapid PTY +/// invalidations overwrite one buffered revision, while evaluation waits for +/// a quiet window or the burst's maximum latency. Results whose revision became +/// stale during capture are discarded, and observers receive effective changes only. +public actor AgentTerminalStateDetectionScheduler { + private let clock: AgentTerminalDetectionClock + private let configuration: AgentTerminalDetectionConfiguration + private var tasks: [UUID: Task] = [:] + private var lastEvaluatedRevision: [UUID: UInt64] = [:] + private var lastPublished: [UUID: AgentTerminalStateClassification] = [:] + + /// Creates a scheduler with an injected clock for deterministic timing tests. + public init(clock: AgentTerminalDetectionClock, configuration: AgentTerminalDetectionConfiguration = .init()) { + self.clock = clock + self.configuration = configuration + } + + /// Starts or replaces detection for one surface. + /// + /// - Parameters: + /// - surfaceID: Stable surface identifier. + /// - signal: Synchronous dirty signal installed in the PTY tee. + /// - evaluate: Deferred snapshot/classification operation for a requested revision. + /// - deliver: Per-surface delivery that cannot overwrite another surface's update. + public func start( + surfaceID: UUID, + signal: AgentTerminalDirtySignal, + evaluate: @escaping @Sendable (_ revision: UInt64) async -> AgentTerminalStateClassification?, + deliver: @escaping @Sendable (AgentTerminalDetectionUpdate) async -> Void + ) { + stop(surfaceID: surfaceID) + tasks[surfaceID] = Task { [weak self] in + await self?.consume( + surfaceID: surfaceID, + signal: signal, + evaluate: evaluate, + deliver: deliver + ) + } + } + + /// Cancels detection and clears cached state for one surface. + public func stop(surfaceID: UUID) { + tasks.removeValue(forKey: surfaceID)?.cancel() + lastEvaluatedRevision.removeValue(forKey: surfaceID) + lastPublished.removeValue(forKey: surfaceID) + } + + /// Cancels every registered surface. + public func stopAll() { + for task in tasks.values { task.cancel() } + tasks.removeAll() + lastEvaluatedRevision.removeAll() + lastPublished.removeAll() + } + + private func consume( + surfaceID: UUID, + signal: AgentTerminalDirtySignal, + evaluate: @escaping @Sendable (UInt64) async -> AgentTerminalStateClassification?, + deliver: @escaping @Sendable (AgentTerminalDetectionUpdate) async -> Void + ) async { + for await receivedRevision in signal.revisions { + guard !Task.isCancelled else { return } + guard receivedRevision > (lastEvaluatedRevision[surfaceID] ?? 0) else { continue } + let burstStartedAt = await clock.now() + var observedRevision = signal.currentRevision() + + while !Task.isCancelled { + let elapsed = await clock.now() - burstStartedAt + if elapsed >= configuration.maximumLatency { break } + let remaining = configuration.maximumLatency - elapsed + let delay = min(configuration.quietWindow, remaining) + let beforeDelay = signal.currentRevision() + do { + // This is the intended cancellable debounce/deadline delay. + try await clock.sleep(for: delay) + } catch { + return + } + observedRevision = signal.currentRevision() + if observedRevision == beforeDelay { break } + } + + guard !Task.isCancelled else { return } + let revision = max(observedRevision, receivedRevision) + guard revision > (lastEvaluatedRevision[surfaceID] ?? 0) else { continue } + var classification = await evaluate(revision) + if classification == nil, !Task.isCancelled, signal.currentRevision() == revision { + do { + try await clock.sleep(for: configuration.quietWindow) + } catch { + return + } + if !Task.isCancelled, signal.currentRevision() == revision { + classification = await evaluate(revision) + } + } + guard let classification else { continue } + guard !Task.isCancelled, signal.currentRevision() == revision else { continue } + lastEvaluatedRevision[surfaceID] = revision + guard lastPublished[surfaceID] != classification else { continue } + lastPublished[surfaceID] = classification + await deliver(AgentTerminalDetectionUpdate( + surfaceID: surfaceID, + revision: revision, + classification: classification + )) + } + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCmuxContextEnvironment.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCmuxContextEnvironment.swift index b2209534bf1d..60116ca40f9e 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCmuxContextEnvironment.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCmuxContextEnvironment.swift @@ -14,15 +14,19 @@ public struct TerminalSurfaceCmuxContextEnvironment: Equatable, Sendable { /// The control socket path (exported as `CMUX_SOCKET_PATH`). public let socketPath: String + /// The unique app-process id exported as `CMUX_RUNTIME_ID`. + public let runtimeId: String? + /// Creates the managed context identity. /// /// - Parameters: /// - workspaceId: The owning workspace id. /// - surfaceId: The surface id. /// - socketPath: The control socket path. - public init(workspaceId: UUID, surfaceId: UUID, socketPath: String) { + public init(workspaceId: UUID, surfaceId: UUID, socketPath: String, runtimeId: String? = nil) { self.workspaceId = workspaceId self.surfaceId = surfaceId self.socketPath = socketPath + self.runtimeId = runtimeId } } diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/AgentTerminalStateClassifierTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/AgentTerminalStateClassifierTests.swift new file mode 100644 index 000000000000..e926079b8bf5 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/AgentTerminalStateClassifierTests.swift @@ -0,0 +1,505 @@ +import Testing +@testable import CmuxTerminalCore + +@Suite +struct AgentTerminalStateClassifierTests { + private let classifier = AgentTerminalStateClassifier() + + @Test(arguments: [ + ("pi", "pi"), + ("omp", "omp"), + ("copilot", "copilot"), + ("devin", "devin"), + ("kimi", "kimi"), + ("hermes-agent", "hermes-agent"), + ("qoder", "qoder"), + ("droid", "droid"), + ("opencode", "opencode"), + ("kilo-code", "kilo"), + ("mastracode", "mastracode"), + ("claude", "claude-code"), + ("codex", "codex"), + ("cursor-agent", "cursor-agent"), + ("amp", "amp"), + ("grok", "grok"), + ("antigravity", "antigravity"), + ("kiro-cli", "kiro"), + ("maki", "maki"), + ("gemini", "gemini"), + ("cline", "cline"), + ]) + func recognizesEveryRequiredFamily(executable: String, expectedID: String) throws { + let profile = try #require(classifier.recognize(process(executable: executable))) + #expect(profile.id == expectedID) + } + + @Test(arguments: [ + "pi", "omp", "copilot", "devin", "kimi", "hermes-agent", "qoder", "droid", + "opencode", "kilo", "mastracode", "claude-code", "codex", "cursor-agent", "amp", + "grok", "antigravity", "kiro", "maki", "gemini", "cline", + ]) + func knownFamiliesConservativelyFallBackToIdle(familyID: String) { + let classification = classifier.classify(screen(familyID: familyID, text: "")) + #expect(classification.state == .idle) + } + + @Test + func unknownFamilyIsUnknown() { + let classification = classifier.classify(screen(familyID: "unsupported", text: "Working...")) + #expect(classification.state == .unknown) + #expect(classification.familyID == nil) + } + + @Test + func recognizesAgentsHiddenByGenericRuntimes() throws { + let node = process(executable: "node", arguments: ["node", "/opt/@openai/codex/bin/codex.js"]) + #expect(try #require(classifier.recognize(node)).id == "codex") + + let bun = process(executable: "bun", arguments: ["bun", "/app/opencode/dist/index.js"]) + #expect(try #require(classifier.recognize(bun)).id == "opencode") + } + + @Test + func recognizesAgentsAfterKnownWrapperOptions() throws { + let npx = process( + executable: "npx", + arguments: ["npx", "-y", "@anthropic-ai/claude-code"] + ) + #expect(try #require(classifier.recognize(npx)).id == "claude-code") + + let uvx = process( + executable: "uvx", + arguments: ["uvx", "--from", "kimi-cli", "kimi-code"] + ) + #expect(try #require(classifier.recognize(uvx)).id == "kimi") + + let nested = process( + executable: "npx", + arguments: ["npx", "-y", "uvx", "--from", "kimi-cli", "kimi-code"] + ) + #expect(try #require(classifier.recognize(nested)).id == "kimi") + } + + @Test + func recognizesAgentsAfterPackageManagerSubcommands() throws { + let npmExec = process( + executable: "npm", + arguments: ["npm", "exec", "--", "@anthropic-ai/claude-code"] + ) + #expect(try #require(classifier.recognize(npmExec)).id == "claude-code") + + let npmX = process( + executable: "npm", + arguments: ["npm", "x", "--", "@openai/codex"] + ) + #expect(try #require(classifier.recognize(npmX)).id == "codex") + + let pnpmDlx = process( + executable: "pnpm", + arguments: ["pnpm", "dlx", "@anthropic-ai/claude-code"] + ) + #expect(try #require(classifier.recognize(pnpmDlx)).id == "claude-code") + } + + @Test + func recognizesOnlyTheRovoDevAcliSubcommand() throws { + let rovo = process( + executable: "acli", + arguments: ["acli", "rovodev", "run"] + ) + #expect(try #require(classifier.recognize(rovo)).id == "rovodev") + + let jira = process( + executable: "acli", + arguments: ["acli", "jira", "issue", "list"] + ) + #expect(classifier.recognize(jira) == nil) + } + + @Test + func exactWrappedAgentExecutableOutranksInheritedHint() throws { + let wrapped = AgentTerminalProcessSnapshot( + identity: identity, + executablePath: "/usr/local/bin/node", + arguments: ["node", "/Users/test/.bun/bin/claude", "--resume", "session-id"], + environment: ["CMUX_AGENT_LAUNCH_KIND": "codex"] + ) + + #expect(try #require(classifier.recognize(wrapped)).id == "claude-code") + } + + @Test + func recognizesCodexExecutableBehindGenericWrappers() throws { + let node = process( + executable: "node", + arguments: ["node", "/Users/test/.bun/bin/codex", "fork", "session-id"] + ) + #expect(try #require(classifier.recognize(node)).id == "codex") + + let bun = process( + executable: "bun", + arguments: ["bun", "/Users/test/.bun/bin/codex", "resume", "session-id"] + ) + #expect(try #require(classifier.recognize(bun)).id == "codex") + } + + @Test + func wrappedPromptTextCannotImpersonateAnAgent() { + let wrapped = process( + executable: "node", + arguments: ["node", "/app/tool.js", "--prompt", "please run @openai/codex next"] + ) + + #expect(classifier.recognize(wrapped) == nil) + } + + @Test + func wrappedRecognitionOnlyInspectsTheLaunchTarget() { + let wrapped = process( + executable: "node", + arguments: ["node", "/tmp/tool.js", "/tmp/codex"] + ) + + #expect(classifier.recognize(wrapped) == nil) + } + + @Test + func versionedPythonRuntimeRecognizesKimiProcessTitle() throws { + let kimi = process(executable: "python3.14", arguments: ["Kimi Code"]) + #expect(try #require(classifier.recognize(kimi)).id == "kimi") + } + + @Test + func unrecognizedProcessGenerationRemainsEligibleForRecognition() { + let identity = AgentTerminalProcessIdentity( + pid: 42, + startSeconds: 100, + startMicroseconds: 200, + runtimeGeneration: 3 + ) + var cache = AgentTerminalRecognitionCache() + + cache.store(identity: identity, familyID: nil) + #expect(cache.requiresSnapshot(for: identity)) + + cache.store(identity: identity, familyID: "kimi") + #expect(!cache.requiresSnapshot(for: identity)) + #expect(cache.familyID(for: identity) == "kimi") + } + + @Test + func scopedHintRecognizesOpaqueWrapper() throws { + let wrapped = AgentTerminalProcessSnapshot( + identity: identity, + executablePath: "/usr/local/bin/sandbox-exec", + arguments: ["sandbox-exec", "guest"], + environment: ["CMUX_AGENT": "claude_code"] + ) + #expect(try #require(classifier.recognize(wrapped)).id == "claude-code") + } + + @Test + func directExecutableOutranksInheritedLaunchHint() throws { + let direct = AgentTerminalProcessSnapshot( + identity: identity, + executablePath: "/usr/local/bin/codex", + arguments: ["codex"], + environment: ["CMUX_AGENT_LAUNCH_KIND": "claude"] + ) + #expect(try #require(classifier.recognize(direct)).id == "codex") + } + + @Test + func launchHintRecognizesOpaqueWrapperOnlyAfterDirectIdentityMisses() throws { + let wrapped = AgentTerminalProcessSnapshot( + identity: identity, + executablePath: "/usr/bin/sandbox-exec", + arguments: ["sandbox-exec", "opaque-wrapper"], + environment: ["CMUX_AGENT_LAUNCH_KIND": "claude"] + ) + #expect(try #require(classifier.recognize(wrapped)).id == "claude-code") + } + + @Test + func shellArgumentsAndInheritedHintsCannotImpersonateAnAgent() { + let shell = AgentTerminalProcessSnapshot( + identity: identity, + executablePath: "/bin/zsh", + arguments: ["zsh", "-c", "echo codex opencode"], + environment: ["CMUX_AGENT_LAUNCH_KIND": "claude"] + ) + #expect(classifier.recognize(shell) == nil) + } + + @Test + func versionedAgentPathRecognizesWithoutGenericArgumentHost() throws { + let versioned = AgentTerminalProcessSnapshot( + identity: identity, + executablePath: "/Users/test/.local/share/claude/versions/2.1.212", + arguments: ["2.1.212"] + ) + #expect(try #require(classifier.recognize(versioned)).id == "claude-code") + } + + @Test + func nestedTmuxIsAnIntentionalBoundary() { + let nested = process(executable: "tmux", arguments: ["tmux", "new", "codex"]) + #expect(classifier.recognize(nested) == nil) + } + + @Test + func currentAgentFixturesClassifyWorkingAndBlocked() { + #expect(classifier.classify(screen( + familyID: "claude-code", + text: "⠋ Editing files (12s · esc to interrupt)" + )).state == .working) + #expect(classifier.classify(screen( + familyID: "codex", + text: "Working (8s • esc to interrupt)" + )).state == .working) + #expect(classifier.classify(screen( + familyID: "pi", + text: "⠙ Working..." + )).state == .working) + #expect(classifier.classify(screen( + familyID: "opencode", + text: "━━━ esc interrupt" + )).state == .working) + #expect(classifier.classify(screen( + familyID: "cursor-agent", + text: "⠋ Running · 4.2k tokens" + )).state == .working) + #expect(classifier.classify(screen( + familyID: "gemini", + text: "Enter your API key" + )).state == .blocked) + #expect(classifier.classify(screen( + familyID: "kimi", + text: "Authorization failed\nSession may have expired\nType /login to re-authenticate" + )).state == .blocked) + #expect(classifier.classify(screen( + familyID: "claude-code", + text: "This command requires approval\nDo you want to proceed?\n1. Yes\n2. No" + )).state == .blocked) + #expect(classifier.classify(screen( + familyID: "codex", + text: "Would you like to run the following command?\n1. Yes\n2. No" + )).state == .blocked) + } + + @Test + func exactBlockedPromptMustOccupyItsOwnRenderedLine() { + #expect(classifier.classify(screen( + familyID: "gemini", + text: "Enter your API key" + )).state == .blocked) + #expect(classifier.classify(screen( + familyID: "gemini", + text: "The setup guide says to enter your API key before continuing." + )).state == .idle) + } + + @Test + func geminiAPIKeyDialogRequiresItsCorroboratingPromptRows() { + let capturedInteraction = [ + "Gemini CLI v0.51.0", + "Enter Gemini API Key", + "Please enter your Gemini API key. It will be securely stored in your system keychain.", + "Paste your API key here", + "(Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key)", + ].joined(separator: "\n") + #expect(classifier.classify(screen(familyID: "gemini", text: capturedInteraction)).state == .blocked) + } + + @Test + func kimiUpdateDialogIgnoresRenderedColumnSpacing() { + let capturedInteraction = [ + "kimi-cli update available", + "[Enter] Upgrade now (uv tool upgrade kimi-cli)", + "[q] Not now, remind me next time", + "[s] Skip reminders for version 1.47.0", + ].joined(separator: "\n") + #expect(classifier.classify(screen(familyID: "kimi", text: capturedInteraction)).state == .blocked) + } + + @Test + func stalePiSpinnerAboveIdleComposerDoesNotRemainWorking() { + let capturedTail = [ + "Read only. Inspect the repository for 20 seconds.", + "", + "⠇ Working...", + "", + "────────────────────────────────────────", + "", + "Error: No API key for provider: openai-codex", + "", + "Warning: cmux Pi integration warning", + "", + "Warning: cmux Pi integration warning", + "", + "────────────────────────────────────────", + " ", + "────────────────────────────────────────", + "~/project (feat-agent-terminal-state-detection)", + "$0.000 (sub) 0.0%/272k (auto) (openai-codex) gpt-5.4-mini", + "", + ].joined(separator: "\n") + #expect(classifier.classify(screen(familyID: "pi", text: capturedTail)).state == .idle) + } + + @Test + func codexMultilineApprovalPromptRemainsInsideBlockedEvidenceWindow() { + let capturedInteraction = [ + "Would you like to run the following command?", + "", + "Environment: local", + "", + "$ touch /tmp/cmux-agent-state-blocked-proof", + "", + "1. Yes, proceed (y)", + "2. Yes, and don't ask again (p)", + "3. No, and tell Codex what to do differently (esc)", + "", + "Press enter to confirm or esc to cancel", + ].joined(separator: "\n") + #expect(classifier.classify(screen(familyID: "codex", text: capturedInteraction)).state == .blocked) + } + + @Test + func codexApprovalPromptUsesRenderedRowsInsteadOfVTLineSeparators() { + let ghosttyRenderedInteraction = [ + "Would you like to run the following command?", + "", + "Environment: local", + "", + "$ touch /tmp/cmux-agent-state-blocked-proof", + "", + "1. Yes, proceed (y)", + "2. Yes, and don't ask again (p)", + "3. No, and tell Codex what to do differently (esc)", + "", + "Press enter to confirm or esc to cancel", + ].joined(separator: "\r\n") + #expect(classifier.classify(screen(familyID: "codex", text: ghosttyRenderedInteraction)).state == .blocked) + } + + @Test + func genericWordsAndQuotedQuestionsDoNotCreateActivity() { + #expect(classifier.classify(screen( + familyID: "cursor-agent", + text: "The running process finished without token output" + )).state == .idle) + #expect(classifier.classify(screen( + familyID: "claude-code", + text: "The documentation says: do you want to proceed" + )).state == .idle) + } + + @Test + func staleScrollbackOutsideLiveTailCannotForceBlocked() { + let historical = "Permission required\n" + Array(repeating: "completed output", count: 40).joined(separator: "\n") + #expect(classifier.classify(screen(familyID: "omp", text: historical + "\ncontext 18%" )).state == .idle) + } + + @Test + func historyViewerPreservesLastReliableState() { + let snapshot = AgentTerminalScreenSnapshot( + processIdentity: identity, + familyID: "codex", + liveBottomText: "Session history\nPermission required", + previousReliableState: .working + ) + #expect(classifier.classify(snapshot).state == .working) + } + + @Test + func historyViewerWithoutPriorStateIsUnknown() { + #expect(classifier.classify(screen( + familyID: "codex", + text: "Session history\nWould you like to run the following command?\nYes\nNo" + )).state == .unknown) + } + + @Test + func lifecycleAuthorityOutranksContradictoryScreenState() { + let resolver = AgentTerminalAuthorityResolver() + #expect(resolver.resolve(authoritative: .blocked, screen: .working) == .blocked) + #expect(resolver.resolve(authoritative: .idle, screen: .working) == .idle) + #expect(resolver.resolve(authoritative: nil, screen: .working) == .working) + #expect(resolver.resolve( + authoritative: .idle, + screen: .working, + lifecycleAuthoritative: false + ) == .working) + } + + @Test + func invalidReplacementCatalogCanLeavePriorCatalogIntact() { + let prior = AgentTerminalProfileCatalog.builtIn + let duplicate = prior.profiles[0] + #expect(AgentTerminalProfileCatalog(profiles: [duplicate, duplicate]) == nil) + #expect(prior.profiles.count >= 26) + } + + @Test + func replacementCatalogNormalizesIdentityDataAndRejectsAmbiguity() throws { + let normalized = try #require(AgentTerminalProfileCatalog(profiles: [profile( + id: " Example_Agent ", + executable: " Example " + )])) + #expect(normalized.profiles[0].id == "example-agent") + #expect(normalized.profiles[0].executableBasenames == ["example"]) + + #expect(AgentTerminalProfileCatalog(profiles: [ + profile(id: "one", executable: "shared"), + profile(id: "two", executable: " SHARED "), + ]) == nil) + #expect(AgentTerminalProfileCatalog(profiles: [ + profile(id: "empty", executable: "empty", argumentNeedles: [" "]), + ]) == nil) + #expect(AgentTerminalProfileCatalog(profiles: [ + profile(id: "empty-group", executable: "empty-group", working: [[]]), + ]) == nil) + #expect(AgentTerminalProfileCatalog(profiles: [ + profile(id: "singleton-blocked", executable: "singleton-blocked", blocked: [["approval required"]]), + ]) == nil) + } + + private var identity: AgentTerminalProcessIdentity { + AgentTerminalProcessIdentity(pid: 42, startSeconds: 100, startMicroseconds: 5, runtimeGeneration: 3) + } + + private func process(executable: String, arguments: [String]? = nil) -> AgentTerminalProcessSnapshot { + AgentTerminalProcessSnapshot( + identity: identity, + executablePath: "/usr/local/bin/\(executable)", + arguments: arguments ?? [executable] + ) + } + + private func screen(familyID: String?, text: String) -> AgentTerminalScreenSnapshot { + AgentTerminalScreenSnapshot( + processIdentity: identity, + familyID: familyID, + liveBottomText: text + ) + } + + private func profile( + id: String, + executable: String, + argumentNeedles: [String] = [], + working: [[String]] = [], + blocked: [[String]] = [] + ) -> AgentTerminalFamilyProfile { + AgentTerminalFamilyProfile( + id: id, + statusKey: id, + displayName: id, + executableBasenames: [executable], + argumentNeedles: argumentNeedles, + workingEvidenceGroups: working, + blockedEvidenceGroups: blocked + ) + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/AgentTerminalStateDetectionSchedulerTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/AgentTerminalStateDetectionSchedulerTests.swift new file mode 100644 index 000000000000..c04548e3fbc2 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/AgentTerminalStateDetectionSchedulerTests.swift @@ -0,0 +1,210 @@ +import Foundation +import Testing +@testable import CmuxTerminalCore + +@Suite +struct AgentTerminalStateDetectionSchedulerTests { + @Test + func bufferingNewestCoalescesBurstAndPublishesLatestRevision() async throws { + let scheduler = AgentTerminalStateDetectionScheduler( + clock: AgentTerminalDetectionClock(now: { .zero }, sleep: { _ in }), + configuration: AgentTerminalDetectionConfiguration(quietWindow: .zero, maximumLatency: .zero) + ) + let signal = AgentTerminalDirtySignal() + let surfaceID = UUID() + let recorder = SchedulerEvaluationRecorder() + let updates = SchedulerUpdateRecorder() + let identity = AgentTerminalProcessIdentity( + pid: 7, + startSeconds: 1, + startMicroseconds: 2, + runtimeGeneration: 3 + ) + + signal.markDirty() + signal.markDirty() + await scheduler.start(surfaceID: surfaceID, signal: signal) { revision in + await recorder.record(revision) + return AgentTerminalStateClassification( + familyID: "codex", + statusKey: "codex", + state: .working, + processIdentity: identity + ) + } deliver: { update in + await updates.record(update) + } + + await updates.waitForCount(1) + let update = try #require(await updates.values.first) + #expect(update.revision == 2) + #expect(await recorder.revisions == [2]) + await scheduler.stopAll() + } + + @Test + func unchangedClassificationDoesNotPublishAgain() async throws { + let scheduler = immediateScheduler() + let signal = AgentTerminalDirtySignal() + let surfaceID = UUID() + let state = SchedulerClassificationState() + let recorder = SchedulerEvaluationRecorder() + let updates = SchedulerUpdateRecorder() + + await scheduler.start(surfaceID: surfaceID, signal: signal) { revision in + await recorder.record(revision) + return await state.classification(revision: revision) + } deliver: { update in + await updates.record(update) + } + signal.markDirty() + await updates.waitForCount(1) + #expect(try #require(await updates.values.first).classification.state == .working) + + signal.markDirty() + await recorder.waitForCount(2) + await state.set(.idle) + signal.markDirty() + await updates.waitForCount(2) + let next = try #require(await updates.values.last) + #expect(next.revision == 3) + #expect(next.classification.state == .idle) + await scheduler.stopAll() + } + + @Test + func resultThatBecomesStaleDuringEvaluationIsDiscarded() async throws { + let scheduler = immediateScheduler() + let signal = AgentTerminalDirtySignal() + let surfaceID = UUID() + let recorder = SchedulerEvaluationRecorder() + let gate = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let updates = SchedulerUpdateRecorder() + + await scheduler.start(surfaceID: surfaceID, signal: signal) { revision in + await recorder.record(revision) + if revision == 1 { + var iterator = gate.stream.makeAsyncIterator() + _ = await iterator.next() + } + return makeClassification(revision == 1 ? .working : .idle, pid: Int32(revision)) + } deliver: { update in + await updates.record(update) + } + signal.markDirty() + await recorder.waitForCount(1) + signal.markDirty() + gate.continuation.yield(()) + + await updates.waitForCount(1) + let update = try #require(await updates.values.first) + #expect(update.revision == 2) + #expect(update.classification.state == .idle) + #expect(await recorder.revisions == [1, 2]) + await scheduler.stopAll() + } + + @Test + func simultaneousSurfacesDeliverIndependently() async throws { + let scheduler = immediateScheduler() + let firstSignal = AgentTerminalDirtySignal() + let secondSignal = AgentTerminalDirtySignal() + let firstSurface = UUID() + let secondSurface = UUID() + let updates = SchedulerUpdateRecorder() + + await scheduler.start(surfaceID: firstSurface, signal: firstSignal) { _ in + makeClassification(.working, pid: 11) + } deliver: { update in + await updates.record(update) + } + await scheduler.start(surfaceID: secondSurface, signal: secondSignal) { _ in + makeClassification(.blocked, pid: 22) + } deliver: { update in + await updates.record(update) + } + firstSignal.markDirty() + secondSignal.markDirty() + + await updates.waitForCount(2) + let delivered = await updates.values + #expect(Set(delivered.map(\.surfaceID)) == Set([firstSurface, secondSurface])) + #expect(delivered.first(where: { $0.surfaceID == firstSurface })?.classification.state == .working) + #expect(delivered.first(where: { $0.surfaceID == secondSurface })?.classification.state == .blocked) + await scheduler.stopAll() + } + + private func immediateScheduler() -> AgentTerminalStateDetectionScheduler { + AgentTerminalStateDetectionScheduler( + clock: AgentTerminalDetectionClock(now: { .zero }, sleep: { _ in }), + configuration: AgentTerminalDetectionConfiguration(quietWindow: .zero, maximumLatency: .zero) + ) + } +} + +private actor SchedulerEvaluationRecorder { + private(set) var revisions: [UInt64] = [] + private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func record(_ revision: UInt64) { + revisions.append(revision) + let ready = waiters.filter { revisions.count >= $0.count } + waiters.removeAll { revisions.count >= $0.count } + for waiter in ready { waiter.continuation.resume() } + } + + func waitForCount(_ count: Int) async { + if revisions.count >= count { return } + await withCheckedContinuation { continuation in + waiters.append((count, continuation)) + } + } +} + +private actor SchedulerUpdateRecorder { + private(set) var values: [AgentTerminalDetectionUpdate] = [] + private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func record(_ update: AgentTerminalDetectionUpdate) { + values.append(update) + let ready = waiters.filter { values.count >= $0.count } + waiters.removeAll { values.count >= $0.count } + for waiter in ready { waiter.continuation.resume() } + } + + func waitForCount(_ count: Int) async { + if values.count >= count { return } + await withCheckedContinuation { continuation in + waiters.append((count, continuation)) + } + } +} + +private actor SchedulerClassificationState { + private var state: AgentTerminalSemanticState = .working + + func set(_ state: AgentTerminalSemanticState) { + self.state = state + } + + func classification(revision: UInt64) -> AgentTerminalStateClassification { + makeClassification(state) + } +} + +private func makeClassification( + _ state: AgentTerminalSemanticState, + pid: Int32 = 7 +) -> AgentTerminalStateClassification { + AgentTerminalStateClassification( + familyID: "codex", + statusKey: "codex", + state: state, + processIdentity: AgentTerminalProcessIdentity( + pid: pid, + startSeconds: 1, + startMicroseconds: 2, + runtimeGeneration: 3 + ) + ) +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 20637e9cf748..ab12889296fe 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -1922,6 +1922,23 @@ } } }, + "agent.kimi.displayName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kimi Code" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kimi Code" + } + } + } + }, "agent.ollama.displayName": { "extractionState": "manual", "localizations": { @@ -34248,6 +34265,23 @@ } } }, + "cli.hooks.usage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux hooks setup [agent] [--agent ] [--yes|-y]\n cmux hooks uninstall [agent] [--agent ] [--yes|-y]\n cmux hooks install [--yes|-y] (opencode supports --project)\n cmux hooks uninstall [--yes|-y] (opencode supports --project)\n cmux hooks [flags]\n cmux hooks feed --source [--event ]\n\nManage and run cmux agent hooks without adding one top-level command per\nagent. Claude Code hooks are injected automatically by the cmux Claude wrapper.\n\nAgents:\n codex, grok, opencode, pi, omp, campfire, amp, cursor, gemini, kiro, antigravity (alias: agy), rovodev (alias: rovo), hermes-agent, copilot, codebuddy, factory, qoder, kimi\n\nHook targets:\n setup Install hooks for all supported agents on PATH\n uninstall Remove hooks for all supported agents\n install Install one agent integration\n uninstall Remove one agent integration\n Internal hook entrypoint used by generated configs\n feed Internal Feed decision bridge\n\nGenerated files:\n ~/.config/opencode/plugins/cmux-session.js\n ~/.config/opencode/plugins/cmux-feed.js\n ~/.pi/agent/extensions/cmux-session.ts\n ~/.omp/agent/extensions/cmux-omp-session.ts\n ~/.campfire/agent/extensions/cmux-campfire-session.ts\n ~/.config/amp/plugins/cmux-session.ts\n ~/.kiro/agents/cmux.json\n ~/.kimi/config.toml\n See docs/agent-hooks.md for the full integration matrix.\n\nExamples:\n cmux hooks setup\n cmux hooks setup --agent codex\n cmux hooks setup rovo\n cmux hooks setup omp\n cmux hooks uninstall rovo\n cmux hooks codex install\n cmux hooks opencode install --project\n cmux hooks uninstall" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使い方: cmux hooks setup [agent] [--agent ] [--yes|-y]\n cmux hooks uninstall [agent] [--agent ] [--yes|-y]\n cmux hooks install [--yes|-y] (opencode は --project に対応)\n cmux hooks uninstall [--yes|-y] (opencode は --project に対応)\n cmux hooks [flags]\n cmux hooks feed --source [--event ]\n\nエージェントごとにトップレベルコマンドを追加せず、cmux のエージェントフックを管理・実行します。\nClaude Code のフックは cmux Claude ラッパーによって自動的に挿入されます。\n\nエージェント:\n codex, grok, opencode, pi, omp, campfire, amp, cursor, gemini, kiro, antigravity (別名: agy), rovodev (別名: rovo), hermes-agent, copilot, codebuddy, factory, qoder, kimi\n\nフック対象:\n setup PATH 上の対応エージェントすべてにフックをインストール\n uninstall 対応エージェントすべてのフックを削除\n install 1 つのエージェント連携をインストール\n uninstall 1 つのエージェント連携を削除\n 生成された設定が使う内部フックエントリポイント\n feed 内部 Feed 判定ブリッジ\n\n生成されるファイル:\n ~/.config/opencode/plugins/cmux-session.js\n ~/.config/opencode/plugins/cmux-feed.js\n ~/.pi/agent/extensions/cmux-session.ts\n ~/.omp/agent/extensions/cmux-omp-session.ts\n ~/.campfire/agent/extensions/cmux-campfire-session.ts\n ~/.config/amp/plugins/cmux-session.ts\n ~/.kiro/agents/cmux.json\n ~/.kimi/config.toml\n 連携一覧は docs/agent-hooks.md を参照してください。\n\n例:\n cmux hooks setup\n cmux hooks setup --agent codex\n cmux hooks setup rovo\n cmux hooks setup omp\n cmux hooks uninstall rovo\n cmux hooks codex install\n cmux hooks opencode install --project\n cmux hooks uninstall" + } + } + } + }, "cli.hooks.antigravity.aborted": { "extractionState": "manual", "localizations": { @@ -35319,6 +35353,125 @@ } } }, + "cli.hooks.copilot.aborted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Aborted." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中止しました。" + } + } + } + }, + "cli.hooks.copilot.alreadyUpToDate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ hooks already up to date at %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ hooks はすでに %@ で最新です" + } + } + } + }, + "cli.hooks.copilot.confirmProceed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "\nProceed? [y/N] " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "\n続行しますか? [y/N] " + } + } + } + }, + "cli.hooks.copilot.error.invalidConfig": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ has an unsupported Copilot hook schema. Fix or remove it before changing hooks." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の Copilot hook スキーマはサポートされていません。hook を変更する前に修正または削除してください。" + } + } + } + }, + "cli.hooks.copilot.installed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ hooks installed at %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ hooks を %@ にインストールしました" + } + } + } + }, + "cli.hooks.copilot.noneFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No Copilot cmux hooks found at %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に Copilot cmux hooks が見つかりません" + } + } + } + }, + "cli.hooks.copilot.removed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Removed %lld Copilot cmux hook(s)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Copilot cmux hook を %lld 個削除しました" + } + } + } + }, "cli.hooks.error.configDirectoryIsFile": { "extractionState": "manual", "localizations": { @@ -43334,19 +43487,215 @@ } } }, + "cli.agents.list.error.unknownActivity": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents list: unknown activity '%@'" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents list: 不明なアクティビティ '%@'" } } + } + }, + "cli.agents.list.error.unknownState": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents list: unknown state '%@'" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents list: 不明な状態 '%@'" } } + } + }, + "cli.agents.list.error.unknownWorkKind": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents list: unknown workload kind '%@'" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents list: 不明なワークロード種別 '%@'" } } + } + }, + "cli.agents.error.runtimeMismatch": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents: live observation runtime does not match the connected cmux instance" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents: ライブ観測のランタイムが接続中の cmux インスタンスと一致しません" } } + } + }, + "cli.agents.error.stateUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%@: saved agent state at %@ is unavailable" } }, + "ja": { "stringUnit": { "state": "translated", "value": "%@: %@ に保存されたエージェント状態を利用できません" } } + } + }, + "cli.agents.error.storeLoadFailed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents: [%@] saved %@ agent state at %@ could not be read and no complete fallback is available" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents: [%@] %@ エージェントの保存状態 (%@) を読み込めず、完全な代替データもありません" } } + } + }, + "cli.agents.error.storageLimitExceeded": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents: [%@] saved %@ agent state at %@ exceeds the %@ inspection limit (%lld bytes observed, %lld maximum); %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents: [%@] %@ エージェントの保存状態 (%@) が %@ の検査上限を超えています(確認済み %lld バイト、上限 %lld バイト)。%@" } } + } + }, + "cli.agents.error.storageLimitExceeded.session": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents: [%@] saved %@ agent state at %@ exceeds the %@ inspection limit for session %@ (%lld bytes observed, %lld maximum); %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents: [%@] %@ エージェントの保存状態 (%@) が %@ の検査上限をセッション %@ で超えています(確認済み %lld バイト、上限 %lld バイト)。%@" } } + } + }, + "cli.agents.error.storageLimitExceededCount": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents: [%@] saved %@ agent state at %@ exceeds the %@ inspection limit (%lld entries observed, %lld maximum); %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents: [%@] %@ エージェントの保存状態 (%@) が %@ の検査上限を超えています(確認済み %lld 件、上限 %lld 件)。%@" } } + } + }, + "cli.agents.error.storageLimitExceededCount.session": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents: [%@] saved %@ agent state at %@ exceeds the %@ inspection limit for session %@ (%lld entries observed, %lld maximum); %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents: [%@] %@ エージェントの保存状態 (%@) が %@ の検査上限をセッション %@ で超えています(確認済み %lld 件、上限 %lld 件)。%@" } } + } + }, + "cli.agents.error.storageLimitGuidance.canonical": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Retry with --agent %@ to narrow the selection; inspect the canonical store at %@ before changing it." } }, + "ja": { "stringUnit": { "state": "translated", "value": "--agent %@ で対象を絞って再実行してください。変更する前に正規ストア %@ を確認してください。" } } + } + }, + "cli.agents.error.storageLimitGuidance.graph": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Narrow the selection with --agent %@ or raise --max-nodes, up to %lld." } }, + "ja": { "stringUnit": { "state": "translated", "value": "--agent %@ で対象を絞るか、--max-nodes を最大 %lld まで増やしてください。" } } + } + }, + "cli.agents.error.storageLimitGuidance.legacy": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Move %@ aside without deleting it, then rerun so cmux can rebuild it from %@. If that database has no %@ rows, keep the moved file and restore it before proceeding." } }, + "ja": { "stringUnit": { "state": "translated", "value": "%@ を削除せずに別の場所へ移動し、再実行して %@ から再構築してください。そのデータベースに %@ の行がない場合は、移動したファイルを保持し、作業を続ける前に元へ戻してください。" } } + } + }, + "cli.agents.warning.authoritativeSnapshotDecodeFailed.legacy": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Warning [%@]: saved %@ agent state at %@ is damaged; using the last complete fallback, so newer sessions may be missing." } }, + "ja": { "stringUnit": { "state": "translated", "value": "警告 [%@]: %@ エージェントの保存状態 (%@) が破損しています。最後の完全な代替データを使用するため、新しいセッションが表示されない可能性があります。" } } + } + }, + "cli.agents.warning.legacySourceImportFailed.registry": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Warning [%@]: saved %@ agent state at %@ could not be imported; using the last complete registry snapshot, so newer sessions may be missing." } }, + "ja": { "stringUnit": { "state": "translated", "value": "警告 [%@]: %@ エージェントの保存状態 (%@) を取り込めませんでした。レジストリ内の最後の完全なスナップショットを使用するため、新しいセッションが表示されない可能性があります。" } } + } + }, + "cli.agents.tree.error.agentRequiresValue": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents tree: --agent requires a value" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents tree: --agent には値が必要です" } } + } + }, + "cli.agents.tree.error.depthTooLarge": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents tree: --depth must not exceed %lld" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents tree: --depth は %lld 以下にしてください" } } + } + }, + "cli.agents.tree.error.invalidDepth": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents tree: --depth must be a positive integer" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents tree: --depth には正の整数を指定してください" } } + } + }, + "cli.agents.tree.error.invalidMaximumNodes": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents tree: --max-nodes must be an integer from 1 through %lld" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents tree: --max-nodes には 1 から %lld までの整数を指定してください" } } + } + }, + "cli.agents.tree.error.nodeBudgetExceeded": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents tree: [%@] graph exceeds --max-nodes %lld (observed at least %lld); narrow the filters or raise --max-nodes, up to %lld" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents tree: [%@] グラフが --max-nodes %lld を超えています(少なくとも %lld 件を確認)。フィルターを絞るか、--max-nodes を最大 %lld まで増やしてください" } } + } + }, + "cli.agents.tree.error.recordTooLarge": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents tree: [%@] saved %@ session %@ is %lld bytes; narrow --agent or repair the store (maximum record: %lld bytes)" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents tree: [%@] 保存済み %@ セッション %@ は %lld バイトです。--agent で絞り込むか、ストアを修復してください(レコード上限: %lld バイト)" } } + } + }, + "cli.agents.tree.error.unexpectedArgument": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents tree: unexpected argument '%@'" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents tree: 予期しない引数 '%@'" } } + } + }, + "cli.agents.tree.error.unknownActivity": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents tree: unknown activity '%@'" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents tree: 不明なアクティビティ '%@'" } } + } + }, + "cli.agents.tree.error.unknownAgent": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents tree: unknown agent '%@'" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents tree: 不明なエージェント '%@'" } } + } + }, + "cli.agents.tree.error.unknownRelationship": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents tree: unknown relationship '%@'" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents tree: 不明な関係 '%@'" } } + } + }, + "cli.agents.tree.error.unknownState": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents tree: unknown state '%@'" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents tree: 不明な状態 '%@'" } } + } + }, + "cli.agents.tree.error.unknownWorkKind": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "agents tree: unknown workload kind '%@'" } }, + "ja": { "stringUnit": { "state": "translated", "value": "agents tree: 不明なワークロード種別 '%@'" } } + } + }, + "cli.agents.tree.output.noMatches": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "No saved agent runs matched." } }, + "ja": { "stringUnit": { "state": "translated", "value": "一致する保存済みエージェント実行はありません。" } } + } + }, "cli.sessions.error.agentRequiresValue": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "sessions list: --agent requires a value" + "value": "%@ list: --agent requires a value" } }, "ja": { "stringUnit": { "state": "translated", - "value": "sessions list: --agent には値が必要です" + "value": "%@ list: --agent には値が必要です" } } } @@ -43357,13 +43706,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "sessions list: --limit must be a positive integer" + "value": "%@ list: --limit must be a positive integer" } }, "ja": { "stringUnit": { "state": "translated", - "value": "sessions list: --limit には正の整数を指定してください" + "value": "%@ list: --limit には正の整数を指定してください" } } } @@ -43374,13 +43723,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "sessions list: unexpected argument '%@'" + "value": "%@ list: unexpected argument '%@'" } }, "ja": { "stringUnit": { "state": "translated", - "value": "sessions list: 予期しない引数 '%@'" + "value": "%@ list: 予期しない引数 '%@'" } } } @@ -43391,13 +43740,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "sessions list: unknown agent '%@'" + "value": "%@ list: unknown agent '%@'" } }, "ja": { "stringUnit": { "state": "translated", - "value": "sessions list: 不明なエージェント '%@'" + "value": "%@ list: 不明なエージェント '%@'" } } } @@ -43408,13 +43757,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "sessions list: unknown flag '%@'" + "value": "%@ list: unknown flag '%@'" } }, "ja": { "stringUnit": { "state": "translated", - "value": "sessions list: 不明なフラグ '%@'" + "value": "%@ list: 不明なフラグ '%@'" } } } @@ -43425,13 +43774,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Unknown sessions subcommand: %@. Usage: cmux sessions list [options]" + "value": "Unknown %@ subcommand: %@. Usage: cmux %@ list [options]" } }, "ja": { "stringUnit": { "state": "translated", - "value": "不明な sessions サブコマンドです: %@。使用方法: cmux sessions list [options]" + "value": "不明な %@ サブコマンドです: %@。使用方法: cmux %@ list [options]" } } } @@ -43453,6 +43802,23 @@ } } }, + "cli.sessions.output.moreLimitedAll": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "... %lld more. Raise --limit ." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "... %lld 件追加で一致。--limit を増やしてください。" + } + } + } + }, "cli.sessions.output.noMatches": { "extractionState": "manual", "localizations": { @@ -43476,13 +43842,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Usage: cmux sessions list [options]\n cmux sessions [options]\n\nPrint saved agent session records from ~/.cmuxterm/*-hook-sessions.json.\nThis command does not require a running cmux socket.\nBy default, broad output shows active, restorable, or transcript-backed records.\nPass --all to inspect every saved hook record.\n\nOptions:\n --agent Filter to one agent, for example codex or claude\n --session Filter to one agent session id\n --workspace Filter to one saved workspace id\n --surface Filter to one saved surface id\n --cwd Filter by saved cwd or launch working directory\n --state-dir Override hook state directory\n --codex-home Override the default Codex home used for transcript checks\n --limit Limit text output (default: 100)\n --all Print all matches\n --json Print structured JSON\n\nCodex rows include whether the saved id exists in CODEX_HOME/session_index.jsonl\nand whether a matching transcript file exists under CODEX_HOME/sessions or\nCODEX_HOME/archived_sessions.\n\nCompatibility aliases:\n cmux sessions debug [options]\n cmux session-debug [options]" + "value": "Usage: cmux agents list [options]\n cmux agents tree [options]\n cmux agents [options]\n\nPrint saved session lifecycle plus cached live terminal state.\nWith a cmux socket, this queries runtime identity and cached observations once.\nWithout a socket, it reads saved hook state from ~/.cmuxterm.\nInside cmux, default output is scoped to that running app process.\nPass --all to inspect cross-runtime history.\n\n`agents tree` renders process-spawn and conversation-fork relationships.\nAdd --json for a flat, versioned nodes-and-edges graph.\n\nOptions:\n --agent Filter to one agent, for example codex or claude\n --session Filter to one agent session id\n --workspace Filter to one saved workspace id\n --surface Filter to one saved surface id\n --state-dir Override hook state directory\n --state Filter by effective state\n --activity Filter by busy, idle, or unknown activity\n --work-kind Filter by an active workload kind\n --all Print all matches\n --json Print structured JSON\n\nList options:\n --cwd Filter by saved cwd or launch working directory\n --codex-home Override the default Codex home used for transcript checks\n --limit Limit rows (default: 100; with --all: unlimited)\n\nTree options:\n --relation Filter edges to spawned, forked, or resumed\n --depth Limit rendered tree depth (default: 64; maximum: 4096)\n --max-nodes Cap graph nodes (default: 10000; maximum: 20000)\n\nCodex rows include whether the saved id exists in CODEX_HOME/session_index.jsonl\nand whether a matching transcript file exists under CODEX_HOME/sessions or\nCODEX_HOME/archived_sessions.\n\nCompatibility aliases:\n cmux sessions [list|tree] [options]\n cmux sessions debug [options]\n cmux session-debug [options]" } }, "ja": { "stringUnit": { "state": "translated", - "value": "使用方法: cmux sessions list [options]\n cmux sessions [options]\n\n~/.cmuxterm/*-hook-sessions.json から保存済みエージェントセッション記録を表示します。\nこのコマンドは実行中の cmux ソケットを必要としません。\n既定では、広範な出力にはアクティブ、復元可能、またはトランスクリプト付きの記録だけを表示します。\n保存済みフック記録をすべて確認するには --all を指定します。\n\nオプション:\n --agent 1 つのエージェントに絞り込みます。例: codex または claude\n --session 1 つのエージェントセッション ID に絞り込みます\n --workspace 保存済みワークスペース ID に絞り込みます\n --surface 保存済みサーフェス ID に絞り込みます\n --cwd 保存済み cwd または起動時の作業ディレクトリで絞り込みます\n --state-dir フック状態ディレクトリを上書きします\n --codex-home トランスクリプト確認に使う既定の Codex ホームを上書きします\n --limit テキスト出力の件数を制限します(既定: 100)\n --all すべての一致を表示します\n --json 構造化 JSON を出力します\n\nCodex 行には、保存済み ID が CODEX_HOME/session_index.jsonl に存在するか、\n一致するトランスクリプトファイルが CODEX_HOME/sessions または\nCODEX_HOME/archived_sessions に存在するかが含まれます。\n\n互換エイリアス:\n cmux sessions debug [options]\n cmux session-debug [options]" + "value": "使用方法: cmux agents list [options]\n cmux agents tree [options]\n cmux agents [options]\n\n保存済みセッションのライフサイクルとキャッシュ済みライブ端末状態を表示します。\ncmux ソケット接続時は、ランタイム ID とキャッシュ済み観測を 1 回取得します。\nソケット未接続時は ~/.cmuxterm の保存済みフック状態を読み取ります。\ncmux 内では、既定の出力をその実行中アプリプロセスに限定します。\nランタイムをまたぐ履歴を確認するには --all を指定します。\n\n`agents tree` はプロセス生成と会話フォークの関係を表示します。\n--json を追加すると、バージョン付きのノード・エッジ形式で出力します。\n\nオプション:\n --agent 1 つのエージェントに絞り込みます。例: codex または claude\n --session 1 つのエージェントセッション ID に絞り込みます\n --workspace 保存済みワークスペース ID に絞り込みます\n --surface 保存済みサーフェス ID に絞り込みます\n --state-dir フック状態ディレクトリを上書きします\n --state 実効状態で絞り込みます\n --activity busy、idle、unknown のアクティビティで絞り込みます\n --work-kind アクティブなワークロード種別で絞り込みます\n --all すべての一致を表示します\n --json 構造化 JSON を出力します\n\nリストオプション:\n --cwd 保存済み cwd または起動時の作業ディレクトリで絞り込みます\n --codex-home トランスクリプト確認に使う既定の Codex ホームを上書きします\n --limit 出力件数を制限します(既定: 100、--all 指定時: 無制限)\n\nツリーオプション:\n --relation spawned、forked、resumed の関係で絞り込みます\n --depth 表示するツリーの深さを制限します(既定: 64、最大: 4096)\n --max-nodes グラフのノード数を制限します(既定: 10000、最大: 20000)\n\nCodex 行には、保存済み ID が CODEX_HOME/session_index.jsonl に存在するか、\n一致するトランスクリプトファイルが CODEX_HOME/sessions または\nCODEX_HOME/archived_sessions に存在するかが含まれます。\n\n互換エイリアス:\n cmux sessions [list|tree] [options]\n cmux sessions debug [options]\n cmux session-debug [options]" } } } diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 1cd5d09487bd..ddd7ecc89ae9 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -397,12 +397,12 @@ cmux_claude_config_dir_has_session() { claude_option_consumes_value() { case "$1" in --add-dir|--agent|--agents|--allowedTools|--allowed-tools|\ - --append-system-prompt|--betas|--debug-file|--disallowedTools|\ + --append-system-prompt|--append-system-prompt-file|--betas|--debug-file|--disallowedTools|\ --disallowed-tools|--effort|--fallback-model|--file|\ --input-format|--json-schema|--max-budget-usd|--mcp-config|\ --model|-m|-n|--name|--output-format|--permission-mode|--plugin-dir|\ --plugin-url|--remote-control-session-name-prefix|--setting-sources|\ - --settings|--system-prompt|--tools) + --settings|--system-prompt|--system-prompt-file|--teammate-mode|--tools) return 0 ;; esac @@ -754,7 +754,7 @@ claude_interactive_entry_flag() { case "$1" in --print|--print=*|-p|--resume|--resume=*|-r|--continue|-c|\ --session-id|--session-id=*|--remote-control|--remote-control=*|\ - --from-pr|--from-pr=*|--worktree|--worktree=*|-w|-w=*) + --from-pr|--from-pr=*|--tmux|--tmux=*|--worktree|--worktree=*|-w|-w=*) return 0 ;; esac @@ -773,7 +773,7 @@ claude_passthrough_option_flag() { claude_builtin_command_name() { case "$1" in agents|auth|auto-mode|config|api-key|daemon|doctor|install|mcp|\ - experimental-next|plugin|plugins|project|rc|remote-control|setup-token|\ + experimental-next|gateway|plugin|plugins|project|rc|remote-control|setup-token|\ ultrareview|update|upgrade) return 0 ;; @@ -795,13 +795,34 @@ claude_command_like_invocation() { should_inject_claude_hooks() { (( $# == 0 )) && return 0 + # Help/version win regardless of where Commander accepts them. Do this + # before session-entry flags so `claude --resume --help` cannot install or + # inject hooks for a command that exits immediately. Tokens after `--` are + # prompt text and deliberately do not participate. + local candidate + for candidate in "$@"; do + [[ "$candidate" == "--" ]] && break + case "$candidate" in + --help|-h|--version|-v) + return 1 + ;; + esac + done + local arg local skip_next=false + local skip_optional_value=false for arg in "$@"; do if [[ "$skip_next" == true ]]; then skip_next=false continue fi + if [[ "$skip_optional_value" == true ]]; then + skip_optional_value=false + if [[ "$arg" != -* ]]; then + continue + fi + fi case "$arg" in --) return 0 @@ -813,6 +834,10 @@ should_inject_claude_hooks() { if claude_interactive_entry_flag "$arg"; then return 0 fi + if [[ "$arg" != *=* ]] && [[ "$arg" == "--debug" || "$arg" == "-d" || "$arg" == "--prompt-suggestions" ]]; then + skip_optional_value=true + continue + fi if [[ "$arg" != *=* ]] && claude_option_consumes_value "$arg"; then skip_next=true fi diff --git a/Resources/bin/cmux-codex-wrapper b/Resources/bin/cmux-codex-wrapper index 5f84c0543816..a01c8eba564e 100755 --- a/Resources/bin/cmux-codex-wrapper +++ b/Resources/bin/cmux-codex-wrapper @@ -1,13 +1,14 @@ #!/usr/bin/env bash -# cmux codex wrapper - per-invocation Codex hook injection + launch detection. +# cmux codex wrapper - stable Codex hook setup + launch detection. # # When running inside a cmux terminal (CMUX_SURFACE_ID is set), this wrapper -# makes `codex` carry cmux's hooks for THIS invocation only (nothing is written -# to ~/.codex), so Codex's own SessionStart/UserPromptSubmit/Stop/PreToolUse/ -# PostToolUse/PermissionRequest fire back into cmux with Codex's real session_id. -# It also fires a best-effort one-way `cmux hooks codex session-start` BEFORE -# exec so a session is detected at launch even if Codex's own SessionStart is -# delayed; the registry dedups by session id so the two are idempotent. +# ensures cmux's stable persistent hooks and their trust hashes are installed, +# then Codex's SessionStart/UserPromptSubmit/Stop/PreToolUse/PostToolUse/ +# PermissionRequest events fire back into the exact bundled cmux CLI with the +# real session_id. Codex 0.144+ treats hooks injected through session flags as +# new, untrusted hooks even with --dangerously-bypass-hook-trust, so persistent +# hooks are the authoritative path. Session-flag injection remains a fail-open +# fallback when the persistent installation cannot be prepared. # # Outside cmux (no CMUX_SURFACE_ID / live socket), or when the user opts out # (CMUX_CODEX_HOOKS_DISABLED=1), the wrapper does NOTHING but exec the real @@ -105,8 +106,8 @@ resolve_hook_cmux_bin() { printf '%s' "cmux" } -# The interactive session, `exec`/`e`, and `resume` entrypoints all START a -# Codex session, so all three must receive cmux's hook injection; everything +# The interactive session, `exec`/`e`, `resume`, and `fork` entrypoints all START a +# Codex session, so all four must receive cmux's hook injection; everything # else (review, login, mcp, doctor, --help, --version, ...) passes through # untouched. `resume` is critical: cmux auto-resumes a session as `codex resume # ` after a Mac relaunch, and without hooks the resumed process fires no @@ -117,10 +118,10 @@ resolve_hook_cmux_bin() { # `--dangerously-bypass-hook-trust` / `-c hooks.X=...` flags before the `resume` # subcommand, so prepending them is safe. Mirrors should_inject_claude_hooks: a # leading non-option token that is a known Codex subcommand means "not a session" -# unless it is exec or resume. +# unless it is exec, resume, or fork. codex_subcommand_starts_session() { case "$1" in - exec|e|resume) return 0 ;; + exec|e|resume|fork) return 0 ;; *) return 1 ;; esac } @@ -143,7 +144,29 @@ codex_known_subcommand() { codex_option_consumes_value() { case "$1" in -c|--config|-m|--model|-p|--profile|-C|--cd|--remote|-a|--ask-for-approval|\ - -s|--sandbox|--output-last-message|--enable|--disable) + -s|--sandbox|--output-last-message|--enable|--disable|\ + --remote-auth-token-env|--local-provider|--add-dir) + return 0 + ;; + esac + return 1 +} + +codex_option_consumes_variadic_values() { + case "$1" in + -i|--image) + return 0 + ;; + esac + return 1 +} + +# Clap accepts required short-option values attached to the option token (for +# example `-mgpt-5.4`, `-cfoo=bar`, and `-i/tmp/a.png`). In that form the value +# is complete in the current token and the next bare token remains positional. +codex_option_has_attached_short_value() { + case "$1" in + -c?*|-m?*|-p?*|-C?*|-a?*|-s?*|-i?*) return 0 ;; esac @@ -162,18 +185,38 @@ codex_passthrough_option_flag() { # Decide whether this invocation is a Codex SESSION entrypoint we should inject # hooks into. Bare `codex` (no args) and `codex [prompt]` are interactive # sessions; `codex exec ...` is a non-interactive session; `codex resume ...` -# (picker, , --last, --all ) resumes a session; any other leading +# and `codex fork ...` resume or fork a session; any other leading # subcommand is not. should_inject_codex_hooks() { (( $# == 0 )) && return 0 + # Clap accepts global help/version flags after prompts, subcommands, and + # variadic image values. They always win over session entrypoint detection. + # Tokens after `--` are prompt text and deliberately do not participate. + local candidate + for candidate in "$@"; do + [[ "$candidate" == "--" ]] && break + case "$candidate" in + --help|-h|--version|-V) + return 1 + ;; + esac + done + local arg local skip_next=false + local skip_variadic_values=false for arg in "$@"; do if [[ "$skip_next" == true ]]; then skip_next=false continue fi + if [[ "$skip_variadic_values" == true ]]; then + if [[ "$arg" != -* ]]; then + continue + fi + skip_variadic_values=false + fi case "$arg" in --) return 0 @@ -182,6 +225,14 @@ should_inject_codex_hooks() { if codex_passthrough_option_flag "$arg"; then return 1 fi + if codex_option_has_attached_short_value "$arg"; then + continue + fi + local option_name="${arg%%=*}" + if codex_option_consumes_variadic_values "$option_name"; then + [[ "$arg" == *=* ]] || skip_variadic_values=true + continue + fi if [[ "$arg" != *=* ]] && codex_option_consumes_value "$arg"; then skip_next=true fi @@ -207,23 +258,153 @@ should_inject_codex_hooks() { # pre-relaunch record whose pid is already dead, the exit watcher flips it to # `.ended`, and the iOS chat shows it read-only with no input bar. The wrapper, # unlike codex, knows the resumed id (it is in argv) and the new live pid ($$), -# so it fires the session-start itself (below). The id is the first UUID-shaped -# token after the `resume` subcommand; flags (`--all`, `-c k=v`, `--last`, ...) -# are skipped because they are not UUID-shaped. `codex resume` with no id (the -# interactive picker) and `--last` carry no id at launch, so this returns -# non-zero and those are left to codex. +# so it fires the session-start itself (below). Parse the current root/resume +# option widths so UUID-shaped option values and prompt text can never be +# mistaken for the selected session. `codex resume` with no id (the interactive +# picker), a named selector, and `--last` carry no authoritative UUID at launch, +# so this returns non-zero and those are left to codex. +codex_root_boolean_option() { + case "$1" in + --strict-config|--oss|--yolo|\ + --dangerously-bypass-approvals-and-sandbox|--dangerously-bypass-hook-trust|\ + --search|--no-alt-screen) + return 0 + ;; + esac + return 1 +} + +codex_resume_boolean_option() { + codex_root_boolean_option "$1" && return 0 + case "$1" in + --all|--include-non-interactive|--last) + return 0 + ;; + esac + return 1 +} + cmux_codex_resume_session_id() { - local seen_resume=false arg + local seen_resume=false + local saw_selector=false + local selected_last=false + local after_terminator=false + local skip_next=false + local skip_variadic_values=false + local variadic_needs_value=false + local candidate="" + local arg option_name + + # Codex 0.144.3's Clap contract ends a variadic option's value list when its + # first value uses `--option=value`; a following bare token is positional. + # Split-form variadics keep consuming bare values until an option boundary. + # Keep this in lockstep with should_inject_codex_hooks. for arg in "$@"; do - if [[ "$seen_resume" == true ]]; then - if [[ "$arg" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then - printf '%s' "$arg" - return 0 + if [[ "$skip_next" == true ]]; then + # A flag where a required value belongs is invalid. Fail closed + # instead of letting a later UUID manufacture a rebind. + [[ "$arg" == -* ]] && return 1 + skip_next=false + continue + fi + if [[ "$skip_variadic_values" == true ]]; then + if [[ "$arg" != -* ]]; then + variadic_needs_value=false + continue fi - elif [[ "$arg" == "resume" ]]; then - seen_resume=true + [[ "$variadic_needs_value" == false ]] || return 1 + skip_variadic_values=false + variadic_needs_value=false fi + + if [[ "$seen_resume" == false ]]; then + case "$arg" in + --) + return 1 + ;; + -*) + if codex_option_has_attached_short_value "$arg"; then + : + else + option_name="${arg%%=*}" + if codex_option_consumes_variadic_values "$option_name"; then + if [[ "$arg" != *=* ]]; then + skip_variadic_values=true + variadic_needs_value=true + fi + elif codex_option_consumes_value "$option_name"; then + [[ "$arg" == *=* ]] || skip_next=true + elif codex_root_boolean_option "$option_name"; then + # Clap booleans reject `--flag=value`. Do not emit a + # rebind for an invocation Codex will reject. + [[ "$arg" != *=* ]] || return 1 + else + return 1 + fi + fi + ;; + resume) + seen_resume=true + ;; + *) + return 1 + ;; + esac + continue + fi + + if [[ "$after_terminator" == true ]]; then + if [[ "$saw_selector" == false ]]; then + saw_selector=true + if [[ "$arg" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then + candidate="$arg" + fi + fi + continue + fi + + case "$arg" in + --) + after_terminator=true + ;; + --last) + selected_last=true + ;; + -*) + if codex_option_has_attached_short_value "$arg"; then + : + else + option_name="${arg%%=*}" + if codex_option_consumes_variadic_values "$option_name"; then + if [[ "$arg" != *=* ]]; then + skip_variadic_values=true + variadic_needs_value=true + fi + elif codex_option_consumes_value "$option_name"; then + [[ "$arg" == *=* ]] || skip_next=true + elif codex_resume_boolean_option "$option_name"; then + [[ "$arg" != *=* ]] || return 1 + else + return 1 + fi + fi + ;; + *) + if [[ "$saw_selector" == false ]]; then + saw_selector=true + if [[ "$arg" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then + candidate="$arg" + fi + fi + ;; + esac done + + [[ "$skip_next" == false && "$variadic_needs_value" == false ]] || return 1 + if [[ "$seen_resume" == true && "$selected_last" == false && -n "$candidate" ]]; then + printf '%s' "$candidate" + return 0 + fi return 1 } @@ -268,8 +449,18 @@ fi # Export launch identity so the hook subprocesses and cmux's agent-pid / # ended-detection can bind this Codex to its surface and pid. Because we exec # codex below, $$ becomes the real codex pid. +# +# Persistent hooks resolve CMUX_BUNDLED_CLI_PATH from this invocation, so they +# always call the same cmux version that launched Codex. Older cmux instances +# may still update their compatibility JSON projection concurrently, but the +# generation-fenced SQLite registry is authoritative and cannot be downgraded. +# Explicit opt-out already returned through passthrough above. Normal nested +# Codex launches inherit this wrapper's zero value and remain instrumented. +export CMUX_CODEX_HOOKS_DISABLED=0 +export CMUX_CODEX_WRAPPER_HOOK_OWNER=1 export CMUX_CODEX_PID=$$ -export CMUX_CODEX_HOOK_CMUX_BIN="$(resolve_hook_cmux_bin)" +CMUX_CODEX_HOOK_CMUX_BIN="$(resolve_hook_cmux_bin)" +export CMUX_CODEX_HOOK_CMUX_BIN export CMUX_AGENT_LAUNCH_KIND="codex" export CMUX_AGENT_LAUNCH_EXECUTABLE="$REAL_CODEX" export CMUX_AGENT_LAUNCH_CWD="$PWD" @@ -285,14 +476,25 @@ export CMUX_AGENT_LAUNCH_CWD="$PWD" [[ -n "$cmux_codex_argv_b64" ]] && export CMUX_AGENT_LAUNCH_ARGV_B64="$cmux_codex_argv_b64" } -# On a FRESH launch, no wrapper-fired session-start: codex's own injected +# Codex loads hooks and trust state at startup. Prepare the stable persistent +# entries before exec so a first launch and an upgraded cmux both receive a real +# SessionStart event without a trust prompt. The installer is local file I/O, +# atomic, and a no-op when current. Failure falls through to session-flag +# injection below, and can never prevent Codex from launching. +cmux_codex_persistent_hooks_ready=0 +if [[ -n "$CMUX_CODEX_HOOK_CMUX_BIN" && -x "$CMUX_CODEX_HOOK_CMUX_BIN" ]] \ + && "$CMUX_CODEX_HOOK_CMUX_BIN" hooks codex install --yes >/dev/null 2>&1; then + cmux_codex_persistent_hooks_ready=1 +fi + +# On a FRESH launch, no wrapper-fired session-start: codex's persistent # SessionStart hook (below) fires within ~1s carrying codex's REAL session_id, so # it is the single authoritative signal. A pre-exec wrapper-fired session-start # there had no session id and minted a junk `fallback-*` record (a phantom in the # GUI), so it stays omitted for fresh launches. # # RESUME is different: codex does NOT fire SessionStart when resuming, so the -# injected hooks alone would never re-bind the session (its only pid-refreshing +# persistent hooks alone would never re-bind the session (its only pid-refreshing # event never arrives), leaving it stuck on the dead pre-relaunch pid -> `.ended` # -> read-only with no input bar, with no way out (you cannot submit a prompt # from a GUI that has no composer). The wrapper has the resumed id (from argv) and @@ -304,7 +506,10 @@ export CMUX_AGENT_LAUNCH_CWD="$PWD" cmux_codex_resume_sid="$(cmux_codex_resume_session_id "$@")" if [[ -n "$cmux_codex_resume_sid" \ && -n "$CMUX_CODEX_HOOK_CMUX_BIN" && -x "$CMUX_CODEX_HOOK_CMUX_BIN" ]]; then - cmux_codex_resume_payload="{\"session_id\":\"$cmux_codex_resume_sid\",\"cwd\":\"$PWD\"}" + # The handler already prefers CMUX_AGENT_LAUNCH_CWD/PWD from this exported + # launch environment. Keep cwd out of hand-built JSON so quotes, backslashes, + # and newlines in a valid project path cannot corrupt the critical rebind. + cmux_codex_resume_payload="{\"session_id\":\"$cmux_codex_resume_sid\"}" if [[ -n "${CMUX_SOCKET_PATH:-}" ]]; then ( printf '%s' "$cmux_codex_resume_payload" \ | nohup "$CMUX_CODEX_HOOK_CMUX_BIN" --socket "$CMUX_SOCKET_PATH" hooks codex session-start >/dev/null 2>&1 ) & @@ -315,7 +520,15 @@ if [[ -n "$cmux_codex_resume_sid" \ disown 2>/dev/null || true fi -# Build the per-invocation [hooks] injection. The cmux CLI emits the exact arg +# Stable persistent hooks are trusted and active. Avoid duplicate session-flag +# hooks, which Codex 0.144+ lists as review-required even when the bypass flag is +# present. Keep the launch identity exports above for the persistent scripts. +if [[ "$cmux_codex_persistent_hooks_ready" == "1" ]]; then + exec "$REAL_CODEX" "$@" +fi + +# Persistent setup failed. Build the per-invocation [hooks] injection as a +# fail-open compatibility fallback. The cmux CLI emits the exact arg # list (NUL-separated) that enables hooks and injects cmux's FIRE-AND-FORGET hook # command for each event. Fire-and-forget is critical: codex runs hooks # synchronously and BLOCKS until they return, so a synchronous `cmux hooks codex diff --git a/Sources/AgentExecutableResolver.swift b/Sources/AgentExecutableResolver.swift index c55d6a415c25..41341a5d0749 100644 --- a/Sources/AgentExecutableResolver.swift +++ b/Sources/AgentExecutableResolver.swift @@ -1,4 +1,5 @@ import CmuxSettings +import Darwin import Foundation struct AgentExecutableResolver { @@ -280,3 +281,635 @@ struct AgentExecutableResolver { return nil } } + +/// The executable lookup inputs derived from the same argv and environment policy +/// used to render a resume or fork command. Only PATH affects executable lookup, +/// so keeping that value instead of the complete process environment makes this a +/// compact cache key during large session restores. +struct AgentCommandExecutionDescriptor: Hashable, Sendable { + let executable: String + let searchPath: String? + let workingDirectory: String? + let fallbackExecutables: [String] + + init( + executable: String, + searchPath: String?, + workingDirectory: String?, + fallbackExecutables: [String] = [] + ) { + self.executable = executable + self.searchPath = searchPath + self.workingDirectory = workingDirectory + self.fallbackExecutables = fallbackExecutables + } +} + +struct AgentCommandExecutableResolution: Hashable, Sendable { + let descriptor: AgentCommandExecutionDescriptor + let lookupPath: String + let realPath: String + let cachePart: String + let watchDirectories: [String] +} + +struct AgentCommandExecutableLookup: Sendable, Equatable { + let resolution: AgentCommandExecutableResolution? + let candidateLookupPath: String? + let watchDirectories: [String] +} + +/// Resolves generated agent commands without invoking a shell. One resolver is +/// shared across a restore batch so equal command/PATH/cwd triples are statted once. +final class AgentCommandExecutableResolver { + /// XNU scans bytes 0.. AgentCommandExecutableLookup { + if case .value(let cached)? = lookupsByDescriptor[descriptor] { + return cached + } + let lookup = Self.lookupUncached(descriptor) + lookupsByDescriptor[descriptor] = .value(lookup) + return lookup + } + + func resolve(_ descriptor: AgentCommandExecutionDescriptor) -> AgentCommandExecutableResolution? { + lookup(descriptor).resolution + } + + static func revalidate(_ resolution: AgentCommandExecutableResolution) -> Bool { + lookupUncached(resolution.descriptor).resolution == resolution + } + + static func lookupUncached( + _ descriptor: AgentCommandExecutionDescriptor + ) -> AgentCommandExecutableLookup { + if !descriptor.fallbackExecutables.isEmpty { + var firstCandidate: String? + var watchDirectories: [String] = [] + var seenWatchDirectories = Set() + for executable in [descriptor.executable] + descriptor.fallbackExecutables { + let candidateDescriptor = AgentCommandExecutionDescriptor( + executable: executable, + searchPath: descriptor.searchPath, + workingDirectory: descriptor.workingDirectory + ) + let lookup = lookupUncached(candidateDescriptor) + firstCandidate = firstCandidate ?? lookup.candidateLookupPath + for directory in lookup.watchDirectories + where seenWatchDirectories.insert(directory).inserted { + watchDirectories.append(directory) + } + if let resolution = lookup.resolution { + return AgentCommandExecutableLookup( + resolution: AgentCommandExecutableResolution( + descriptor: descriptor, + lookupPath: resolution.lookupPath, + realPath: resolution.realPath, + cachePart: resolution.cachePart, + watchDirectories: watchDirectories + ), + candidateLookupPath: resolution.lookupPath, + watchDirectories: watchDirectories + ) + } + } + return AgentCommandExecutableLookup( + resolution: nil, + candidateLookupPath: firstCandidate, + watchDirectories: watchDirectories + ) + } + let normalizedWorkingDirectory = descriptor.workingDirectory? + .trimmingCharacters(in: .whitespacesAndNewlines) + let baseURL = normalizedWorkingDirectory.flatMap { directory in + directory.hasPrefix("/") ? URL(fileURLWithPath: directory, isDirectory: true) : nil + } + + func absolutePath(_ path: String) -> String? { + if path.hasPrefix("/") { + return URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL.path + } + guard let baseURL else { return nil } + return URL(fileURLWithPath: path, relativeTo: baseURL).standardizedFileURL.path + } + + if descriptor.executable.contains("/") { + guard let candidate = absolutePath(descriptor.executable) else { + // A cwd-ignored command runs in the destination terminal's + // startup directory, not cmux's process directory. Relative + // executable lookup is therefore unknowable here and must not + // be authorized against an unrelated local file. + return AgentCommandExecutableLookup( + resolution: nil, + candidateLookupPath: nil, + watchDirectories: [] + ) + } + let watchDirectories = [URL(fileURLWithPath: candidate).deletingLastPathComponent().path] + let attempt = executableResolution( + descriptor: descriptor, + lookupPath: candidate, + watchDirectories: watchDirectories + ) + return AgentCommandExecutableLookup( + resolution: attempt.resolution, + candidateLookupPath: candidate, + watchDirectories: attempt.watchDirectories + ) + } + + let rawSearchPath = descriptor.searchPath ?? "/usr/bin:/bin" + let searchDirectories = rawSearchPath.isEmpty + ? [""] + : rawSearchPath.split(separator: ":", omittingEmptySubsequences: false).map(String.init) + var firstCandidate: String? + var watchDirectories: [String] = [] + var seenWatchDirectories = Set() + for directory in searchDirectories { + let relativeCandidate = (directory.isEmpty ? "." : directory) + "/" + descriptor.executable + guard let candidate = absolutePath(relativeCandidate) else { + // Empty and relative PATH components are evaluated against the + // shell's cwd in order. If that cwd is unknown, even a later + // absolute match is ambiguous because an earlier candidate may + // win when the command actually runs. + return AgentCommandExecutableLookup( + resolution: nil, + candidateLookupPath: firstCandidate, + watchDirectories: watchDirectories + ) + } + firstCandidate = firstCandidate ?? candidate + let watchDirectory = URL(fileURLWithPath: candidate).deletingLastPathComponent().path + if seenWatchDirectories.insert(watchDirectory).inserted { + watchDirectories.append(watchDirectory) + } + let attempt = executableResolution( + descriptor: descriptor, + lookupPath: candidate, + watchDirectories: watchDirectories + ) + for directory in attempt.watchDirectories + where seenWatchDirectories.insert(directory).inserted { + watchDirectories.append(directory) + } + if let resolution = attempt.resolution { + return AgentCommandExecutableLookup( + resolution: AgentCommandExecutableResolution( + descriptor: resolution.descriptor, + lookupPath: resolution.lookupPath, + realPath: resolution.realPath, + cachePart: resolution.cachePart, + watchDirectories: watchDirectories + ), + candidateLookupPath: candidate, + watchDirectories: watchDirectories + ) + } + if attempt.matchedExecutable { + // PATH lookup stops at the first executable file. A broken + // shebang there cannot fall through to a later namesake. + return AgentCommandExecutableLookup( + resolution: nil, + candidateLookupPath: candidate, + watchDirectories: watchDirectories + ) + } + } + return AgentCommandExecutableLookup( + resolution: nil, + candidateLookupPath: firstCandidate, + watchDirectories: watchDirectories + ) + } + + private static func executableResolution( + descriptor: AgentCommandExecutionDescriptor, + lookupPath: String, + watchDirectories: [String] + ) -> ExecutableResolutionAttempt { + guard let identity = executableFileIdentity(at: lookupPath) else { + return ExecutableResolutionAttempt( + resolution: nil, + watchDirectories: watchDirectories, + matchedExecutable: false + ) + } + let dependencies = shebangDependencies( + at: lookupPath, + descriptor: descriptor, + visitedRealPaths: [identity.realPath], + remainingEnvExecs: maximumEnvExecDepth + ) + let allWatchDirectories = uniqueDirectories( + watchDirectories + dependencies.watchDirectories + ) + guard dependencies.isRunnable else { + return ExecutableResolutionAttempt( + resolution: nil, + watchDirectories: allWatchDirectories, + matchedExecutable: true + ) + } + let cachePart = ([identity.cachePart] + dependencies.cacheParts.map { "dependency=\($0)" }) + .joined(separator: "\u{1e}") + return ExecutableResolutionAttempt( + resolution: AgentCommandExecutableResolution( + descriptor: descriptor, + lookupPath: lookupPath, + realPath: identity.realPath, + cachePart: cachePart, + watchDirectories: allWatchDirectories + ), + watchDirectories: allWatchDirectories, + matchedExecutable: true + ) + } + + private static func executableFileIdentity(at lookupPath: String) -> ExecutableFileIdentity? { + var status = stat() + guard stat(lookupPath, &status) == 0, + (status.st_mode & S_IFMT) == S_IFREG, + Darwin.access(lookupPath, X_OK) == 0 else { + return nil + } + guard let realPath = Darwin.realpath(lookupPath, nil).map({ pointer in + defer { free(pointer) } + return String(cString: pointer) + }) else { return nil } + let cachePart = [ + realPath, + "dev=\(status.st_dev)", + "ino=\(status.st_ino)", + "mode=\(status.st_mode)", + "size=\(status.st_size)", + "mtime=\(status.st_mtimespec.tv_sec).\(status.st_mtimespec.tv_nsec)", + "ctime=\(status.st_ctimespec.tv_sec).\(status.st_ctimespec.tv_nsec)", + ].joined(separator: ":") + return ExecutableFileIdentity( + realPath: realPath, + cachePart: cachePart + ) + } + + private static func shebangDependencies( + at executablePath: String, + descriptor: AgentCommandExecutionDescriptor, + visitedRealPaths: Set, + remainingEnvExecs: Int + ) -> ExecutableDependencyLookup { + switch readShebang(at: executablePath) { + case .none: + return dependencyResult(true) + case .invalid: + return dependencyResult(false) + case .command(let interpreter, let argument): + guard interpreter.hasPrefix("/") else { return dependencyResult(false) } + let interpreterLookup = dependencyCandidate( + executable: interpreter, + searchPath: descriptor.searchPath, + workingDirectory: descriptor.workingDirectory, + searchesPath: false + ) + guard let interpreterPath = interpreterLookup.lookupPath, + let interpreterIdentity = interpreterLookup.identity, + !visitedRealPaths.contains(interpreterIdentity.realPath) else { + return dependencyResult( + false, + watchDirectories: interpreterLookup.watchDirectories + ) + } + let interpreterParts = ["lookup=\(interpreterPath):\(interpreterIdentity.cachePart)"] + + // XNU allows only one script activation per exec. The direct + // interpreter must therefore be a binary, not another script. + guard case .none(let isLoadableDarwinBinary) = readShebang(at: interpreterPath), + isLoadableDarwinBinary else { + return dependencyResult( + false, + cacheParts: interpreterParts, + watchDirectories: interpreterLookup.watchDirectories + ) + } + guard (interpreterIdentity.realPath as NSString).lastPathComponent == "env" else { + return dependencyResult( + true, + cacheParts: interpreterParts, + watchDirectories: interpreterLookup.watchDirectories + ) + } + guard remainingEnvExecs > 0 else { + return dependencyResult( + false, + cacheParts: interpreterParts, + watchDirectories: interpreterLookup.watchDirectories + ) + } + guard let envCommand = envShebangCommand( + argument: argument, + inheritedSearchPath: descriptor.searchPath + ) else { + return dependencyResult( + false, + cacheParts: interpreterParts, + watchDirectories: interpreterLookup.watchDirectories + ) + } + let commandLookup = dependencyCandidate( + executable: envCommand.executable, + searchPath: envCommand.searchPath, + workingDirectory: descriptor.workingDirectory, + searchesPath: !envCommand.executable.contains("/") + ) + let combinedWatchDirectories = uniqueDirectories( + interpreterLookup.watchDirectories + commandLookup.watchDirectories + ) + guard let commandPath = commandLookup.lookupPath, + let commandIdentity = commandLookup.identity, + !visitedRealPaths.contains(commandIdentity.realPath), + commandIdentity.realPath != interpreterIdentity.realPath else { + return dependencyResult( + false, + cacheParts: interpreterParts, + watchDirectories: combinedWatchDirectories + ) + } + let commandParts = interpreterParts + [ + "lookup=\(commandPath):\(commandIdentity.cachePart)" + ] + let nested = shebangDependencies( + at: commandPath, + descriptor: AgentCommandExecutionDescriptor( + executable: descriptor.executable, + searchPath: envCommand.searchPath, + workingDirectory: descriptor.workingDirectory, + fallbackExecutables: descriptor.fallbackExecutables + ), + // `env` starts a fresh exec, so the same env binary may be the + // next script's interpreter. Track target executables instead. + visitedRealPaths: visitedRealPaths.union([commandIdentity.realPath]), + remainingEnvExecs: remainingEnvExecs - 1 + ) + return dependencyResult( + nested.isRunnable, + cacheParts: commandParts + nested.cacheParts, + watchDirectories: uniqueDirectories( + combinedWatchDirectories + nested.watchDirectories + ) + ) + } + } + + private static func dependencyCandidate( + executable: String, + searchPath: String?, + workingDirectory: String?, + searchesPath: Bool + ) -> DependencyCandidate { + let normalizedWorkingDirectory = workingDirectory? + .trimmingCharacters(in: .whitespacesAndNewlines) + let baseURL = normalizedWorkingDirectory.flatMap { directory in + directory.hasPrefix("/") ? URL(fileURLWithPath: directory, isDirectory: true) : nil + } + + func absolutePath(_ path: String) -> String? { + if path.hasPrefix("/") { + return URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL.path + } + guard let baseURL else { return nil } + return URL(fileURLWithPath: path, relativeTo: baseURL).standardizedFileURL.path + } + + let candidatePaths: [String] + if searchesPath { + let rawSearchPath = searchPath ?? "/usr/bin:/bin" + let searchDirectories = rawSearchPath.isEmpty + ? [""] + : rawSearchPath.split(separator: ":", omittingEmptySubsequences: false).map(String.init) + var candidates: [String] = [] + for directory in searchDirectories { + let relativeCandidate = (directory.isEmpty ? "." : directory) + "/" + executable + guard let candidate = absolutePath(relativeCandidate) else { + return DependencyCandidate( + lookupPath: nil, + identity: nil, + watchDirectories: uniqueDirectories( + candidates.map { URL(fileURLWithPath: $0).deletingLastPathComponent().path } + ) + ) + } + candidates.append(candidate) + } + candidatePaths = candidates + } else { + guard let candidate = absolutePath(executable) else { + return DependencyCandidate( + lookupPath: nil, + identity: nil, + watchDirectories: [] + ) + } + candidatePaths = [candidate] + } + + var watchDirectories: [String] = [] + for candidate in candidatePaths { + watchDirectories = uniqueDirectories( + watchDirectories + [URL(fileURLWithPath: candidate).deletingLastPathComponent().path] + ) + guard let identity = executableFileIdentity(at: candidate) else { continue } + return DependencyCandidate( + lookupPath: candidate, + identity: identity, + watchDirectories: watchDirectories + ) + } + return DependencyCandidate( + lookupPath: nil, + identity: nil, + watchDirectories: watchDirectories + ) + } + + private static func readShebang(at path: String) -> ShebangReadResult { + let fileDescriptor = Darwin.open(path, O_RDONLY | O_CLOEXEC) + guard fileDescriptor >= 0 else { return .invalid } + defer { Darwin.close(fileDescriptor) } + var bytes = [UInt8](repeating: 0, count: shebangBufferSize) + var count = 0 + while count < bytes.count { + let bytesRead = bytes.withUnsafeMutableBytes { buffer in + Darwin.read( + fileDescriptor, + buffer.baseAddress?.advanced(by: count), + buffer.count - count + ) + } + if bytesRead > 0 { + count += bytesRead + } else if bytesRead == 0 { + break + } else if errno != EINTR { + return .invalid + } + } + guard count >= 2, bytes[0] == 0x23, bytes[1] == 0x21 else { + return .none(isLoadableDarwinBinary: hasLoadableDarwinMagic(bytes, count: count)) + } + // Darwin treats `#` as a shebang comment terminator. This is unlike + // Linux, but `#!/bin/sh#comment` succeeds through execve on macOS. + guard let lineEnd = bytes[2.. Bool { + guard count >= 4 else { return false } + let magic = bytes.prefix(4).reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + switch magic { + case 0xFEED_FACE, 0xCEFA_EDFE, 0xFEED_FACF, 0xCFFA_EDFE, + 0xCAFE_BABE, 0xBEBA_FECA, 0xCAFE_BABF, 0xBFBA_FECA: + return true + default: + return false + } + } + + private static func envShebangCommand( + argument: String?, + inheritedSearchPath: String? + ) -> EnvShebangCommand? { + guard let argument = argument?.trimmingCharacters(in: .whitespacesAndNewlines), + !argument.isEmpty else { + return nil + } + // XNU tokenizes every shebang tail on space/tab before invoking the + // interpreter. Plain `env node --flag` is split on Darwin; `-S` is not + // required as it is on Linux. + var words = argument.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + var searchPath = inheritedSearchPath + var index = 0 + if words.first == "-S" || words.first == "--split-string" { + index = 1 + } else if let first = words.first, first.hasPrefix("--split-string=") { + words[0] = String(first.dropFirst("--split-string=".count)) + } + while index < words.count { + let word = words[index] + if word == "--" { + index += 1 + break + } + if let assignment = environmentAssignment(word) { + if assignment.key == "PATH" { searchPath = assignment.value } + index += 1 + continue + } + if word.hasPrefix("-") { return nil } + break + } + guard index < words.count else { return nil } + return EnvShebangCommand(executable: words[index], searchPath: searchPath) + } + + private static func environmentAssignment(_ word: String) -> (key: String, value: String)? { + guard let equals = word.firstIndex(of: "="), equals != word.startIndex else { return nil } + let key = String(word[.. ExecutableDependencyLookup { + ExecutableDependencyLookup( + isRunnable: isRunnable, + cacheParts: cacheParts, + watchDirectories: watchDirectories + ) + } + + private static func uniqueDirectories(_ directories: [String]) -> [String] { + var seen = Set() + return directories.filter { seen.insert($0).inserted } + } +} diff --git a/Sources/AgentForkSupport.swift b/Sources/AgentForkSupport.swift index 868741210781..d9640b453391 100644 --- a/Sources/AgentForkSupport.swift +++ b/Sources/AgentForkSupport.swift @@ -47,6 +47,7 @@ enum AgentForkSupport { case notRequired case skipRemoteLikeContext case unresolved + case command(AgentCommandExecutionDescriptor) case run( probe: (executable: String, arguments: [String]), processEnvironment: [String: String], @@ -56,10 +57,9 @@ enum AgentForkSupport { } static let minimumOpenCodeForkVersion = SemanticVersion(major: 1, minor: 14, patch: 50) - // Pi v0.60.0 and OMP v13.15.0 are the first releases containing the - // upstream CLI `--fork ` implementation. + // Pi v0.60.0 is the first release containing the upstream CLI + // `--fork ` implementation. static let minimumPiForkVersion = SemanticVersion(major: 0, minor: 60, patch: 0) - static let minimumOmpForkVersion = SemanticVersion(major: 13, minor: 15, patch: 0) private static let piFamilyBareVersionExpression = try! NSRegularExpression( pattern: #"^v?\d+\.\d+(?:\.\d+)?$"# ) @@ -103,6 +103,18 @@ enum AgentForkSupport { snapshot.forkStartupInput(allowLauncherScript: false) == nil { return false } + let capabilityProbeOwnsExecutableValidation = requiresLocalPiFamilyCapabilityProbe(snapshot) + || (snapshot.kind == .opencode + && snapshot.launchCommand?.launcher != "omo" + && AgentResumeCommandBuilder.openCodeVersionProbe( + launchCommand: snapshot.launchCommand + ) != nil) + if !isRemoteContext && !capabilityProbeOwnsExecutableValidation { + guard let descriptor = snapshot.forkExecutionDescriptor, + AgentCommandExecutableResolver().resolve(descriptor) != nil else { + return false + } + } if requiresLocalPiFamilyCapabilityProbe(snapshot) { if isRemoteContext { return false @@ -369,8 +381,6 @@ enum AgentForkSupport { return registration.forkCommand == CmuxVaultAgentRegistration.builtInPi.forkCommand case .custom("pi"): return snapshot.registration?.forkCommand == CmuxVaultAgentRegistration.builtInPi.forkCommand - case .custom("omp"): - return snapshot.registration?.forkCommand == CmuxVaultAgentRegistration.builtInOmp.forkCommand default: return false } @@ -435,19 +445,13 @@ enum AgentForkSupport { agentID: String, acceptsBareVersionOutput: Bool = false ) -> Bool { + guard agentID == "pi" else { return false } guard let version = piFamilyProbeVersion( in: output, agentID: agentID, acceptsBareVersionOutput: acceptsBareVersionOutput ) else { return false } - switch agentID { - case "pi": - return version >= minimumPiForkVersion - case "omp": - return version >= minimumOmpForkVersion - default: - return false - } + return version >= minimumPiForkVersion } private static func piFamilyProbeVersion( @@ -628,8 +632,6 @@ enum AgentForkSupport { return nil case .rejectMissingWorkingDirectory: return nil - case .rejectMissingExecutable: - return nil } return forkProbeExecutableIdentity( executable: probe.executable, @@ -643,36 +645,19 @@ enum AgentForkSupport { processEnvironment: [String: String], workingDirectory: String? ) -> ForkProbeExecutableIdentity? { - guard let executableResolution = resolvedProbeExecutable( + let descriptor = AgentCommandExecutionDescriptor( executable: executable, - processEnvironment: processEnvironment, + searchPath: processEnvironment["PATH"], workingDirectory: workingDirectory - ) else { - return nil - } - let executablePath = executableResolution.path - var status = stat() - guard stat(executablePath, &status) == 0 else { + ) + guard let resolution = AgentCommandExecutableResolver().resolve(descriptor) else { return nil } - let realPath = realpath(executablePath, nil).map { pointer in - defer { free(pointer) } - return String(cString: pointer) - } ?? executablePath - let cachePart = [ - realPath, - "dev=\(status.st_dev)", - "ino=\(status.st_ino)", - "mode=\(status.st_mode)", - "size=\(status.st_size)", - "mtime=\(status.st_mtimespec.tv_sec).\(status.st_mtimespec.tv_nsec)", - "ctime=\(status.st_ctimespec.tv_sec).\(status.st_ctimespec.tv_nsec)", - ].joined(separator: ":") return ( - lookupPath: executablePath, - realPath: realPath, - cachePart: cachePart, - watchDirectories: executableResolution.watchDirectories + lookupPath: resolution.lookupPath, + realPath: resolution.realPath, + cachePart: resolution.cachePart, + watchDirectories: resolution.watchDirectories ) } @@ -680,13 +665,7 @@ enum AgentForkSupport { snapshot: SessionRestorableAgentSnapshot, isRemoteContext: Bool = false ) -> Bool { - guard !isRemoteContext else { return false } - if requiresLocalPiFamilyCapabilityProbe(snapshot) { - return true - } - return snapshot.kind == .opencode - && snapshot.launchCommand?.launcher != "omo" - && AgentResumeCommandBuilder.openCodeVersionProbe(launchCommand: snapshot.launchCommand) != nil + !isRemoteContext && snapshot.forkExecutionDescriptor != nil } static func forkValidationExecutableResolution( @@ -703,15 +682,47 @@ enum AgentForkSupport { return ("skipRemoteLikeContext", nil, nil, nil, []) case .unresolved: return ("unresolved", nil, nil, nil, []) + case .command(let descriptor): + let lookup = AgentCommandExecutableResolver().lookup(descriptor) + guard let resolution = lookup.resolution else { + return ( + "unresolved", + lookup.candidateLookupPath, + nil, + nil, + lookup.watchDirectories + ) + } + return ( + "resolved", + resolution.lookupPath, + resolution.realPath, + resolution.cachePart, + resolution.watchDirectories + ) case .run(let probe, let processEnvironment, let workingDirectory, _): - guard let identity = forkProbeExecutableIdentity( + let descriptor = AgentCommandExecutionDescriptor( executable: probe.executable, - processEnvironment: processEnvironment, + searchPath: processEnvironment["PATH"], workingDirectory: workingDirectory - ) else { - return ("unresolved", nil, nil, nil, []) + ) + let lookup = AgentCommandExecutableResolver().lookup(descriptor) + guard let resolution = lookup.resolution else { + return ( + "unresolved", + lookup.candidateLookupPath, + nil, + nil, + lookup.watchDirectories + ) } - return ("resolved", identity.lookupPath, identity.realPath, identity.cachePart, identity.watchDirectories) + return ( + "resolved", + resolution.lookupPath, + resolution.realPath, + resolution.cachePart, + resolution.watchDirectories + ) } } @@ -732,6 +743,13 @@ enum AgentForkSupport { ] + probe.arguments + processEnvironment.keys.sorted().compactMap { key in processEnvironment[key].map { "\(key)=\($0)" } }).joined(separator: "\u{1f}") + case .command(let descriptor): + return ([ + "remote=\(isRemoteContext)", + descriptor.executable, + "path=\(descriptor.searchPath ?? "")", + "cwd=\(descriptor.workingDirectory ?? "")", + ] + descriptor.fallbackExecutables).joined(separator: "\u{1f}") case .notRequired, .skipRemoteLikeContext, .unresolved: return nil } @@ -763,7 +781,10 @@ enum AgentForkSupport { probe = openCodeProbe useDefaultDirectoryWhenWorkingDirectoryIsMissing = false } else { - return .notRequired + guard let descriptor = snapshot.forkExecutionDescriptor else { + return .notRequired + } + return .command(descriptor) } let requestedWorkingDirectory = probeWorkingDirectory(snapshot: snapshot) @@ -781,8 +802,6 @@ enum AgentForkSupport { return .skipRemoteLikeContext case .rejectMissingWorkingDirectory: return .unresolved - case .rejectMissingExecutable: - return .unresolved } return .run( probe: probe, @@ -834,48 +853,6 @@ enum AgentForkSupport { return (lookupPath, realPath, cachePart) } - private static func resolvedProbeExecutable( - executable: String, - processEnvironment: [String: String], - workingDirectory: String? - ) -> (path: String, watchDirectories: [String])? { - let baseDirectory = workingDirectory ?? FileManager.default.currentDirectoryPath - func absolutePath(_ path: String) -> String { - if path.hasPrefix("/") { - return path - } - return URL(fileURLWithPath: path, relativeTo: URL(fileURLWithPath: baseDirectory, isDirectory: true)) - .standardizedFileURL - .path - } - - if executable.contains("/") { - let path = absolutePath(executable) - guard isRegularExecutableFile(atPath: path) else { return nil } - return (path, [URL(fileURLWithPath: path).deletingLastPathComponent().path]) - } - - let pathDirectories = (processEnvironment["PATH"] ?? "") - .split(separator: ":", omittingEmptySubsequences: false) - .map(String.init) - var watchDirectories: [String] = [] - for directory in pathDirectories { - let candidate = absolutePath((directory.isEmpty ? "." : directory) + "/" + executable) - watchDirectories.append(URL(fileURLWithPath: candidate).deletingLastPathComponent().path) - if isRegularExecutableFile(atPath: candidate) { - return (candidate, watchDirectories) - } - } - return nil - } - - private static func isRegularExecutableFile(atPath path: String) -> Bool { - var status = stat() - guard stat(path, &status) == 0 else { return false } - guard (status.st_mode & S_IFMT) == S_IFREG else { return false } - return access(path, X_OK) == 0 - } - static func probeOutputPipeHandles( readFileDescriptor: Int32, writeFileDescriptor: Int32 @@ -1206,21 +1183,15 @@ enum AgentForkSupport { case run case skipRemoteLikeContext case rejectMissingWorkingDirectory - case rejectMissingExecutable } private static func localForkProbeDecision( - probe: (executable: String, arguments: [String]), + probe _: (executable: String, arguments: [String]), workingDirectory: String? ) -> LocalForkProbeDecision { if let workingDirectory, localDirectoryURL(path: workingDirectory) == nil { return .rejectMissingWorkingDirectory } - if probe.executable.hasPrefix("/") { - return isRegularExecutableFile(atPath: probe.executable) - ? .run - : .rejectMissingExecutable - } return .run } diff --git a/Sources/AgentHibernation/AgentHibernationLifecycleState.swift b/Sources/AgentHibernation/AgentHibernationLifecycleState.swift index cbb14c776343..ee97d768e435 100644 --- a/Sources/AgentHibernation/AgentHibernationLifecycleState.swift +++ b/Sources/AgentHibernation/AgentHibernationLifecycleState.swift @@ -1,12 +1,12 @@ import Foundation -enum AgentHibernationLifecycleState: String, Codable, Sendable, Equatable, CaseIterable { +public enum AgentHibernationLifecycleState: String, Codable, Sendable, Equatable, CaseIterable { case unknown case running case idle case needsInput - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let rawValue = try container.decode(String.self) self = Self.parse(rawValue) ?? .unknown @@ -16,7 +16,7 @@ enum AgentHibernationLifecycleState: String, Codable, Sendable, Equatable, CaseI self == .idle } - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(rawValue) } @@ -57,19 +57,42 @@ enum AgentHibernationLifecycleStatusKeys { key == manualKey || key.hasPrefix("\(manualKey):") } + private static let detectionPrefix = "screen:" + + static func detectionKey(familyID: String) -> String { + detectionPrefix + familyID + } + + static func isDetectionKey(_ key: String) -> Bool { + key.hasPrefix(detectionPrefix) + } + + static func detectionFamilyID(key: String) -> String? { + guard isDetectionKey(key) else { return nil } + return String(key.dropFirst(detectionPrefix.count)) + } + static let allowedStatusKeys: Set = [ "amp", "antigravity", + "campfire", "claude_code", + "cline", "codebuddy", "codex", "copilot", "cursor", + "devin", "factory", "gemini", "grok", "hermes-agent", + "kilo", "kiro", + "kimi", + "maki", + "mastracode", + "ollama", "omp", "opencode", "pi", @@ -81,3 +104,14 @@ enum AgentHibernationLifecycleStatusKeys { allowedStatusKeys.contains(key) } } + +extension AgentHibernationLifecycleState { + static func effective(_ states: S) -> AgentHibernationLifecycleState where S.Element == Self { + let values = Array(states) + if values.contains(.running) { return .running } + if values.contains(.needsInput) { return .needsInput } + if values.contains(.unknown) { return .unknown } + if values.contains(.idle) { return .idle } + return .unknown + } +} diff --git a/Sources/AgentRelaunchCommandBuilder.swift b/Sources/AgentRelaunchCommandBuilder.swift index d9b57bd61b12..b6beda495713 100644 --- a/Sources/AgentRelaunchCommandBuilder.swift +++ b/Sources/AgentRelaunchCommandBuilder.swift @@ -3,6 +3,30 @@ import Foundation /// Builds shell commands for agents whose upstream CLI can only start a fresh conversation. struct AgentRelaunchCommandBuilder { + func executionDescriptor( + kind: RestorableAgentKind, + launchCommand: AgentLaunchCommandSnapshot?, + workingDirectory: String? + ) -> AgentCommandExecutionDescriptor? { + guard kind.restoreMode == .relaunchCommand, + let launchCommand, + let argv = AgentResumeArgv().builtInRelaunchKind( + kind: kind.rawValue, + executablePath: launchCommand.executablePath, + arguments: launchCommand.arguments + ), + !argv.isEmpty else { + return nil + } + return AgentResumeCommandBuilder.executionDescriptor( + argv: argv, + kind: kind, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + customRegistration: nil + ) + } + /// Returns a sanitized command-level restore in the captured working directory. /// /// Ollama has no session identifier or resume verb. Relaunch restores the diff --git a/Sources/AgentSessions/AgentHookSessionStateWriter.swift b/Sources/AgentSessions/AgentHookSessionStateWriter.swift new file mode 100644 index 000000000000..1533928fbf66 --- /dev/null +++ b/Sources/AgentSessions/AgentHookSessionStateWriter.swift @@ -0,0 +1,1767 @@ +import CmuxFoundation +import CmuxControlSocket +import CmuxSettings +import Darwin +import Foundation +import os + +/// Process-wide access to the package listener's lock-backed read mirror. +/// The server is installed once by TerminalController and exposes only +/// nonisolated snapshot reads here, so hook writes never hop to the main actor. +enum AgentHookRuntimeSocketState { + private nonisolated static let socketServer = OSAllocatedUnfairLock( + initialState: nil + ) + + @MainActor + static func install(socketServer: SocketControlServer) { + self.socketServer.withLock { $0 = socketServer } + } + + nonisolated static func resolve( + preferredPath: String + ) -> (activePath: String, pathOwnedByCurrentListener: Bool) { + guard let server = socketServer.withLock({ $0 }) else { + return (preferredPath, false) + } + let activePath = server.activeSocketPath(preferredPath: preferredPath) + return ( + activePath, + server.listenerHealth( + expectedSocketPath: activePath + ).socketPathOwnedByListener + ) + } +} + +/// Completes a hook-store session after cmux observes the root TUI return to its +/// shell prompt. Work runs on a utility-priority task and uses the same sidecar lock as +/// hook writers, so terminal UI delivery never waits on disk or JSON work. +struct AgentHookSessionStateWriter: Sendable { + /// The hook stores are process-wide files, so app-originated mutations share + /// one actor. Timestamp fences make each mutation safe even if independently + /// created tasks reach this actor in a different order. + private actor WriteCoordinator { + func complete( + using writer: AgentHookSessionStateWriter, + provider: String, + stateURL: URL, + sessionId: String, + expectedRecordUpdatedAt: TimeInterval?, + now: TimeInterval + ) { + writer.complete( + provider: provider, + stateURL: stateURL, + sessionId: sessionId, + expectedRecordUpdatedAt: expectedRecordUpdatedAt, + now: now + ) + } + + func setLifecycle( + _ lifecycle: AgentSessionLifecycleState, + using writer: AgentHookSessionStateWriter, + provider: String, + stateURL: URL, + sessionId: String, + now: TimeInterval + ) { + writer.setLifecycle( + lifecycle, + provider: provider, + stateURL: stateURL, + sessionId: sessionId, + now: now + ) + } + + func projectRestoredHibernationsToLegacy( + using writer: AgentHookSessionStateWriter, + provider: String, + stateURL: URL, + requests: [RestoredHibernationAdoptionRequest], + now: TimeInterval + ) { + writer.projectRestoredHibernationsToLegacy( + provider: provider, + stateURL: stateURL, + requests: requests, + now: now + ) + } + + func projectHibernatedResumesToLegacy( + using writer: AgentHookSessionStateWriter, + provider: String, + stateURL: URL, + claims: [HibernatedResumeAuthorityClaim], + now: TimeInterval + ) { + writer.projectHibernatedResumesToLegacy( + provider: provider, + stateURL: stateURL, + claims: claims, + now: now + ) + } + + func projectEstablishedHibernationToLegacy( + using writer: AgentHookSessionStateWriter, + provider: String, + stateURL: URL, + request: HibernatedResumeAuthorityRequest, + legacyStampAtClaim: CmuxAgentSessionRegistry.LegacyStamp?, + now: TimeInterval + ) { + writer.projectEstablishedHibernationToLegacy( + provider: provider, + stateURL: stateURL, + request: request, + legacyStampAtClaim: legacyStampAtClaim, + now: now + ) + } + + func projectCanonicalLegacy( + using writer: AgentHookSessionStateWriter, + provider: String, + stateURL: URL + ) { + writer.projectCanonicalLegacy(provider: provider, stateURL: stateURL) + } + + } + + private static let writeCoordinator = WriteCoordinator() + struct RestoredHibernationAdoptionRequest: Sendable { + var agent: SessionRestorableAgentSnapshot + var previousWorkspaceId: UUID? + var previousSurfaceId: UUID + var workspaceId: UUID + var surfaceId: UUID + var rebindWorkspaceActiveSlot = false + var adoptionId = UUID() + } + enum RestoredHibernationAdoptionOutcome: Equatable, Sendable { + case adopted + case rejected + case unavailable + } + struct HibernatedResumeAuthorityRequest: Sendable { + var agent: SessionRestorableAgentSnapshot + var workspaceId: UUID + var surfaceId: UUID + var attemptId = UUID() + } + enum HibernatedResumeAuthorityOutcome: Equatable, Sendable { + case acquired + case rejected + case unavailable + } + struct HibernatedResumeAuthorityClaim: Sendable { + var request: HibernatedResumeAuthorityRequest + var legacyStampAtClaim: CmuxAgentSessionRegistry.LegacyStamp? + } + private struct RestoredHibernationOwnerPreflight: Sendable { + let recordFingerprint: Data + let canonicalWorkspaceId: String? + let canonicalSurfaceId: String? + let lifecycle: String? + let isDetached: Bool + let hasResumeAttempt: Bool + let runtimeEvidence: AgentRuntimeOwnershipProbe.Evidence + } + private struct ProjectedRecordAuthority: Sendable { + let restoreAuthority: Bool + let runtimeID: String? + } + private enum LegacyReadLockMode: Sendable, Equatable { + case immediate + case wait + } + private struct MonotonicBusyBudget: Sendable { + private let deadlineNanoseconds: UInt64 + + init(milliseconds: Int32) { + let now = DispatchTime.now().uptimeNanoseconds + let duration = UInt64(max(0, milliseconds)).multipliedReportingOverflow(by: 1_000_000) + if duration.overflow { + deadlineNanoseconds = .max + } else { + deadlineNanoseconds = now.addingReportingOverflow(duration.partialValue).overflow + ? .max + : now + duration.partialValue + } + } + + func remainingMilliseconds() -> Int32 { + let remainingNanoseconds = remainingNanoseconds() + let roundedUpMilliseconds = remainingNanoseconds / 1_000_000 + + (remainingNanoseconds % 1_000_000 == 0 ? 0 : 1) + return Int32(min(UInt64(Int32.max), roundedUpMilliseconds)) + } + + func remainingNanoseconds() -> UInt64 { + let now = DispatchTime.now().uptimeNanoseconds + return now < deadlineNanoseconds ? deadlineNanoseconds - now : 0 + } + } + private final class LegacyLockCancellationSignal: @unchecked Sendable { + let readDescriptor: Int32 + private let writeDescriptor: Int32 + + init?() { + var descriptors = [Int32](repeating: -1, count: 2) + guard pipe(&descriptors) == 0 else { return nil } + readDescriptor = descriptors[0] + writeDescriptor = descriptors[1] + _ = fcntl(readDescriptor, F_SETFD, FD_CLOEXEC) + _ = fcntl(writeDescriptor, F_SETFD, FD_CLOEXEC) + _ = fcntl(writeDescriptor, F_SETFL, O_NONBLOCK) + } + + deinit { + Darwin.close(readDescriptor) + Darwin.close(writeDescriptor) + } + + func cancel() { + var byte: UInt8 = 1 + _ = withUnsafePointer(to: &byte) { + Darwin.write(writeDescriptor, $0, 1) + } + } + } + typealias CurrentSocketStateResolver = @Sendable ( + _ preferredPath: String + ) -> (activePath: String, pathOwnedByCurrentListener: Bool) + typealias ProcessIdentityResolver = @Sendable (pid_t) -> AgentPIDProcessIdentity? + private let homeDirectory: String + private let environment: [String: String] + private let currentSocketStateResolver: CurrentSocketStateResolver + private let processIdentityResolver: ProcessIdentityResolver + + init( + homeDirectory: String = NSHomeDirectory(), + environment: [String: String] = ProcessInfo.processInfo.environment, + currentSocketStateResolver: CurrentSocketStateResolver? = nil, + processIdentityResolver: @escaping ProcessIdentityResolver = { AgentPIDProcessIdentity(pid: $0) } + ) { + self.homeDirectory = homeDirectory + self.environment = environment + self.currentSocketStateResolver = currentSocketStateResolver ?? { + AgentHookRuntimeSocketState.resolve(preferredPath: $0) + } + self.processIdentityResolver = processIdentityResolver + } + + private static func productionWriter() -> AgentHookSessionStateWriter { + var environment = ProcessInfo.processInfo.environment + if environment["CMUX_BUNDLE_ID"] == nil, + let bundleIdentifier = Bundle.main.bundleIdentifier { + environment["CMUX_BUNDLE_ID"] = bundleIdentifier + } + return AgentHookSessionStateWriter(environment: environment) + } + + static func rootExitCandidate( + previousWasRunning: Bool, + isPromptIdle: Bool, + isHibernated: Bool, + binding: SurfaceResumeBindingSnapshot? + ) -> SurfaceResumeBindingSnapshot? { + previousWasRunning && isPromptIdle && !isHibernated && binding?.isAgentHookBinding == true + ? binding + : nil + } + + static func recordRootExitIfNeeded( + binding: SurfaceResumeBindingSnapshot? + ) { + guard let kindValue = binding?.kind, + let kind = RestorableAgentKind(rawValue: kindValue), + let sessionId = binding?.checkpointId else { return } + productionWriter().schedule( + kind: kind, + sessionId: sessionId, + expectedRecordUpdatedAt: binding?.updatedAt + ) + } + + static func recordLifecycle( + agent: SessionRestorableAgentSnapshot?, + state: AgentSessionLifecycleState + ) { + guard let agent else { return } + productionWriter().scheduleLifecycle( + kind: agent.kind, + sessionId: agent.sessionId, + state: state + ) + } + + @discardableResult + static func recordRestoredHibernation( + agent: SessionRestorableAgentSnapshot, + previousWorkspaceId: UUID?, + previousSurfaceId: UUID, + workspaceId: UUID, + surfaceId: UUID + ) -> Bool { + recordRestoredHibernations([ + RestoredHibernationAdoptionRequest( + agent: agent, + previousWorkspaceId: previousWorkspaceId, + previousSurfaceId: previousSurfaceId, + workspaceId: workspaceId, + surfaceId: surfaceId + ), + ]).contains(surfaceId) + } + + static func recordRestoredHibernations( + _ requests: [RestoredHibernationAdoptionRequest], + now: TimeInterval = Date().timeIntervalSince1970 + ) -> Set { + Set(recordRestoredHibernationOutcomes(requests, now: now).compactMap { + $0.value == .adopted ? $0.key : nil + }) + } + + static func recordRestoredHibernationOutcomes( + _ requests: [RestoredHibernationAdoptionRequest], + now: TimeInterval = Date().timeIntervalSince1970 + ) -> [UUID: RestoredHibernationAdoptionOutcome] { + productionWriter().recordRestoredHibernationOutcomesSynchronously( + requests, + now: now + ) + } + + /// Waits once for an in-flight registry writer, then claims every restored + /// hibernation in the same provider-batched transactions used by restore. + /// Cancellation is checked inside the acquired transaction before any row + /// is changed, so closing a pending panel cannot apply a delayed claim. + static func waitForRestoredHibernationOutcomes( + _ requests: [RestoredHibernationAdoptionRequest], + now: TimeInterval = Date().timeIntervalSince1970, + busyTimeoutMilliseconds: Int32 = 2_000, + legacyReadLockWaitWillBegin: @escaping @Sendable () -> Void = {} + ) async -> [UUID: RestoredHibernationAdoptionOutcome] { + guard !requests.isEmpty else { return [:] } + let writer = productionWriter() + let cancellationSignal = LegacyLockCancellationSignal() + let operation = Task.detached(priority: .utility) { + writer.recordRestoredHibernationOutcomesSynchronously( + requests, + now: now, + busyTimeoutMilliseconds: max(0, busyTimeoutMilliseconds), + legacyReadLockMode: .wait, + legacyReadLockWaitWillBegin: legacyReadLockWaitWillBegin, + legacyReadLockCancellationDescriptor: cancellationSignal?.readDescriptor, + cancellationCheck: { Task.isCancelled } + ) + } + return await withTaskCancellationHandler( + operation: { await operation.value }, + onCancel: { + cancellationSignal?.cancel() + operation.cancel() + } + ) + } + + /// Completes only the exact adoption generation written by a delayed + /// background claim. A newer retry or resume replaces/removes the token, + /// making this compensation a no-op instead of revoking its authority. + static func releaseCanceledRestoredHibernations( + _ requests: [RestoredHibernationAdoptionRequest], + now: TimeInterval = Date().timeIntervalSince1970 + ) async { + guard !requests.isEmpty else { return } + let writer = productionWriter() + await Task.detached(priority: .utility) { + writer.releaseCanceledRestoredHibernationsSynchronously( + requests, + now: now + ) + }.value + } + + /// Atomically claims the durable surface owner immediately before cmux + /// queues a hibernated agent's resume input. A missing or changed slot is a + /// lost authority lease, even when the record still carries the old binding. + @discardableResult + static func acquireHibernatedResumeAuthority( + agent: SessionRestorableAgentSnapshot, + workspaceId: UUID, + surfaceId: UUID, + now: TimeInterval = Date().timeIntervalSince1970 + ) -> HibernatedResumeAuthorityOutcome { + acquireHibernatedResumeAuthorities([ + HibernatedResumeAuthorityRequest( + agent: agent, + workspaceId: workspaceId, + surfaceId: surfaceId + ), + ], now: now)[surfaceId] ?? .unavailable + } + + /// Claims many hibernated records with one bounded SQLite transaction per + /// provider. Rejected siblings do not prevent independent claims from + /// succeeding in the same transaction. + static func acquireHibernatedResumeAuthorities( + _ requests: [HibernatedResumeAuthorityRequest], + now: TimeInterval = Date().timeIntervalSince1970 + ) -> [UUID: HibernatedResumeAuthorityOutcome] { + productionWriter().acquireHibernatedResumeAuthoritiesSynchronously( + requests, + now: now + ) + } + + /// Establishes the durable hibernated lease at the native teardown commit + /// point. Failure leaves the live runtime intact and retryable. + static func establishHibernatedAuthority( + agent: SessionRestorableAgentSnapshot, + workspaceId: UUID, + surfaceId: UUID, + attemptId: UUID, + now: TimeInterval = Date().timeIntervalSince1970 + ) -> HibernatedResumeAuthorityOutcome { + productionWriter().establishHibernatedAuthoritySynchronously( + request: HibernatedResumeAuthorityRequest( + agent: agent, + workspaceId: workspaceId, + surfaceId: surfaceId, + attemptId: attemptId + ), + now: now + ) + } + + /// Detaches only the durable hibernation generation whose native teardown + /// lost to a portal close after the registry commit. Recovery authority is + /// retained without active slots so closed-history/session restore can + /// adopt it; a later generation makes this exact-token write a no-op. + static func releaseFailedHibernationAuthority( + agent: SessionRestorableAgentSnapshot, + workspaceId: UUID, + surfaceId: UUID, + attemptId: UUID, + now: TimeInterval = Date().timeIntervalSince1970 + ) async { + let writer = productionWriter() + let request = HibernatedResumeAuthorityRequest( + agent: agent, + workspaceId: workspaceId, + surfaceId: surfaceId, + attemptId: attemptId + ) + await Task.detached(priority: .utility) { + writer.releaseFailedHibernationAuthoritySynchronously( + request: request, + now: now, + busyTimeoutMilliseconds: 2_000 + ) + }.value + } + + static func projectCanonicalLegacy(agent: SessionRestorableAgentSnapshot) { + let writer = productionWriter() + let provider = agent.kind.rawValue + let stateURL = agent.kind.hookStoreFileURL( + homeDirectory: writer.homeDirectory, + environment: writer.environment + ) + Task(priority: .utility) { + await Self.writeCoordinator.projectCanonicalLegacy( + using: writer, + provider: provider, + stateURL: stateURL + ) + } + } + + /// Returns an exact resume claim to hibernated when its already-built + /// local plan can no longer be applied. The active surface slot remains + /// owned by the same session, so a later resume can retry safely. + static func releaseFailedHibernatedResumeAuthority( + _ request: HibernatedResumeAuthorityRequest, + now: TimeInterval = Date().timeIntervalSince1970 + ) { + productionWriter().releaseFailedHibernatedResumeAuthoritySynchronously( + request, + now: now + ) + } + + private func recordRestoredHibernationOutcomesSynchronously( + _ requests: [RestoredHibernationAdoptionRequest], + now: TimeInterval, + busyTimeoutMilliseconds: Int32 = 25, + legacyReadLockMode: LegacyReadLockMode = .immediate, + legacyReadLockWaitWillBegin: @escaping @Sendable () -> Void = {}, + legacyReadLockCancellationDescriptor: Int32? = nil, + cancellationCheck: @Sendable () -> Bool = { false } + ) -> [UUID: RestoredHibernationAdoptionOutcome] { + var outcomes: [UUID: RestoredHibernationAdoptionOutcome] = [:] + let busyBudget = MonotonicBusyBudget(milliseconds: busyTimeoutMilliseconds) + let requestsByProvider = Dictionary(grouping: requests, by: { $0.agent.kind.rawValue }) + .sorted { $0.key < $1.key } + for (provider, providerRequests) in requestsByProvider { + guard !cancellationCheck() else { break } + guard let kind = providerRequests.first?.agent.kind else { continue } + let stateURL = kind.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ) + let providerResult = adoptRestoredHibernationsHoldingLegacyReadLock( + provider: provider, + stateURL: stateURL, + requests: providerRequests, + now: now, + busyBudget: busyBudget, + legacyReadLockMode: legacyReadLockMode, + legacyReadLockWaitWillBegin: legacyReadLockWaitWillBegin, + legacyReadLockCancellationDescriptor: legacyReadLockCancellationDescriptor, + cancellationCheck: cancellationCheck + ) + outcomes.merge(providerResult.outcomes) { _, new in new } + let adopted = providerResult.adopted + guard !adopted.isEmpty else { continue } + Task(priority: .utility) { + await Self.writeCoordinator.projectRestoredHibernationsToLegacy( + using: self, + provider: provider, + stateURL: stateURL, + requests: adopted, + now: now + ) + } + } + return outcomes + } + + private func releaseCanceledRestoredHibernationsSynchronously( + _ requests: [RestoredHibernationAdoptionRequest], + now: TimeInterval + ) { + let busyBudget = MonotonicBusyBudget(milliseconds: 2_000) + let requestsByProvider = Dictionary( + grouping: requests, + by: { $0.agent.kind.rawValue } + ).sorted { $0.key < $1.key } + for (provider, providerRequests) in requestsByProvider { + guard let kind = providerRequests.first?.agent.kind else { continue } + let stateURL = kind.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ) + let normalizedRequests = providerRequests.compactMap { + request -> (RestoredHibernationAdoptionRequest, String)? in + guard let sessionId = normalized(request.agent.sessionId) else { return nil } + return (request, sessionId) + } + guard !normalizedRequests.isEmpty else { continue } + do { + try registry( + provider: provider, + stateURL: stateURL, + busyTimeoutMilliseconds: busyBudget.remainingMilliseconds() + ).withRecordRebindBatch { batch in + for (request, sessionId) in normalizedRequests { + let result = try batch.patchRecordRebindingActiveSlots( + provider: provider, + sessionID: sessionId, + updatedAt: now, + previousSlots: [ + .init(scope: .workspace, scopeID: request.workspaceId.uuidString), + .init(scope: .surface, scopeID: request.surfaceId.uuidString), + ], + activeSlots: [], + monotonicUpdatedAt: true, + shouldMutate: { record in + guard normalized(record["cmuxRestoreAdoptionId"] as? String) + == request.adoptionId.uuidString, + record["sessionState"] as? String + == AgentSessionLifecycleState.hibernated.rawValue, + !hasCompletion(record), + projectedRecordCanBeMutatedByCurrentRuntime(record), + let workspaceId = normalized(record["workspaceId"] as? String), + let surfaceId = normalized(record["surfaceId"] as? String) else { + return false + } + return identifiersEqual(workspaceId, request.workspaceId.uuidString) + && identifiersEqual(surfaceId, request.surfaceId.uuidString) + } + ) { record in + let effectiveNow = max( + now, + record["updatedAt"] as? TimeInterval ?? now + ) + applyCompletion(to: &record, now: effectiveNow) + record.removeValue(forKey: "cmuxRestoreAdoptionId") + } + if result == .patched { + NSLog( + "[Workspace] released canceled restored hibernation session=%@ surface=%@", + sessionId, + request.surfaceId.uuidString + ) + } + } + } + Task(priority: .utility) { + await Self.writeCoordinator.projectCanonicalLegacy( + using: self, + provider: provider, + stateURL: stateURL + ) + } + } catch { + NSLog( + "[Workspace] failed to release canceled restored hibernation provider=%@ error=%@", + provider, + String(describing: error) + ) + } + } + } + + func schedule( + kind: RestorableAgentKind, + sessionId: String, + expectedRecordUpdatedAt: TimeInterval? = nil, + now: TimeInterval = Date().timeIntervalSince1970 + ) { + let normalized = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return } + let stateURL = kind.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ) + Task(priority: .utility) { + await Self.writeCoordinator.complete( + using: self, + provider: kind.rawValue, + stateURL: stateURL, + sessionId: normalized, + expectedRecordUpdatedAt: expectedRecordUpdatedAt, + now: now + ) + } + } + + func completeSynchronously( + kind: RestorableAgentKind, + sessionId: String, + expectedRecordUpdatedAt: TimeInterval? = nil, + now: TimeInterval + ) { + let normalized = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return } + complete( + provider: kind.rawValue, + stateURL: kind.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ), + sessionId: normalized, + expectedRecordUpdatedAt: expectedRecordUpdatedAt, + now: now + ) + } + + func scheduleLifecycle( + kind: RestorableAgentKind, + sessionId: String, + state: AgentSessionLifecycleState, + now: TimeInterval = Date().timeIntervalSince1970 + ) { + let normalized = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return } + let stateURL = kind.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ) + Task(priority: .utility) { + await Self.writeCoordinator.setLifecycle( + state, + using: self, + provider: kind.rawValue, + stateURL: stateURL, + sessionId: normalized, + now: now + ) + } + } + + func setLifecycleSynchronously( + kind: RestorableAgentKind, + sessionId: String, + state: AgentSessionLifecycleState, + now: TimeInterval + ) { + let normalized = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return } + setLifecycle( + state, + provider: kind.rawValue, + stateURL: kind.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ), + sessionId: normalized, + now: now + ) + } + + @discardableResult + func recordRestoredHibernationSynchronously( + kind: RestorableAgentKind, + sessionId: String, + previousWorkspaceId: String?, + previousSurfaceId: String, + workspaceId: String, + surfaceId: String, + now: TimeInterval = Date().timeIntervalSince1970 + ) -> Bool { + guard let normalizedSessionId = normalized(sessionId), + let normalizedPreviousSurfaceId = normalized(previousSurfaceId), + let normalizedWorkspaceId = normalized(workspaceId), + let normalizedSurfaceId = normalized(surfaceId), + let previousSurfaceUUID = UUID(uuidString: normalizedPreviousSurfaceId), + let workspaceUUID = UUID(uuidString: normalizedWorkspaceId), + let surfaceUUID = UUID(uuidString: normalizedSurfaceId) else { return false } + let previousWorkspaceUUID: UUID? + if let previousWorkspaceId { + guard let value = normalized(previousWorkspaceId), + let uuid = UUID(uuidString: value) else { return false } + previousWorkspaceUUID = uuid + } else { + previousWorkspaceUUID = nil + } + let request = RestoredHibernationAdoptionRequest( + agent: SessionRestorableAgentSnapshot( + kind: kind, + sessionId: normalizedSessionId, + workingDirectory: nil, + launchCommand: nil + ), + previousWorkspaceId: previousWorkspaceUUID, + previousSurfaceId: previousSurfaceUUID, + workspaceId: workspaceUUID, + surfaceId: surfaceUUID + ) + return recordRestoredHibernationOutcomesSynchronously( + [request], + now: now + )[surfaceUUID] == .adopted + } + + private func complete( + provider: String, + stateURL: URL, + sessionId: String, + expectedRecordUpdatedAt: TimeInterval?, + now: TimeInterval + ) { + let registry = preparedRegistry(provider: provider, stateURL: stateURL) + _ = try? registry.patchRecord( + provider: provider, + sessionID: sessionId, + updatedAt: now, + activeSlotRemoval: expectedRecordUpdatedAt.map { + .updatedThrough($0) + } ?? .all, + shouldMutate: { record in + guard let expectedRecordUpdatedAt else { return true } + guard let actualUpdatedAt = record["updatedAt"] as? TimeInterval else { return false } + return actualUpdatedAt <= expectedRecordUpdatedAt + } + ) { registryRecord in + applyCompletion(to: ®istryRecord, now: now) + } + + projectCanonicalLegacy(provider: provider, stateURL: stateURL) + } + + private func setLifecycle( + _ lifecycle: AgentSessionLifecycleState, + provider: String, + stateURL: URL, + sessionId: String, + now: TimeInterval + ) { + let registry = preparedRegistry(provider: provider, stateURL: stateURL) + _ = try? registry.patchRecord( + provider: provider, + sessionID: sessionId, + updatedAt: now, + shouldMutate: { record in + guard let actualUpdatedAt = record["updatedAt"] as? TimeInterval else { return false } + return actualUpdatedAt <= now + } + ) { registryRecord in + applyLifecycle(lifecycle, to: ®istryRecord, now: now) + } + projectCanonicalLegacy(provider: provider, stateURL: stateURL) + } + + private func establishHibernatedAuthoritySynchronously( + request: HibernatedResumeAuthorityRequest, + now: TimeInterval + ) -> HibernatedResumeAuthorityOutcome { + guard let sessionId = normalized(request.agent.sessionId) else { return .rejected } + let provider = request.agent.kind.rawValue + let stateURL = request.agent.kind.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ) + let result: CmuxAgentSessionRegistry.RecordRebindResult + do { + result = try registry( + provider: provider, + stateURL: stateURL, + busyTimeoutMilliseconds: 25 + ).patchRecordRebindingActiveSlots( + provider: provider, + sessionID: sessionId, + updatedAt: now, + previousSlots: [], + activeSlots: [.init(scope: .surface, scopeID: request.surfaceId.uuidString)], + requireExistingActiveSlots: true, + monotonicUpdatedAt: true, + shouldMutate: { record in + let allowedStates: Set = [ + AgentSessionLifecycleState.active.rawValue, + AgentSessionLifecycleState.restoring.rawValue, + AgentSessionLifecycleState.hibernated.rawValue, + ] + guard let state = record["sessionState"] as? String, + allowedStates.contains(state), + !hasCompletion(record), + record["updatedAt"] is TimeInterval, + projectedRecordCanBeMutatedByCurrentRuntime(record), + let recordWorkspaceId = normalized(record["workspaceId"] as? String), + let recordSurfaceId = normalized(record["surfaceId"] as? String) else { + return false + } + return identifiersEqual(recordWorkspaceId, request.workspaceId.uuidString) + && identifiersEqual(recordSurfaceId, request.surfaceId.uuidString) + } + ) { record in + let effectiveNow = max(now, record["updatedAt"] as? TimeInterval ?? now) + applyLifecycle(.hibernated, to: &record, now: effectiveNow) + record.removeValue(forKey: "cmuxRestoreAdoptionId") + record["cmuxHibernationAttemptId"] = request.attemptId.uuidString + record["cmuxHibernatedAt"] = effectiveNow + record["cmuxHibernationDetached"] = false + record.removeValue(forKey: "cmuxHibernationResumeAttemptId") + record.removeValue(forKey: "cmuxHibernationResumeStartedAt") + record.removeValue(forKey: "cmuxHibernationResumeFromAttemptId") + } + } catch { + return .unavailable + } + guard result == .patched else { return .rejected } + return .acquired + } + + private func acquireHibernatedResumeAuthoritiesSynchronously( + _ requests: [HibernatedResumeAuthorityRequest], + now: TimeInterval + ) -> [UUID: HibernatedResumeAuthorityOutcome] { + var outcomes: [UUID: HibernatedResumeAuthorityOutcome] = [:] + let busyBudget = MonotonicBusyBudget(milliseconds: 25) + let requestsByProvider = Dictionary( + grouping: requests, + by: { $0.agent.kind.rawValue } + ).sorted { $0.key < $1.key } + for (provider, providerRequests) in requestsByProvider { + guard let kind = providerRequests.first?.agent.kind else { continue } + let stateURL = kind.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ) + let normalizedRequests = providerRequests.compactMap { + request -> (request: HibernatedResumeAuthorityRequest, sessionId: String)? in + guard let sessionId = normalized(request.agent.sessionId) else { + outcomes[request.surfaceId] = .rejected + return nil + } + return (request, sessionId) + } + guard !normalizedRequests.isEmpty else { continue } + let legacyStampAtClaim = CmuxAgentSessionRegistry.LegacyStamp.read( + path: stateURL.path + ) + let providerClaims: [HibernatedResumeAuthorityClaim] + let providerOutcomes: [UUID: HibernatedResumeAuthorityOutcome] + do { + let result = try registry( + provider: provider, + stateURL: stateURL, + busyTimeoutMilliseconds: busyBudget.remainingMilliseconds() + ).withRecordRebindBatch { batch in + var accepted: [HibernatedResumeAuthorityClaim] = [] + var transactionOutcomes: [UUID: HibernatedResumeAuthorityOutcome] = [:] + accepted.reserveCapacity(normalizedRequests.count) + for (request, sessionId) in normalizedRequests { + let activeSurfaceSlot = CmuxAgentSessionRegistry.ActiveSlotKey( + scope: .surface, + scopeID: request.surfaceId.uuidString + ) + let result = try batch.patchRecordRebindingActiveSlots( + provider: provider, + sessionID: sessionId, + updatedAt: now, + previousSlots: [], + activeSlots: [activeSurfaceSlot], + requireExistingActiveSlots: true, + monotonicUpdatedAt: true, + shouldMutate: { record in + guard record["sessionState"] as? String + == AgentSessionLifecycleState.hibernated.rawValue, + record["cmuxHibernationDetached"] as? Bool != true, + !hasCompletion(record), + record["updatedAt"] is TimeInterval, + projectedRecordCanBeMutatedByCurrentRuntime(record), + let recordWorkspaceId = normalized(record["workspaceId"] as? String), + let recordSurfaceId = normalized(record["surfaceId"] as? String) else { + return false + } + return identifiersEqual( + recordWorkspaceId, + request.workspaceId.uuidString + ) && identifiersEqual( + recordSurfaceId, + request.surfaceId.uuidString + ) + } + ) { record in + let effectiveNow = max( + now, + record["updatedAt"] as? TimeInterval ?? now + ) + if let hibernationId = normalized( + record["cmuxHibernationAttemptId"] as? String + ) { + record["cmuxHibernationResumeFromAttemptId"] = hibernationId + } else { + record.removeValue(forKey: "cmuxHibernationResumeFromAttemptId") + } + applyLifecycle(.restoring, to: &record, now: effectiveNow) + record.removeValue(forKey: "cmuxRestoreAdoptionId") + record["cmuxHibernationResumeAttemptId"] = request.attemptId.uuidString + record["cmuxHibernationResumeStartedAt"] = effectiveNow + } + if result == .patched { + transactionOutcomes[request.surfaceId] = .acquired + accepted.append(HibernatedResumeAuthorityClaim( + request: request, + legacyStampAtClaim: legacyStampAtClaim + )) + } else { + transactionOutcomes[request.surfaceId] = .rejected + } + } + return (accepted, transactionOutcomes) + } + providerClaims = result.0 + providerOutcomes = result.1 + } catch { + for request in providerRequests { + outcomes[request.surfaceId] = .unavailable + } + continue + } + outcomes.merge(providerOutcomes) { _, new in new } + guard !providerClaims.isEmpty else { continue } + Task(priority: .utility) { + await Self.writeCoordinator.projectHibernatedResumesToLegacy( + using: self, + provider: provider, + stateURL: stateURL, + claims: providerClaims, + now: now + ) + } + } + return outcomes + } + + private func releaseFailedHibernationAuthoritySynchronously( + request: HibernatedResumeAuthorityRequest, + now: TimeInterval, + busyTimeoutMilliseconds: Int32 + ) { + guard let sessionId = normalized(request.agent.sessionId) else { return } + let provider = request.agent.kind.rawValue + let stateURL = request.agent.kind.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ) + do { + _ = try registry( + provider: provider, + stateURL: stateURL, + busyTimeoutMilliseconds: busyTimeoutMilliseconds + ).patchRecordRebindingActiveSlots( + provider: provider, + sessionID: sessionId, + updatedAt: now, + previousSlots: [ + .init(scope: .workspace, scopeID: request.workspaceId.uuidString), + .init(scope: .surface, scopeID: request.surfaceId.uuidString), + ], + activeSlots: [], + monotonicUpdatedAt: true, + shouldMutate: { record in + guard normalized(record["cmuxHibernationAttemptId"] as? String) + == request.attemptId.uuidString, + record["sessionState"] as? String + == AgentSessionLifecycleState.hibernated.rawValue, + !hasCompletion(record), + projectedRecordCanBeMutatedByCurrentRuntime(record), + let workspaceId = normalized(record["workspaceId"] as? String), + let surfaceId = normalized(record["surfaceId"] as? String) else { + return false + } + return identifiersEqual(workspaceId, request.workspaceId.uuidString) + && identifiersEqual(surfaceId, request.surfaceId.uuidString) + } + ) { record in + let effectiveNow = max(now, record["updatedAt"] as? TimeInterval ?? now) + applyLifecycle(.hibernated, to: &record, now: effectiveNow) + record["cmuxHibernationDetached"] = true + record.removeValue(forKey: "cmuxHibernationResumeAttemptId") + record.removeValue(forKey: "cmuxHibernationResumeStartedAt") + record.removeValue(forKey: "cmuxHibernationResumeFromAttemptId") + record.removeValue(forKey: "cmuxRestoreAdoptionId") + } + } catch { + NSLog( + "[Workspace] failed to release hibernation attempt provider=%@ session=%@ error=%@", + provider, + sessionId, + String(describing: error) + ) + return + } + Task(priority: .utility) { + await Self.writeCoordinator.projectCanonicalLegacy( + using: self, + provider: provider, + stateURL: stateURL + ) + } + } + + private func releaseFailedHibernatedResumeAuthoritySynchronously( + _ request: HibernatedResumeAuthorityRequest, + now: TimeInterval + ) { + guard let sessionId = normalized(request.agent.sessionId) else { return } + let provider = request.agent.kind.rawValue + let stateURL = request.agent.kind.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ) + let result: CmuxAgentSessionRegistry.RecordRebindResult + do { + result = try registry( + provider: provider, + stateURL: stateURL, + busyTimeoutMilliseconds: 25 + ).patchRecordRebindingActiveSlots( + provider: provider, + sessionID: sessionId, + updatedAt: now, + previousSlots: [], + activeSlots: [.init(scope: .surface, scopeID: request.surfaceId.uuidString)], + requireExistingActiveSlots: true, + monotonicUpdatedAt: true, + shouldMutate: { record in + guard normalized(record["cmuxHibernationResumeAttemptId"] as? String) + == request.attemptId.uuidString, + record["sessionState"] as? String + == AgentSessionLifecycleState.restoring.rawValue, + !hasCompletion(record), + projectedRecordCanBeMutatedByCurrentRuntime(record), + let workspaceId = normalized(record["workspaceId"] as? String), + let surfaceId = normalized(record["surfaceId"] as? String) else { + return false + } + return identifiersEqual(workspaceId, request.workspaceId.uuidString) + && identifiersEqual(surfaceId, request.surfaceId.uuidString) + } + ) { record in + let effectiveNow = max(now, record["updatedAt"] as? TimeInterval ?? now) + applyLifecycle(.hibernated, to: &record, now: effectiveNow) + record.removeValue(forKey: "cmuxHibernationResumeAttemptId") + record.removeValue(forKey: "cmuxHibernationResumeStartedAt") + record.removeValue(forKey: "cmuxHibernationResumeFromAttemptId") + } + } catch { + return + } + guard result == .patched else { return } + Task(priority: .utility) { + await Self.writeCoordinator.projectCanonicalLegacy( + using: self, + provider: provider, + stateURL: stateURL + ) + } + } + + /// Waits for compatibility writers without retry sleeps. Darwin reports + /// `NOTE_FUNLOCK` through kqueue when another process releases `flock`, and + /// a pipe wakes the same kernel wait on task cancellation. The monotonic + /// budget is shared with the following SQLite transaction. + private func acquireLegacyReadLock( + descriptor: Int32, + mode: LegacyReadLockMode, + busyBudget: MonotonicBusyBudget, + waitWillBegin: @escaping @Sendable () -> Void, + cancellationDescriptor: Int32?, + cancellationCheck: @Sendable () -> Bool + ) -> Bool { + if flock(descriptor, LOCK_SH | LOCK_NB) == 0 { return true } + guard errno == EWOULDBLOCK || errno == EAGAIN, + mode == .wait, + !cancellationCheck() else { + return false + } + + waitWillBegin() + let queue = kqueue() + guard queue >= 0 else { return false } + defer { Darwin.close(queue) } + + var changes = [kevent64_s()] + changes[0].ident = UInt64(descriptor) + changes[0].filter = Int16(EVFILT_VNODE) + changes[0].flags = UInt16(EV_ADD | EV_CLEAR) + changes[0].fflags = UInt32(NOTE_FUNLOCK) + if let cancellationDescriptor { + changes.append(kevent64_s()) + changes[1].ident = UInt64(cancellationDescriptor) + changes[1].filter = Int16(EVFILT_READ) + changes[1].flags = UInt16(EV_ADD | EV_CLEAR) + } + let registrationResult = changes.withUnsafeBufferPointer { buffer in + kevent64(queue, buffer.baseAddress, Int32(buffer.count), nil, 0, 0, nil) + } + guard registrationResult == 0 else { return false } + + while !cancellationCheck() { + // Close the registration race: the owner may have unlocked between + // the first flock attempt and installing NOTE_FUNLOCK. + if flock(descriptor, LOCK_SH | LOCK_NB) == 0 { return true } + guard errno == EWOULDBLOCK || errno == EAGAIN else { return false } + + let remainingNanoseconds = busyBudget.remainingNanoseconds() + guard remainingNanoseconds > 0 else { return false } + var timeout = timespec( + tv_sec: Int(remainingNanoseconds / 1_000_000_000), + tv_nsec: Int(remainingNanoseconds % 1_000_000_000) + ) + var event = kevent64_s() + let eventCount = kevent64(queue, nil, 0, &event, 1, 0, &timeout) + if eventCount == 0 { return false } + if eventCount < 0 { + guard errno == EINTR else { return false } + continue + } + if event.filter == Int16(EVFILT_READ) { return false } + } + return false + } + + private func adoptRestoredHibernationsHoldingLegacyReadLock( + provider: String, + stateURL: URL, + requests: [RestoredHibernationAdoptionRequest], + now: TimeInterval, + busyBudget: MonotonicBusyBudget, + legacyReadLockMode: LegacyReadLockMode, + legacyReadLockWaitWillBegin: @escaping @Sendable () -> Void, + legacyReadLockCancellationDescriptor: Int32?, + cancellationCheck: @Sendable () -> Bool = { false } + ) -> ( + adopted: [RestoredHibernationAdoptionRequest], + outcomes: [UUID: RestoredHibernationAdoptionOutcome] + ) { + var initialOutcomes: [UUID: RestoredHibernationAdoptionOutcome] = [:] + let normalizedRequests = requests.compactMap { request -> (RestoredHibernationAdoptionRequest, String)? in + guard let sessionId = normalized(request.agent.sessionId) else { + initialOutcomes[request.surfaceId] = .rejected + return nil + } + return (request, sessionId) + } + guard !normalizedRequests.isEmpty else { return ([], initialOutcomes) } + func unavailableResult() -> ( + adopted: [RestoredHibernationAdoptionRequest], + outcomes: [UUID: RestoredHibernationAdoptionOutcome] + ) { + var outcomes = initialOutcomes + for (request, _) in normalizedRequests { + outcomes[request.surfaceId] = .unavailable + } + return ([], outcomes) + } + let descriptor = open( + stateURL.path + ".lock", + O_CREAT | O_RDWR, + mode_t(S_IRUSR | S_IWUSR) + ) + guard descriptor >= 0 else { return unavailableResult() } + defer { Darwin.close(descriptor) } + guard acquireLegacyReadLock( + descriptor: descriptor, + mode: legacyReadLockMode, + busyBudget: busyBudget, + waitWillBegin: legacyReadLockWaitWillBegin, + cancellationDescriptor: legacyReadLockCancellationDescriptor, + cancellationCheck: cancellationCheck + ) else { return unavailableResult() } + defer { _ = flock(descriptor, LOCK_UN) } + guard !cancellationCheck() else { return unavailableResult() } + + let registry = registry( + provider: provider, + stateURL: stateURL, + busyTimeoutMilliseconds: busyBudget.remainingMilliseconds() + ) + guard let ownerPreflights = restoredHibernationOwnerPreflights( + provider: provider, + stateURL: stateURL, + normalizedRequests: normalizedRequests, + registry: registry + ) else { + return unavailableResult() + } + do { + return try registry.withLegacySourceRebindBatch( + provider: provider, + legacyURL: stateURL + ) { batch in + if cancellationCheck() { throw CancellationError() } + var adopted: [RestoredHibernationAdoptionRequest] = [] + var outcomes = initialOutcomes + adopted.reserveCapacity(normalizedRequests.count) + for (request, sessionId) in normalizedRequests { + if cancellationCheck() { throw CancellationError() } + guard let ownerPreflight = ownerPreflights[sessionId], + let canonicalWorkspaceId = ownerPreflight.canonicalWorkspaceId, + let canonicalSurfaceId = ownerPreflight.canonicalSurfaceId else { + outcomes[request.surfaceId] = .rejected + continue + } + let pendingLifecycles: Set = [ + AgentSessionLifecycleState.hibernated.rawValue, + AgentSessionLifecycleState.restoring.rawValue, + ] + let isPendingOwner = ownerPreflight.lifecycle.map( + pendingLifecycles.contains + ) == true + switch ownerPreflight.runtimeEvidence { + case .provablyLiveForeign: + outcomes[request.surfaceId] = isPendingOwner + ? .unavailable + : .rejected + continue + case .unknownForeign: + outcomes[request.surfaceId] = .unavailable + continue + case .provablyDeadForeign + where ownerPreflight.lifecycle + == AgentSessionLifecycleState.restoring.rawValue + && !ownerPreflight.hasResumeAttempt: + outcomes[request.surfaceId] = .unavailable + continue + case .current where !ownerPreflight.isDetached: + outcomes[request.surfaceId] = isPendingOwner + ? .unavailable + : .rejected + continue + case .current, .provablyDeadForeign: + break + } + let canonicalSurfaceSlot = CmuxAgentSessionRegistry.ActiveSlotKey( + scope: .surface, + scopeID: canonicalSurfaceId + ) + let activeSurfaceSlot = CmuxAgentSessionRegistry.ActiveSlotKey( + scope: .surface, + scopeID: request.surfaceId.uuidString + ) + let canonicalSurfaceOwner = try batch.activeSlotSessionID( + provider: provider, + key: canonicalSurfaceSlot + ) + let activeSurfaceOwner = try batch.activeSlotSessionID( + provider: provider, + key: activeSurfaceSlot + ) + let canonicalWorkspaceSlot = CmuxAgentSessionRegistry.ActiveSlotKey( + scope: .workspace, + scopeID: canonicalWorkspaceId + ) + let rebindWorkspaceActiveSlot = try batch.activeSlotSessionID( + provider: provider, + key: canonicalWorkspaceSlot + ) == sessionId + var previousSlots = [canonicalSurfaceSlot] + var activeSlots = [activeSurfaceSlot] + if rebindWorkspaceActiveSlot { + previousSlots.append(canonicalWorkspaceSlot) + activeSlots.append( + CmuxAgentSessionRegistry.ActiveSlotKey( + scope: .workspace, + scopeID: request.workspaceId.uuidString + ) + ) + } + let result = try batch.patchRecordRebindingActiveSlots( + provider: provider, + sessionID: sessionId, + updatedAt: now, + previousSlots: previousSlots, + activeSlots: activeSlots, + monotonicUpdatedAt: true, + shouldMutate: { record in + guard restoredRecordCanBeAdopted( + record, + canonicalWorkspaceId: canonicalWorkspaceId, + canonicalSurfaceId: canonicalSurfaceId, + workspaceId: request.workspaceId.uuidString, + surfaceId: request.surfaceId.uuidString, + allowRestoringAttempt: + ownerPreflight.runtimeEvidence == .provablyDeadForeign + && ownerPreflight.hasResumeAttempt + ), + restoredHibernationRecordFingerprint(record) + == ownerPreflight.recordFingerprint, + let recordWorkspaceId = normalized(record["workspaceId"] as? String), + let recordSurfaceId = normalized(record["surfaceId"] as? String) else { + return false + } + // A record still on the exact preflight binding + // needs that canonical surface slot. An idempotent + // repeat after transfer instead needs the target + // slot. Workspace slots differ: sibling panels can + // share one workspace, so only its actual owner + // transfers that optional slot above. + let alreadyAdopted = identifiersEqual( + recordWorkspaceId, + request.workspaceId.uuidString + ) && identifiersEqual( + recordSurfaceId, + request.surfaceId.uuidString + ) + return alreadyAdopted + ? activeSurfaceOwner == sessionId + : ownerPreflight.isDetached || canonicalSurfaceOwner == sessionId + } + ) { record in + let effectiveNow = max(now, record["updatedAt"] as? TimeInterval ?? now) + applyRestoredHibernation( + to: &record, + workspaceId: request.workspaceId.uuidString, + surfaceId: request.surfaceId.uuidString, + now: effectiveNow + ) + record["cmuxRestoreAdoptionId"] = request.adoptionId.uuidString + } + if result == .patched { + var adoptedRequest = request + adoptedRequest.rebindWorkspaceActiveSlot = rebindWorkspaceActiveSlot + adopted.append(adoptedRequest) + outcomes[request.surfaceId] = .adopted + } else { + outcomes[request.surfaceId] = .rejected + } + } + return (adopted, outcomes) + } + } catch { + return unavailableResult() + } + } + + func projectRestoredHibernationsToLegacy( + provider: String, + stateURL: URL, + requests: [RestoredHibernationAdoptionRequest], + now: TimeInterval + ) { + projectCanonicalLegacy(provider: provider, stateURL: stateURL) + } + + private func projectHibernatedResumesToLegacy( + provider: String, + stateURL: URL, + claims: [HibernatedResumeAuthorityClaim], + now: TimeInterval + ) { + projectCanonicalLegacy(provider: provider, stateURL: stateURL) + } + + func projectEstablishedHibernationToLegacy( + provider: String, + stateURL: URL, + request: HibernatedResumeAuthorityRequest, + legacyStampAtClaim: CmuxAgentSessionRegistry.LegacyStamp?, + now: TimeInterval + ) { + projectCanonicalLegacy(provider: provider, stateURL: stateURL) + } + + private func applyCompletion(to record: inout [String: Any], now: TimeInterval) { + record["completedAt"] = now + record["updatedAt"] = now + record["runtimeStatus"] = "idle" + record["agentLifecycle"] = "idle" + if record["foregroundState"] as? String != "interrupted" { + record["foregroundState"] = "completed" + } + record["attentionState"] = "none" + record["sessionState"] = "ended" + record["restoreAuthority"] = false + record.removeValue(forKey: "activeRunId") + record["runs"] = completeRuns(record["runs"], now: now) + record["workloads"] = cancelWorkloads(record["workloads"], now: now) + } + + private func applyLifecycle( + _ lifecycle: AgentSessionLifecycleState, + to record: inout [String: Any], + now: TimeInterval + ) { + record["sessionState"] = lifecycle.rawValue + record["updatedAt"] = now + if let runtime = runtimePayload() { + record["cmuxRuntime"] = runtime + record["runs"] = assigningRuntime( + runtime, + to: record["runs"], + activeRunId: record["activeRunId"] as? String + ) + } + } + + private func applyRestoredHibernation( + to record: inout [String: Any], + workspaceId: String, + surfaceId: String, + now: TimeInterval + ) { + applyLifecycle(.hibernated, to: &record, now: now) + record["workspaceId"] = workspaceId + record["surfaceId"] = surfaceId + record["cmuxHibernationDetached"] = false + record.removeValue(forKey: "cmuxHibernationResumeAttemptId") + record.removeValue(forKey: "cmuxHibernationResumeStartedAt") + record.removeValue(forKey: "cmuxHibernationResumeFromAttemptId") + } + + private func restoredRecordCanBeAdopted( + _ record: [String: Any], + canonicalWorkspaceId: String, + canonicalSurfaceId: String, + workspaceId: String, + surfaceId: String, + allowRestoringAttempt: Bool = false + ) -> Bool { + let lifecycle = record["sessionState"] as? String + let lifecycleCanBeAdopted = lifecycle == AgentSessionLifecycleState.hibernated.rawValue + || (allowRestoringAttempt + && lifecycle == AgentSessionLifecycleState.restoring.rawValue + && normalized(record["cmuxHibernationResumeAttemptId"] as? String) != nil) + guard lifecycleCanBeAdopted, + projectedRecordAuthority(record)?.restoreAuthority == true, + !hasCompletion(record), + record["updatedAt"] is TimeInterval, + let recordWorkspaceId = normalized(record["workspaceId"] as? String), + let recordSurfaceId = normalized(record["surfaceId"] as? String) else { + return false + } + let alreadyAdopted = identifiersEqual(recordWorkspaceId, workspaceId) + && identifiersEqual(recordSurfaceId, surfaceId) + let matchesCanonicalBinding = identifiersEqual(recordWorkspaceId, canonicalWorkspaceId) + && identifiersEqual(recordSurfaceId, canonicalSurfaceId) + return alreadyAdopted || matchesCanonicalBinding + } + + private func preparedRegistry( + provider: String, + stateURL: URL + ) -> CmuxAgentSessionRegistry { + let registry = registry(provider: provider, stateURL: stateURL) + _ = try? registry.refreshLegacySources( + [.init(provider: provider, url: stateURL)] + ) + return registry + } + + private func projectCanonicalLegacy(provider: String, stateURL: URL) { + let registry = registry( + provider: provider, + stateURL: stateURL, + busyTimeoutMilliseconds: 2_000 + ) + do { + let status = try registry.hookProjectionStatus(provider: provider) + try registry.projectHookLegacyStore( + provider: provider, + to: stateURL, + including: status.revision + ) + } catch { + NSLog( + "[AgentHookSessionStateWriter] canonical projection failed provider=%@ error=%@", + provider, + String(describing: error) + ) + } + } + + private func registry( + provider: String, + stateURL: URL, + busyTimeoutMilliseconds: Int32 = 100 + ) -> CmuxAgentSessionRegistry { + let registryURL: URL + if let explicit = environment["CMUX_AGENT_SESSION_REGISTRY_PATH"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !explicit.isEmpty { + registryURL = URL(fileURLWithPath: NSString(string: explicit).expandingTildeInPath) + } else { + registryURL = stateURL.deletingLastPathComponent() + .appendingPathComponent(CmuxAgentSessionRegistry.filename, isDirectory: false) + } + return CmuxAgentSessionRegistry( + url: registryURL, + busyTimeoutMilliseconds: busyTimeoutMilliseconds + ) + } + + private func completeRuns(_ value: Any?, now: TimeInterval) -> [[String: Any]] { + guard let runs = value as? [[String: Any]] else { return [] } + return runs.map { run in + var run = run + if run["endedAt"] == nil { + run["endedAt"] = now + run["updatedAt"] = now + run["restoreAuthority"] = false + } + return run + } + } + + private func runtimePayload() -> [String: Any]? { + guard let id = environment["CMUX_RUNTIME_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !id.isEmpty else { return nil } + var payload: [String: Any] = ["id": id] + let preferredSocketPath = SocketControlSettings.socketPath( + environment: environment, + bundleIdentifier: normalized(environment["CMUX_BUNDLE_ID"]) + ) + let socketState = currentSocketStateResolver(preferredSocketPath) + if socketState.pathOwnedByCurrentListener, + let socketPath = normalized(socketState.activePath) { + payload["socketPath"] = socketPath + } + if let bundleIdentifier = environment["CMUX_BUNDLE_ID"], !bundleIdentifier.isEmpty { + payload["bundleIdentifier"] = bundleIdentifier + } + if let processIdentity = processIdentityResolver(getpid()) { + payload["processId"] = Int(processIdentity.pid) + payload["processStartSeconds"] = processIdentity.startSeconds + payload["processStartMicroseconds"] = processIdentity.startMicroseconds + } + return payload + } + + /// A nonempty run history is the sole source of restore and runtime + /// authority. Root fields remain a compatibility fallback only for legacy + /// records that have no runs. + private func projectedRecordAuthority( + _ record: [String: Any] + ) -> ProjectedRecordAuthority? { + guard JSONSerialization.isValidJSONObject(record), + let data = try? JSONSerialization.data(withJSONObject: record), + let projection = CmuxAgentSessionRunAuthorityProjection() + .projection(recordJSON: data) else { + return nil + } + let runtimeID: String? + if let run = projection.run { + runtimeID = normalized(run.cmuxRuntime?.id) + } else { + let runtime = record["cmuxRuntime"] as? [String: Any] + runtimeID = normalized(runtime?["id"] as? String) + } + return ProjectedRecordAuthority( + restoreAuthority: projection.restoreAuthority, + runtimeID: runtimeID + ) + } + + private func projectedRecordCanBeMutatedByCurrentRuntime( + _ record: [String: Any] + ) -> Bool { + guard let currentRuntimeID = normalized(environment["CMUX_RUNTIME_ID"]), + let authority = projectedRecordAuthority(record) else { + return false + } + return authority.restoreAuthority && authority.runtimeID == currentRuntimeID + } + + /// Reads and probes the prospective owner before opening the SQLite writer + /// transaction. The transaction compares the exact canonicalized record, + /// turning this preflight into a CAS instead of holding a database lock + /// across filesystem and socket I/O. + private func restoredHibernationOwnerPreflights( + provider: String, + stateURL: URL, + normalizedRequests: [(RestoredHibernationAdoptionRequest, String)], + registry: CmuxAgentSessionRegistry + ) -> [String: RestoredHibernationOwnerPreflight]? { + let sessionIds = Set(normalizedRequests.map(\.1)) + guard let canonicalRecords = try? registry.records( + provider: provider, + sessionIDs: sessionIds + ) else { return nil } + let canonicalBySessionId = Dictionary( + canonicalRecords.map { ($0.sessionID, $0) }, + uniquingKeysWith: { existing, _ in existing } + ) + + var legacyAdmission: CmuxAgentSessionRegistry.HookLegacySourceAdmission? + if let legacyStamp = CmuxAgentSessionRegistry.LegacyStamp.read(path: stateURL.path) { + guard let canSkipInitialStamp = try? registry.canonicalRebindCanSkipLegacySource( + provider: provider, + stamp: legacyStamp + ) else { return nil } + if !canSkipInitialStamp { + guard let admission = try? registry + .hookLegacySourceAdmissionRetryingOneReplacement( + source: .init(provider: provider, url: stateURL), + expectedStamp: legacyStamp + ), + let canSkipAdmittedStamp = try? registry + .canonicalRebindCanSkipLegacySource( + provider: provider, + stamp: admission.stamp + ) else { + return nil + } + if !canSkipAdmittedStamp { + legacyAdmission = admission + } + } + } + let legacySessions: [String: Any] = { + guard let legacyAdmission, + let root = try? JSONSerialization.jsonObject( + with: legacyAdmission.json + ) as? [String: Any] else { + return [:] + } + return root["sessions"] as? [String: Any] ?? [:] + }() + var result: [String: RestoredHibernationOwnerPreflight] = [:] + var runtimeOwnershipProbe = AgentRuntimeOwnershipProbe( + environment: environment, + currentSocketStateResolver: currentSocketStateResolver, + processIdentityResolver: processIdentityResolver + ) + result.reserveCapacity(sessionIds.count) + for sessionId in sessionIds { + let canonical = canonicalBySessionId[sessionId] + let record: [String: Any]? + if let canonical, canonical.writerGeneration > 0 { + record = try? JSONSerialization.jsonObject(with: canonical.json) as? [String: Any] + } else if legacyAdmission != nil, + let legacyRecord = legacySessions[sessionId] as? [String: Any] { + record = legacyRecord + } else if let canonical { + record = try? JSONSerialization.jsonObject(with: canonical.json) as? [String: Any] + } else { + record = nil + } + guard let record, + let fingerprint = restoredHibernationRecordFingerprint(record) else { + return nil + } + result[sessionId] = RestoredHibernationOwnerPreflight( + recordFingerprint: fingerprint, + canonicalWorkspaceId: normalized(record["workspaceId"] as? String), + canonicalSurfaceId: normalized(record["surfaceId"] as? String), + lifecycle: normalized(record["sessionState"] as? String), + isDetached: record["cmuxHibernationDetached"] as? Bool == true, + hasResumeAttempt: + normalized(record["cmuxHibernationResumeAttemptId"] as? String) != nil, + runtimeEvidence: runtimeOwnershipProbe.evidence(for: record) + ) + } + return result + } + + private func restoredHibernationRecordFingerprint(_ record: [String: Any]) -> Data? { + guard JSONSerialization.isValidJSONObject(record) else { return nil } + return try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + } + + private func assigningRuntime( + _ runtime: [String: Any], + to value: Any?, + activeRunId: String? + ) -> [[String: Any]] { + guard let runs = value as? [[String: Any]], let activeRunId else { return value as? [[String: Any]] ?? [] } + return runs.map { run in + guard run["runId"] as? String == activeRunId else { return run } + var updated = run + updated["cmuxRuntime"] = runtime + return updated + } + } + + private func cancelWorkloads(_ value: Any?, now: TimeInterval) -> [[String: Any]] { + guard let workloads = value as? [[String: Any]] else { return [] } + let activePhases: Set = ["queued", "running", "watching", "waiting"] + return workloads.map { workload in + var workload = workload + if let phase = workload["phase"] as? String, activePhases.contains(phase) { + workload["phase"] = "cancelled" + workload["updatedAt"] = now + workload["endedAt"] = now + workload["endReason"] = "root_exited" + } + return workload + } + } + + private func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } + return value + } + + private func identifiersEqual(_ lhs: String, _ rhs: String) -> Bool { + lhs.caseInsensitiveCompare(rhs) == .orderedSame + } + + private func hasCompletion(_ record: [String: Any]) -> Bool { + guard let completedAt = record["completedAt"] else { return false } + return !(completedAt is NSNull) + } + +} diff --git a/Sources/AgentSessions/AgentRuntimeOwnershipProbe.swift b/Sources/AgentSessions/AgentRuntimeOwnershipProbe.swift new file mode 100644 index 000000000000..33c0bff4bf19 --- /dev/null +++ b/Sources/AgentSessions/AgentRuntimeOwnershipProbe.swift @@ -0,0 +1,244 @@ +import CmuxControlSocket +import CmuxFoundation +import CmuxSettings +import Darwin +import Foundation + +/// Classifies persisted cmux runtime ownership without treating missing +/// process or socket evidence as proof that a foreign owner is dead. +struct AgentRuntimeOwnershipProbe: Sendable { + static let defaultMaximumExternalProbes = 128 + + enum Evidence: Equatable, Sendable { + case current + case provablyLiveForeign + case provablyDeadForeign + case unknownForeign + } + + typealias CurrentSocketState = ( + activePath: String, + pathOwnedByCurrentListener: Bool + ) + typealias CurrentSocketStateResolver = @Sendable (String) -> CurrentSocketState + typealias ProcessIdentityResolver = @Sendable (pid_t) -> AgentPIDProcessIdentity? + + private enum ComponentEvidence: Equatable, Sendable { + case live + case dead + case unknown + } + + private let environment: [String: String] + private let currentSocketStateResolver: CurrentSocketStateResolver + private let processIdentityResolver: ProcessIdentityResolver + private let maximumExternalProbes: Int + private let socketTransport = SocketTransport() + private var currentSocketState: CurrentSocketState? + private var socketEvidenceByPath: [String: SocketPathProbeResult] = [:] + private var processIdentityByPID: [pid_t: AgentPIDProcessIdentity] = [:] + private var processEvidenceByExpectedIdentity: [ + AgentPIDProcessIdentity: ComponentEvidence + ] = [:] + private var unavailableProcessEvidenceByPID: [pid_t: ComponentEvidence] = [:] + private(set) var externalProbeCount = 0 + + init( + environment: [String: String], + currentSocketStateResolver: @escaping CurrentSocketStateResolver, + processIdentityResolver: @escaping ProcessIdentityResolver, + maximumExternalProbes: Int = AgentRuntimeOwnershipProbe.defaultMaximumExternalProbes + ) { + self.environment = environment + self.currentSocketStateResolver = currentSocketStateResolver + self.processIdentityResolver = processIdentityResolver + self.maximumExternalProbes = max(0, maximumExternalProbes) + } + + mutating func evidence(for record: [String: Any]) -> Evidence { + let currentRuntimeID = Self.normalized(environment["CMUX_RUNTIME_ID"]) + let runtimes: [[String: Any]] + if let runs = record["runs"] as? [[String: Any]], !runs.isEmpty { + guard JSONSerialization.isValidJSONObject(record), + let data = try? JSONSerialization.data(withJSONObject: record), + let projection = CmuxAgentSessionRunAuthorityProjection() + .projection(recordJSON: data), + let runtime = projection.run?.cmuxRuntime else { + return .unknownForeign + } + runtimes = [Self.runtimeObject(runtime)] + } else if let runtime = record["cmuxRuntime"] as? [String: Any] { + runtimes = [runtime] + } else { + runtimes = [] + } + + var sawCurrentRuntime = false + var sawForeignRuntime = false + var sawDeadForeignRuntime = false + var sawUnknownForeignRuntime = false + for runtime in runtimes { + if let runtimeID = Self.normalized(runtime["id"] as? String), + runtimeID == currentRuntimeID { + sawCurrentRuntime = true + continue + } + sawForeignRuntime = true + var runtimeHasDeadEvidence = false + var runtimeHasUnknownEvidence = false + + if let expectedProcessIdentity = Self.processIdentity(runtime) { + switch processEvidence(for: expectedProcessIdentity) { + case .live: + return .provablyLiveForeign + case .dead: + runtimeHasDeadEvidence = true + case .unknown: + runtimeHasUnknownEvidence = true + } + } + + if let socketPath = Self.normalized(runtime["socketPath"] as? String) { + if currentSocketState == nil { + let preferredSocketPath = SocketControlSettings.socketPath( + environment: environment, + bundleIdentifier: Self.normalized(environment["CMUX_BUNDLE_ID"]) + ) + currentSocketState = currentSocketStateResolver(preferredSocketPath) + } + if let currentSocketState, + currentSocketState.pathOwnedByCurrentListener, + SocketControlSettings.pathsMatch( + socketPath, + currentSocketState.activePath + ) { + // The current listener has replaced this persisted foreign endpoint. + runtimeHasDeadEvidence = true + } else { + switch socketEvidence(at: socketPath) { + case .connected: + return .provablyLiveForeign + case .refused, .stale: + runtimeHasDeadEvidence = true + case .occupiedOrIndeterminate: + runtimeHasUnknownEvidence = true + } + } + } + + if runtimeHasUnknownEvidence || !runtimeHasDeadEvidence { + sawUnknownForeignRuntime = true + } else { + sawDeadForeignRuntime = true + } + } + + if sawUnknownForeignRuntime { return .unknownForeign } + if sawCurrentRuntime { return .current } + if sawForeignRuntime, sawDeadForeignRuntime { return .provablyDeadForeign } + return .unknownForeign + } + + private mutating func processEvidence( + for expectedIdentity: AgentPIDProcessIdentity + ) -> ComponentEvidence { + if let cached = processEvidenceByExpectedIdentity[expectedIdentity] { + return cached + } + let pid = expectedIdentity.pid + let evidence: ComponentEvidence + if let currentIdentity = processIdentityByPID[pid] { + evidence = currentIdentity == expectedIdentity ? .live : .dead + } else if let unavailableEvidence = unavailableProcessEvidenceByPID[pid] { + evidence = unavailableEvidence + } else if reserveExternalProbe() { + if let currentIdentity = processIdentityResolver(pid) { + processIdentityByPID[pid] = currentIdentity + evidence = currentIdentity == expectedIdentity ? .live : .dead + } else { + errno = 0 + let result = Darwin.kill(pid, 0) + let resultError = errno + if result != 0, resultError == ESRCH { + evidence = .dead + } else { + // A successful existence probe, EPERM, and other failures + // prove neither death nor PID generation ownership. + evidence = .unknown + } + unavailableProcessEvidenceByPID[pid] = evidence + } + } else { + evidence = .unknown + unavailableProcessEvidenceByPID[pid] = evidence + } + processEvidenceByExpectedIdentity[expectedIdentity] = evidence + return evidence + } + + private mutating func socketEvidence(at path: String) -> SocketPathProbeResult { + if let cached = socketEvidenceByPath[path] { + return cached + } + let evidence: SocketPathProbeResult + if reserveExternalProbe() { + evidence = socketTransport.pathProbeResult(at: path) + } else { + evidence = .occupiedOrIndeterminate + } + socketEvidenceByPath[path] = evidence + return evidence + } + + private mutating func reserveExternalProbe() -> Bool { + guard externalProbeCount < maximumExternalProbes else { return false } + externalProbeCount += 1 + return true + } + + private static func processIdentity( + _ runtime: [String: Any] + ) -> AgentPIDProcessIdentity? { + guard let pidValue = (runtime["processId"] as? NSNumber)?.int64Value, + pidValue > 0, + pidValue <= Int64(Int32.max), + let startSeconds = (runtime["processStartSeconds"] as? NSNumber)?.int64Value, + startSeconds >= 0, + let startMicroseconds = (runtime["processStartMicroseconds"] as? NSNumber)?.int64Value, + startMicroseconds >= 0, + startMicroseconds < 1_000_000 else { + return nil + } + return AgentPIDProcessIdentity( + pid: pid_t(pidValue), + startSeconds: startSeconds, + startMicroseconds: startMicroseconds + ) + } + + private static func runtimeObject( + _ runtime: CmuxAgentSessionRunAuthorityProjection.Runtime + ) -> [String: Any] { + var object: [String: Any] = ["id": runtime.id] + if let socketPath = runtime.socketPath { object["socketPath"] = socketPath } + if let bundleIdentifier = runtime.bundleIdentifier { + object["bundleIdentifier"] = bundleIdentifier + } + if let processId = runtime.processId { object["processId"] = processId } + if let processStartSeconds = runtime.processStartSeconds { + object["processStartSeconds"] = processStartSeconds + } + if let processStartMicroseconds = runtime.processStartMicroseconds { + object["processStartMicroseconds"] = processStartMicroseconds + } + return object + } + + private static func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { + return nil + } + return value + } +} diff --git a/Sources/AgentSessions/AgentSessionLifecycleFlowDocumentation.swift b/Sources/AgentSessions/AgentSessionLifecycleFlowDocumentation.swift new file mode 100644 index 000000000000..46c8fae6688c --- /dev/null +++ b/Sources/AgentSessions/AgentSessionLifecycleFlowDocumentation.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Joins hook history and live process observations into restorable surface owners. +/// +/// Agent lifecycle has one directional authority boundary: +/// +/// ```text +/// provider hook ───────▶ semantic adapter ───────▶ turn + workload observations +/// process/shell event ─▶ bounded PID ancestry ───▶ session + run graph +/// │ +/// ┌─────────────────────────────┴──────────────────────┐ +/// ▼ ▼ +/// child / unverified run verified surface root +/// own activity + subtree rollup only restore-authority candidate +/// │ +/// active passive workload ──▶ hibernation blocked ▼ +/// surface continuation binding +/// ``` +/// +/// A child remains observable but never enters this index. Hibernation snapshots +/// the root continuation before terminating its process tree, then restoration +/// creates a new run for the same logical session. Forking copies continuation +/// intent into a new surface and records `forked` parentage without copying the +/// source surface's authority. A normal `/exit`, Ctrl-D, or exit-producing Ctrl-C +/// completes the run and clears restoration eligibility; a Stop hook that leaves +/// the process alive changes activity only. +/// +/// ```text +/// ACTIVE + no work ──hibernate──▶ HIBERNATED ──resume──▶ ACTIVE (new run) +/// ACTIVE ────────────fork───────▶ ACTIVE (source) + ACTIVE (new surface root) +/// ACTIVE ──root process exit───────────────▶ ENDED + owned workloads cancelled +/// turn interrupted + root alive────────────▶ ACTIVE + INTERRUPTED (restorable) +/// ``` +/// +/// Observability is file-backed and does not wait on the app socket. Each cmux +/// app process exports one opaque runtime id to local and remote terminals: +/// +/// ```text +/// cmux app launch ─▶ CMUX_RUNTIME_ID ─▶ terminal ─▶ agent hook ─▶ session run +/// │ +/// cmux agents ───────────────── current runtime id ──────────────┤──▶ current tree +/// cmux agents --all ─────────────────────────────────────────────┘──▶ retained history +/// ``` +/// +/// Runtime filtering is one string comparison per run. PID start-time checks +/// validate liveness only for displayed runs and never scan the process table. +enum AgentSessionLifecycleFlowDocumentation {} diff --git a/Sources/AgentSessions/AgentSessionState.swift b/Sources/AgentSessions/AgentSessionState.swift new file mode 100644 index 000000000000..f77e9e10a1cd --- /dev/null +++ b/Sources/AgentSessions/AgentSessionState.swift @@ -0,0 +1,59 @@ +import Foundation + +enum AgentProcessState: String, Codable, Sendable, Equatable { + case alive + case exited + case unknown +} + +enum AgentSessionLifecycleState: String, Codable, Sendable, Equatable { + case active + case hibernated + case restoring + case ended +} + +enum AgentForegroundState: String, Codable, Sendable, Equatable { + case working + case completed + case interrupted + case failed + case idle + case unknown +} + +enum AgentAttentionState: String, Codable, Sendable, Equatable { + case none + case needsInput = "needs_input" + case error + case unknown +} + +enum AgentActivityState: String, Codable, Sendable, Equatable { + case busy + case idle + case unknown +} + +enum AgentActivityMode: String, Codable, Sendable, Equatable, CaseIterable { + case foreground + case background + case monitoring + case scheduled + case subagents + case tools +} + +enum AgentEffectiveState: String, Codable, Sendable, Equatable { + case working + case monitoring + case scheduled + case needsInput = "needs_input" + case interrupted + case idle + case hibernated + case restoring + case ended + case error + case unknown +} diff --git a/Sources/AgentSessions/AgentWorkloadKind.swift b/Sources/AgentSessions/AgentWorkloadKind.swift new file mode 100644 index 000000000000..6fa5c296c44e --- /dev/null +++ b/Sources/AgentSessions/AgentWorkloadKind.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Provider-neutral kinds of work owned by an agent run. +enum AgentWorkloadKind: String, Codable, Sendable, Equatable, CaseIterable { + case foreground + case backgroundTerminal = "background_terminal" + case monitor + case scheduled + case subagent + case tool + case other +} diff --git a/Sources/AgentSessions/AgentWorkloadPhase.swift b/Sources/AgentSessions/AgentWorkloadPhase.swift new file mode 100644 index 000000000000..86a334e9195e --- /dev/null +++ b/Sources/AgentSessions/AgentWorkloadPhase.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Lifecycle of one provider-reported workload. +enum AgentWorkloadPhase: String, Codable, Sendable, Equatable { + case queued + case running + case watching + case waiting + case completed + case failed + case cancelled + case unknown + + var isActive: Bool { + switch self { + case .queued, .running, .watching, .waiting: true + case .completed, .failed, .cancelled, .unknown: false + } + } +} diff --git a/Sources/AgentSessions/AgentWorkloadRecord.swift b/Sources/AgentSessions/AgentWorkloadRecord.swift new file mode 100644 index 000000000000..9191e32acc47 --- /dev/null +++ b/Sources/AgentSessions/AgentWorkloadRecord.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Sanitized state for one terminal, monitor, scheduled task, subagent, or tool. +/// Commands, prompts, output, and environment values are intentionally excluded. +struct AgentWorkloadRecord: Codable, Sendable, Equatable { + var id: String + var kind: AgentWorkloadKind + var phase: AgentWorkloadPhase + var keepsSessionBusy: Bool + var startedAt: TimeInterval + var updatedAt: TimeInterval + var endedAt: TimeInterval? + var endReason: String? +} diff --git a/Sources/AgentTerminalClassificationWorker.swift b/Sources/AgentTerminalClassificationWorker.swift new file mode 100644 index 000000000000..2d6718b73b17 --- /dev/null +++ b/Sources/AgentTerminalClassificationWorker.swift @@ -0,0 +1,26 @@ +import CmuxTerminalCore +import Foundation + +/// Serializes bounded screen classification and reuses results for unchanged snapshots. +actor AgentTerminalClassificationWorker { + private let classifier: AgentTerminalStateClassifier + private var cachedSnapshots: [UUID: AgentTerminalScreenSnapshot] = [:] + private var cachedResults: [UUID: AgentTerminalStateClassification] = [:] + + init(classifier: AgentTerminalStateClassifier = .init()) { + self.classifier = classifier + } + + func classify(surfaceID: UUID, snapshot: AgentTerminalScreenSnapshot) -> AgentTerminalStateClassification { + if cachedSnapshots[surfaceID] == snapshot, let cached = cachedResults[surfaceID] { return cached } + let result = classifier.classify(snapshot) + cachedSnapshots[surfaceID] = snapshot + cachedResults[surfaceID] = result + return result + } + + func remove(surfaceID: UUID) { + cachedSnapshots[surfaceID] = nil + cachedResults[surfaceID] = nil + } +} diff --git a/Sources/AgentTerminalStateRuntime.swift b/Sources/AgentTerminalStateRuntime.swift new file mode 100644 index 000000000000..2fa91e0d088c --- /dev/null +++ b/Sources/AgentTerminalStateRuntime.swift @@ -0,0 +1,160 @@ +import CmuxFoundation +import CmuxTerminal +import CmuxTerminalCore +import Foundation +import os + +/// Thread-safe, content-free projection of the detector's last published values. +/// Writers update it after classification; readers only copy cached metadata. +final class AgentTerminalObservationCache: @unchecked Sendable { + private let state = OSAllocatedUnfairLock(initialState: [UUID: CmuxAgentTerminalObservation]()) + + func replace(surfaceID: UUID, with observation: CmuxAgentTerminalObservation?) { + state.withLock { observations in + observations[surfaceID] = observation + } + } + + func snapshot() -> [CmuxAgentTerminalObservation] { + state.withLock { observations in + observations.values.sorted { + if $0.workspaceID != $1.workspaceID { + return $0.workspaceID.uuidString < $1.workspaceID.uuidString + } + return $0.surfaceID.uuidString < $1.surfaceID.uuidString + } + } + } +} + +/// App composition adapter from terminal dirty signals to Workspace lifecycle state. +@MainActor +final class AgentTerminalStateRuntime { + private let scheduler: AgentTerminalStateDetectionScheduler + nonisolated private let observationCache: AgentTerminalObservationCache + private let classificationWorker = AgentTerminalClassificationWorker() + private let surfaceTasks = AgentTerminalSurfaceTaskSequencer() + private var observers: [UUID: AgentTerminalStateSurfaceObserver] = [:] + + nonisolated init(observationCache: AgentTerminalObservationCache) { + self.observationCache = observationCache + scheduler = AgentTerminalStateDetectionScheduler(clock: .continuous()) + } + + func install( + workspaceID: UUID, + surfaceID: UUID, + expectedRuntimeGeneration: UInt64, + signal: AgentTerminalDirtySignal + ) { + let observer = AgentTerminalStateSurfaceObserver( + workspaceID: workspaceID, + surfaceID: surfaceID, + expectedRuntimeGeneration: expectedRuntimeGeneration + ) + observers[surfaceID] = observer + observationCache.replace(surfaceID: surfaceID, with: nil) + let worker = classificationWorker + let deliver: @Sendable (AgentTerminalDetectionUpdate) async -> Void = { [weak self] update in + await self?.apply( + update, + expectedRuntimeGeneration: expectedRuntimeGeneration + ) + } + surfaceTasks.install(surfaceID: surfaceID) { [scheduler] in + await scheduler.start( + surfaceID: surfaceID, + signal: signal, + evaluate: { revision in + guard signal.currentRevision() == revision, + let snapshot = await observer.capture() else { return nil } + guard signal.currentRevision() == revision else { return nil } + return await worker.classify(surfaceID: surfaceID, snapshot: snapshot) + }, + deliver: deliver + ) + } + // Runtime installation itself is new evidence even before the first PTY chunk. + signal.markDirty() + } + + func drop(surfaceID: UUID, surfaceGeneration: UInt64) { + guard observers[surfaceID]?.expectedRuntimeGeneration == surfaceGeneration else { return } + let observer = observers.removeValue(forKey: surfaceID) + observationCache.replace(surfaceID: surfaceID, with: nil) + let scheduler = scheduler + let classificationWorker = classificationWorker + surfaceTasks.drop(surfaceID: surfaceID) { + await scheduler.stop(surfaceID: surfaceID) + await classificationWorker.remove(surfaceID: surfaceID) + } + guard let observer, let workspace = AppDelegate.shared?.workspaceFor(tabId: observer.workspaceID) else { return } + workspace.clearDetectedAgentLifecycle(panelId: surfaceID) + } + + private func apply( + _ update: AgentTerminalDetectionUpdate, + expectedRuntimeGeneration: UInt64 + ) { + guard let observer = observers[update.surfaceID], + observer.expectedRuntimeGeneration == expectedRuntimeGeneration, + update.classification.processIdentity.runtimeGeneration == expectedRuntimeGeneration, + let workspace = AppDelegate.shared?.workspaceFor(tabId: observer.workspaceID) else { return } + workspace.setDetectedAgentLifecycle( + statusKey: update.classification.statusKey, + familyID: update.classification.familyID, + panelId: update.surfaceID, + state: update.classification.state + ) + updateObservation(update, observer: observer) + observer.recordPublished(update.classification) + } + + private func updateObservation( + _ update: AgentTerminalDetectionUpdate, + observer: AgentTerminalStateSurfaceObserver + ) { + let classification = update.classification + guard let familyID = classification.familyID, + let sessionProviderID = classification.sessionProviderID, + let state = classification.state.observedState else { + observationCache.replace(surfaceID: update.surfaceID, with: nil) + return + } + let process = classification.processIdentity + let cwd = Workspace.processCurrentWorkingDirectory(pid: process.pid) + ?? GhosttyApp.terminalSurfaceRegistry + .terminalSurface(id: update.surfaceID)? + .requestedWorkingDirectory + observationCache.replace( + surfaceID: update.surfaceID, + with: CmuxAgentTerminalObservation( + runtimeID: TerminalSurface.managedCmuxRuntimeId, + workspaceID: observer.workspaceID, + surfaceID: update.surfaceID, + surfaceGeneration: observer.expectedRuntimeGeneration, + revision: update.revision, + familyID: familyID, + sessionProviderID: sessionProviderID, + lifecycleAuthoritative: classification.lifecycleAuthoritative, + state: state, + pid: process.pid, + processStartSeconds: process.startSeconds, + processStartMicroseconds: process.startMicroseconds, + cwd: cwd, + publishedAt: Date().timeIntervalSince1970 + ) + ) + } +} + +private extension AgentTerminalSemanticState { + var observedState: CmuxAgentObservedState? { + switch self { + case .unknown: nil + case .idle: .idle + case .working: .working + case .blocked: .blocked + } + } +} diff --git a/Sources/AgentTerminalStateSurfaceObserver.swift b/Sources/AgentTerminalStateSurfaceObserver.swift new file mode 100644 index 000000000000..ff9ff05b16ed --- /dev/null +++ b/Sources/AgentTerminalStateSurfaceObserver.swift @@ -0,0 +1,80 @@ +import CmuxTerminal +import CmuxTerminalCore +import Foundation + +/// Captures generation-checked foreground identity and live-bottom terminal evidence. +@MainActor +final class AgentTerminalStateSurfaceObserver { + let workspaceID: UUID + let surfaceID: UUID + let expectedRuntimeGeneration: UInt64 + private let inspector: AgentTerminalProcessInspector + private let classifier: AgentTerminalStateClassifier + private var previousReliableState: AgentTerminalSemanticState? + private var previousReliableIdentity: AgentTerminalProcessIdentity? + private var recognitionCache = AgentTerminalRecognitionCache() + + init( + workspaceID: UUID, + surfaceID: UUID, + expectedRuntimeGeneration: UInt64, + inspector: AgentTerminalProcessInspector = .init(), + classifier: AgentTerminalStateClassifier = .init() + ) { + self.workspaceID = workspaceID + self.surfaceID = surfaceID + self.expectedRuntimeGeneration = expectedRuntimeGeneration + self.inspector = inspector + self.classifier = classifier + } + + func capture() async -> AgentTerminalScreenSnapshot? { + guard let surface = GhosttyApp.terminalSurfaceRegistry.terminalSurface(id: surfaceID), + surface.runtimeSurfaceGeneration == expectedRuntimeGeneration, + let rawPID = surface.foregroundProcessID() else { return nil } + let pid = Int32(rawPID) + let runtimeGeneration = expectedRuntimeGeneration + guard let identity = await inspector.identity(pid: pid, runtimeGeneration: runtimeGeneration) else { return nil } + let familyID: String? + if !recognitionCache.requiresSnapshot(for: identity) { + familyID = recognitionCache.familyID(for: identity) + } else { + guard let process = await inspector.snapshot(pid: pid, runtimeGeneration: runtimeGeneration), + process.identity == identity else { return nil } + familyID = classifier.recognize(process)?.id + recognitionCache.store(identity: identity, familyID: familyID) + previousReliableIdentity = nil + previousReliableState = nil + } + guard surface.runtimeSurfaceGeneration == runtimeGeneration, + surface.foregroundProcessID() == rawPID else { return nil } + guard let gridRows = surface.rawSizingSample()?.rows, gridRows > 0 else { return nil } + guard let liveBottom = await surface.boundedActiveScreenTailText( + maxRows: 48, + maxBytes: 48 * 1024 + ) else { return nil } + guard surface.runtimeSurfaceGeneration == runtimeGeneration, + surface.foregroundProcessID() == rawPID, + surface.rawSizingSample()?.rows == gridRows else { return nil } + let identityAfterCapture = await inspector.identity(pid: pid, runtimeGeneration: runtimeGeneration) + guard identityAfterCapture == identity, + surface.runtimeSurfaceGeneration == runtimeGeneration, + surface.foregroundProcessID() == rawPID else { return nil } + return AgentTerminalScreenSnapshot( + processIdentity: identity, + familyID: familyID, + liveBottomText: liveBottom, + previousReliableState: previousReliableIdentity == identity ? previousReliableState : nil + ) + } + + func recordPublished(_ classification: AgentTerminalStateClassification) { + guard classification.familyID != nil, classification.state != .unknown else { + previousReliableIdentity = nil + previousReliableState = nil + return + } + previousReliableIdentity = classification.processIdentity + previousReliableState = classification.state + } +} diff --git a/Sources/AgentTerminalSurfaceTaskSequencer.swift b/Sources/AgentTerminalSurfaceTaskSequencer.swift new file mode 100644 index 000000000000..1cd4971dc379 --- /dev/null +++ b/Sources/AgentTerminalSurfaceTaskSequencer.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Serializes setup and teardown for each detected-agent terminal surface. +/// +/// A replacement setup waits for any prior teardown. Repeated teardown calls +/// are ignored while cleanup is already in flight. +@MainActor +final class AgentTerminalSurfaceTaskSequencer { + typealias Operation = @MainActor @Sendable () async -> Void + + private enum Phase { + case active(token: UUID, task: Task) + case stopping(token: UUID, task: Task) + + var task: Task { + switch self { + case .active(_, let task), .stopping(_, let task): task + } + } + } + + private var phases: [UUID: Phase] = [:] + + deinit { + phases.values.forEach { $0.task.cancel() } + } + + func install(surfaceID: UUID, operation: @escaping Operation) { + let predecessor: Task? + if let phase = phases[surfaceID] { + predecessor = phase.task + if case .active = phase { predecessor?.cancel() } + } else { + predecessor = nil + } + + let token = UUID() + let task = Task { @MainActor in + _ = await predecessor?.value + guard !Task.isCancelled else { return } + await operation() + } + phases[surfaceID] = .active(token: token, task: task) + } + + func drop(surfaceID: UUID, operation: @escaping Operation) { + guard case .active(_, let registrationTask) = phases[surfaceID] else { return } + registrationTask.cancel() + + let token = UUID() + let task = Task { @MainActor [weak self] in + _ = await registrationTask.value + await operation() + guard let self, + let phase = self.phases[surfaceID], + case .stopping(let currentToken, _) = phase, + currentToken == token else { return } + self.phases.removeValue(forKey: surfaceID) + } + phases[surfaceID] = .stopping(token: token, task: task) + } +} diff --git a/Sources/App/AgentHibernationController+InFlightTeardown.swift b/Sources/App/AgentHibernationController+InFlightTeardown.swift index aec7c0429bf3..0e98af451680 100644 --- a/Sources/App/AgentHibernationController+InFlightTeardown.swift +++ b/Sources/App/AgentHibernationController+InFlightTeardown.swift @@ -1,7 +1,45 @@ +import CmuxTerminal import Foundation +import os extension AgentHibernationController { - struct InFlightTeardown: Sendable { + final class InFlightTeardown: @unchecked Sendable { let requestID: UUID + private weak var terminalSurface: TerminalSurface? + + private enum State { + case valid + case invalidated + case claimed + } + + private let state = OSAllocatedUnfairLock(initialState: State.valid) + + @MainActor + init(requestID: UUID, terminalSurface: TerminalSurface) { + self.requestID = requestID + self.terminalSurface = terminalSurface + } + + @MainActor + func invalidate() { + // Always revoke the package's last native-free gate, including + // when the controller token was already claimed. This closes the + // handoff gap between the outer claim and the package's claim. + terminalSurface?.invalidateProvisionalAgentHibernation() + state.withLock { value in + if value == .valid { value = .invalidated } + } + } + + /// One-way handoff immediately before native free. Once claimed, later + /// activity cannot turn a committed free into an unsafe restoration. + func claim() -> Bool { + state.withLock { value in + guard value == .valid else { return false } + value = .claimed + return true + } + } } } diff --git a/Sources/App/AgentHibernationController+PostTeardownRestoreTask.swift b/Sources/App/AgentHibernationController+PostTeardownRestoreTask.swift index 21602ddae620..44d03edd20cc 100644 --- a/Sources/App/AgentHibernationController+PostTeardownRestoreTask.swift +++ b/Sources/App/AgentHibernationController+PostTeardownRestoreTask.swift @@ -1,4 +1,41 @@ import Foundation +import os + +final class AgentHibernationRestoreMonitorStartGate: @unchecked Sendable { + private struct State { + var result: Bool? + var continuation: CheckedContinuation? + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + func wait() async -> Bool { + if Task.isCancelled { return false } + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let immediate = state.withLock { state -> Bool? in + if let result = state.result { return result } + state.continuation = continuation + return nil + } + if let immediate { continuation.resume(returning: immediate) } + } + } onCancel: { + self.resolve(false) + } + } + + func resolve(_ result: Bool) { + let continuation = state.withLock { state -> CheckedContinuation? in + guard state.result == nil else { return nil } + state.result = result + let continuation = state.continuation + state.continuation = nil + return continuation + } + continuation?.resume(returning: result) + } +} extension AgentHibernationController { struct PostTeardownRestoreTask { diff --git a/Sources/App/AgentHibernationController+Records.swift b/Sources/App/AgentHibernationController+Records.swift index aea21c9b7e3d..c62e62eb9b10 100644 --- a/Sources/App/AgentHibernationController+Records.swift +++ b/Sources/App/AgentHibernationController+Records.swift @@ -1,6 +1,32 @@ import Foundation extension AppDelegate { + @MainActor + func agentHibernationOpenTerminalPanelKeys( + maximumCount: Int + ) -> Set? { + guard maximumCount >= 0 else { return nil } + var panelKeys = Set() + var seenManagers = Set() + + func visit(tabManager manager: TabManager) -> Bool { + guard seenManagers.insert(ObjectIdentifier(manager)).inserted else { return true } + for workspace in manager.tabs { + for (panelID, panel) in workspace.panels where panel is TerminalPanel { + panelKeys.insert(.init(workspaceId: workspace.id, panelId: panelID)) + guard panelKeys.count <= maximumCount else { return false } + } + } + return true + } + + for context in mainWindowContexts.values { + guard visit(tabManager: context.tabManager) else { return nil } + } + if let tabManager, !visit(tabManager: tabManager) { return nil } + return panelKeys + } + @MainActor func agentHibernationPanelIsProtected(workspace: Workspace, panelId: UUID) -> Bool { for context in mainWindowContexts.values { @@ -49,6 +75,31 @@ extension AppDelegate { panelId: panelId, fallback: index.lifecycle(workspaceId: workspace.id, panelId: panelId) ) + let indexedEvidence = index.processEvidence( + workspaceId: workspace.id, + panelId: panelId + ) + let processEvidence: AgentHibernationProcessEvidence + if case .confirmedProcessFree(let lease) = indexedEvidence, + lease.workspaceId == workspace.id, + lease.panelId == panelId, + workspace.surfaceTTYDevices[panelId] == lease.ttyDevice, + !workspace.isRemoteWorkspace, + !workspace.isRemoteTerminalSurface(panelId), + AgentHibernationController.passesPromptAndCloseGates( + workspaceShellActivity: workspace.panelShellActivityStates[panelId], + panelShellActivity: terminalPanel.shellActivity.state, + rawNeedsConfirmClose: terminalPanel.needsConfirmClose(), + workspaceNeedsConfirmClose: workspace.panelNeedsConfirmClose(panelId: panelId) + ) { + processEvidence = indexedEvidence + } else { + processEvidence = .unverified( + processIDs: indexedEvidence.processIDs.union( + index.processIDs(workspaceId: workspace.id, panelId: panelId) + ) + ) + } records.append( AgentHibernationRecord( key: key, @@ -59,8 +110,7 @@ extension AppDelegate { hasUnconfirmedTerminalInput: terminalInputAt > lifecycleChangeAt, lastActivityAt: max(indexActivity, localActivity, createdAt), isProtected: workspaceIsVisible && visiblePanelIds.contains(panelId), - hasLiveProcess: index.hasLiveProcess(workspaceId: workspace.id, panelId: panelId), - processIDs: index.processIDs(workspaceId: workspace.id, panelId: panelId) + processEvidence: processEvidence ) ) } diff --git a/Sources/App/AgentHibernationController+Teardown.swift b/Sources/App/AgentHibernationController+Teardown.swift index 3022da25907f..16f2828d4858 100644 --- a/Sources/App/AgentHibernationController+Teardown.swift +++ b/Sources/App/AgentHibernationController+Teardown.swift @@ -15,6 +15,7 @@ extension AgentHibernationController { let confirmationFingerprint: String let effectiveLastActivityAt: TimeInterval let requestID: UUID + let inFlight: InFlightTeardown let epoch: UInt64 let generation: UInt64 } @@ -34,18 +35,54 @@ extension AgentHibernationController { } var snapshotOutcomes = await Self.snapshotOutcomes(for: requests) + let recordsByKey = Dictionary( + requests.map { ($0.record.key, $0.record) }, + uniquingKeysWith: { first, _ in first } + ) var restoreOwnedSnapshotPaths: Set = [] defer { - for outcome in snapshotOutcomes.values { + for (key, outcome) in snapshotOutcomes { guard case .snapshot(let snapshot) = outcome, - !restoreOwnedSnapshotPaths.contains(snapshot.snapshotPath), - AgentHibernationTranscriptGuard.liveFileVersionStillMatches(snapshot) else { + !restoreOwnedSnapshotPaths.contains(snapshot.snapshotPath) else { continue } - // A snapshot is disposable only while the live path still - // identifies the exact bytes it protected. Any later rewrite - // retains the snapshot as a recovery backup. - try? FileManager.default.removeItem(atPath: snapshot.snapshotPath) + if AgentHibernationTranscriptGuard.liveFileVersionStillMatches(snapshot), + let snapshotVersion = AgentHibernationTranscriptGuard.stableRegularFileVersion( + atPath: snapshot.snapshotPath + ) { + _ = AgentHibernationTranscriptGuard.durablyRemoveRecoverySnapshot( + atPath: snapshot.snapshotPath, + afterSynchronizingLivePath: snapshot.transcriptPath, + expectedSnapshotVersion: snapshotVersion + ) + } else { + // Every unowned abort converges through the retained + // slot. Successive prefix-related captures replace one + // slot instead of leaking UUID-sized transcript copies. + AgentHibernationTranscriptGuard.retainSnapshotForRecovery( + snapshot, + sessionId: recordsByKey[key]?.agent.sessionId + ) + } + } + } + + @MainActor func protectAbortedHandoff( + _ snapshot: AgentHibernationTranscriptGuard.TeardownTranscriptSnapshot, + record: AgentHibernationRecord + ) { + if self.armPostTeardownRestoreMonitor( + snapshot: snapshot, + processIDs: record.processIDs, + snapshotDisposal: .retainForRecovery(sessionId: record.agent.sessionId) + ) { + restoreOwnedSnapshotPaths.insert(snapshot.snapshotPath) + } else { + AgentHibernationTranscriptGuard.retainSnapshotForRecovery( + snapshot, + sessionId: record.agent.sessionId + ) + restoreOwnedSnapshotPaths.insert(snapshot.snapshotPath) } } @@ -56,10 +93,6 @@ extension AgentHibernationController { await drainCancelledPostTeardownRestoreTasks() let revalidation = await Self.revalidatedSnapshotOutcomes(in: snapshotOutcomes) snapshotOutcomes = revalidation.outcomes - let recordsByKey = Dictionary( - requests.map { ($0.record.key, $0.record) }, - uniquingKeysWith: { first, _ in first } - ) for (key, snapshot) in revalidation.forfeitedSnapshots { // The live path changed under the fresh snapshot (e.g. an older // monitor restored a stale copy). Arm a restore monitor on the @@ -92,6 +125,10 @@ extension AgentHibernationController { panelId: record.key.panelId, index: postSnapshotIndex ) + let currentProcessEvidence = postSnapshotIndex.processEvidence( + workspaceId: record.key.workspaceId, + panelId: record.key.panelId + ) let currentLifecycle = postSnapshotLifecycle(for: record, index: postSnapshotIndex) let currentEffectiveLastActivityAt = postSnapshotEffectiveLastActivityAt( for: record, @@ -103,11 +140,11 @@ extension AgentHibernationController { // tick will re-arm if still idle. guard AgentHibernationTrackingGate.isEnabled(), record.isStillOwnedByOriginalWorkspace, - !postSnapshotIndex.hasLiveProcess(workspaceId: record.key.workspaceId, panelId: record.key.panelId), - TabManager.restorableAgentSnapshotFingerprint(currentAgent) == - TabManager.restorableAgentSnapshotFingerprint(record.agent), + currentProcessEvidence == record.processEvidence, + currentAgent == record.agent, !record.terminalPanel.isAgentHibernated, record.terminalPanel.surface.hasLiveSurface, + record.satisfiesPromptAndCloseGates, AppDelegate.shared?.agentHibernationPanelIsProtected( workspace: record.workspace, panelId: record.key.panelId @@ -157,27 +194,41 @@ extension AgentHibernationController { // The quiesce above disarmed the path's previous monitor, so // forfeiting must re-arm protection with the fresh snapshot; // its restore checks fail closed if the live file has turns. - if self.armPostTeardownRestoreMonitor( - snapshot: snapshot, - processIDs: record.processIDs, - snapshotDisposal: .retainForRecovery(sessionId: record.agent.sessionId) - ) { - restoreOwnedSnapshotPaths.insert(snapshot.snapshotPath) - } else { - AgentHibernationTranscriptGuard.retainSnapshotForRecovery( - snapshot, - sessionId: record.agent.sessionId - ) - } + protectAbortedHandoff(snapshot, record: record) continue } } - self.terminateScopedProcessesForHibernation(record: record) - record.workspace.enterAgentHibernation( + guard case .confirmedProcessFree(let lease) = record.processEvidence, + lease.workspaceId == record.key.workspaceId, + lease.panelId == record.key.panelId, + record.workspace.surfaceTTYDevices[record.key.panelId] == lease.ttyDevice, + record.satisfiesPromptAndCloseGates else { + if let snapshot { protectAbortedHandoff(snapshot, record: record) } + continue + } + let inFlight = request.inFlight + let didHibernate = await record.workspace.enterAgentHibernation( panelId: record.key.panelId, agent: record.agent, - lastActivityAt: Date(timeIntervalSince1970: request.effectiveLastActivityAt) + lastActivityAt: Date(timeIntervalSince1970: request.effectiveLastActivityAt), + finalValidation: { + lease.isStillProcessFree() + }, + finalTeardownPreparation: { + guard let frozenLease = lease.freezeForFinalTeardown( + finalProcessFreeValidation: { + lease.isStillProcessFree() && inFlight.claim() + } + ) else { + return nil + } + return { frozenLease.resume() } + } ) + guard didHibernate else { + if let snapshot { protectAbortedHandoff(snapshot, record: record) } + continue + } guard let snapshot else { continue } if self.armPostTeardownRestoreMonitor(snapshot: snapshot, processIDs: record.processIDs) { restoreOwnedSnapshotPaths.insert(snapshot.snapshotPath) @@ -189,7 +240,7 @@ extension AgentHibernationController { private static func snapshotOutcomes( for requests: [ConfirmedTeardownRequest] ) async -> [AgentHibernationPanelKey: AgentHibernationTranscriptGuard.TeardownSnapshotOutcome] { - let agents = requests.map { ($0.record.key, $0.record.agent) } + let agents = requests.map { ($0.record.key, $0.record.agent, $0.record.processIDs) } return await withTaskGroup( of: (AgentHibernationPanelKey, AgentHibernationTranscriptGuard.TeardownSnapshotOutcome).self, returning: [AgentHibernationPanelKey: AgentHibernationTranscriptGuard.TeardownSnapshotOutcome].self @@ -197,20 +248,34 @@ extension AgentHibernationController { var nextAgentIndex = 0 let initialTaskCount = min(Self.maxConcurrentTeardownSnapshotTasks, agents.count) for _ in 0.., - snapshotDisposal: AgentHibernationTranscriptGuard.PostTeardownSnapshotDisposal = .deleteWhenSafe + snapshotDisposal: AgentHibernationTranscriptGuard.PostTeardownSnapshotDisposal = .deleteWhenSafe, + initialRetryDelaysNanoseconds: [UInt64] = [ + 0, 250_000_000, 500_000_000, 1_000_000_000, 2_000_000_000, + ], + backstopDelaysSeconds: [UInt64] = AgentHibernationTranscriptGuard.restoreCheckDelaysSeconds ) -> Bool { let transcriptPath = snapshot.transcriptPath let requestID = UUID() let cancellationState = PostTeardownRestoreCancellationState() + let startGate = AgentHibernationRestoreMonitorStartGate() let task = Task.detached(priority: .utility) { + guard await startGate.wait() else { return } await AgentHibernationTranscriptGuard.runPostTeardownRestoreChecks( snapshot: snapshot, processIDs: processIDs, + initialRetryDelaysNanoseconds: initialRetryDelaysNanoseconds, + backstopDelaysSeconds: backstopDelaysSeconds, snapshotDisposal: snapshotDisposal, shouldContinue: { await MainActor.run { @@ -316,6 +389,12 @@ extension AgentHibernationController { await MainActor.run { cancellationState.restoresSnapshotOnCancellation } + }, + recoveryAuthorityRetired: { + await MainActor.run { + AgentHibernationController.shared + .enqueueTranscriptRecovery() + } } ) await MainActor.run { @@ -325,12 +404,16 @@ extension AgentHibernationController { ) } } - return storePostTeardownRestoreTask( + let didStore = storePostTeardownRestoreTask( task, transcriptPath: transcriptPath, requestID: requestID, cancellationState: cancellationState ) + // The task cannot inspect ownership or restore until the registry + // insertion (or duplicate rejection) is final. + startGate.resolve(didStore) + return didStore } @discardableResult diff --git a/Sources/App/AgentHibernationController.swift b/Sources/App/AgentHibernationController.swift index e9e66810d380..358d1ee24a1e 100644 --- a/Sources/App/AgentHibernationController.swift +++ b/Sources/App/AgentHibernationController.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxWorkspaces import Darwin import Foundation @@ -17,8 +18,121 @@ struct AgentHibernationRecord { let hasUnconfirmedTerminalInput: Bool let lastActivityAt: TimeInterval let isProtected: Bool - let hasLiveProcess: Bool - let processIDs: Set + let processEvidence: AgentHibernationProcessEvidence + + var processIDs: Set { + processEvidence.processIDs.union(processEvidence.lease?.guardedProcessIDs ?? []) + } + + var satisfiesPromptAndCloseGates: Bool { + AgentHibernationController.passesPromptAndCloseGates( + workspaceShellActivity: workspace.panelShellActivityStates[key.panelId], + panelShellActivity: terminalPanel.shellActivity.state, + rawNeedsConfirmClose: terminalPanel.needsConfirmClose(), + workspaceNeedsConfirmClose: workspace.panelNeedsConfirmClose(panelId: key.panelId) + ) + } + + init( + key: AgentHibernationPanelKey, + workspace: Workspace, + terminalPanel: TerminalPanel, + agent: SessionRestorableAgentSnapshot, + lifecycle: AgentHibernationLifecycleState, + hasUnconfirmedTerminalInput: Bool, + lastActivityAt: TimeInterval, + isProtected: Bool, + processEvidence: AgentHibernationProcessEvidence + ) { + self.key = key + self.workspace = workspace + self.terminalPanel = terminalPanel + self.agent = agent + self.lifecycle = lifecycle + self.hasUnconfirmedTerminalInput = hasUnconfirmedTerminalInput + self.lastActivityAt = lastActivityAt + self.isProtected = isProtected + self.processEvidence = processEvidence + } +} + +@MainActor +final class AgentHibernationStartupRecoveryCoordinator { + typealias RecoveryOperation = @Sendable ( + _ cancellationCheck: @escaping @Sendable () -> Bool + ) async -> Int + + private let recoveryOperation: RecoveryOperation + private var isStarted = false + private var runGeneration: UInt64 = 0 + private var taskSequence: UInt64 = 0 + private var completedRunGeneration: UInt64? + private var taskRunGeneration: UInt64? + private var task: Task? + + init(recoveryOperation: @escaping RecoveryOperation) { + self.recoveryOperation = recoveryOperation + } + + var hasDeferredRecoveryForCurrentStart: Bool { + isStarted + && task != nil + && taskRunGeneration != runGeneration + } + + func start() { + if !isStarted { + isStarted = true + runGeneration &+= 1 + } + scheduleIfNeeded() + } + + func stop() { + isStarted = false + task?.cancel() + } + + func requestRecovery() { + guard isStarted else { return } + // Coalesce any number of requests behind the current generation. Its + // completion schedules exactly one successor for the newest request. + runGeneration &+= 1 + scheduleIfNeeded() + } + + private func scheduleIfNeeded() { + guard isStarted, + task == nil, + completedRunGeneration != runGeneration else { + return + } + taskSequence &+= 1 + let sequence = taskSequence + let scheduledRunGeneration = runGeneration + let recoveryOperation = recoveryOperation + taskRunGeneration = scheduledRunGeneration + task = Task.detached(priority: .utility) { + _ = await recoveryOperation { Task.isCancelled } + await MainActor.run { [weak self] in + self?.taskDidFinish( + sequence: sequence, + runGeneration: scheduledRunGeneration + ) + } + } + } + + private func taskDidFinish(sequence: UInt64, runGeneration: UInt64) { + guard sequence == taskSequence, + taskRunGeneration == runGeneration else { + return + } + task = nil + taskRunGeneration = nil + completedRunGeneration = runGeneration + scheduleIfNeeded() + } } @MainActor @@ -29,6 +143,17 @@ final class AgentHibernationController { private let timerQueue = DispatchQueue(label: "com.cmux.agent-hibernation", qos: .utility) private var timer: DispatchSourceTimer? + private var hibernationIndexLoadInFlight = false + private let transcriptRecoveryCoordinator = AgentHibernationStartupRecoveryCoordinator { + cancellationCheck in + let restored = await AgentHibernationTranscriptGuard.recoverPendingSnapshotsAwaitingLock( + cancellationCheck: cancellationCheck + ) + if restored > 0 { + NSLog("[AgentHibernation] recovered %d protected transcript snapshot(s)", restored) + } + return restored + } private var settingsObserver: NSObjectProtocol? var activityByPanel: [AgentHibernationPanelKey: TimeInterval] = [:] var terminalInputByPanel: [AgentHibernationPanelKey: TimeInterval] = [:] @@ -46,7 +171,20 @@ final class AgentHibernationController { private init() {} + nonisolated static func passesPromptAndCloseGates( + workspaceShellActivity: PanelShellActivityState?, + panelShellActivity: PanelShellActivityState, + rawNeedsConfirmClose: Bool, + workspaceNeedsConfirmClose: Bool + ) -> Bool { + workspaceShellActivity == .promptIdle && + panelShellActivity == .promptIdle && + !rawNeedsConfirmClose && + !workspaceNeedsConfirmClose + } + func start() { + transcriptRecoveryCoordinator.start() guard settingsObserver == nil else { updateTimerForCurrentSettings() return @@ -64,16 +202,21 @@ final class AgentHibernationController { } func stop() { + transcriptRecoveryCoordinator.stop() timer?.cancel() timer = nil AgentHibernationTrackingGate.setEnabled(false) - clearTrackingState() + clearTrackingState(cancelRestoreMonitors: true) if let settingsObserver { NotificationCenter.default.removeObserver(settingsObserver) self.settingsObserver = nil } } + func enqueueTranscriptRecovery() { + transcriptRecoveryCoordinator.requestRecovery() + } + func recordTerminalInput(workspaceId: UUID, panelId: UUID, recordedAt: Date? = nil) { guard AgentHibernationTrackingGate.isEnabled() else { return } let recordedAt = recordedAt ?? Date() @@ -104,6 +247,7 @@ final class AgentHibernationController { private func recordActivity(workspaceId: UUID, panelId: UUID, recordedAt: Date) -> AgentHibernationPanelKey { let key = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: panelId) activityByPanel[key] = recordedAt.timeIntervalSince1970 + teardownInFlightByPanel[key]?.invalidate() bumpTeardownValidationEpoch(key) confirmations.removeValue(forKey: key) unableToProtectByPanel.removeValue(forKey: key) @@ -115,6 +259,7 @@ final class AgentHibernationController { } private func recordSettingsChange() { + teardownInFlightByPanel.values.forEach { $0.invalidate() } teardownValidationGeneration = teardownValidationGeneration &+ 1 confirmations.removeAll(keepingCapacity: false) unableToProtectByPanel.removeAll(keepingCapacity: false) @@ -135,13 +280,20 @@ final class AgentHibernationController { timer.schedule(deadline: .now() + 5, repeating: 30) timer.setEventHandler { let now = Date() - Task.detached(priority: .utility) { - let index = await RestorableAgentSessionIndex.loadIncludingProcessDetectedSnapshots() - await MainActor.run { - let settings = AgentHibernationSettings.values() - guard settings.enabled else { return } - AgentHibernationController.shared.evaluate(index: index, settings: settings, now: now) - } + Task { @MainActor in + guard !AgentHibernationController.shared.hibernationIndexLoadInFlight, + let appDelegate = AppDelegate.shared, + let panelKeys = appDelegate.agentHibernationOpenTerminalPanelKeys( + maximumCount: RestorableAgentSessionIndex.maximumHibernationPanelContexts + ) else { return } + AgentHibernationController.shared.hibernationIndexLoadInFlight = true + defer { AgentHibernationController.shared.hibernationIndexLoadInFlight = false } + let index = await RestorableAgentSessionIndex.loadIncludingProcessDetectedSnapshots( + hibernationPanelKeys: panelKeys + ) + let settings = AgentHibernationSettings.values() + guard settings.enabled else { return } + AgentHibernationController.shared.evaluate(index: index, settings: settings, now: now) } } timer.resume() @@ -170,7 +322,7 @@ final class AgentHibernationController { let isLiveByKey = Dictionary(uniqueKeysWithValues: records.map { record in ( record.key, - (record.terminalPanel.surface.hasLiveSurface || record.hasLiveProcess) && + (record.terminalPanel.surface.hasLiveSurface || !record.processIDs.isEmpty) && !record.terminalPanel.isAgentHibernated ) }) @@ -180,7 +332,7 @@ final class AgentHibernationController { let plannerInputs = records.map { record in let isLive = isLiveByKey[record.key] ?? false var effectiveLastActivityAt = record.lastActivityAt - if record.hasLiveProcess { + if !record.processEvidence.allowsHibernation || !record.satisfiesPromptAndCloseGates { bumpTeardownValidationEpoch(record.key) tailFingerprintSamples.removeValue(forKey: record.key) confirmations.removeValue(forKey: record.key) @@ -189,7 +341,8 @@ final class AgentHibernationController { if shouldMaintainTailSamples, isLive, !record.isProtected, - !record.hasLiveProcess, + record.processEvidence.allowsHibernation, + record.satisfiesPromptAndCloseGates, record.lifecycle.allowsHibernation, !record.hasUnconfirmedTerminalInput, let tailActivityAt = updateTailFingerprintSample(record: record, now: nowTime) { @@ -205,7 +358,7 @@ final class AgentHibernationController { key: record.key, hasRestorableAgent: true, isLive: isLive, - hasLiveProcess: record.hasLiveProcess, + processEvidence: record.processEvidence, isProtected: record.isProtected, lifecycle: record.lifecycle, isTemporarilyUnableToProtect: unableToProtectMarkerApplies, @@ -242,7 +395,8 @@ final class AgentHibernationController { guard record.lifecycle.allowsHibernation, !record.hasUnconfirmedTerminalInput, !record.isProtected, - !record.hasLiveProcess, + record.processEvidence.allowsHibernation, + record.satisfiesPromptAndCloseGates, record.terminalPanel.surface.hasLiveSurface, !record.terminalPanel.isAgentHibernated else { confirmations.removeValue(forKey: record.key) @@ -263,13 +417,18 @@ final class AgentHibernationController { return nil } let requestID = UUID() - teardownInFlightByPanel[record.key] = InFlightTeardown(requestID: requestID) + let inFlight = InFlightTeardown( + requestID: requestID, + terminalSurface: record.terminalPanel.surface + ) + teardownInFlightByPanel[record.key] = inFlight confirmations.removeValue(forKey: record.key) return ConfirmedTeardownRequest( record: record, confirmationFingerprint: confirmation.fingerprint, effectiveLastActivityAt: effectiveLastActivityAt, requestID: requestID, + inFlight: inFlight, epoch: teardownValidationEpochByPanel[record.key] ?? 0, generation: teardownValidationGeneration ) @@ -391,31 +550,9 @@ final class AgentHibernationController { ) } - func terminateScopedProcessesForHibernation(record: AgentHibernationRecord) { - guard !record.processIDs.isEmpty else { return } - let currentProcessID = getpid() - let currentProcessGroupID = getpgrp() - var signaledProcessGroups: Set = [] - for rawPID in record.processIDs.sorted(by: >) { - guard rawPID > 0, rawPID <= Int(Int32.max) else { continue } - let pid = pid_t(rawPID) - guard pid != currentProcessID else { continue } - guard let process = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: rawPID), - process.matchesCMUXScope(workspaceId: record.key.workspaceId, surfaceId: record.key.panelId) else { - continue - } - let processGroupID = getpgid(pid) - if processGroupID > 1, - processGroupID != currentProcessGroupID, - signaledProcessGroups.insert(processGroupID).inserted { - _ = kill(-processGroupID, SIGTERM) - } - _ = kill(pid, SIGTERM) - } - } - - private func clearTrackingState() { - cancelPostTeardownRestoreTasks() + private func clearTrackingState(cancelRestoreMonitors: Bool = false) { + if cancelRestoreMonitors { cancelPostTeardownRestoreTasks() } + teardownInFlightByPanel.values.forEach { $0.invalidate() } teardownValidationGeneration = teardownValidationGeneration &+ 1 activityByPanel.removeAll(keepingCapacity: false) terminalInputByPanel.removeAll(keepingCapacity: false) @@ -445,6 +582,7 @@ final class AgentHibernationController { func clearInFlightTeardown(_ key: AgentHibernationPanelKey, requestID: UUID) { guard teardownInFlightByPanel[key]?.requestID == requestID else { return } + teardownInFlightByPanel[key]?.invalidate() teardownInFlightByPanel.removeValue(forKey: key) } } diff --git a/Sources/App/AgentHibernationPlanner.swift b/Sources/App/AgentHibernationPlanner.swift index d1d14d8e2b3f..75e9c8209740 100644 --- a/Sources/App/AgentHibernationPlanner.swift +++ b/Sources/App/AgentHibernationPlanner.swift @@ -16,7 +16,7 @@ enum AgentHibernationPlanner { let eligible = liveRestorable .filter { input in !input.isProtected && - !input.hasLiveProcess && + input.processEvidence.allowsHibernation && input.lifecycle.allowsHibernation && !input.isTemporarilyUnableToProtect && !input.hasUnconfirmedTerminalInput && diff --git a/Sources/App/AgentHibernationPlannerInput.swift b/Sources/App/AgentHibernationPlannerInput.swift index 07f86b4693dc..492d2fd7c12d 100644 --- a/Sources/App/AgentHibernationPlannerInput.swift +++ b/Sources/App/AgentHibernationPlannerInput.swift @@ -1,10 +1,533 @@ +import Darwin import Foundation +import os + +private func agentHibernationKevent( + _ queue: Int32, + _ changes: UnsafePointer?, + _ changeCount: Int32, + _ events: UnsafeMutablePointer?, + _ eventCount: Int32, + _ timeout: UnsafePointer? +) -> Int32 { + let systemKevent: ( + Int32, + UnsafePointer?, + Int32, + UnsafeMutablePointer?, + Int32, + UnsafePointer? + ) -> Int32 = kevent + return systemKevent(queue, changes, changeCount, events, eventCount, timeout) +} + +struct AgentHibernationProcessGenerationFence: @unchecked Sendable { + enum State: Equatable { + case originalGenerationAlive + case originalGenerationExited + case unavailable + } + + private final class KernelFence: @unchecked Sendable { + private enum Storage { + case active + case exited + case unavailable + } + + private let descriptor: Int32 + private let processID: pid_t + private let storage = OSAllocatedUnfairLock(initialState: Storage.active) + + init?(processID: pid_t) { + let descriptor = kqueue() + guard descriptor >= 0 else { return nil } + var event = kevent( + ident: UInt(processID), + filter: Int16(EVFILT_PROC), + flags: UInt16(EV_ADD | EV_ENABLE | EV_CLEAR), + fflags: UInt32(NOTE_EXIT), + data: 0, + udata: nil + ) + guard agentHibernationKevent(descriptor, &event, 1, nil, 0, nil) == 0 else { + Darwin.close(descriptor) + return nil + } + self.descriptor = descriptor + self.processID = processID + } + + deinit { + Darwin.close(descriptor) + } + + func currentState() -> State { + storage.withLock { storage in + switch storage { + case .exited: + return .originalGenerationExited + case .unavailable: + return .unavailable + case .active: + break + } + var event = kevent() + var timeout = timespec(tv_sec: 0, tv_nsec: 0) + while true { + let result = agentHibernationKevent( + descriptor, + nil, + 0, + &event, + 1, + &timeout + ) + if result == 0 { + return .originalGenerationAlive + } + if result == 1, + event.filter == Int16(EVFILT_PROC), + event.ident == UInt(processID), + event.fflags & UInt32(NOTE_EXIT) != 0 { + storage = .exited + return .originalGenerationExited + } + if result < 0, errno == EINTR { + continue + } + storage = .unavailable + return .unavailable + } + } + } + } + + private let stateProvider: @Sendable () -> State + + init?(processID: pid_t) { + guard let fence = KernelFence(processID: processID) else { return nil } + stateProvider = { fence.currentState() } + } + + init(stateProvider: @escaping @Sendable () -> State) { + self.stateProvider = stateProvider + } + + func currentState() -> State { + stateProvider() + } +} + +final class AgentHibernationFrozenShellLease: @unchecked Sendable { + private enum State { + case active + case resumed + case ownerGoneOrReplaced + } + + let processFreeLease: AgentHibernationProcessFreeLease + private let state = OSAllocatedUnfairLock(initialState: State.active) + private let generationFence: AgentHibernationProcessGenerationFence + private let processIdentity: @Sendable (Int) -> AgentPIDProcessIdentity? + private let processStatus: @Sendable (Int) -> UInt32? + private let sendSignal: @Sendable (pid_t, Int32) -> Int32 + + fileprivate init( + processFreeLease: AgentHibernationProcessFreeLease, + generationFence: AgentHibernationProcessGenerationFence, + processIdentity: @escaping @Sendable (Int) -> AgentPIDProcessIdentity?, + processStatus: @escaping @Sendable (Int) -> UInt32?, + sendSignal: @escaping @Sendable (pid_t, Int32) -> Int32 + ) { + self.processFreeLease = processFreeLease + self.generationFence = generationFence + self.processIdentity = processIdentity + self.processStatus = processStatus + self.sendSignal = sendSignal + } + + var guardedProcessIDs: Set { [processFreeLease.shellPID] } + + func isStillFrozenAndProcessFree( + finalProcessFreeValidation: (@Sendable () -> Bool)? = nil + ) -> Bool { + guard state.withLock({ $0 == .active }), + processIdentity(processFreeLease.shellPID) == processFreeLease.shellIdentity, + generationFence.currentState() == .originalGenerationAlive, + processStatus(processFreeLease.shellPID) == UInt32(SSTOP) else { + return false + } + return finalProcessFreeValidation?() ?? processFreeLease.isStillProcessFree() + } + + func resume() { + state.withLock { state in + guard state == .active else { return } + let currentIdentity = processIdentity(processFreeLease.shellPID) + if let currentIdentity, + currentIdentity != processFreeLease.shellIdentity { + state = .ownerGoneOrReplaced + return + } + if currentIdentity == nil { + switch generationFence.currentState() { + case .originalGenerationExited: + state = .ownerGoneOrReplaced + return + case .unavailable: + os_log(.fault, "Unable to prove frozen shell generation before SIGCONT") + return + case .originalGenerationAlive: + break + } + } + + errno = 0 + guard sendSignal(processFreeLease.shellIdentity.pid, SIGCONT) == 0 else { + let signalError = errno + if signalError == ESRCH { + state = .ownerGoneOrReplaced + } else { + os_log( + .fault, + "SIGCONT failed for proven frozen shell generation: errno=%{public}d", + signalError + ) + } + return + } + state = .resumed + } + } + + deinit { + resume() + } +} + +struct AgentHibernationProcessFreeLease: Sendable, Equatable { + struct ProcessTopology: Sendable, Equatable { + let parentPID: Int + let name: String + let ttyDevice: Int64? + let processGroupID: Int? + let terminalProcessGroupID: Int? + } + + let workspaceId: UUID + let panelId: UUID + let shellPID: Int + let shellIdentity: AgentPIDProcessIdentity + let shellParentPID: Int + let shellName: String + let executablePath: String + let arguments: [String] + let ttyDevice: Int64 + let sessionID: Int + let processGroupID: Int + let terminalProcessGroupID: Int + + var guardedProcessIDs: Set { [shellPID] } + + func freezeForFinalTeardown( + processIdentity: @escaping @Sendable (Int) -> AgentPIDProcessIdentity? = { + guard $0 > 0, $0 <= Int(Int32.max) else { return nil } + return AgentPIDProcessIdentity(pid: pid_t($0)) + }, + processStatus: @escaping @Sendable (Int) -> UInt32? = { + Self.currentProcessStatus(pid: $0) + }, + processGenerationFence: @escaping @Sendable (pid_t) -> AgentHibernationProcessGenerationFence? = { + AgentHibernationProcessGenerationFence(processID: $0) + }, + sendSignal: @escaping @Sendable (pid_t, Int32) -> Int32 = { pid, signal in + Darwin.kill(pid, signal) + }, + waitForStoppedChild: @escaping @Sendable (pid_t) -> Bool = { pid in + Self.waitForStoppedDirectChild(pid: pid) + }, + finalProcessFreeValidation: (@Sendable () -> Bool)? = nil + ) -> AgentHibernationFrozenShellLease? { + // Never resume a shell that the user or debugger had already stopped. + guard processIdentity(shellPID) == shellIdentity, + let initialStatus = processStatus(shellPID), + initialStatus != UInt32(SSTOP), + let generationFence = processGenerationFence(shellIdentity.pid), + processIdentity(shellPID) == shellIdentity, + generationFence.currentState() == .originalGenerationAlive, + sendSignal(shellIdentity.pid, SIGSTOP) == 0 else { + return nil + } + let frozenLease = AgentHibernationFrozenShellLease( + processFreeLease: self, + generationFence: generationFence, + processIdentity: processIdentity, + processStatus: processStatus, + sendSignal: sendSignal + ) + guard waitForStoppedChild(shellIdentity.pid), + frozenLease.isStillFrozenAndProcessFree( + finalProcessFreeValidation: finalProcessFreeValidation + ) else { + frozenLease.resume() + return nil + } + return frozenLease + } + + private static func waitForStoppedDirectChild(pid: pid_t) -> Bool { + var information = siginfo_t() + while true { + let result = waitid( + P_PID, + id_t(pid), + &information, + WSTOPPED | WEXITED | WNOWAIT + ) + if result == 0 { + return information.si_pid == pid + && information.si_code == CLD_STOPPED + && information.si_status == SIGSTOP + } + if errno != EINTR { return false } + } + } + + func isStillProcessFree( + processArguments: (Int) -> CmuxTopProcessArguments? = { + CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: $0) + }, + processIdentity: (Int) -> AgentPIDProcessIdentity? = { + guard $0 > 0, $0 <= Int(Int32.max) else { return nil } + return AgentPIDProcessIdentity(pid: pid_t($0)) + }, + processExecutablePath: (Int) -> String? = { + CmuxTopProcessSnapshot.processExecutablePath(for: $0) + }, + processSessionID: (Int) -> pid_t? = { + guard $0 > 0, $0 <= Int(Int32.max) else { return nil } + let value = getsid(pid_t($0)) + return value > 0 ? value : nil + }, + ttyProcessIDs: (Int64) -> CmuxTopTargetedPIDEnumeration = { + CmuxTopProcessSnapshot.processIDs(forTTYDevice: $0) + }, + childProcessIDs: (Int) -> CmuxTopTargetedPIDEnumeration = { + CmuxTopProcessSnapshot.childProcessIDs(of: $0) + }, + processTopology: (Int) -> ProcessTopology? = { + Self.currentTopology(pid: $0) + } + ) -> Bool { + guard processIdentity(shellPID) == shellIdentity, + let topology = processTopology(shellPID), + topology.parentPID == shellParentPID, + topology.name == shellName, + topology.ttyDevice == ttyDevice, + topology.processGroupID == processGroupID, + topology.terminalProcessGroupID == terminalProcessGroupID, + processSessionID(shellPID) == pid_t(sessionID), + processExecutablePath(shellPID) == executablePath, + let currentArguments = processArguments(shellPID), + currentArguments.arguments == arguments, + currentArguments.matchesCMUXScope(workspaceId: workspaceId, surfaceId: panelId), + case .complete(let ttyPIDs) = ttyProcessIDs(ttyDevice), + ttyPIDs == [shellPID], + case .complete(let childPIDs) = childProcessIDs(shellPID), + childPIDs.isEmpty, + processIdentity(shellPID) == shellIdentity else { + return false + } + return true + } + + private static func currentTopology( + pid: Int + ) -> ProcessTopology? { + guard pid > 0, pid <= Int(Int32.max) else { return nil } + var info = proc_bsdinfo() + let expectedSize = MemoryLayout.stride + let size = proc_pidinfo(pid_t(pid), PROC_PIDTBSDINFO, 0, &info, Int32(expectedSize)) + guard size == expectedSize else { return nil } + let name = withUnsafeBytes(of: info.pbi_comm) { rawBuffer in + let end = rawBuffer.firstIndex(of: 0) ?? rawBuffer.endIndex + return String(decoding: rawBuffer[.. 0 ? tty : nil, + processGroupID: processGroupID > 0 ? processGroupID : nil, + terminalProcessGroupID: terminalProcessGroupID > 0 ? terminalProcessGroupID : nil + ) + } + + private static func currentProcessStatus(pid: Int) -> UInt32? { + guard pid > 0, pid <= Int(Int32.max) else { return nil } + var info = proc_bsdinfo() + let expectedSize = MemoryLayout.stride + let size = proc_pidinfo(pid_t(pid), PROC_PIDTBSDINFO, 0, &info, Int32(expectedSize)) + guard size == expectedSize else { return nil } + return info.pbi_status + } +} + +enum AgentHibernationProcessEvidence: Sendable, Equatable { + case confirmedProcessFree(AgentHibernationProcessFreeLease) + case unverified(processIDs: Set) + + var allowsHibernation: Bool { + if case .confirmedProcessFree = self { return true } + return false + } + + var processIDs: Set { + switch self { + case .confirmedProcessFree: + return [] + case .unverified(let processIDs): + return processIDs + } + } + + var lease: AgentHibernationProcessFreeLease? { + guard case .confirmedProcessFree(let lease) = self else { return nil } + return lease + } +} + +struct AgentHibernationProcessTopologyIndex { + private let evidenceByPanel: [RestorableAgentSessionIndex.PanelKey: AgentHibernationProcessEvidence] + + var allEvidence: [RestorableAgentSessionIndex.PanelKey: AgentHibernationProcessEvidence] { + evidenceByPanel + } + + init( + processSnapshot: CmuxTopProcessSnapshot, + targetPanelKeys: Set, + targetPanelIDs: Set = [], + processArguments: (Int) -> CmuxTopProcessArguments? = { + CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: $0) + }, + processIdentity: (Int) -> AgentPIDProcessIdentity? = { + guard $0 > 0, $0 <= Int(Int32.max) else { return nil } + return AgentPIDProcessIdentity(pid: pid_t($0)) + }, + processExecutablePath: (Int) -> String? = { + CmuxTopProcessSnapshot.processExecutablePath(for: $0) + }, + processSessionID: (Int) -> pid_t? = { + guard $0 > 0, $0 <= Int(Int32.max) else { return nil } + let value = getsid(pid_t($0)) + return value > 0 ? value : nil + }, + ttyProcessIDs: (Int64) -> CmuxTopTargetedPIDEnumeration = { + CmuxTopProcessSnapshot.processIDs(forTTYDevice: $0) + }, + childProcessIDs: (Int) -> CmuxTopTargetedPIDEnumeration = { + CmuxTopProcessSnapshot.childProcessIDs(of: $0) + } + ) { + var scopedByPanel: [RestorableAgentSessionIndex.PanelKey: [CmuxTopProcessInfo]] = [:] + for process in processSnapshot.cmuxScopedProcesses() { + guard let workspaceId = process.cmuxWorkspaceID, + let panelId = process.cmuxSurfaceID else { + continue + } + let key = RestorableAgentSessionIndex.PanelKey(workspaceId: workspaceId, panelId: panelId) + guard targetPanelKeys.contains(key) || targetPanelIDs.contains(panelId) else { continue } + scopedByPanel[key, default: []].append(process) + } + + var ttyEnumerationCache: [Int64: CmuxTopTargetedPIDEnumeration] = [:] + var evidence: [RestorableAgentSessionIndex.PanelKey: AgentHibernationProcessEvidence] = [:] + let effectiveTargetKeys = targetPanelKeys.union(scopedByPanel.keys) + evidence.reserveCapacity(effectiveTargetKeys.count) + for key in effectiveTargetKeys { + let scoped = scopedByPanel[key] ?? [] + let observedPIDs = Set(scoped.map(\.pid)) + guard scoped.count == 1, + let shell = scoped.first, + Self.isTerminalShell(shell), + let ttyDevice = shell.ttyDevice, + let processGroupID = shell.processGroupID, + let terminalProcessGroupID = shell.terminalProcessGroupID, + processGroupID == terminalProcessGroupID, + let shellIdentity = shell.generationIdentity, + processIdentity(shell.pid) == shellIdentity, + let executablePath = processExecutablePath(shell.pid), + let sessionID = processSessionID(shell.pid), + let arguments = processArguments(shell.pid), + arguments.matchesCMUXScope(workspaceId: key.workspaceId, surfaceId: key.panelId), + processIdentity(shell.pid) == shellIdentity else { + evidence[key] = .unverified(processIDs: observedPIDs) + continue + } + + let ttyEnumeration: CmuxTopTargetedPIDEnumeration + if let cached = ttyEnumerationCache[ttyDevice] { + ttyEnumeration = cached + } else { + ttyEnumeration = ttyProcessIDs(ttyDevice) + ttyEnumerationCache[ttyDevice] = ttyEnumeration + } + guard case .complete(let ttyPIDs) = ttyEnumeration, + ttyPIDs == [shell.pid], + case .complete(let children) = childProcessIDs(shell.pid), + children.isEmpty else { + let extraPIDs: Set + if case .complete(let ttyPIDs) = ttyEnumeration { + extraPIDs = observedPIDs.union(ttyPIDs) + } else { + extraPIDs = observedPIDs + } + evidence[key] = .unverified(processIDs: extraPIDs) + continue + } + + evidence[key] = .confirmedProcessFree(AgentHibernationProcessFreeLease( + workspaceId: key.workspaceId, + panelId: key.panelId, + shellPID: shell.pid, + shellIdentity: shellIdentity, + shellParentPID: shell.parentPID, + shellName: shell.name, + executablePath: executablePath, + arguments: arguments.arguments, + ttyDevice: ttyDevice, + sessionID: Int(sessionID), + processGroupID: processGroupID, + terminalProcessGroupID: terminalProcessGroupID + )) + } + evidenceByPanel = evidence + } + + func evidence(for key: RestorableAgentSessionIndex.PanelKey) -> AgentHibernationProcessEvidence { + evidenceByPanel[key] ?? .unverified(processIDs: []) + } + + private static func isTerminalShell(_ process: CmuxTopProcessInfo) -> Bool { + let name = process.name.lowercased() + let basename = ((process.path ?? process.name) as NSString).lastPathComponent.lowercased() + let shells: Set = [ + "bash", "csh", "dash", "elvish", "fish", "ksh", "nu", "sh", "tcsh", "xonsh", "zsh", + ] + return shells.contains(name) || shells.contains(basename) + } +} struct AgentHibernationPlannerInput: Sendable { let key: AgentHibernationPanelKey let hasRestorableAgent: Bool let isLive: Bool - let hasLiveProcess: Bool + let processEvidence: AgentHibernationProcessEvidence let isProtected: Bool let lifecycle: AgentHibernationLifecycleState let isTemporarilyUnableToProtect: Bool @@ -15,7 +538,7 @@ struct AgentHibernationPlannerInput: Sendable { key: AgentHibernationPanelKey, hasRestorableAgent: Bool, isLive: Bool, - hasLiveProcess: Bool = false, + processEvidence: AgentHibernationProcessEvidence = .unverified(processIDs: []), isProtected: Bool, lifecycle: AgentHibernationLifecycleState, isTemporarilyUnableToProtect: Bool = false, @@ -25,7 +548,7 @@ struct AgentHibernationPlannerInput: Sendable { self.key = key self.hasRestorableAgent = hasRestorableAgent self.isLive = isLive - self.hasLiveProcess = hasLiveProcess + self.processEvidence = processEvidence self.isProtected = isProtected self.lifecycle = lifecycle self.isTemporarilyUnableToProtect = isTemporarilyUnableToProtect diff --git a/Sources/App/AgentHibernationTranscriptGuard+ClaudeWorkflow.swift b/Sources/App/AgentHibernationTranscriptGuard+ClaudeWorkflow.swift index d243a78dd6d1..7e017cb05c1e 100644 --- a/Sources/App/AgentHibernationTranscriptGuard+ClaudeWorkflow.swift +++ b/Sources/App/AgentHibernationTranscriptGuard+ClaudeWorkflow.swift @@ -1,6 +1,60 @@ +import Darwin import Foundation extension AgentHibernationTranscriptGuard { + private static let maximumClaudeWorkflowScanEntries = 16_384 + private static let maximumClaudeWorkflowScanBytes: UInt64 = 64 * 1_024 * 1_024 + + private struct ClaudeWorkflowScanBudget { + var remainingEntries = maximumClaudeWorkflowScanEntries + var remainingBytes = maximumClaudeWorkflowScanBytes + + mutating func consumeEntry() -> Bool { + guard remainingEntries > 0 else { return false } + remainingEntries -= 1 + return true + } + + mutating func consumeFile(byteCount: UInt64) -> Bool { + guard byteCount <= remainingBytes else { return false } + remainingBytes -= byteCount + return true + } + } + + private struct ClaudeTranscriptCandidateAccumulator { + private(set) var conversationPath: String? + private(set) var metadataOnlyPath: String? + private(set) var isUnsafeOrAmbiguous = false + + mutating func inspect(path: String, fileManager: FileManager) { + guard !isUnsafeOrAmbiguous else { return } + if transcriptHasConversationTurns(atPath: path, fileManager: fileManager) { + guard conversationPath == nil else { + isUnsafeOrAmbiguous = true + return + } + conversationPath = path + return + } + if transcriptContainsOnlyNonProtectiveMetadata(atPath: path, fileManager: fileManager) { + if let current = metadataOnlyPath { + metadataOnlyPath = min(current, path) + } else { + metadataOnlyPath = path + } + return + } + isUnsafeOrAmbiguous = true + } + } + + private enum ClaudeRegularFileProbe { + case absentOrUnsupported + case unsafe + case regular(byteCount: UInt64) + } + static func resolveClaudeTranscriptPath( agent: SessionRestorableAgentSnapshot, panelKey: AgentHibernationPanelKey?, @@ -27,6 +81,16 @@ extension AgentHibernationTranscriptGuard { return (resolution.path, resolution.shouldStop) } + func inspectWorkflowCandidate( + _ path: String, + accumulator: inout ClaudeTranscriptCandidateAccumulator + ) -> Bool { + let standardized = (path as NSString).standardizingPath + guard seenCandidates.insert(standardized).inserted else { return true } + accumulator.inspect(path: path, fileManager: fileManager) + return !accumulator.isUnsafeOrAmbiguous + } + let recordedTranscript = recordedTranscriptPath( agent: agent, panelKey: panelKey, @@ -44,42 +108,88 @@ extension AgentHibernationTranscriptGuard { } let configRoots = claudeConfigRoots(for: agent, homeDirectory: homeDirectory, fileManager: fileManager) + var exactProjectRoots: [String] = [] if let workingDirectory = normalized(agent.workingDirectory) { var standardCandidates: [String] = [] - var workflowCandidates: [String] = [] for configRoot in configRoots { let projectsRoot = (configRoot as NSString).appendingPathComponent("projects") let projectRoot = (projectsRoot as NSString) .appendingPathComponent(RestorableAgentSessionIndex.encodeClaudeProjectDir(workingDirectory)) + exactProjectRoots.append(projectRoot) for candidate in transcriptCandidates(projectRoot: projectRoot, sessionId: agent.sessionId) { appendCandidate(candidate, to: &standardCandidates) } - for candidate in workflowTranscriptCandidates(projectRoot: projectRoot, sessionId: agent.sessionId, fileManager: fileManager) { - appendCandidate(candidate, to: &workflowCandidates) - } } let standardResolution = resolve(standardCandidates, requireUniqueConversation: true) if standardResolution.shouldStop { return standardResolution.path } - let workflowResolution = resolve(workflowCandidates, requireUniqueConversation: true) - if workflowResolution.shouldStop { return workflowResolution.path } } - var fallbackCandidates: [String] = [] + var workflowBudget = ClaudeWorkflowScanBudget() + if !exactProjectRoots.isEmpty { + var exactWorkflowResolution = ClaudeTranscriptCandidateAccumulator() + for projectRoot in exactProjectRoots { + let completed = scanClaudeWorkflowTranscripts( + projectRoot: projectRoot, + sessionId: agent.sessionId, + budget: &workflowBudget + ) { candidate in + inspectWorkflowCandidate(candidate, accumulator: &exactWorkflowResolution) + } + guard completed else { return nil } + } + metadataOnlyCandidate = metadataOnlyCandidate ?? exactWorkflowResolution.metadataOnlyPath + if exactWorkflowResolution.isUnsafeOrAmbiguous { return nil } + if let conversationPath = exactWorkflowResolution.conversationPath { + return conversationPath + } + } + + var fallbackProjectRoots: [String] = [] for configRoot in configRoots { let projectsRoot = (configRoot as NSString).appendingPathComponent("projects") - guard let projectDirs = try? fileManager.contentsOfDirectory(atPath: projectsRoot) else { continue } - for projectDir in projectDirs.sorted() { - let projectRoot = (projectsRoot as NSString).appendingPathComponent(projectDir) - for candidate in transcriptCandidates(projectRoot: projectRoot, sessionId: agent.sessionId) { - appendCandidate(candidate, to: &fallbackCandidates) - } - for candidate in workflowTranscriptCandidates(projectRoot: projectRoot, sessionId: agent.sessionId, fileManager: fileManager) { - appendCandidate(candidate, to: &fallbackCandidates) + guard collectClaudeProjectDirectories( + projectsRoot: projectsRoot, + budget: &workflowBudget, + into: &fallbackProjectRoots + ) else { + return nil + } + } + let exactProjectRootSet = Set(exactProjectRoots.map { ($0 as NSString).standardizingPath }) + fallbackProjectRoots.removeAll { exactProjectRootSet.contains(($0 as NSString).standardizingPath) } + + var fallbackResolution = ClaudeTranscriptCandidateAccumulator() + for projectRoot in fallbackProjectRoots { + for candidate in transcriptCandidates(projectRoot: projectRoot, sessionId: agent.sessionId) { + let standardized = (candidate as NSString).standardizingPath + guard seenCandidates.insert(standardized).inserted else { continue } + switch probeClaudeRegularFile(atPath: candidate) { + case .absentOrUnsupported: + continue + case .unsafe: + return nil + case .regular(let byteCount): + guard workflowBudget.consumeFile(byteCount: byteCount) else { return nil } } + fallbackResolution.inspect(path: candidate, fileManager: fileManager) + if fallbackResolution.isUnsafeOrAmbiguous { return nil } + } + } + for projectRoot in fallbackProjectRoots { + let completed = scanClaudeWorkflowTranscripts( + projectRoot: projectRoot, + sessionId: agent.sessionId, + budget: &workflowBudget + ) { candidate in + inspectWorkflowCandidate(candidate, accumulator: &fallbackResolution) } + guard completed else { return nil } + } + metadataOnlyCandidate = metadataOnlyCandidate ?? fallbackResolution.metadataOnlyPath + if fallbackResolution.isUnsafeOrAmbiguous { return nil } + if let conversationPath = fallbackResolution.conversationPath { + return conversationPath } - let fallbackResolution = resolve(fallbackCandidates, requireUniqueConversation: true) - if fallbackResolution.shouldStop { return fallbackResolution.path } return metadataOnlyCandidate } @@ -100,7 +210,11 @@ extension AgentHibernationTranscriptGuard { continue } if transcriptContainsOnlyNonProtectiveMetadata(atPath: candidate, fileManager: fileManager) { - metadataOnlyPath = metadataOnlyPath ?? candidate + if let current = metadataOnlyPath { + metadataOnlyPath = min(current, candidate) + } else { + metadataOnlyPath = candidate + } continue } return (nil, metadataOnlyPath, true) @@ -109,95 +223,152 @@ extension AgentHibernationTranscriptGuard { return (nil, metadataOnlyPath, false) } - static func workflowTranscriptCandidates( + private static func probeClaudeRegularFile(atPath path: String) -> ClaudeRegularFileProbe { + var pathStatus = stat() + guard lstat(path, &pathStatus) == 0 else { return .absentOrUnsupported } + guard pathStatus.st_mode & S_IFMT == S_IFREG else { + return pathStatus.st_mode & S_IFMT == S_IFLNK ? .unsafe : .absentOrUnsupported + } + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard descriptor >= 0 else { return .unsafe } + defer { Darwin.close(descriptor) } + var descriptorStatus = stat() + guard fstat(descriptor, &descriptorStatus) == 0, + descriptorStatus.st_mode & S_IFMT == S_IFREG, + descriptorStatus.st_dev == pathStatus.st_dev, + descriptorStatus.st_ino == pathStatus.st_ino else { + return .unsafe + } + guard descriptorStatus.st_size > 0 else { return .absentOrUnsupported } + return .regular(byteCount: UInt64(descriptorStatus.st_size)) + } + + private static func collectClaudeProjectDirectories( + projectsRoot: String, + budget: inout ClaudeWorkflowScanBudget, + into projectRoots: inout [String] + ) -> Bool { + let descriptor = open(projectsRoot, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { return true } + guard let stream = fdopendir(descriptor) else { + Darwin.close(descriptor) + return true + } + defer { closedir(stream) } + + while true { + errno = 0 + guard let entry = readdir(stream) else { return errno == 0 } + let name = claudeDirectoryEntryName(entry) + guard name != ".", name != ".." else { continue } + guard budget.consumeEntry() else { return false } + let childDescriptor = name.withCString { + openat(dirfd(stream), $0, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + } + guard childDescriptor >= 0 else { continue } + Darwin.close(childDescriptor) + projectRoots.append((projectsRoot as NSString).appendingPathComponent(name)) + } + } + + private static func scanClaudeWorkflowTranscripts( projectRoot: String, sessionId: String, - fileManager: FileManager - ) -> [String] { + budget: inout ClaudeWorkflowScanBudget, + visit: (String) -> Bool + ) -> Bool { + let descriptor = open(projectRoot, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { return true } + guard let stream = fdopendir(descriptor) else { + Darwin.close(descriptor) + return true + } let targetName = "\(sessionId).jsonl" let directPath = (projectRoot as NSString).appendingPathComponent(targetName) let nestedPath = (((projectRoot as NSString).appendingPathComponent(sessionId) as NSString) .appendingPathComponent("messages") as NSString) .appendingPathComponent(targetName) - let standardPaths = Set([directPath, nestedPath].map { ($0 as NSString).standardizingPath }) - var matches: [String] = [] - var hasMetadataOnlyMatch = false - var protectiveMatchCount = 0 - collectWorkflowTranscriptCandidates( - inDirectory: projectRoot, + let excludedPaths = Set([directPath, nestedPath].map { ($0 as NSString).standardizingPath }) + return scanClaudeWorkflowDirectory( + stream: stream, + directoryPath: projectRoot, targetName: targetName, - excludedPaths: standardPaths, + excludedPaths: excludedPaths, remainingDirectoryDepth: 4, - fileManager: fileManager, - matches: &matches, - hasMetadataOnlyMatch: &hasMetadataOnlyMatch, - protectiveMatchCount: &protectiveMatchCount + budget: &budget, + visit: visit ) - return matches } - private static func collectWorkflowTranscriptCandidates( - inDirectory directory: String, + private static func scanClaudeWorkflowDirectory( + stream: UnsafeMutablePointer, + directoryPath: String, targetName: String, excludedPaths: Set, remainingDirectoryDepth: Int, - fileManager: FileManager, - matches: inout [String], - hasMetadataOnlyMatch: inout Bool, - protectiveMatchCount: inout Int - ) { - guard protectiveMatchCount < 2, - let children = try? fileManager.contentsOfDirectory(atPath: directory) else { - return - } - for child in children.sorted() { - guard protectiveMatchCount < 2 else { return } - let childPath = (directory as NSString).appendingPathComponent(child) - if child == targetName { + budget: inout ClaudeWorkflowScanBudget, + visit: (String) -> Bool + ) -> Bool { + defer { closedir(stream) } + while true { + errno = 0 + guard let entry = readdir(stream) else { return errno == 0 } + let name = claudeDirectoryEntryName(entry) + guard name != ".", name != ".." else { continue } + guard budget.consumeEntry() else { return false } + let childPath = (directoryPath as NSString).appendingPathComponent(name) + + if name == targetName { let standardized = (childPath as NSString).standardizingPath - guard !excludedPaths.contains(standardized), - workflowRegularNonEmptyFileExists(atPath: childPath, fileManager: fileManager) else { - continue + guard !excludedPaths.contains(standardized) else { continue } + let fileDescriptor = name.withCString { + openat(dirfd(stream), $0, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) } - if transcriptContainsOnlyNonProtectiveMetadata(atPath: childPath, fileManager: fileManager) { - if !hasMetadataOnlyMatch { - matches.append(childPath) - hasMetadataOnlyMatch = true - } + guard fileDescriptor >= 0 else { continue } + var status = stat() + let statusRead = fstat(fileDescriptor, &status) + Darwin.close(fileDescriptor) + guard statusRead == 0, + status.st_mode & S_IFMT == S_IFREG, + status.st_size > 0 else { continue } - matches.append(childPath) - protectiveMatchCount += 1 - } else if remainingDirectoryDepth > 0, - workflowDirectoryExists(atPath: childPath, fileManager: fileManager) { - collectWorkflowTranscriptCandidates( - inDirectory: childPath, - targetName: targetName, - excludedPaths: excludedPaths, - remainingDirectoryDepth: remainingDirectoryDepth - 1, - fileManager: fileManager, - matches: &matches, - hasMetadataOnlyMatch: &hasMetadataOnlyMatch, - protectiveMatchCount: &protectiveMatchCount - ) + guard budget.consumeFile(byteCount: UInt64(status.st_size)), + visit(childPath) else { + return false + } + continue } - } - } - private static func workflowRegularNonEmptyFileExists(atPath path: String, fileManager: FileManager) -> Bool { - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), - !isDirectory.boolValue, - let attributes = try? fileManager.attributesOfItem(atPath: path), - let fileType = attributes[.type] as? FileAttributeType, - fileType == .typeRegular else { - return false + guard remainingDirectoryDepth > 0 else { continue } + let childDescriptor = name.withCString { + openat(dirfd(stream), $0, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + } + guard childDescriptor >= 0 else { continue } + guard let childStream = fdopendir(childDescriptor) else { + Darwin.close(childDescriptor) + continue + } + guard scanClaudeWorkflowDirectory( + stream: childStream, + directoryPath: childPath, + targetName: targetName, + excludedPaths: excludedPaths, + remainingDirectoryDepth: remainingDirectoryDepth - 1, + budget: &budget, + visit: visit + ) else { + return false + } } - return ((attributes[.size] as? NSNumber)?.int64Value ?? 0) > 0 } - private static func workflowDirectoryExists(atPath path: String, fileManager: FileManager) -> Bool { - var isDirectory: ObjCBool = false - return fileManager.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue + private static func claudeDirectoryEntryName(_ entry: UnsafeMutablePointer) -> String { + withUnsafePointer(to: &entry.pointee.d_name) { namePointer in + namePointer.withMemoryRebound( + to: CChar.self, + capacity: Int(entry.pointee.d_namlen) + 1 + ) { String(cString: $0) } + } } } diff --git a/Sources/App/AgentHibernationTranscriptGuard+PostTeardownRestore.swift b/Sources/App/AgentHibernationTranscriptGuard+PostTeardownRestore.swift index 8db77762e8de..a9a6d14dc59b 100644 --- a/Sources/App/AgentHibernationTranscriptGuard+PostTeardownRestore.swift +++ b/Sources/App/AgentHibernationTranscriptGuard+PostTeardownRestore.swift @@ -1,18 +1,316 @@ import Darwin import Foundation +import os + +final class AgentHibernationRestoreMonitorScheduler: @unchecked Sendable { + static let shared = AgentHibernationRestoreMonitorScheduler( + maximumConcurrentMonitors: 8 + ) + + private struct State { + var activeCount = 0 + var waiterOrder: [UUID] = [] + var waiterHead = 0 + var waiters: [UUID: CheckedContinuation] = [:] + + mutating func appendWaiter( + id: UUID, + continuation: CheckedContinuation + ) { + waiterOrder.append(id) + waiters[id] = continuation + } + + mutating func removeWaiter( + id: UUID + ) -> CheckedContinuation? { + let continuation = waiters.removeValue(forKey: id) + if waiters.isEmpty { + waiterOrder.removeAll(keepingCapacity: false) + waiterHead = 0 + } + return continuation + } + + mutating func popWaiter() -> CheckedContinuation? { + while waiterHead < waiterOrder.count { + let id = waiterOrder[waiterHead] + waiterHead += 1 + if let continuation = waiters.removeValue(forKey: id) { + compactWaiterOrderIfNeeded() + return continuation + } + } + waiterOrder.removeAll(keepingCapacity: false) + waiterHead = 0 + return nil + } + + private mutating func compactWaiterOrderIfNeeded() { + guard waiterHead >= 64, + waiterHead >= waiterOrder.count - waiterHead else { + return + } + waiterOrder.removeFirst(waiterHead) + waiterHead = 0 + } + } + + private let maximumConcurrentMonitors: Int + private let state = OSAllocatedUnfairLock(initialState: State()) + + init(maximumConcurrentMonitors: Int) { + self.maximumConcurrentMonitors = max(1, maximumConcurrentMonitors) + } + + func acquire() async -> Bool { + guard !Task.isCancelled else { return false } + let id = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let immediate = state.withLock { state -> Bool? in + guard !Task.isCancelled else { return false } + if state.activeCount < maximumConcurrentMonitors { + state.activeCount += 1 + return true + } + state.appendWaiter(id: id, continuation: continuation) + return nil + } + if let immediate { continuation.resume(returning: immediate) } + } + } onCancel: { + let continuation = self.state.withLock { state in + state.removeWaiter(id: id) + } + continuation?.resume(returning: false) + } + } + + func release() { + let continuation = state.withLock { state -> CheckedContinuation? in + precondition(state.activeCount > 0) + state.activeCount -= 1 + guard let continuation = state.popWaiter() else { return nil } + state.activeCount += 1 + return continuation + } + continuation?.resume(returning: true) + } +} + +actor AgentHibernationRestoreDispatchWait { + private var continuation: CheckedContinuation? + private var sources: [any DispatchSourceProtocol] = [] + private var didFinish = false + + func wait( + for sources: [any DispatchSourceProtocol], + onArmed: @Sendable () -> Void = {} + ) async { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if didFinish { + for source in sources { + source.cancel() + source.activate() + } + onArmed() + continuation.resume() + return + } + self.continuation = continuation + self.sources = sources + for source in sources { source.activate() } + onArmed() + } + } onCancel: { + Task { await self.finish() } + } + } + + func finish() { + guard !didFinish else { return } + didFinish = true + let continuation = continuation + let sources = sources + self.continuation = nil + self.sources = [] + for source in sources { source.cancel() } + continuation?.resume() + } +} extension AgentHibernationTranscriptGuard { + private static let restoreMonitorEventQueue = DispatchQueue( + label: "com.cmuxterm.agent-hibernation-transcript-restore-events", + qos: .utility + ) + enum PostTeardownSnapshotDisposal: Sendable { - /// Normal hibernation monitor: a healthy live transcript makes the - /// snapshot redundant, so it is deleted on completion. + /// Normal hibernation monitor: delete only after restore or a stable + /// byte proof that the live transcript contains the snapshot. case deleteWhenSafe - /// Forfeit monitor for a snapshot whose live path provably diverged: - /// "live has turns" no longer implies "live contains the snapshot", so - /// on completion an unrestored snapshot moves to the session's retained - /// recovery slot instead of being deleted. + /// Forfeit monitor: on completion, move an uncommitted snapshot to the + /// session's retained recovery slot instead of leaving UUID copies. case retainForRecovery(sessionId: String?) } + private static func guardedProcessIsAlive( + _ expectedIdentity: AgentPIDProcessIdentity + ) -> Bool { + if let currentIdentity = AgentPIDProcessIdentity(pid: expectedIdentity.pid) { + return currentIdentity == expectedIdentity + } + if kill(expectedIdentity.pid, 0) == 0 { return true } + return errno == EPERM + } + + private static func waitForGuardedProcessExitOrBackstop( + processIdentities: Set, + hasUnwatchedProcesses: Bool, + backstopSeconds: Int + ) async -> Bool { + let liveProcessIdentities = processIdentities.filter(guardedProcessIsAlive) + guard !liveProcessIdentities.isEmpty || hasUnwatchedProcesses else { return true } + let waiter = AgentHibernationRestoreDispatchWait() + var sources: [any DispatchSourceProtocol] = liveProcessIdentities.map { identity in + let source = DispatchSource.makeProcessSource( + identifier: identity.pid, + eventMask: .exit, + queue: restoreMonitorEventQueue + ) + source.setEventHandler { + if !hasUnwatchedProcesses, + !liveProcessIdentities.contains(where: guardedProcessIsAlive) { + Task { await waiter.finish() } + } + } + return source + } + let timer = DispatchSource.makeTimerSource(queue: restoreMonitorEventQueue) + timer.schedule( + deadline: .now() + .seconds(max(0, backstopSeconds)), + leeway: .milliseconds(100) + ) + timer.setEventHandler { Task { await waiter.finish() } } + sources.append(timer) + restoreMonitorEventQueue.async { + if !hasUnwatchedProcesses, + !liveProcessIdentities.contains(where: guardedProcessIsAlive) { + Task { await waiter.finish() } + } + } + await waiter.wait(for: sources) + return !hasUnwatchedProcesses + && !processIdentities.contains(where: guardedProcessIsAlive) + } + + private static func waitForTranscriptMutationOrBackstop( + transcriptPath: String, + delayNanoseconds: UInt64, + observesMutations: Bool, + onArmed: @Sendable () -> Void + ) async { + guard delayNanoseconds > 0 else { return } + let waiter = AgentHibernationRestoreDispatchWait() + var sources: [any DispatchSourceProtocol] = [] + let transcriptDescriptor = observesMutations + ? open(transcriptPath, O_EVTONLY | O_CLOEXEC | O_NOFOLLOW) + : -1 + if transcriptDescriptor >= 0 { + var status = stat() + if fstat(transcriptDescriptor, &status) == 0, + status.st_mode & S_IFMT == S_IFREG { + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: transcriptDescriptor, + eventMask: [.write, .delete, .rename, .extend, .attrib, .revoke], + queue: restoreMonitorEventQueue + ) + source.setEventHandler { Task { await waiter.finish() } } + source.setCancelHandler { Darwin.close(transcriptDescriptor) } + sources.append(source) + } else { + Darwin.close(transcriptDescriptor) + } + } + let directoryPath = (transcriptPath as NSString).deletingLastPathComponent + let descriptor = observesMutations + ? open(directoryPath, O_EVTONLY | O_CLOEXEC | O_NOFOLLOW) + : -1 + if descriptor >= 0 { + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke], + queue: restoreMonitorEventQueue + ) + source.setEventHandler { Task { await waiter.finish() } } + source.setCancelHandler { Darwin.close(descriptor) } + sources.append(source) + } + let timer = DispatchSource.makeTimerSource(queue: restoreMonitorEventQueue) + timer.schedule( + deadline: .now() + .nanoseconds(Int(clamping: delayNanoseconds)), + leeway: .milliseconds(10) + ) + timer.setEventHandler { Task { await waiter.finish() } } + sources.append(timer) + await waiter.wait(for: sources, onArmed: onArmed) + } + + private static func nanoseconds( + in duration: ContinuousClock.Duration + ) -> UInt64 { + let components = duration.components + guard components.seconds >= 0 else { return 0 } + let seconds = UInt64(components.seconds) + let secondNanoseconds = seconds.multipliedReportingOverflow(by: 1_000_000_000) + if secondNanoseconds.overflow { return UInt64.max } + let attoseconds = UInt64(max(0, components.attoseconds)) + let subsecondNanoseconds = attoseconds / 1_000_000_000 + let total = secondNanoseconds.partialValue.addingReportingOverflow(subsecondNanoseconds) + return total.overflow ? UInt64.max : total.partialValue + } + + private static func runRestoreChecksUntilBackstop( + delayNanoseconds: UInt64, + transcriptPath: String, + clock: ContinuousClock, + stopIfNoLongerCurrent: () async -> Bool, + restoreBeforeStoppedReturn: () async -> Void, + refreshSnapshotCommitProof: () -> Void, + maximumMutationChecks: Int, + onMutationWaitArmed: @Sendable () -> Void + ) async -> (completed: Bool, consumedMutationChecks: Int) { + let deadline = clock.now.advanced( + by: .nanoseconds(Int64(clamping: delayNanoseconds)) + ) + var mutationChecks = 0 + repeat { + var observedMutations = false + if delayNanoseconds > 0 { + let remainingNanoseconds = nanoseconds(in: clock.now.duration(to: deadline)) + if remainingNanoseconds > 0 { + observedMutations = mutationChecks < maximumMutationChecks + await waitForTranscriptMutationOrBackstop( + transcriptPath: transcriptPath, + delayNanoseconds: remainingNanoseconds, + observesMutations: observedMutations, + onArmed: onMutationWaitArmed + ) + } + } + if Task.isCancelled { + await restoreBeforeStoppedReturn() + return (false, mutationChecks) + } + if await stopIfNoLongerCurrent() { return (false, mutationChecks) } + refreshSnapshotCommitProof() + if observedMutations { mutationChecks += 1 } + } while clock.now < deadline + return (true, mutationChecks) + } + static func runPostTeardownRestoreChecks( snapshot: TeardownTranscriptSnapshot, processIDs: Set, @@ -22,23 +320,35 @@ extension AgentHibernationTranscriptGuard { fileManager: FileManager = .default, snapshotDisposal: PostTeardownSnapshotDisposal = .deleteWhenSafe, shouldContinue: @Sendable () async -> Bool = { true }, - shouldRestoreOnCancellation: @Sendable () async -> Bool = { true } + shouldRestoreOnCancellation: @Sendable () async -> Bool = { true }, + recoveryAuthorityRetired: @Sendable () async -> Void = {}, + processExitBackstopSeconds: Int = 30, + maximumMutationChecksPerMonitor: Int = 4, + onMutationWaitArmed: @Sendable () -> Void = {} ) async { - var canDeleteSnapshot = false + var snapshotIsCommitted = false var retainSnapshot = false - var restoredSnapshot = false + let snapshotFileVersion = stableRegularFileVersion( + atPath: snapshot.snapshotPath, + fileManager: fileManager + ) - func markSnapshotDeletableIfSafe() { + func refreshSnapshotCommitProof() { let restored = restoreIfClobbered(snapshot, fileManager: fileManager) - let safe = transcriptHasConversationTurns(atPath: snapshot.transcriptPath, fileManager: fileManager) - restoredSnapshot = restoredSnapshot || restored - canDeleteSnapshot = restored || safe + // A populated live transcript can still be a divergent rewrite. + // Only a successful restore or stable exact/prefix byte proof makes + // the protected copy redundant. + snapshotIsCommitted = restored || file( + atPath: snapshot.transcriptPath, + stablyContainsPrefixAtPath: snapshot.snapshotPath, + fileManager: fileManager + ) } func restoreBeforeStoppedReturn() async { retainSnapshot = true guard await shouldRestoreOnCancellation() else { return } - markSnapshotDeletableIfSafe() + refreshSnapshotCommitProof() } func stopIfNoLongerCurrent() async -> Bool { @@ -50,15 +360,27 @@ extension AgentHibernationTranscriptGuard { } defer { - if !retainSnapshot, !Task.isCancelled, canDeleteSnapshot { + if !retainSnapshot, !Task.isCancelled { switch snapshotDisposal { case .deleteWhenSafe: - try? fileManager.removeItem(atPath: snapshot.snapshotPath) + if snapshotIsCommitted { + if let snapshotFileVersion { + _ = durablyRemoveRecoverySnapshot( + atPath: snapshot.snapshotPath, + afterSynchronizingLivePath: snapshot.transcriptPath, + expectedSnapshotVersion: snapshotFileVersion + ) + } + } case .retainForRecovery(let sessionId): - if restoredSnapshot { - // The snapshot's content was committed back to the live - // path, so the copy is genuinely redundant. - try? fileManager.removeItem(atPath: snapshot.snapshotPath) + if snapshotIsCommitted { + if let snapshotFileVersion { + _ = durablyRemoveRecoverySnapshot( + atPath: snapshot.snapshotPath, + afterSynchronizingLivePath: snapshot.transcriptPath, + expectedSnapshotVersion: snapshotFileVersion + ) + } } else { retainSnapshotForRecovery(snapshot, sessionId: sessionId, fileManager: fileManager) } @@ -66,60 +388,112 @@ extension AgentHibernationTranscriptGuard { } } - if !processIDs.isEmpty { - let deadline = clock.now.advanced(by: .seconds(30)) - while clock.now < deadline { - let anyAlive = processIDs.contains { pid in - pid > 0 && pid <= Int(Int32.max) && kill(pid_t(pid), 0) == 0 - } - if !anyAlive { break } - // Bounded process-exit grace period before transcript restore checks; controller state cancels this task. - do { - try await clock.sleep(for: .milliseconds(250)) - } catch { - await restoreBeforeStoppedReturn() - return - } - if Task.isCancelled { - await restoreBeforeStoppedReturn() - return - } - if await stopIfNoLongerCurrent() { return } - } + guard await AgentHibernationRestoreMonitorScheduler.shared.acquire() else { + await restoreBeforeStoppedReturn() + return } + defer { AgentHibernationRestoreMonitorScheduler.shared.release() } - for delayNanoseconds in initialRetryDelaysNanoseconds { - if delayNanoseconds > 0 { - // Bounded Claude transcript-rewrite check window; controller state cancels this task. - do { - try await clock.sleep(for: .nanoseconds(Int64(clamping: delayNanoseconds))) - } catch { - await restoreBeforeStoppedReturn() - return + if !processIDs.isEmpty { + var processIdentities: Set = [] + var selectedProcessIDs: Set = [] + for identity in snapshot.guardedProcessIdentities.prefix(64) + where processIDs.contains(Int(identity.pid)) { + processIdentities.insert(identity) + selectedProcessIDs.insert(Int(identity.pid)) + } + var hasUnwatchedProcesses = snapshot.hasUncapturedGuardedProcesses + var examinedProcessIDs = 0 + for processID in processIDs where !selectedProcessIDs.contains(processID) { + examinedProcessIDs += 1 + if examinedProcessIDs > 256 || processIdentities.count >= 64 { + hasUnwatchedProcesses = true + break + } + guard processID > 0, processID <= Int(Int32.max) else { continue } + if let identity = AgentPIDProcessIdentity(pid: pid_t(processID)) { + processIdentities.insert(identity) } } + let allGuardedProcessesExited = await waitForGuardedProcessExitOrBackstop( + processIdentities: processIdentities, + hasUnwatchedProcesses: hasUnwatchedProcesses, + backstopSeconds: processExitBackstopSeconds + ) if Task.isCancelled { await restoreBeforeStoppedReturn() return } if await stopIfNoLongerCurrent() { return } - markSnapshotDeletableIfSafe() + guard allGuardedProcessesExited else { + // One total deadline bounds monitor resources. Keep durable + // recovery authority when a process cannot be fully watched or + // outlives the deadline; startup recovery can reconcile it. + retainSnapshot = true + refreshSnapshotCommitProof() + return + } + } + + var remainingMutationChecks = max(0, maximumMutationChecksPerMonitor) + for delayNanoseconds in initialRetryDelaysNanoseconds { + let result = await runRestoreChecksUntilBackstop( + delayNanoseconds: delayNanoseconds, + transcriptPath: snapshot.transcriptPath, + clock: clock, + stopIfNoLongerCurrent: stopIfNoLongerCurrent, + restoreBeforeStoppedReturn: restoreBeforeStoppedReturn, + refreshSnapshotCommitProof: refreshSnapshotCommitProof, + maximumMutationChecks: remainingMutationChecks, + onMutationWaitArmed: onMutationWaitArmed + ) + remainingMutationChecks = max( + 0, + remainingMutationChecks - result.consumedMutationChecks + ) + if !result.completed { return } } for delaySeconds in backstopDelaysSeconds { - // Bounded delayed restore backstop; controller state cancels this task. - do { - try await clock.sleep(for: .seconds(Int64(clamping: delaySeconds))) - } catch { - await restoreBeforeStoppedReturn() - return - } - if Task.isCancelled { - await restoreBeforeStoppedReturn() - return - } - if await stopIfNoLongerCurrent() { return } - markSnapshotDeletableIfSafe() + let nanoseconds = delaySeconds > UInt64.max / 1_000_000_000 + ? UInt64.max + : delaySeconds * 1_000_000_000 + let result = await runRestoreChecksUntilBackstop( + delayNanoseconds: nanoseconds, + transcriptPath: snapshot.transcriptPath, + clock: clock, + stopIfNoLongerCurrent: stopIfNoLongerCurrent, + restoreBeforeStoppedReturn: restoreBeforeStoppedReturn, + refreshSnapshotCommitProof: refreshSnapshotCommitProof, + maximumMutationChecks: remainingMutationChecks, + onMutationWaitArmed: onMutationWaitArmed + ) + remainingMutationChecks = max( + 0, + remainingMutationChecks - result.consumedMutationChecks + ) + if !result.completed { return } + } + + guard !snapshotIsCommitted, + let snapshotFileVersion, + retireCurrentRecoveryOwner( + for: snapshot, + expectedSnapshotVersion: snapshotFileVersion, + fileManager: fileManager + ) else { + return + } + // Retained-slot disposal must finish before recovery is enqueued, or a + // scanner could claim the UUID path while it is being consolidated. + if case .retainForRecovery(let sessionId) = snapshotDisposal { + retainSnapshot = true + retainSnapshotForRecovery( + snapshot, + sessionId: sessionId, + fileManager: fileManager + ) } + await recoveryAuthorityRetired() } } diff --git a/Sources/App/AgentHibernationTranscriptGuard+StableFileComparison.swift b/Sources/App/AgentHibernationTranscriptGuard+StableFileComparison.swift index b83e3cf4c7ed..7a7d0cdd8761 100644 --- a/Sources/App/AgentHibernationTranscriptGuard+StableFileComparison.swift +++ b/Sources/App/AgentHibernationTranscriptGuard+StableFileComparison.swift @@ -1,6 +1,343 @@ +import Darwin import Foundation extension AgentHibernationTranscriptGuard { + private static let transcriptScanChunkBytes = 64 * 1024 + + private struct StableRegularFileReader { + let path: String + let handle: FileHandle + let initialPathStatus: stat + let initialDescriptorStatus: stat + let initialPathVersion: TeardownTranscriptFileVersion + let initialDescriptorVersion: TeardownTranscriptFileVersion + } + + private enum TranscriptLineScanResult { + case completed + case decided(Bool) + case failed + } + + static func synchronizeRegularFileAndContainingDirectory( + atPath path: String + ) -> Bool { + let descriptor = Darwin.open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + guard descriptor >= 0 else { return false } + defer { Darwin.close(descriptor) } + var initialStatus = stat() + guard fstat(descriptor, &initialStatus) == 0, + regularOwnedSingleLink(initialStatus), + Self.path(path, stillNames: initialStatus), + fsync(descriptor) == 0 else { + return false + } + var synchronizedStatus = stat() + guard fstat(descriptor, &synchronizedStatus) == 0, + sameStableFile(synchronizedStatus, initialStatus), + Self.path(path, stillNames: synchronizedStatus), + synchronizeContainingDirectory(atPath: path) else { + return false + } + var finalStatus = stat() + return fstat(descriptor, &finalStatus) == 0 + && sameStableFile(finalStatus, synchronizedStatus) + && Self.path(path, stillNames: finalStatus) + } + + static func synchronizeContainingDirectory(atPath path: String) -> Bool { + let directoryPath = (path as NSString).deletingLastPathComponent + return synchronizeDirectory(atPath: directoryPath) + } + + static func synchronizeDirectory(atPath directoryPath: String) -> Bool { + let descriptor = Darwin.open( + directoryPath, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard descriptor >= 0 else { return false } + let result = fsync(descriptor) + Darwin.close(descriptor) + return result == 0 + } + + static func durablyRemoveRecoverySnapshot( + atPath snapshotPath: String, + afterSynchronizingLivePath livePath: String? = nil, + expectedSnapshotVersion: TeardownTranscriptFileVersion? = nil + ) -> Bool { + if let livePath, + !synchronizeRegularFileAndContainingDirectory(atPath: livePath) { + return false + } + let snapshotURL = URL(fileURLWithPath: snapshotPath) + let filename = snapshotURL.lastPathComponent + guard !filename.isEmpty, filename != ".", filename != "..", !filename.contains("/") else { + return false + } + let directoryDescriptor = Darwin.open( + snapshotURL.deletingLastPathComponent().path, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard directoryDescriptor >= 0 else { return false } + defer { Darwin.close(directoryDescriptor) } + let snapshotDescriptor = openat( + directoryDescriptor, + filename, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + if snapshotDescriptor < 0 { + return errno == ENOENT && fsync(directoryDescriptor) == 0 + } + defer { Darwin.close(snapshotDescriptor) } + var descriptorStatus = stat() + var pathStatus = stat() + guard fstat(snapshotDescriptor, &descriptorStatus) == 0, + regularOwnedSingleLink(descriptorStatus), + fstatat( + directoryDescriptor, + filename, + &pathStatus, + AT_SYMLINK_NOFOLLOW + ) == 0, + regularOwnedSingleLink(pathStatus), + sameStableFile(pathStatus, descriptorStatus), + expectedSnapshotVersion.map({ + regularFileVersion(forDescriptor: snapshotDescriptor) == $0 + }) ?? true, + fsync(snapshotDescriptor) == 0 else { + return false + } + var finalDescriptorStatus = stat() + var finalPathStatus = stat() + guard fstat(snapshotDescriptor, &finalDescriptorStatus) == 0, + sameStableFile(finalDescriptorStatus, descriptorStatus), + fstatat( + directoryDescriptor, + filename, + &finalPathStatus, + AT_SYMLINK_NOFOLLOW + ) == 0, + sameStableFile(finalPathStatus, finalDescriptorStatus), + unlinkat(directoryDescriptor, filename, 0) == 0 else { + return false + } + return fsync(directoryDescriptor) == 0 + } + + static func stableRegularFileVersion( + atPath path: String, + fileManager: FileManager = .default + ) -> TeardownTranscriptFileVersion? { + guard let reader = openStableRegularFile(atPath: path, fileManager: fileManager) else { + return nil + } + defer { try? reader.handle.close() } + return stablePathVersion(for: reader, fileManager: fileManager) + } + + static func copyStableRegularFileBounded( + from sourcePath: String, + to destinationPath: String, + maximumBytes: UInt64, + fileManager: FileManager = .default + ) -> Bool { + guard let source = openStableRegularFile( + atPath: sourcePath, + fileManager: fileManager + ), source.initialDescriptorVersion.size <= maximumBytes else { + return false + } + defer { try? source.handle.close() } + let destinationDescriptor = Darwin.open( + destinationPath, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(S_IRUSR | S_IWUSR) + ) + guard destinationDescriptor >= 0 else { return false } + var shouldRemoveDestination = true + defer { + Darwin.close(destinationDescriptor) + if shouldRemoveDestination { _ = Darwin.unlink(destinationPath) } + } + + var copiedBytes: UInt64 = 0 + var buffer = [UInt8](repeating: 0, count: transcriptScanChunkBytes) + while true { + guard copiedBytes <= maximumBytes else { return false } + let remaining = maximumBytes - copiedBytes + let requested = remaining == 0 + ? 1 + : Int(min(UInt64(buffer.count), remaining)) + let readCount = buffer.withUnsafeMutableBytes { bytes in + Darwin.read( + source.handle.fileDescriptor, + bytes.baseAddress, + requested + ) + } + if readCount < 0 { + if errno == EINTR { continue } + return false + } + if readCount == 0 { break } + guard UInt64(readCount) <= maximumBytes - copiedBytes else { + return false + } + var written = 0 + while written < readCount { + let writeCount = buffer.withUnsafeBytes { bytes in + Darwin.write( + destinationDescriptor, + bytes.baseAddress?.advanced(by: written), + readCount - written + ) + } + if writeCount < 0 { + if errno == EINTR { continue } + return false + } + guard writeCount > 0 else { return false } + written += writeCount + } + copiedBytes += UInt64(readCount) + } + guard copiedBytes == source.initialDescriptorVersion.size, + stablePathVersion(for: source, fileManager: fileManager) != nil, + fchmod(destinationDescriptor, mode_t(S_IRUSR | S_IWUSR)) == 0, + fsync(destinationDescriptor) == 0 else { + return false + } + var destinationStatus = stat() + guard fstat(destinationDescriptor, &destinationStatus) == 0, + regularOwnedSingleLink(destinationStatus), + UInt64(destinationStatus.st_size) == copiedBytes, + destinationStatus.st_mode & mode_t(0o777) == mode_t(0o600), + path(destinationPath, stillNames: destinationStatus), + synchronizeContainingDirectory(atPath: destinationPath) else { + return false + } + var finalDestinationStatus = stat() + guard fstat(destinationDescriptor, &finalDestinationStatus) == 0, + sameStableFile(finalDestinationStatus, destinationStatus), + path(destinationPath, stillNames: finalDestinationStatus) else { + return false + } + shouldRemoveDestination = false + return true + } + + static func copyStableRegularFileBounded( + from sourcePath: String, + toExistingDescriptor destinationDescriptor: Int32, + expectedDestinationPath destinationPath: String, + maximumBytes: UInt64, + fileManager: FileManager = .default + ) -> Bool { + guard let source = openStableRegularFile( + atPath: sourcePath, + fileManager: fileManager + ), source.initialDescriptorVersion.size <= maximumBytes else { + return false + } + defer { try? source.handle.close() } + var initialDestinationStatus = stat() + guard fstat(destinationDescriptor, &initialDestinationStatus) == 0, + regularOwnedSingleLink(initialDestinationStatus), + initialDestinationStatus.st_size == 0, + path(destinationPath, stillNames: initialDestinationStatus), + ftruncate(destinationDescriptor, 0) == 0, + lseek(destinationDescriptor, 0, SEEK_SET) == 0 else { + return false + } + + var copiedBytes: UInt64 = 0 + var buffer = [UInt8](repeating: 0, count: transcriptScanChunkBytes) + while true { + guard copiedBytes <= maximumBytes else { return false } + let remaining = maximumBytes - copiedBytes + let requested = remaining == 0 + ? 1 + : Int(min(UInt64(buffer.count), remaining)) + let readCount = buffer.withUnsafeMutableBytes { bytes in + Darwin.read( + source.handle.fileDescriptor, + bytes.baseAddress, + requested + ) + } + if readCount < 0 { + if errno == EINTR { continue } + return false + } + if readCount == 0 { break } + guard UInt64(readCount) <= maximumBytes - copiedBytes else { + return false + } + var written = 0 + while written < readCount { + let writeCount = buffer.withUnsafeBytes { bytes in + Darwin.write( + destinationDescriptor, + bytes.baseAddress?.advanced(by: written), + readCount - written + ) + } + if writeCount < 0 { + if errno == EINTR { continue } + return false + } + guard writeCount > 0 else { return false } + written += writeCount + } + copiedBytes += UInt64(readCount) + } + guard copiedBytes == source.initialDescriptorVersion.size, + stablePathVersion(for: source, fileManager: fileManager) != nil, + fchmod(destinationDescriptor, mode_t(S_IRUSR | S_IWUSR)) == 0, + fsync(destinationDescriptor) == 0 else { + return false + } + var destinationStatus = stat() + guard fstat(destinationDescriptor, &destinationStatus) == 0, + regularOwnedSingleLink(destinationStatus), + UInt64(destinationStatus.st_size) == copiedBytes, + destinationStatus.st_dev == initialDestinationStatus.st_dev, + destinationStatus.st_ino == initialDestinationStatus.st_ino, + destinationStatus.st_mode & mode_t(0o777) == mode_t(0o600), + path(destinationPath, stillNames: destinationStatus), + synchronizeContainingDirectory(atPath: destinationPath) else { + return false + } + var finalDestinationStatus = stat() + return fstat(destinationDescriptor, &finalDestinationStatus) == 0 + && sameStableFile(finalDestinationStatus, destinationStatus) + && path(destinationPath, stillNames: finalDestinationStatus) + } + + private static func regularOwnedSingleLink(_ status: stat) -> Bool { + status.st_mode & S_IFMT == S_IFREG + && status.st_uid == geteuid() + && status.st_nlink == 1 + && status.st_size >= 0 + } + + static func sameStableFile(_ lhs: stat, _ rhs: stat) -> Bool { + lhs.st_mode & S_IFMT == S_IFREG + && lhs.st_dev == rhs.st_dev + && lhs.st_ino == rhs.st_ino + && lhs.st_size == rhs.st_size + && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec + && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec + } + + private static func path(_ path: String, stillNames descriptorStatus: stat) -> Bool { + var pathStatus = stat() + return lstat(path, &pathStatus) == 0 + && regularOwnedSingleLink(pathStatus) + && sameStableFile(pathStatus, descriptorStatus) + } + static func snapshotStillMatchesLive( _ snapshot: TeardownTranscriptSnapshot, fileManager: FileManager = .default @@ -15,7 +352,9 @@ extension AgentHibernationTranscriptGuard { return TeardownTranscriptSnapshot( transcriptPath: snapshot.transcriptPath, snapshotPath: snapshot.snapshotPath, - liveFileVersion: liveFileVersion + liveFileVersion: liveFileVersion, + guardedProcessIdentities: snapshot.guardedProcessIdentities, + hasUncapturedGuardedProcesses: snapshot.hasUncapturedGuardedProcesses ) } @@ -24,10 +363,17 @@ extension AgentHibernationTranscriptGuard { fileManager: FileManager = .default ) -> Bool { guard let expectedVersion = snapshot.liveFileVersion, - let currentVersion = fileVersion(atPath: snapshot.transcriptPath, fileManager: fileManager) else { + let provenVersion = stablePrefixMatch( + containerPath: snapshot.transcriptPath, + prefixPath: snapshot.snapshotPath, + requireEqualSize: true, + fileManager: fileManager + ) else { return false } - return currentVersion == expectedVersion + // Metadata remains an identity/generation fence, while the streamed + // comparison proves a same-size rewrite did not restore those values. + return provenVersion == expectedVersion } static func matchingLiveFileVersion( @@ -35,38 +381,187 @@ extension AgentHibernationTranscriptGuard { _ rhsPath: String, fileManager: FileManager ) -> TeardownTranscriptFileVersion? { - guard let initialLHSVersion = fileVersion(atPath: lhsPath, fileManager: fileManager), - let initialRHSVersion = fileVersion(atPath: rhsPath, fileManager: fileManager), - initialLHSVersion.size == initialRHSVersion.size, - let lhsHandle = FileHandle(forReadingAtPath: lhsPath), - let rhsHandle = FileHandle(forReadingAtPath: rhsPath) else { + stablePrefixMatch( + containerPath: lhsPath, + prefixPath: rhsPath, + requireEqualSize: true, + fileManager: fileManager + ) + } + + static func file( + atPath containerPath: String, + stablyContainsPrefixAtPath prefixPath: String, + fileManager: FileManager = .default + ) -> Bool { + stablePrefixMatch( + containerPath: containerPath, + prefixPath: prefixPath, + requireEqualSize: false, + fileManager: fileManager + ) != nil + } + + static func boundedTranscriptHasConversationTurns( + atPath path: String, + fileManager: FileManager = .default, + maxScannedLineBytes: Int, + maxScannedBytes: Int = 64 * 1024 * 1024 + ) -> Bool { + let result = scanRegularTranscriptLines( + atPath: path, + fileManager: fileManager, + maxScannedLineBytes: maxScannedLineBytes, + maxScannedBytes: maxScannedBytes, + skipOversizedLines: true + ) { line in + lineDataContainsConversationTurn(line) ? true : nil + } + guard case .decided(let found) = result else { return false } + return found + } + + static func boundedTranscriptContainsOnlyNonProtectiveMetadata( + atPath path: String, + fileManager: FileManager = .default, + maxScannedLineBytes: Int, + maxScannedBytes: Int = 64 * 1024 * 1024 + ) -> Bool { + var sawMetadata = false + let result = scanRegularTranscriptLines( + atPath: path, + fileManager: fileManager, + maxScannedLineBytes: maxScannedLineBytes, + maxScannedBytes: maxScannedBytes, + skipOversizedLines: false + ) { line in + lineDataIsNonProtectiveMetadata(line, sawMetadata: &sawMetadata) ? nil : false + } + switch result { + case .completed: + // Empty and whitespace-only regular files are valid evidence of an + // in-place truncation. The line handler returns false for malformed + // nonempty bytes, so completing without metadata is still safe. + return true + case .decided(let decision): + return decision + case .failed: + return false + } + } + + private static func stablePrefixMatch( + containerPath: String, + prefixPath: String, + requireEqualSize: Bool, + fileManager: FileManager + ) -> TeardownTranscriptFileVersion? { + guard let container = openStableRegularFile( + atPath: containerPath, + fileManager: fileManager + ), let prefix = openStableRegularFile( + atPath: prefixPath, + fileManager: fileManager + ) else { return nil } defer { - try? lhsHandle.close() - try? rhsHandle.close() + try? container.handle.close() + try? prefix.handle.close() } - while true { - let lhsChunk: Data - let rhsChunk: Data + let containerSize = container.initialDescriptorVersion.size + let prefixSize = prefix.initialDescriptorVersion.size + guard prefixSize <= maximumProtectedTranscriptBytes, + containerSize >= prefixSize, + !requireEqualSize || containerSize == prefixSize else { + return nil + } + + var remaining = prefixSize + while remaining > 0 { + let requested = Int(min(UInt64(transcriptScanChunkBytes), remaining)) + let containerChunk: Data + let prefixChunk: Data do { - lhsChunk = try readFullChunk(lhsHandle, upToCount: 64 * 1024) - rhsChunk = try readFullChunk(rhsHandle, upToCount: 64 * 1024) + containerChunk = try readFullChunk(container.handle, upToCount: requested) + prefixChunk = try readFullChunk(prefix.handle, upToCount: requested) } catch { return nil } - guard lhsChunk == rhsChunk else { return nil } - if lhsChunk.isEmpty { break } + guard containerChunk.count == requested, + prefixChunk.count == requested, + containerChunk == prefixChunk else { + return nil + } + remaining -= UInt64(requested) + } + + guard let finalContainerVersion = stablePathVersion( + for: container, + fileManager: fileManager + ), stablePathVersion(for: prefix, fileManager: fileManager) != nil else { + return nil + } + return finalContainerVersion + } + + private static func openStableRegularFile( + atPath path: String, + fileManager: FileManager + ) -> StableRegularFileReader? { + // Check the path's own type before opening. O_NOFOLLOW closes the race + // where a regular path is swapped to a symlink after that check, while + // O_NONBLOCK prevents a swapped FIFO from hanging the caller. + var initialPathStatus = stat() + guard lstat(path, &initialPathStatus) == 0, + regularOwnedSingleLink(initialPathStatus), + let pathVersion = regularFileVersion(forStatus: initialPathStatus) else { + return nil } + let descriptor = Darwin.open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { return nil } + var initialDescriptorStatus = stat() + guard fstat(descriptor, &initialDescriptorStatus) == 0, + regularOwnedSingleLink(initialDescriptorStatus), + sameStableFile(initialDescriptorStatus, initialPathStatus), + let descriptorVersion = regularFileVersion(forStatus: initialDescriptorStatus), + descriptorVersion == pathVersion else { + Darwin.close(descriptor) + return nil + } + return StableRegularFileReader( + path: path, + handle: FileHandle(fileDescriptor: descriptor, closeOnDealloc: true), + initialPathStatus: initialPathStatus, + initialDescriptorStatus: initialDescriptorStatus, + initialPathVersion: pathVersion, + initialDescriptorVersion: descriptorVersion + ) + } - guard let finalLHSVersion = fileVersion(atPath: lhsPath, fileManager: fileManager), - let finalRHSVersion = fileVersion(atPath: rhsPath, fileManager: fileManager), - initialLHSVersion == finalLHSVersion, - initialRHSVersion == finalRHSVersion else { + private static func stablePathVersion( + for reader: StableRegularFileReader, + fileManager: FileManager + ) -> TeardownTranscriptFileVersion? { + var finalDescriptorStatus = stat() + var finalPathStatus = stat() + guard fstat(reader.handle.fileDescriptor, &finalDescriptorStatus) == 0, + lstat(reader.path, &finalPathStatus) == 0, + regularOwnedSingleLink(finalDescriptorStatus), + regularOwnedSingleLink(finalPathStatus), + sameStableFile(finalDescriptorStatus, reader.initialDescriptorStatus), + sameStableFile(finalPathStatus, reader.initialPathStatus), + sameStableFile(finalPathStatus, finalDescriptorStatus), + let finalDescriptorVersion = regularFileVersion( + forStatus: finalDescriptorStatus + ), finalDescriptorVersion == reader.initialDescriptorVersion, + let finalPathVersion = regularFileVersion( + forStatus: finalPathStatus + ), finalPathVersion == reader.initialPathVersion else { return nil } - return finalLHSVersion + return finalPathVersion } // read(upToCount:) may legally return short reads (network/FUSE volumes); @@ -84,19 +579,157 @@ extension AgentHibernationTranscriptGuard { return data } - private static func fileVersion( + private static func scanRegularTranscriptLines( atPath path: String, - fileManager: FileManager + fileManager: FileManager, + maxScannedLineBytes: Int, + maxScannedBytes: Int, + skipOversizedLines: Bool, + lineHandler: (Data) -> Bool? + ) -> TranscriptLineScanResult { + guard maxScannedLineBytes >= 0, + maxScannedBytes > 0, + let reader = openStableRegularFile(atPath: path, fileManager: fileManager) else { + return .failed + } + defer { try? reader.handle.close() } + + func stableResult(_ result: TranscriptLineScanResult) -> TranscriptLineScanResult { + guard case .failed = result else { + return stablePathVersion(for: reader, fileManager: fileManager) != nil + ? result + : .failed + } + return .failed + } + + let initialSize = reader.initialDescriptorVersion.size + let readLimit = min(initialSize, UInt64(maxScannedBytes)) + var bytesRead: UInt64 = 0 + var buffered = Data() + var discardingOversizedLine = false + + func finishAtEndOfInput() -> TranscriptLineScanResult { + if discardingOversizedLine { + return skipOversizedLines ? .completed : .failed + } + guard buffered.count <= maxScannedLineBytes else { + return skipOversizedLines ? .completed : .failed + } + if let decision = lineHandler(buffered) { + return .decided(decision) + } + return .completed + } + + while bytesRead < readLimit { + let requested = Int(min(UInt64(transcriptScanChunkBytes), readLimit - bytesRead)) + let chunk: Data + do { + chunk = try reader.handle.read(upToCount: requested) ?? Data() + } catch { + return .failed + } + if chunk.isEmpty { + return stableResult(finishAtEndOfInput()) + } + bytesRead += UInt64(chunk.count) + + var remainderStart = chunk.startIndex + if discardingOversizedLine { + guard let newlineIndex = chunk[remainderStart...].firstIndex(of: 10) else { + continue + } + remainderStart = chunk.index(after: newlineIndex) + discardingOversizedLine = false + } + + buffered.append(contentsOf: chunk[remainderStart...]) + var cursor = buffered.startIndex + while cursor < buffered.endIndex, + let newlineIndex = buffered[cursor...].firstIndex(of: 10) { + let line = buffered[cursor.. maxScannedLineBytes { + guard skipOversizedLines else { return .failed } + } else if let decision = lineHandler(Data(line)) { + return stableResult(.decided(decision)) + } + cursor = buffered.index(after: newlineIndex) + } + if cursor > buffered.startIndex { + // Compact once per input chunk, never once per JSONL record. + buffered.removeSubrange(buffered.startIndex.. maxScannedLineBytes { + guard skipOversizedLines else { return .failed } + buffered.removeAll(keepingCapacity: true) + discardingOversizedLine = true + } + } + + guard initialSize <= UInt64(maxScannedBytes) else { + return .failed + } + return stableResult(finishAtEndOfInput()) + } + + private static func lineDataContainsConversationTurn(_ data: Data) -> Bool { + guard !data.isEmpty, + data.range(of: Data(#""type""#.utf8)) != nil, + (data.range(of: Data(#""user""#.utf8)) != nil || + data.range(of: Data(#""assistant""#.utf8)) != nil), + String(data: data, encoding: .utf8) != nil, + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = object["type"] as? String else { + return false + } + return type == "user" || type == "assistant" + } + + private static func lineDataIsNonProtectiveMetadata( + _ data: Data, + sawMetadata: inout Bool + ) -> Bool { + guard let line = String(data: data, encoding: .utf8) else { return false } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return true } + guard let object = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) as? [String: Any], + let type = object["type"] as? String else { + return false + } + guard type == "last-prompt" || type == "ai-title" || type == "mode" else { + return false + } + sawMetadata = true + return true + } + + static func regularFileVersion( + forDescriptor descriptor: Int32 ) -> TeardownTranscriptFileVersion? { - guard let attributes = try? fileManager.attributesOfItem(atPath: path), - let fileNumber = (attributes[.systemFileNumber] as? NSNumber)?.uint64Value, - let size = (attributes[.size] as? NSNumber)?.uint64Value, - let modificationDate = attributes[.modificationDate] as? Date else { + var fileStatus = stat() + guard fstat(descriptor, &fileStatus) == 0, + fileStatus.st_mode & S_IFMT == S_IFREG, + fileStatus.st_size >= 0 else { return nil } + return regularFileVersion(forStatus: fileStatus) + } + + private static func regularFileVersion( + forStatus fileStatus: stat + ) -> TeardownTranscriptFileVersion? { + guard fileStatus.st_mode & S_IFMT == S_IFREG, + fileStatus.st_size >= 0 else { + return nil + } + let modificationDate = Date( + timeIntervalSince1970: TimeInterval(fileStatus.st_mtimespec.tv_sec) + + TimeInterval(fileStatus.st_mtimespec.tv_nsec) / 1_000_000_000 + ) return TeardownTranscriptFileVersion( - fileNumber: fileNumber, - size: size, + fileNumber: UInt64(fileStatus.st_ino), + size: UInt64(fileStatus.st_size), modificationDate: modificationDate ) } diff --git a/Sources/App/AgentHibernationTranscriptGuard+TeardownTranscriptSnapshot.swift b/Sources/App/AgentHibernationTranscriptGuard+TeardownTranscriptSnapshot.swift index 27a2e47c45f7..44f31a5af324 100644 --- a/Sources/App/AgentHibernationTranscriptGuard+TeardownTranscriptSnapshot.swift +++ b/Sources/App/AgentHibernationTranscriptGuard+TeardownTranscriptSnapshot.swift @@ -5,15 +5,21 @@ extension AgentHibernationTranscriptGuard { let transcriptPath: String let snapshotPath: String let liveFileVersion: TeardownTranscriptFileVersion? + let guardedProcessIdentities: [AgentPIDProcessIdentity] + let hasUncapturedGuardedProcesses: Bool init( transcriptPath: String, snapshotPath: String, - liveFileVersion: TeardownTranscriptFileVersion? = nil + liveFileVersion: TeardownTranscriptFileVersion? = nil, + guardedProcessIdentities: [AgentPIDProcessIdentity] = [], + hasUncapturedGuardedProcesses: Bool = false ) { self.transcriptPath = transcriptPath self.snapshotPath = snapshotPath self.liveFileVersion = liveFileVersion + self.guardedProcessIdentities = guardedProcessIdentities + self.hasUncapturedGuardedProcesses = hasUncapturedGuardedProcesses } } } diff --git a/Sources/App/AgentHibernationTranscriptGuard.swift b/Sources/App/AgentHibernationTranscriptGuard.swift index 222cd84cdbc2..eb908dfa2af9 100644 --- a/Sources/App/AgentHibernationTranscriptGuard.swift +++ b/Sources/App/AgentHibernationTranscriptGuard.swift @@ -1,9 +1,198 @@ import CMUXAgentLaunch +import Darwin import Foundation +import os enum AgentHibernationTranscriptGuard { static let restoreCheckDelaysSeconds: [UInt64] = [20, 60, 180, 600] private static let maxScannedLineBytes = 16 * 1024 * 1024 + private static let recoveryMetadataName = "com.cmux.agent-transcript-recovery" + private static let maxRecoveryMetadataBytes = 64 * 1024 + private static let maxStartupRecoverySnapshots = 256 + private static let maxStartupRecoveryDirectoryEntries = 1_024 + private static let maxStartupRecoveryCandidateMetadataBytes = + maxStartupRecoverySnapshots * maxRecoveryMetadataBytes + private static let maxStartupRecoveryCandidateContentBytes: UInt64 = + 512 * 1_024 * 1_024 + private static let maxStartupRecoveryInvalidMovesPerLaunch = 1_024 + private static let maxStartupRecoveryProcessIdentityProbes = 2_048 + // Protecting larger transcripts would make every copy and proof + // proportional to unbounded user-controlled input. Claude histories above + // 256 MiB fail hibernation closed and remain live. + static let maximumProtectedTranscriptBytes: UInt64 = 256 * 1_024 * 1_024 + // Recovery authority is never deleted merely because it is old. Bound new + // admission instead, so repeated divergent branches cannot consume the + // user's disk without limit while every existing branch remains intact. + private static let maximumRecoveryStorageFileCount = 1_024 + private static let maximumRecoveryStorageBytes: UInt64 = 2 * 1_024 * 1_024 * 1_024 + private static let recoveryLockFilename = ".agent-transcript-recovery.lock" + private static let maxRecoveryCursorBytes = 16 * 1024 + private static let maximumLiveStubBytes: UInt64 = 16 * 1_024 * 1_024 + private static let recoveryLockWaitQueue = DispatchQueue( + label: "com.cmux.agent-hibernation.recovery-lock-wait", + qos: .utility + ) + private static let atomicSwapCapabilityCache = OSAllocatedUnfairLock( + initialState: [UInt64: Bool]() + ) + + private struct RecoveryProcessIdentity: Codable, Equatable { + let processId: Int32 + let processStartSeconds: Int64 + let processStartMicroseconds: Int64 + + init(_ identity: AgentPIDProcessIdentity) { + processId = identity.pid + processStartSeconds = identity.startSeconds + processStartMicroseconds = identity.startMicroseconds + } + + var processIdentity: AgentPIDProcessIdentity? { + guard processId > 0, + processStartSeconds >= 0, + processStartMicroseconds >= 0, + processStartMicroseconds < 1_000_000 else { + return nil + } + return AgentPIDProcessIdentity( + pid: processId, + startSeconds: processStartSeconds, + startMicroseconds: processStartMicroseconds + ) + } + } + + private struct RecoveryMetadata: Codable, Equatable { + let version: Int + let sessionId: String + let transcriptPath: String + let snapshotPath: String? + let candidateId: String? + let candidateState: String? + let externalCandidatePath: String? + let externalFileDevice: UInt64? + let externalFileNumber: UInt64? + let capturedAt: Date? + let liveFileNumber: UInt64? + let liveFileSize: UInt64? + let liveFileModificationDate: Date? + let ownerProcessId: Int32? + let ownerProcessStartSeconds: Int64? + let ownerProcessStartMicroseconds: Int64? + let ownerRuntimeId: String? + let ownerBundleIdentifier: String? + let guardedProcesses: [RecoveryProcessIdentity]? + let hasUncapturedGuardedProcesses: Bool? + + var liveFileVersion: TeardownTranscriptFileVersion? { + guard let liveFileNumber, + let liveFileSize, + let liveFileModificationDate else { + return nil + } + return TeardownTranscriptFileVersion( + fileNumber: liveFileNumber, + size: liveFileSize, + modificationDate: liveFileModificationDate + ) + } + + var ownerProcessIdentity: AgentPIDProcessIdentity? { + guard let ownerProcessId, + let ownerProcessStartSeconds, + let ownerProcessStartMicroseconds, + ownerProcessId > 0, + ownerProcessStartSeconds >= 0, + ownerProcessStartMicroseconds >= 0, + ownerProcessStartMicroseconds < 1_000_000 else { + return nil + } + return AgentPIDProcessIdentity( + pid: ownerProcessId, + startSeconds: ownerProcessStartSeconds, + startMicroseconds: ownerProcessStartMicroseconds + ) + } + } + + private struct PendingRecoverySnapshot { + let url: URL + let contentURL: URL + let authorityVersion: TeardownTranscriptFileVersion + let contentVersion: TeardownTranscriptFileVersion + let metadata: RecoveryMetadata + let metadataByteCount: Int + let contentByteCount: UInt64 + let capturedAt: Date + let modificationDate: Date + } + + private struct RecoveryMetadataEnvelope { + let metadata: RecoveryMetadata + let byteCount: Int + } + + private struct RecoveryOwnerRuntimeMetadata { + let runtimeId: String? + let bundleIdentifier: String? + } + + private struct RecoveryCursorState: Codable { + var transcriptPath: String? + } + + private enum CachedRecoveryProcessIdentity { + case missing + case present(AgentPIDProcessIdentity) + } + + private enum RecoveryOwnerState: Equatable { + case retired + case live + case unknown + } + + private struct RecoveryProcessProbeBudget { + var remaining: Int + var cache: [pid_t: CachedRecoveryProcessIdentity] = [:] + + mutating func identity( + for processID: pid_t + ) -> (known: Bool, identity: AgentPIDProcessIdentity?) { + if let cached = cache[processID] { + switch cached { + case .missing: + return (true, nil) + case .present(let identity): + return (true, identity) + } + } + guard remaining > 0 else { return (false, nil) } + remaining -= 1 + let identity = AgentPIDProcessIdentity(pid: processID) + cache[processID] = identity.map(CachedRecoveryProcessIdentity.present) + ?? .missing + return (true, identity) + } + } + + private struct RecoveryStorageFileIdentity: Hashable { + let device: UInt64 + let fileNumber: UInt64 + + init(_ status: stat) { + device = UInt64(status.st_dev) + fileNumber = UInt64(status.st_ino) + } + } + + private struct RecoveryCandidateScan { + let candidates: [PendingRecoverySnapshot] + let budgetBlockedTranscriptPaths: Set + let invalidEntries: [URL] + let examinedEntries: Int + let reachedEnd: Bool + } static func resolveTranscriptPath( agent: SessionRestorableAgentSnapshot, @@ -28,55 +217,26 @@ enum AgentHibernationTranscriptGuard { fileManager: FileManager = .default, maxScannedLineBytes: Int = Self.maxScannedLineBytes ) -> Bool { - guard fileManager.fileExists(atPath: path), - let handle = FileHandle(forReadingAtPath: path) else { - return false - } - defer { try? handle.close() } - - var buffered = Data() - var discardingOversizedLine = false - while true { - guard let chunk = try? handle.read(upToCount: 64 * 1024), - !chunk.isEmpty else { - guard !discardingOversizedLine, - buffered.count <= maxScannedLineBytes else { - return false - } - return lineDataHasConversationTurn(buffered) - } - - var chunkRemainder = chunk[chunk.startIndex.. maxScannedLineBytes { - // Oversized malformed lines are skipped, not fatal; later normal - // turns must remain visible to avoid false-negative live scans. - buffered.removeAll(keepingCapacity: true) - discardingOversizedLine = true - } - } + boundedTranscriptHasConversationTurns( + atPath: path, + fileManager: fileManager, + maxScannedLineBytes: maxScannedLineBytes + ) } static func snapshotBeforeTeardown( agent: SessionRestorableAgentSnapshot, panelKey: AgentHibernationPanelKey? = nil, + guardedProcessIDs: Set = [], homeDirectory: String = NSHomeDirectory(), snapshotDirectory: URL? = nil, - fileManager: FileManager = .default + fileManager: FileManager = .default, + maximumGuardedProcessIdentities: Int = 64, + maximumRecoveryStorageFileCount: Int = Self.maximumRecoveryStorageFileCount, + maximumRecoveryStorageBytes: UInt64 = Self.maximumRecoveryStorageBytes, + recoveryMetadataOwnerProcessIdentity: AgentPIDProcessIdentity? = AgentPIDProcessIdentity( + pid: getpid() + ) ) -> TeardownSnapshotOutcome { guard agent.kind == .claude else { return .nothingToProtect } guard isSafeSessionIdPathComponent(agent.sessionId) else { return .unableToProtect } @@ -101,18 +261,137 @@ enum AgentHibernationTranscriptGuard { } do { - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + guard ensurePrivateRecoveryDirectory( + at: directory, + createIfMissing: true, + fileManager: fileManager + ) else { + return .unableToProtect + } + guard let sourceVersion = stableRegularFileVersion( + atPath: transcriptPath, + fileManager: fileManager + ), sourceVersion.size <= maximumProtectedTranscriptBytes, + let admissionLock = acquireRecoveryDirectoryLockSynchronously( + in: directory + ) else { + return .unableToProtect + } pruneOldSnapshots(in: directory, fileManager: fileManager) - let snapshotURL = directory.appendingPathComponent("\(agent.sessionId)-\(UUID().uuidString).jsonl", isDirectory: false) - try fileManager.copyItem(atPath: transcriptPath, toPath: snapshotURL.path) + let canAdmitCapture = recoveryStorageCanAdmit( + in: directory, + additionalFileCount: 1, + additionalBytes: sourceVersion.size, + maximumFileCount: maximumRecoveryStorageFileCount, + maximumBytes: maximumRecoveryStorageBytes + ) + releaseRecoveryDirectoryLock(admissionLock) + guard canAdmitCapture else { return .unableToProtect } + let guardedProcessCapture = capturedGuardedProcessIdentities( + from: guardedProcessIDs, + maximumIdentities: max(0, maximumGuardedProcessIdentities) + ) + let guardedProcessIdentities = guardedProcessCapture.identities + let hasUncapturedGuardedProcesses = guardedProcessCapture.hasUncaptured + let candidateId = UUID().uuidString + let snapshotURL = directory.appendingPathComponent( + "\(agent.sessionId)-\(candidateId).jsonl", + isDirectory: false + ) + let stagingURL = directory.appendingPathComponent( + ".\(agent.sessionId)-capture-\(candidateId).tmp", + isDirectory: false + ) + let capturedAt = Date() + guard !hasUncapturedGuardedProcesses else { + return .unableToProtect + } + guard copyStableRegularFileBounded( + from: transcriptPath, + to: stagingURL.path, + maximumBytes: maximumProtectedTranscriptBytes, + fileManager: fileManager + ) else { + return .unableToProtect + } let copiedSnapshotHasConversation = transcriptHasConversationTurns( - atPath: snapshotURL.path, + atPath: stagingURL.path, fileManager: fileManager ) guard copiedSnapshotHasConversation else { - try? fileManager.removeItem(at: snapshotURL) + try? fileManager.removeItem(at: stagingURL) + return .unableToProtect + } + let stagedSnapshot = TeardownTranscriptSnapshot( + transcriptPath: transcriptPath, + snapshotPath: stagingURL.path, + guardedProcessIdentities: guardedProcessIdentities, + hasUncapturedGuardedProcesses: hasUncapturedGuardedProcesses + ) + guard persistRecoveryMetadata( + for: stagedSnapshot, + sessionId: agent.sessionId, + capturedAt: capturedAt, + ownerProcessIdentity: recoveryMetadataOwnerProcessIdentity, + guardedProcessIdentities: guardedProcessIdentities, + hasUncapturedGuardedProcesses: hasUncapturedGuardedProcesses, + candidateId: candidateId + ) else { + try? fileManager.removeItem(at: stagingURL) + return .unableToProtect + } + guard synchronizeRegularFileAndContainingDirectory( + atPath: stagingURL.path + ) else { + return .unableToProtect + } + // Copies run concurrently, but namespace admission is serialized. + // Recount complete staging files under the directory lock so a + // transcript that grew during copying, or a concurrent capture, + // can never publish beyond the durable quota. + guard let commitLock = acquireRecoveryDirectoryLockSynchronously( + in: directory + ) else { + return .unableToProtect + } + var releaseCommitLock = true + defer { + if releaseCommitLock { + releaseRecoveryDirectoryLock(commitLock) + } + } + guard recoveryStorageCanAdmit( + in: directory, + additionalFileCount: 0, + additionalBytes: 0, + maximumFileCount: maximumRecoveryStorageFileCount, + maximumBytes: maximumRecoveryStorageBytes + ) else { + if let stagingVersion = stableRegularFileVersion( + atPath: stagingURL.path, + fileManager: fileManager + ) { + _ = durablyRemoveRecoverySnapshot( + atPath: stagingURL.path, + expectedSnapshotVersion: stagingVersion + ) + } return .unableToProtect } + guard atomicallyRename(stagingURL, to: snapshotURL), + synchronizeRegularFileAndContainingDirectory( + atPath: snapshotURL.path + ) else { + // Keep a complete staged capture. Deleting it here would lose + // the only protected copy if teardown already clobbered live. + return .unableToProtect + } + let unvalidatedSnapshot = TeardownTranscriptSnapshot( + transcriptPath: transcriptPath, + snapshotPath: snapshotURL.path, + guardedProcessIdentities: guardedProcessIdentities, + hasUncapturedGuardedProcesses: hasUncapturedGuardedProcesses + ) guard let liveFileVersion = matchingLiveFileVersion( transcriptPath, snapshotURL.path, @@ -122,18 +401,40 @@ enum AgentHibernationTranscriptGuard { // have won a replace race. Keep the populated copy for recovery in // the session's single retained slot so repeated failed attempts // replace it instead of accumulating full-transcript copies. + releaseRecoveryDirectoryLock(commitLock) + releaseCommitLock = false retainSnapshotForRecovery( - TeardownTranscriptSnapshot(transcriptPath: transcriptPath, snapshotPath: snapshotURL.path), + unvalidatedSnapshot, sessionId: agent.sessionId, fileManager: fileManager ) return .unableToProtect } - try fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path) + // The first metadata write makes a copy recoverable even when the + // live-file comparison loses a race. Enrich it only after equality + // is proven, so a later startup can also discard an unchanged copy. + _ = persistRecoveryMetadata( + for: unvalidatedSnapshot, + sessionId: agent.sessionId, + capturedAt: capturedAt, + liveFileVersion: liveFileVersion, + ownerProcessIdentity: recoveryMetadataOwnerProcessIdentity, + guardedProcessIdentities: guardedProcessIdentities, + hasUncapturedGuardedProcesses: hasUncapturedGuardedProcesses, + candidateId: candidateId + ) + try fileManager.setAttributes([.modificationDate: capturedAt], ofItemAtPath: snapshotURL.path) + guard synchronizeRegularFileAndContainingDirectory( + atPath: snapshotURL.path + ) else { + return .unableToProtect + } return .snapshot(TeardownTranscriptSnapshot( transcriptPath: transcriptPath, snapshotPath: snapshotURL.path, - liveFileVersion: liveFileVersion + liveFileVersion: liveFileVersion, + guardedProcessIdentities: guardedProcessIdentities, + hasUncapturedGuardedProcesses: hasUncapturedGuardedProcesses )) } catch { return .unableToProtect @@ -144,145 +445,1589 @@ enum AgentHibernationTranscriptGuard { static func restoreIfClobbered( _ snapshot: TeardownTranscriptSnapshot, fileManager: FileManager = .default + ) -> Bool { + let snapshotDirectory = URL(fileURLWithPath: snapshot.snapshotPath) + .deletingLastPathComponent() + guard let lockDescriptor = acquireRecoveryDirectoryLock(in: snapshotDirectory) else { + return false + } + defer { releaseRecoveryDirectoryLock(lockDescriptor) } + return restoreIfClobberedWhileHoldingDirectoryLock( + snapshot, + fileManager: fileManager + ) + } + + private static func restoreIfClobberedWhileHoldingDirectoryLock( + _ snapshot: TeardownTranscriptSnapshot, + fileManager: FileManager ) -> Bool { let transcriptURL = URL(fileURLWithPath: snapshot.transcriptPath) - let protectedExists = fileManager.fileExists(atPath: transcriptURL.path) - let protectedAttributes = try? fileManager.attributesOfItem(atPath: transcriptURL.path) - let protectedFile = (protectedAttributes?[.systemFileNumber] as? NSNumber)?.uint64Value - let protectedSize = (protectedAttributes?[.size] as? NSNumber)?.uint64Value - let protectedModificationDate = protectedAttributes?[.modificationDate] as? Date - guard transcriptHasConversationTurns(atPath: snapshot.snapshotPath, fileManager: fileManager), - !transcriptHasConversationTurns(atPath: snapshot.transcriptPath, fileManager: fileManager) else { + var protectedStatus = stat() + let protectedStatusResult = lstat(transcriptURL.path, &protectedStatus) + let protectedExists = protectedStatusResult == 0 + guard protectedExists || errno == ENOENT else { return false } + if protectedExists { + guard protectedStatus.st_mode & S_IFMT == S_IFREG, + protectedStatus.st_uid == geteuid(), + protectedStatus.st_nlink == 1 else { + return false + } + } + guard transcriptHasConversationTurns( + atPath: snapshot.snapshotPath, + fileManager: fileManager + ) else { return false } + let liveIsProtectedPrefix = protectedExists && file( + atPath: snapshot.snapshotPath, + stablyContainsPrefixAtPath: snapshot.transcriptPath, + fileManager: fileManager + ) + let liveIsMetadataOnly = protectedExists + && !transcriptHasConversationTurns( + atPath: snapshot.transcriptPath, + fileManager: fileManager + ) + && transcriptContainsOnlyNonProtectiveMetadata( + atPath: snapshot.transcriptPath, + fileManager: fileManager + ) + guard !protectedExists || liveIsProtectedPrefix || liveIsMetadataOnly else { return false } - guard !protectedExists || transcriptContainsOnlyNonProtectiveMetadata(atPath: snapshot.transcriptPath, fileManager: fileManager) else { return false } - let classifiedAttributes = try? fileManager.attributesOfItem(atPath: transcriptURL.path) - guard fileManager.fileExists(atPath: transcriptURL.path) == protectedExists, - (classifiedAttributes?[.systemFileNumber] as? NSNumber)?.uint64Value == protectedFile, - (classifiedAttributes?[.size] as? NSNumber)?.uint64Value == protectedSize, - (classifiedAttributes?[.modificationDate] as? Date) == protectedModificationDate else { return false } + guard pathStillMatches( + transcriptURL.path, + expectedExists: protectedExists, + expectedStatus: protectedStatus + ) else { return false } let directoryURL = transcriptURL.deletingLastPathComponent() - let tempURL = directoryURL.appendingPathComponent(".\(transcriptURL.lastPathComponent).restore-\(UUID().uuidString).tmp", isDirectory: false) + let displacementCandidateId = UUID().uuidString + let tempURL = directoryURL.appendingPathComponent( + ".\(transcriptURL.lastPathComponent).cmux-recovery-\(displacementCandidateId).jsonl", + isDirectory: false + ) do { try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) - try? fileManager.removeItem(at: tempURL) - try fileManager.copyItem(atPath: snapshot.snapshotPath, toPath: tempURL.path) - try appendLiveStubIfPresent(from: transcriptURL, toRestoreFile: tempURL, fileManager: fileManager) - let currentAttributes = try? fileManager.attributesOfItem(atPath: transcriptURL.path) - guard fileManager.fileExists(atPath: transcriptURL.path) == protectedExists, - (currentAttributes?[.systemFileNumber] as? NSNumber)?.uint64Value == protectedFile, - (currentAttributes?[.size] as? NSNumber)?.uint64Value == protectedSize, - (currentAttributes?[.modificationDate] as? Date) == protectedModificationDate, - !protectedExists || transcriptContainsOnlyNonProtectiveMetadata(atPath: transcriptURL.path, fileManager: fileManager) else { - try? fileManager.removeItem(at: tempURL) + if protectedExists, + !volumeSupportsAtomicSwap(in: directoryURL) { + return false + } + guard let stagingAuthority = prepareExternalRestoreStagingAuthority( + transcriptURL: transcriptURL, + externalURL: tempURL, + protectedSnapshot: snapshot, + candidateId: displacementCandidateId, + fileManager: fileManager + ) else { + return false + } + var shouldCleanStagingAuthority = true + defer { + if shouldCleanStagingAuthority { + _ = cleanupExternalRestoreStagingAuthority( + at: stagingAuthority.pointerURL, + fileManager: fileManager + ) + } + Darwin.close(stagingAuthority.externalDescriptor) + } + guard copyStableRegularFileBounded( + from: snapshot.snapshotPath, + toExistingDescriptor: stagingAuthority.externalDescriptor, + expectedDestinationPath: tempURL.path, + maximumBytes: maximumProtectedTranscriptBytes, + fileManager: fileManager + ) else { + return false + } + if liveIsMetadataOnly { + try appendLiveStubIfPresent( + from: transcriptURL, + toRestoreFile: tempURL, + fileManager: fileManager + ) + } + guard synchronizeRegularFileAndContainingDirectory(atPath: tempURL.path) else { + return false + } + guard let tempVersion = stableRegularFileVersion( + atPath: tempURL.path, + fileManager: fileManager + ) else { return false } + guard pathStillMatches( + transcriptURL.path, + expectedExists: protectedExists, + expectedStatus: protectedStatus + ), + !protectedExists || (liveIsProtectedPrefix + ? file( + atPath: snapshot.snapshotPath, + stablyContainsPrefixAtPath: transcriptURL.path, + fileManager: fileManager + ) + : transcriptContainsOnlyNonProtectiveMetadata( + atPath: transcriptURL.path, + fileManager: fileManager + )) else { return false } if protectedExists { - _ = try fileManager.replaceItemAt(transcriptURL, withItemAt: tempURL) + guard let displacedAuthority = prepareAtomicSwapDisplacementAuthority( + transcriptURL: transcriptURL, + externalURL: tempURL, + protectedSnapshot: snapshot, + expectedLiveStatus: protectedStatus, + liveWasProtectedPrefix: liveIsProtectedPrefix, + candidateId: displacementCandidateId, + fileManager: fileManager + ) else { + return false + } + guard renamex_np( + tempURL.path, + transcriptURL.path, + UInt32(RENAME_SWAP) + ) == 0 else { + _ = durablyRemoveRecoverySnapshot( + atPath: displacedAuthority.authorityURL.path, + expectedSnapshotVersion: displacedAuthority.authorityVersion + ) + return false + } + // The namespace swap is atomic: live now names the complete + // composite while temp names the exact displaced inode. From + // this point temp is durable recovery content, never scratch. + shouldCleanStagingAuthority = false + var displacedStatus = stat() + guard lstat(tempURL.path, &displacedStatus) == 0, + sameRegularFileIdentity( + displacedStatus, + displacedAuthority.contentIdentity + ), + stableRegularFileVersion( + atPath: transcriptURL.path, + fileManager: fileManager + ) == tempVersion, + synchronizeRegularFileAndContainingDirectory( + atPath: transcriptURL.path + ), + synchronizeRegularFileAndContainingDirectory( + atPath: tempURL.path + ), + synchronizeRegularFileAndContainingDirectory( + atPath: displacedAuthority.authorityURL.path + ) else { + return false + } + _ = fremovexattr( + stagingAuthority.externalDescriptor, + recoveryMetadataName, + 0 + ) + _ = fsync(stagingAuthority.externalDescriptor) + _ = durablyRemoveRecoverySnapshot( + atPath: stagingAuthority.pointerURL.path, + expectedSnapshotVersion: stagingAuthority.pointerVersion + ) } else { - try fileManager.moveItem(at: tempURL, to: transcriptURL) + // RENAME_EXCL is the missing-path CAS. A newly created live + // branch wins and remains untouched. + guard renamex_np( + tempURL.path, + transcriptURL.path, + UInt32(RENAME_EXCL) + ) == 0 else { + return false + } + shouldCleanStagingAuthority = false + _ = fremovexattr( + stagingAuthority.externalDescriptor, + recoveryMetadataName, + 0 + ) + _ = fsync(stagingAuthority.externalDescriptor) + _ = durablyRemoveRecoverySnapshot( + atPath: stagingAuthority.pointerURL.path, + expectedSnapshotVersion: stagingAuthority.pointerVersion + ) } - return true + return synchronizeRegularFileAndContainingDirectory(atPath: transcriptURL.path) } catch { - try? fileManager.removeItem(at: tempURL) return false } } - /// Moves a populated snapshot whose live path drifted into the session's - /// single retained recovery slot. Repeated failed protection attempts - /// replace the slot instead of accumulating full-transcript copies; the - /// slot ages out through the regular snapshot pruning. Never touches the - /// UUID-suffixed snapshots that active restore monitors own. - static func retainSnapshotForRecovery( - _ snapshot: TeardownTranscriptSnapshot, - sessionId: String?, - fileManager: FileManager = .default - ) { - let snapshotURL = URL(fileURLWithPath: snapshot.snapshotPath) - guard let sessionId, isSafeSessionIdPathComponent(sessionId) else { - try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path) - return - } - let retainedURL = snapshotURL.deletingLastPathComponent() - .appendingPathComponent("\(sessionId)-retained.jsonl", isDirectory: false) - guard retainedURL.path != snapshotURL.path else { - try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: retainedURL.path) - return - } - do { - try? fileManager.removeItem(at: retainedURL) - try fileManager.moveItem(at: snapshotURL, to: retainedURL) - try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: retainedURL.path) - } catch { - try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path) - } - } - - static func transcriptCandidates(projectRoot: String, sessionId: String) -> [String] { - let directPath = (projectRoot as NSString).appendingPathComponent("\(sessionId).jsonl") - let nestedPath = (((projectRoot as NSString).appendingPathComponent(sessionId) as NSString).appendingPathComponent("messages") as NSString).appendingPathComponent("\(sessionId).jsonl") - return [directPath, nestedPath] + struct DisplacedTranscriptAuthority { + let authorityURL: URL + let authorityVersion: TeardownTranscriptFileVersion + let contentURL: URL + let contentIdentity: stat } - private static func isSafeSessionIdPathComponent(_ sessionId: String) -> Bool { - !sessionId.isEmpty && sessionId != "." && sessionId != ".." && !sessionId.contains("/") + private struct ExternalRestoreStagingAuthority { + let pointerURL: URL + let pointerVersion: TeardownTranscriptFileVersion + let externalURL: URL + let externalDescriptor: Int32 } - // Mirrors regularNonEmptyFileExists in RestorableAgentSession.swift: an empty - // recorded/derived file must not shadow a populated transcript elsewhere. - static func isRegularFile(atPath path: String, fileManager: FileManager) -> Bool { - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), - !isDirectory.boolValue, - let attributes = try? fileManager.attributesOfItem(atPath: path), - let fileType = attributes[.type] as? FileAttributeType, - fileType == .typeRegular else { - return false + private static func prepareExternalRestoreStagingAuthority( + transcriptURL: URL, + externalURL: URL, + protectedSnapshot: TeardownTranscriptSnapshot, + candidateId: String, + fileManager: FileManager + ) -> ExternalRestoreStagingAuthority? { + let sourceMetadata = recoveryMetadata( + atSnapshotPath: protectedSnapshot.snapshotPath + ) + let sessionId = sourceMetadata.flatMap { + isSafeSessionIdPathComponent($0.sessionId) ? $0.sessionId : nil + } ?? "staging-\(UUID().uuidString)" + let recoveryDirectory = URL( + fileURLWithPath: protectedSnapshot.snapshotPath + ).deletingLastPathComponent() + let pointerURL = recoveryDirectory.appendingPathComponent( + "\(sessionId)-staging-\(candidateId).jsonl", + isDirectory: false + ) + let guardedProcesses = sourceMetadata?.guardedProcesses? + .compactMap(\.processIdentity) ?? protectedSnapshot.guardedProcessIdentities + let hasUncaptured = sourceMetadata?.hasUncapturedGuardedProcesses + ?? protectedSnapshot.hasUncapturedGuardedProcesses + let ownerIdentity = AgentPIDProcessIdentity(pid: getpid()) + let pointerSnapshot = TeardownTranscriptSnapshot( + transcriptPath: protectedSnapshot.transcriptPath, + snapshotPath: pointerURL.path, + liveFileVersion: protectedSnapshot.liveFileVersion, + guardedProcessIdentities: guardedProcesses, + hasUncapturedGuardedProcesses: hasUncaptured + ) + let capturedAt = Date() + guard createRecoveryPointerFile(at: pointerURL) else { return nil } + var keepPointer = false + var externalDescriptor: Int32 = -1 + defer { + if !keepPointer { + if externalDescriptor >= 0 { + Darwin.close(externalDescriptor) + _ = Darwin.unlink(externalURL.path) + _ = synchronizeContainingDirectory(atPath: externalURL.path) + } + _ = durablyRemoveRecoverySnapshot(atPath: pointerURL.path) + } } - return ((attributes[.size] as? NSNumber)?.int64Value ?? 0) > 0 - } - - private static func directoryExists(atPath path: String, fileManager: FileManager) -> Bool { - var isDirectory: ObjCBool = false - return fileManager.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue + guard persistRecoveryMetadata( + for: pointerSnapshot, + sessionId: sessionId, + capturedAt: capturedAt, + liveFileVersion: protectedSnapshot.liveFileVersion, + ownerProcessIdentity: ownerIdentity, + guardedProcessIdentities: guardedProcesses, + hasUncapturedGuardedProcesses: hasUncaptured, + candidateId: candidateId, + candidateState: "external-staging-pending", + externalCandidatePath: externalURL.path + ), + synchronizeRegularFileAndContainingDirectory( + atPath: pointerURL.path + ) else { + return nil + } + externalDescriptor = open( + externalURL.path, + O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(S_IRUSR | S_IWUSR) + ) + guard externalDescriptor >= 0 else { return nil } + var externalStatus = stat() + guard fstat(externalDescriptor, &externalStatus) == 0, + externalStatus.st_mode & S_IFMT == S_IFREG, + externalStatus.st_uid == geteuid(), + externalStatus.st_nlink == 1, + externalStatus.st_size == 0, + path(externalURL.path, names: externalStatus) else { + return nil + } + let externalDevice = UInt64(externalStatus.st_dev) + let externalFile = UInt64(externalStatus.st_ino) + let externalSnapshot = TeardownTranscriptSnapshot( + transcriptPath: protectedSnapshot.transcriptPath, + snapshotPath: externalURL.path, + liveFileVersion: protectedSnapshot.liveFileVersion, + guardedProcessIdentities: guardedProcesses, + hasUncapturedGuardedProcesses: hasUncaptured + ) + guard persistRecoveryMetadata( + for: pointerSnapshot, + sessionId: sessionId, + capturedAt: capturedAt, + liveFileVersion: protectedSnapshot.liveFileVersion, + ownerProcessIdentity: ownerIdentity, + guardedProcessIdentities: guardedProcesses, + hasUncapturedGuardedProcesses: hasUncaptured, + candidateId: candidateId, + candidateState: "external-staging", + externalCandidatePath: externalURL.path, + externalFileDevice: externalDevice, + externalFileNumber: externalFile + ), synchronizeRegularFileAndContainingDirectory(atPath: pointerURL.path), + persistRecoveryMetadata( + for: externalSnapshot, + sessionId: sessionId, + capturedAt: capturedAt, + liveFileVersion: protectedSnapshot.liveFileVersion, + ownerProcessIdentity: ownerIdentity, + guardedProcessIdentities: guardedProcesses, + hasUncapturedGuardedProcesses: hasUncaptured, + candidateId: candidateId, + candidateState: "external-staging", + externalCandidatePath: externalURL.path, + externalFileDevice: externalDevice, + externalFileNumber: externalFile, + destinationDescriptor: externalDescriptor + ), synchronizeContainingDirectory(atPath: externalURL.path), + let pointerVersion = stableRegularFileVersion( + atPath: pointerURL.path, + fileManager: fileManager + ) else { + return nil + } + keepPointer = true + return ExternalRestoreStagingAuthority( + pointerURL: pointerURL, + pointerVersion: pointerVersion, + externalURL: externalURL, + externalDescriptor: externalDescriptor + ) } - static func recordedTranscriptPath( - agent: SessionRestorableAgentSnapshot, - panelKey: AgentHibernationPanelKey?, - homeDirectory: String, + @discardableResult + private static func cleanupExternalRestoreStagingAuthority( + at pointerURL: URL, fileManager: FileManager - ) -> (path: String?, isAmbiguous: Bool) { - let storeURL = RestorableAgentKind.claude.hookStoreFileURL(homeDirectory: homeDirectory) - guard let data = fileManager.contents(atPath: storeURL.path), - let store = try? JSONDecoder().decode(AgentHibernationTranscriptHookStoreFileMirror.self, from: data), - let sessions = store.sessions else { - return (nil, false) + ) -> Bool { + guard let metadata = recoveryMetadata(atSnapshotPath: pointerURL.path), + metadata.version == 2, + (metadata.candidateState == "external-staging" + || metadata.candidateState == "external-staging-pending"), + isSafeSessionIdPathComponent(metadata.sessionId), + let candidateId = metadata.candidateId, + UUID(uuidString: candidateId) != nil, + let externalPath = metadata.externalCandidatePath, + externalPath.hasPrefix("/"), + !externalPath.contains("\0"), + metadata.transcriptPath.hasPrefix("/"), + !metadata.transcriptPath.contains("\0") else { + return false } - - var paths: [String] = [] - var seenPaths: Set = [] - for record in sessions.values { - guard normalized(record.sessionId) == agent.sessionId, - panelKey.map({ record.matches(panelKey: $0) }) ?? true, - let transcriptPath = normalized(record.transcriptPath) else { - continue + let transcriptURL = URL(fileURLWithPath: metadata.transcriptPath) + let expectedExternalURL = transcriptURL.deletingLastPathComponent() + .appendingPathComponent( + ".\(transcriptURL.lastPathComponent).cmux-recovery-\(candidateId).jsonl", + isDirectory: false + ) + guard (externalPath as NSString).standardizingPath + == (expectedExternalURL.path as NSString).standardizingPath, + let pointerVersion = stableRegularFileVersion( + atPath: pointerURL.path, + fileManager: fileManager + ) else { + return false + } + var externalStatus = stat() + if lstat(externalPath, &externalStatus) == 0 { + let exactStagingInode: Bool + if metadata.candidateState == "external-staging-pending" { + exactStagingInode = metadata.externalFileDevice == nil + && metadata.externalFileNumber == nil + && externalStatus.st_mode & S_IFMT == S_IFREG + && externalStatus.st_uid == geteuid() + && externalStatus.st_nlink == 1 + && externalStatus.st_size == 0 + } else { + exactStagingInode = UInt64(externalStatus.st_dev) + == metadata.externalFileDevice + && UInt64(externalStatus.st_ino) + == metadata.externalFileNumber + && externalStatus.st_mode & S_IFMT == S_IFREG + && externalStatus.st_uid == geteuid() + && externalStatus.st_nlink == 1 } - let expandedPath = expandTilde(in: transcriptPath, homeDirectory: homeDirectory) - let standardizedPath = (expandedPath as NSString).standardizingPath - if seenPaths.insert(standardizedPath).inserted, - isRegularFile(atPath: expandedPath, fileManager: fileManager) { - paths.append(expandedPath) + if exactStagingInode, + let externalVersion = stableRegularFileVersion( + atPath: externalPath, + fileManager: fileManager + ) { + _ = durablyRemoveRecoverySnapshot( + atPath: externalPath, + expectedSnapshotVersion: externalVersion + ) } + } else if errno != ENOENT { + return false } - guard let path = paths.first else { return (nil, false) } - return paths.count == 1 ? (path, false) : (nil, true) + return durablyRemoveRecoverySnapshot( + atPath: pointerURL.path, + expectedSnapshotVersion: pointerVersion + ) } - static func claudeConfigRoots( - for agent: SessionRestorableAgentSnapshot, + /// Prepares a pointer that becomes valid at the same instant as an atomic + /// namespace swap. Before the swap it cannot authorize the restore temp; + /// after the swap it names the displaced live inode, including any writes + /// that arrive through a descriptor held across teardown. + private static func prepareAtomicSwapDisplacementAuthority( + transcriptURL: URL, + externalURL: URL, + protectedSnapshot: TeardownTranscriptSnapshot, + expectedLiveStatus: stat, + liveWasProtectedPrefix: Bool, + candidateId: String, + fileManager: FileManager + ) -> DisplacedTranscriptAuthority? { + let sourceMetadata = recoveryMetadata( + atSnapshotPath: protectedSnapshot.snapshotPath + ) + let sessionId: String + if let metadataSessionId = sourceMetadata?.sessionId, + isSafeSessionIdPathComponent(metadataSessionId) { + sessionId = metadataSessionId + } else { + sessionId = "displaced-\(UUID().uuidString)" + } + let recoveryDirectory = URL( + fileURLWithPath: protectedSnapshot.snapshotPath + ).deletingLastPathComponent() + let pointerURL = recoveryDirectory.appendingPathComponent( + "\(sessionId)-pointer-\(candidateId).jsonl", + isDirectory: false + ) + guard createRecoveryPointerFile(at: pointerURL) else { + return nil + } + guard let pointerInitialVersion = stableRegularFileVersion( + atPath: pointerURL.path, + fileManager: fileManager + ) else { + _ = durablyRemoveRecoverySnapshot(atPath: pointerURL.path) + return nil + } + var keepPointer = false + defer { + if !keepPointer { + _ = durablyRemoveRecoverySnapshot( + atPath: pointerURL.path, + expectedSnapshotVersion: pointerInitialVersion + ) + } + } + let liveVersion = stableRegularFileVersion( + atPath: transcriptURL.path, + fileManager: fileManager + ) + let guardedProcesses = sourceMetadata?.guardedProcesses? + .compactMap(\.processIdentity) ?? protectedSnapshot.guardedProcessIdentities + let hasUncaptured = sourceMetadata?.hasUncapturedGuardedProcesses + ?? protectedSnapshot.hasUncapturedGuardedProcesses + let ownerIdentity = AgentPIDProcessIdentity(pid: getpid()) + let pointerSnapshot = TeardownTranscriptSnapshot( + transcriptPath: protectedSnapshot.transcriptPath, + snapshotPath: pointerURL.path, + liveFileVersion: liveVersion, + guardedProcessIdentities: guardedProcesses, + hasUncapturedGuardedProcesses: hasUncaptured + ) + let liveSnapshot = TeardownTranscriptSnapshot( + transcriptPath: protectedSnapshot.transcriptPath, + snapshotPath: transcriptURL.path, + liveFileVersion: liveVersion, + guardedProcessIdentities: guardedProcesses, + hasUncapturedGuardedProcesses: hasUncaptured + ) + let externalDevice = UInt64(expectedLiveStatus.st_dev) + let externalFile = UInt64(expectedLiveStatus.st_ino) + let capturedAt = Date() + guard persistRecoveryMetadata( + for: pointerSnapshot, + sessionId: sessionId, + capturedAt: capturedAt, + liveFileVersion: liveVersion, + ownerProcessIdentity: ownerIdentity, + guardedProcessIdentities: guardedProcesses, + hasUncapturedGuardedProcesses: hasUncaptured, + candidateId: candidateId, + externalCandidatePath: externalURL.path, + externalFileDevice: externalDevice, + externalFileNumber: externalFile + ), synchronizeRegularFileAndContainingDirectory(atPath: pointerURL.path), + persistRecoveryMetadata( + for: liveSnapshot, + sessionId: sessionId, + capturedAt: capturedAt, + liveFileVersion: liveVersion, + ownerProcessIdentity: ownerIdentity, + guardedProcessIdentities: guardedProcesses, + hasUncapturedGuardedProcesses: hasUncaptured, + candidateId: candidateId, + externalCandidatePath: externalURL.path, + externalFileDevice: externalDevice, + externalFileNumber: externalFile + ), synchronizeRegularFileAndContainingDirectory(atPath: transcriptURL.path), + pathStillMatches( + transcriptURL.path, + expectedExists: true, + expectedStatus: expectedLiveStatus + ), (liveWasProtectedPrefix + ? file( + atPath: protectedSnapshot.snapshotPath, + stablyContainsPrefixAtPath: transcriptURL.path, + fileManager: fileManager + ) + : transcriptContainsOnlyNonProtectiveMetadata( + atPath: transcriptURL.path, + fileManager: fileManager + )), let durablePointerVersion = stableRegularFileVersion( + atPath: pointerURL.path, + fileManager: fileManager + ) else { + return nil + } + keepPointer = true + return DisplacedTranscriptAuthority( + authorityURL: pointerURL, + authorityVersion: durablePointerVersion, + contentURL: externalURL, + contentIdentity: expectedLiveStatus + ) + } + + private static func preserveDisplacedLiveTranscript( + transcriptURL: URL, + protectedSnapshot: TeardownTranscriptSnapshot, + expectedLiveStatus: stat, + fileManager: FileManager + ) -> DisplacedTranscriptAuthority? { + let sourceMetadata = recoveryMetadata( + atSnapshotPath: protectedSnapshot.snapshotPath + ) + let sessionId: String + if let metadataSessionId = sourceMetadata?.sessionId, + isSafeSessionIdPathComponent(metadataSessionId) { + sessionId = metadataSessionId + } else { + sessionId = "displaced-\(UUID().uuidString)" + } + let candidateId = UUID().uuidString + let capturedAt = Date() + let recoveryDirectory = URL( + fileURLWithPath: protectedSnapshot.snapshotPath + ).deletingLastPathComponent() + let preservedURL = recoveryDirectory.appendingPathComponent( + "\(sessionId)-displaced-\(candidateId).jsonl", + isDirectory: false + ) + let liveSnapshot = TeardownTranscriptSnapshot( + transcriptPath: protectedSnapshot.transcriptPath, + snapshotPath: transcriptURL.path + ) + guard persistRecoveryMetadata( + for: liveSnapshot, + sessionId: sessionId, + capturedAt: capturedAt, + liveFileVersion: protectedSnapshot.liveFileVersion, + ownerProcessIdentity: AgentPIDProcessIdentity(pid: getpid()), + guardedProcessIdentities: protectedSnapshot.guardedProcessIdentities, + hasUncapturedGuardedProcesses: + protectedSnapshot.hasUncapturedGuardedProcesses, + candidateId: candidateId + ), synchronizeRegularFileAndContainingDirectory(atPath: transcriptURL.path), + pathStillMatches( + transcriptURL.path, + expectedExists: true, + expectedStatus: expectedLiveStatus + ), transcriptContainsOnlyNonProtectiveMetadata( + atPath: transcriptURL.path, + fileManager: fileManager + ) else { + return nil + } + if atomicallyRename(transcriptURL, to: preservedURL) { + guard synchronizeContainingDirectory(atPath: transcriptURL.path), + synchronizeRegularFileAndContainingDirectory(atPath: preservedURL.path) else { + if atomicallyRename(preservedURL, to: transcriptURL) { + _ = synchronizeRegularFileAndContainingDirectory( + atPath: transcriptURL.path + ) + } + return nil + } + guard let authorityVersion = stableRegularFileVersion( + atPath: preservedURL.path, + fileManager: fileManager + ) else { return nil } + var contentIdentity = stat() + guard lstat(preservedURL.path, &contentIdentity) == 0 else { return nil } + return DisplacedTranscriptAuthority( + authorityURL: preservedURL, + authorityVersion: authorityVersion, + contentURL: preservedURL, + contentIdentity: contentIdentity + ) + } + guard errno == EXDEV else { return nil } + return preserveDisplacedLiveTranscriptAcrossVolumes( + transcriptURL: transcriptURL, + recoveryDirectory: recoveryDirectory, + protectedSnapshot: protectedSnapshot, + expectedLiveStatus: expectedLiveStatus, + sessionId: sessionId, + candidateId: candidateId, + capturedAt: capturedAt, + fileManager: fileManager + ) + } + + static func preserveDisplacedLiveTranscriptAcrossVolumes( + transcriptURL: URL, + recoveryDirectory: URL, + protectedSnapshot: TeardownTranscriptSnapshot, + expectedLiveStatus: stat, + sessionId: String, + candidateId: String, + capturedAt: Date, + fileManager: FileManager + ) -> DisplacedTranscriptAuthority? { + let externalURL = transcriptURL.deletingLastPathComponent() + .appendingPathComponent( + ".\(transcriptURL.lastPathComponent).cmux-recovery-\(candidateId).jsonl", + isDirectory: false + ) + let pointerURL = recoveryDirectory.appendingPathComponent( + "\(sessionId)-pointer-\(candidateId).jsonl", + isDirectory: false + ) + guard createRecoveryPointerFile(at: pointerURL) else { return nil } + guard let pointerInitialVersion = stableRegularFileVersion( + atPath: pointerURL.path, + fileManager: fileManager + ) else { + _ = durablyRemoveRecoverySnapshot(atPath: pointerURL.path) + return nil + } + var keepPointer = false + defer { + if !keepPointer { + _ = durablyRemoveRecoverySnapshot( + atPath: pointerURL.path, + expectedSnapshotVersion: pointerInitialVersion + ) + } + } + let externalDevice = UInt64(expectedLiveStatus.st_dev) + let externalFile = UInt64(expectedLiveStatus.st_ino) + let liveVersion = stableRegularFileVersion( + atPath: transcriptURL.path, + fileManager: fileManager + ) + let ownerIdentity = AgentPIDProcessIdentity(pid: getpid()) + let pointerSnapshot = TeardownTranscriptSnapshot( + transcriptPath: protectedSnapshot.transcriptPath, + snapshotPath: pointerURL.path, + liveFileVersion: liveVersion, + guardedProcessIdentities: protectedSnapshot.guardedProcessIdentities, + hasUncapturedGuardedProcesses: + protectedSnapshot.hasUncapturedGuardedProcesses + ) + let liveSnapshot = TeardownTranscriptSnapshot( + transcriptPath: protectedSnapshot.transcriptPath, + snapshotPath: transcriptURL.path, + liveFileVersion: liveVersion, + guardedProcessIdentities: protectedSnapshot.guardedProcessIdentities, + hasUncapturedGuardedProcesses: + protectedSnapshot.hasUncapturedGuardedProcesses + ) + guard persistRecoveryMetadata( + for: pointerSnapshot, + sessionId: sessionId, + capturedAt: capturedAt, + liveFileVersion: liveVersion, + ownerProcessIdentity: ownerIdentity, + guardedProcessIdentities: protectedSnapshot.guardedProcessIdentities, + hasUncapturedGuardedProcesses: + protectedSnapshot.hasUncapturedGuardedProcesses, + candidateId: candidateId, + externalCandidatePath: externalURL.path, + externalFileDevice: externalDevice, + externalFileNumber: externalFile + ), synchronizeRegularFileAndContainingDirectory(atPath: pointerURL.path), + persistRecoveryMetadata( + for: liveSnapshot, + sessionId: sessionId, + capturedAt: capturedAt, + liveFileVersion: liveVersion, + ownerProcessIdentity: ownerIdentity, + guardedProcessIdentities: protectedSnapshot.guardedProcessIdentities, + hasUncapturedGuardedProcesses: + protectedSnapshot.hasUncapturedGuardedProcesses, + candidateId: candidateId, + externalCandidatePath: externalURL.path, + externalFileDevice: externalDevice, + externalFileNumber: externalFile + ), synchronizeRegularFileAndContainingDirectory(atPath: transcriptURL.path), + pathStillMatches( + transcriptURL.path, + expectedExists: true, + expectedStatus: expectedLiveStatus + ), transcriptContainsOnlyNonProtectiveMetadata( + atPath: transcriptURL.path, + fileManager: fileManager + ) else { + return nil + } + guard atomicallyRename(transcriptURL, to: externalURL) else { return nil } + keepPointer = true + guard synchronizeContainingDirectory(atPath: transcriptURL.path), + synchronizeRegularFileAndContainingDirectory(atPath: externalURL.path), + let authorityVersion = stableRegularFileVersion( + atPath: pointerURL.path, + fileManager: fileManager + ) else { + if atomicallyRename(externalURL, to: transcriptURL), + synchronizeRegularFileAndContainingDirectory(atPath: transcriptURL.path) { + keepPointer = false + } + return nil + } + var contentIdentity = stat() + guard lstat(externalURL.path, &contentIdentity) == 0, + UInt64(contentIdentity.st_dev) == externalDevice, + UInt64(contentIdentity.st_ino) == externalFile else { + return nil + } + return DisplacedTranscriptAuthority( + authorityURL: pointerURL, + authorityVersion: authorityVersion, + contentURL: externalURL, + contentIdentity: contentIdentity + ) + } + + private static func createRecoveryPointerFile(at url: URL) -> Bool { + let descriptor = open( + url.path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(S_IRUSR | S_IWUSR) + ) + guard descriptor >= 0 else { return false } + defer { Darwin.close(descriptor) } + let bytes = Data("{\"type\":\"mode\",\"mode\":\"recovery-pointer\"}\n".utf8) + let written = bytes.withUnsafeBytes { buffer in + Darwin.write(descriptor, buffer.baseAddress, buffer.count) + } + guard written == bytes.count, + fchmod(descriptor, mode_t(S_IRUSR | S_IWUSR)) == 0, + fsync(descriptor) == 0 else { + _ = Darwin.unlink(url.path) + return false + } + return synchronizeContainingDirectory(atPath: url.path) + } + + @discardableResult + private static func rollBackDisplacedTranscript( + _ authority: DisplacedTranscriptAuthority, + to transcriptURL: URL, + fileManager: FileManager + ) -> Bool { + var contentStatus = stat() + guard lstat(authority.contentURL.path, &contentStatus) == 0, + sameRegularFileIdentity(contentStatus, authority.contentIdentity), + atomicallyRename(authority.contentURL, to: transcriptURL), + synchronizeRegularFileAndContainingDirectory( + atPath: transcriptURL.path + ) else { + return false + } + if authority.authorityURL.path != authority.contentURL.path { + _ = durablyRemoveRecoverySnapshot( + atPath: authority.authorityURL.path, + afterSynchronizingLivePath: transcriptURL.path, + expectedSnapshotVersion: authority.authorityVersion + ) + } + return true + } + + private static func pathStillMatches( + _ path: String, + expectedExists: Bool, + expectedStatus: stat + ) -> Bool { + var status = stat() + if lstat(path, &status) != 0 { + return !expectedExists && errno == ENOENT + } + return expectedExists + && stableFileStatus(status, matches: expectedStatus) + && status.st_uid == geteuid() + && status.st_nlink == 1 + } + + /// Moves a populated snapshot whose live path drifted into the session's + /// single retained recovery slot. Repeated failed protection attempts + /// replace the slot instead of accumulating full-transcript copies; the + /// slot ages out through the regular snapshot pruning. Never touches the + /// UUID-suffixed snapshots that active restore monitors own. + static func retainSnapshotForRecovery( + _ snapshot: TeardownTranscriptSnapshot, + sessionId: String?, + fileManager: FileManager = .default + ) { + let snapshotURL = URL(fileURLWithPath: snapshot.snapshotPath) + guard let sessionId, isSafeSessionIdPathComponent(sessionId) else { + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path) + return + } + guard transcriptHasConversationTurns(atPath: snapshotURL.path, fileManager: fileManager) else { + return + } + guard let lockDescriptor = acquireRecoveryDirectoryLock( + in: snapshotURL.deletingLastPathComponent() + ) else { + // The UUID snapshot already carries durable recovery metadata. If a + // concurrent recovery owns the directory, leaving it in place is + // safer than racing the shared retained slot. + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path) + return + } + defer { releaseRecoveryDirectoryLock(lockDescriptor) } + + let retainedURL = snapshotURL.deletingLastPathComponent() + .appendingPathComponent("\(sessionId)-retained.jsonl", isDirectory: false) + var sourceMetadata = recoveryMetadata(atSnapshotPath: snapshotURL.path) + let metadataMatchesSnapshot = sourceMetadata.map { + $0.sessionId == sessionId && + ($0.transcriptPath as NSString).standardizingPath == + (snapshot.transcriptPath as NSString).standardizingPath + } == true + if !metadataMatchesSnapshot { + _ = persistRecoveryMetadata( + for: snapshot, + sessionId: sessionId, + capturedAt: snapshotModificationDate(snapshotURL, fileManager: fileManager), + liveFileVersion: snapshot.liveFileVersion, + guardedProcessIdentities: snapshot.guardedProcessIdentities, + hasUncapturedGuardedProcesses: snapshot.hasUncapturedGuardedProcesses + ) + sourceMetadata = recoveryMetadata(atSnapshotPath: snapshotURL.path) + } + guard let sourceMetadata, + sourceMetadata.sessionId == sessionId, + (sourceMetadata.transcriptPath as NSString).standardizingPath == + (snapshot.transcriptPath as NSString).standardizingPath else { + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path) + return + } + let capturedAt = sourceMetadata.capturedAt ?? snapshotModificationDate(snapshotURL, fileManager: fileManager) + guard retainedURL.path != snapshotURL.path else { + _ = persistRecoveryMetadata( + for: snapshot, + sessionId: sessionId, + capturedAt: capturedAt, + liveFileVersion: sourceMetadata.liveFileVersion, + ownerProcessIdentity: sourceMetadata.ownerProcessIdentity, + ownerRuntimeMetadata: .init( + runtimeId: sourceMetadata.ownerRuntimeId, + bundleIdentifier: sourceMetadata.ownerBundleIdentifier + ), + guardedProcessIdentities: sourceMetadata.guardedProcesses?.compactMap(\.processIdentity) ?? [], + hasUncapturedGuardedProcesses: + sourceMetadata.hasUncapturedGuardedProcesses == true, + candidateId: sourceMetadata.candidateId, + externalCandidatePath: sourceMetadata.externalCandidatePath, + externalFileDevice: sourceMetadata.externalFileDevice, + externalFileNumber: sourceMetadata.externalFileNumber + ) + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: retainedURL.path) + return + } + + var displacedRetainedVersion: TeardownTranscriptFileVersion? + var retainedMetadata: RecoveryMetadata? + var retainedExists = fileManager.fileExists(atPath: retainedURL.path) + if retainedExists { + retainedMetadata = validatedDurableRetainedMetadata( + at: retainedURL, + fileManager: fileManager + ) + if retainedMetadata == nil { + moveInvalidRecoveryEntriesAside( + [retainedURL], + in: retainedURL.deletingLastPathComponent(), + fileManager: fileManager, + cancellationCheck: { false } + ) + retainedExists = fileManager.fileExists(atPath: retainedURL.path) + } + } + if retainedExists { + guard let retainedMetadata, + retainedMetadata.sessionId == sessionId, + (retainedMetadata.transcriptPath as NSString).standardizingPath == + (sourceMetadata.transcriptPath as NSString).standardizingPath else { + // Unknown bytes already occupy the single retained slot. Keep + // both recoverable files instead of destroying either branch. + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path) + return + } + if file( + atPath: retainedURL.path, + stablyContainsPrefixAtPath: snapshotURL.path, + fileManager: fileManager + ) { + // The retained copy already contains every protected byte. + if let snapshotVersion = stableRegularFileVersion( + atPath: snapshotURL.path, + fileManager: fileManager + ) { + _ = durablyRemoveRecoverySnapshot( + atPath: snapshotURL.path, + afterSynchronizingLivePath: retainedURL.path, + expectedSnapshotVersion: snapshotVersion + ) + } + return + } + guard capturedAt >= (retainedMetadata.capturedAt ?? .distantPast), + let sourceVersion = stableRegularFileVersion( + atPath: snapshotURL.path, + fileManager: fileManager + ), + let retainedVersion = stableRegularFileVersion( + atPath: retainedURL.path, + fileManager: fileManager + ), + file( + atPath: snapshotURL.path, + stablyContainsPrefixAtPath: retainedURL.path, + fileManager: fileManager + ), + stableRegularFileVersion( + atPath: snapshotURL.path, + fileManager: fileManager + ) == sourceVersion, + stableRegularFileVersion( + atPath: retainedURL.path, + fileManager: fileManager + ) == retainedVersion else { + // Later timestamps alone do not prove append-only ancestry. + // Preserve divergent branches as separate UUID snapshots. + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path) + return + } + // Preserve both recovery generations through the replacement. + // The swap is accepted only when both exact inodes and their + // append-only ancestry still match after the namespace commit. + guard renamex_np( + snapshotURL.path, + retainedURL.path, + UInt32(RENAME_SWAP) + ) == 0 else { + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path) + return + } + let swapIsExact = stableRegularFileVersion( + atPath: retainedURL.path, + fileManager: fileManager + ) == sourceVersion + && stableRegularFileVersion( + atPath: snapshotURL.path, + fileManager: fileManager + ) == retainedVersion + && file( + atPath: retainedURL.path, + stablyContainsPrefixAtPath: snapshotURL.path, + fileManager: fileManager + ) + guard swapIsExact else { + if stableRegularFileVersion( + atPath: retainedURL.path, + fileManager: fileManager + ) == sourceVersion, + stableRegularFileVersion( + atPath: snapshotURL.path, + fileManager: fileManager + ) == retainedVersion { + _ = renamex_np( + snapshotURL.path, + retainedURL.path, + UInt32(RENAME_SWAP) + ) + } + return + } + displacedRetainedVersion = retainedVersion + } else { + guard let sourceVersion = stableRegularFileVersion( + atPath: snapshotURL.path, + fileManager: fileManager + ) else { + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path) + return + } + guard atomicallyRename(snapshotURL, to: retainedURL), + stableRegularFileVersion( + atPath: retainedURL.path, + fileManager: fileManager + ) == sourceVersion else { + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path) + return + } + } + let metadataPersisted = persistRecoveryMetadata( + for: TeardownTranscriptSnapshot( + transcriptPath: snapshot.transcriptPath, + snapshotPath: retainedURL.path + ), + sessionId: sessionId, + capturedAt: capturedAt, + liveFileVersion: sourceMetadata.liveFileVersion, + ownerProcessIdentity: sourceMetadata.ownerProcessIdentity, + ownerRuntimeMetadata: .init( + runtimeId: sourceMetadata.ownerRuntimeId, + bundleIdentifier: sourceMetadata.ownerBundleIdentifier + ), + guardedProcessIdentities: sourceMetadata.guardedProcesses?.compactMap(\.processIdentity) ?? [], + hasUncapturedGuardedProcesses: + sourceMetadata.hasUncapturedGuardedProcesses == true, + candidateId: sourceMetadata.candidateId, + externalCandidatePath: sourceMetadata.externalCandidatePath, + externalFileDevice: sourceMetadata.externalFileDevice, + externalFileNumber: sourceMetadata.externalFileNumber + ) + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: retainedURL.path) + guard metadataPersisted, + synchronizeRegularFileAndContainingDirectory(atPath: retainedURL.path) else { + // Both inodes remain metadata-bearing recovery candidates. Do not + // retire the displaced generation without durable new authority. + return + } + if let displacedRetainedVersion { + _ = durablyRemoveRecoverySnapshot( + atPath: snapshotURL.path, + afterSynchronizingLivePath: retainedURL.path, + expectedSnapshotVersion: displacedRetainedVersion + ) + } + } + + /// Reconciles snapshots whose in-memory post-teardown monitor disappeared + /// with the prior app process. Metadata is stored on the snapshot inode, so + /// the retained-slot rename cannot separate the protected bytes from their + /// destination path. + @discardableResult + static func recoverPendingSnapshots( + snapshotDirectory: URL? = nil, + fileManager: FileManager = .default, + maximumProcessIdentityProbes: Int = Self.maxStartupRecoveryProcessIdentityProbes, + cancellationCheck: @Sendable () -> Bool = { false } + ) -> Int { + guard !cancellationCheck() else { return 0 } + guard let directory = snapshotDirectory ?? defaultSnapshotDirectoryURL() else { + return 0 + } + guard ensurePrivateRecoveryDirectory( + at: directory, + createIfMissing: false, + fileManager: fileManager + ) else { return 0 } + guard let lockDescriptor = acquireRecoveryDirectoryLock(in: directory) else { + return 0 + } + defer { releaseRecoveryDirectoryLock(lockDescriptor) } + guard let directoryStream = recoveryDirectoryStream(in: directory) else { return 0 } + defer { closedir(directoryStream) } + let cursor = recoveryCursor(lockDescriptor: lockDescriptor) + var processProbeBudget = RecoveryProcessProbeBudget( + remaining: max(0, maximumProcessIdentityProbes) + ) + let scan = validatedRecoveryCandidates( + in: directory, + directoryStream: directoryStream, + maximumEntries: maxStartupRecoveryDirectoryEntries, + afterTranscriptPath: cursor.transcriptPath, + fileManager: fileManager, + processProbeBudget: &processProbeBudget, + cancellationCheck: cancellationCheck + ) + let restored = recoverPendingCandidates( + scan.candidates, + lockDescriptor: lockDescriptor, + maximumCandidates: maxStartupRecoverySnapshots, + fileManager: fileManager, + cancellationCheck: cancellationCheck + ).restoredCount + moveInvalidRecoveryEntriesAside( + scan.invalidEntries, + in: directory, + fileManager: fileManager, + cancellationCheck: cancellationCheck + ) + return restored + } + + static func recoverPendingSnapshotsAwaitingLock( + snapshotDirectory: URL? = nil, + fileManager: FileManager = .default, + maximumProcessIdentityProbes: Int = Self.maxStartupRecoveryProcessIdentityProbes, + cancellationCheck: @escaping @Sendable () -> Bool = { false } + ) async -> Int { + guard !cancellationCheck(), + let directory = snapshotDirectory ?? defaultSnapshotDirectoryURL() else { + return 0 + } + guard ensurePrivateRecoveryDirectory( + at: directory, + createIfMissing: false, + fileManager: fileManager + ) else { return 0 } + // A dedicated serial Dispatch queue owns the only blocking flock wait; + // it never occupies the main actor or Swift's cooperative executor. + // Cancellation cannot interrupt flock, so a cancelled generation + // releases immediately after acquisition and its successor then runs. + let lockDescriptor = await acquireRecoveryDirectoryLockAwaitingContention( + in: directory + ) + guard let lockDescriptor else { return 0 } + defer { releaseRecoveryDirectoryLock(lockDescriptor) } + guard !cancellationCheck() else { return 0 } + guard let directoryStream = recoveryDirectoryStream(in: directory) else { return 0 } + defer { closedir(directoryStream) } + let cursor = recoveryCursor(lockDescriptor: lockDescriptor) + + // DIR cookies are valid only for this open stream. Scan fixed-size + // batches and yield between them; never persist seek offsets across a + // reopened or mutated directory. + var accumulatedCandidates: [PendingRecoverySnapshot] = [] + var accumulatedMetadataBytes = 0 + var accumulatedContentBytes: UInt64 = 0 + var budgetBlockedTranscriptPaths: Set = [] + var invalidEntries: [URL] = [] + var processProbeBudget = RecoveryProcessProbeBudget( + remaining: max(0, maximumProcessIdentityProbes) + ) + while !cancellationCheck() { + let scan = validatedRecoveryCandidates( + in: directory, + directoryStream: directoryStream, + maximumEntries: maxStartupRecoveryDirectoryEntries, + afterTranscriptPath: cursor.transcriptPath, + fileManager: fileManager, + processProbeBudget: &processProbeBudget, + cancellationCheck: cancellationCheck + ) + budgetBlockedTranscriptPaths.formUnion(scan.budgetBlockedTranscriptPaths) + accumulatedCandidates.removeAll { candidate in + budgetBlockedTranscriptPaths.contains( + (candidate.metadata.transcriptPath as NSString).standardizingPath + ) + } + accumulatedMetadataBytes = accumulatedCandidates.reduce(0) { + $0 + $1.metadataByteCount + } + accumulatedContentBytes = accumulatedCandidates.reduce(0) { + $0 + $1.contentByteCount + } + for candidate in scan.candidates { + insertRecoveryCandidate( + candidate, + into: &accumulatedCandidates, + metadataBytes: &accumulatedMetadataBytes, + contentBytes: &accumulatedContentBytes, + budgetBlockedTranscriptPaths: &budgetBlockedTranscriptPaths, + afterTranscriptPath: cursor.transcriptPath + ) + } + if invalidEntries.count < maxStartupRecoveryInvalidMovesPerLaunch { + invalidEntries.append(contentsOf: scan.invalidEntries.prefix( + maxStartupRecoveryInvalidMovesPerLaunch - invalidEntries.count + )) + } + if scan.reachedEnd || scan.examinedEntries == 0 { break } + await Task.yield() + } + let restoredCount = recoverPendingCandidates( + accumulatedCandidates, + lockDescriptor: lockDescriptor, + maximumCandidates: maxStartupRecoverySnapshots, + fileManager: fileManager, + cancellationCheck: cancellationCheck + ).restoredCount + moveInvalidRecoveryEntriesAside( + invalidEntries, + in: directory, + fileManager: fileManager, + cancellationCheck: cancellationCheck + ) + return restoredCount + } + + private static func recoveryDirectoryStream( + in directory: URL + ) -> UnsafeMutablePointer? { + let descriptor = open( + directory.path, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard descriptor >= 0 else { return nil } + var status = stat() + var pathStatus = stat() + guard fstat(descriptor, &status) == 0, + status.st_mode & S_IFMT == S_IFDIR, + status.st_uid == geteuid(), + lstat(directory.path, &pathStatus) == 0, + pathStatus.st_mode & S_IFMT == S_IFDIR, + pathStatus.st_uid == geteuid(), + pathStatus.st_dev == status.st_dev, + pathStatus.st_ino == status.st_ino else { + Darwin.close(descriptor) + return nil + } + guard let stream = fdopendir(descriptor) else { + Darwin.close(descriptor) + return nil + } + return stream + } + + private static func recoverPendingCandidates( + _ candidates: [PendingRecoverySnapshot], + lockDescriptor: Int32, + maximumCandidates: Int, + fileManager: FileManager, + cancellationCheck: @Sendable () -> Bool + ) -> (restoredCount: Int, processedCount: Int) { + let cursor = recoveryCursor(lockDescriptor: lockDescriptor) + var candidatesByTranscript: [String: [PendingRecoverySnapshot]] = [:] + for candidate in candidates { + guard !cancellationCheck() else { break } + let transcriptKey = (candidate.metadata.transcriptPath as NSString).standardizingPath + candidatesByTranscript[transcriptKey, default: []].append(candidate) + } + guard !cancellationCheck(), maximumCandidates > 0 else { return (0, 0) } + guard !candidatesByTranscript.isEmpty else { return (0, 0) } + + for key in Array(candidatesByTranscript.keys) { + guard let values = candidatesByTranscript[key], + let ordered = recoveryCandidatesWithUniversalAppendSuperset(values) else { + candidatesByTranscript.removeValue(forKey: key) + continue + } + candidatesByTranscript[key] = ordered + } + let orderedTranscriptKeys = rotatedRecoveryTranscriptKeys( + Array(candidatesByTranscript.keys).sorted(), + after: cursor.transcriptPath + ) + var nextIndexByTranscript: [String: Int] = [:] + var selectedByTranscript: [String: [PendingRecoverySnapshot]] = [:] + var selectedCount = 0 + var lastSelectedTranscript: String? + while selectedCount < maximumCandidates, !cancellationCheck() { + var appendedInPass = false + for transcriptKey in orderedTranscriptKeys where selectedCount < maximumCandidates { + guard !cancellationCheck() else { break } + let nextIndex = nextIndexByTranscript[transcriptKey, default: 0] + guard let candidates = candidatesByTranscript[transcriptKey], + nextIndex < candidates.count else { + continue + } + selectedByTranscript[transcriptKey, default: []].append(candidates[nextIndex]) + nextIndexByTranscript[transcriptKey] = nextIndex + 1 + selectedCount += 1 + lastSelectedTranscript = transcriptKey + appendedInPass = true + } + if !appendedInPass { break } + } + if let lastSelectedTranscript { + persistRecoveryCursor( + RecoveryCursorState(transcriptPath: lastSelectedTranscript), + lockDescriptor: lockDescriptor + ) + } + + var restoredCount = 0 + for transcriptKey in orderedTranscriptKeys { + guard !cancellationCheck() else { break } + guard let newestFirst = selectedByTranscript[transcriptKey] else { continue } + var newestValidSnapshotWasCommitted = false + for originalCandidate in newestFirst { + guard !cancellationCheck() else { break } + guard let candidate = claimRecoveryCandidate( + originalCandidate, + fileManager: fileManager + ) else { + // A newer candidate that cannot be claimed is still + // unresolved authority. Never commit an older generation + // ahead of it. + break + } + let snapshot = TeardownTranscriptSnapshot( + transcriptPath: candidate.metadata.transcriptPath, + snapshotPath: candidate.contentURL.path, + liveFileVersion: candidate.metadata.liveFileVersion, + guardedProcessIdentities: candidate.metadata.guardedProcesses? + .compactMap(\.processIdentity) ?? [], + hasUncapturedGuardedProcesses: + candidate.metadata.hasUncapturedGuardedProcesses == true + ) + guard let claimedAuthorityVersion = stableRegularFileVersion( + atPath: candidate.url.path, + fileManager: fileManager + ), + let claimedSnapshotVersion = stableRegularFileVersion( + atPath: snapshot.snapshotPath, + fileManager: fileManager + ) else { + preserveClaimedRecoveryCandidate( + candidate, + at: originalCandidate.url, + fileManager: fileManager + ) + break + } + guard transcriptHasConversationTurns( + atPath: snapshot.snapshotPath, + fileManager: fileManager + ) else { + if transcriptContainsOnlyNonProtectiveMetadata( + atPath: snapshot.snapshotPath, + fileManager: fileManager + ) { + if durablyRemoveClaimedRecoveryCandidate( + candidate, + authorityVersion: claimedAuthorityVersion, + contentVersion: claimedSnapshotVersion, + afterSynchronizingLivePath: nil + ) { + continue + } else { + preserveClaimedRecoveryCandidate( + candidate, + at: originalCandidate.url, + fileManager: fileManager + ) + } + } else { + preserveClaimedRecoveryCandidate( + candidate, + at: originalCandidate.url, + fileManager: fileManager + ) + } + break + } + if newestValidSnapshotWasCommitted { + if file( + atPath: snapshot.transcriptPath, + stablyContainsPrefixAtPath: snapshot.snapshotPath, + fileManager: fileManager + ) { + _ = durablyRemoveClaimedRecoveryCandidate( + candidate, + authorityVersion: claimedAuthorityVersion, + contentVersion: claimedSnapshotVersion, + afterSynchronizingLivePath: snapshot.transcriptPath + ) + } else { + preserveClaimedRecoveryCandidate( + candidate, + at: originalCandidate.url, + fileManager: fileManager + ) + } + continue + } + if file( + atPath: snapshot.transcriptPath, + stablyContainsPrefixAtPath: snapshot.snapshotPath, + fileManager: fileManager + ) { + _ = durablyRemoveClaimedRecoveryCandidate( + candidate, + authorityVersion: claimedAuthorityVersion, + contentVersion: claimedSnapshotVersion, + afterSynchronizingLivePath: snapshot.transcriptPath + ) + newestValidSnapshotWasCommitted = true + continue + } + if restoreIfClobberedWhileHoldingDirectoryLock(snapshot, fileManager: fileManager) { + restoredCount += 1 + _ = durablyRemoveClaimedRecoveryCandidate( + candidate, + authorityVersion: claimedAuthorityVersion, + contentVersion: claimedSnapshotVersion, + afterSynchronizingLivePath: snapshot.transcriptPath + ) + newestValidSnapshotWasCommitted = true + continue + } + preserveClaimedRecoveryCandidate( + candidate, + at: originalCandidate.url, + fileManager: fileManager + ) + // A populated divergent live file means this transcript has + // branched. Never restore an older candidate over that branch. + break + } + } + return (restoredCount, selectedCount) + } + + /// Wall clocks are not generation authority. A clock rollback can make a + /// later append snapshot look older, so restore only a candidate whose + /// stable bytes contain every other admitted generation for the transcript. + /// Equal-size contenders must be byte-equivalent or no generation wins. + private static func recoveryCandidatesWithUniversalAppendSuperset( + _ candidates: [PendingRecoverySnapshot], + fileManager: FileManager = .default + ) -> [PendingRecoverySnapshot]? { + guard let maximumContentBytes = candidates.map(\.contentByteCount).max() else { + return nil + } + let contenders = candidates + .filter { $0.contentByteCount == maximumContentBytes } + .sorted(by: recoveryCandidateIsNewer) + guard let universal = contenders.first else { return nil } + + for candidate in candidates { + if candidate.contentURL.path == universal.contentURL.path { + guard candidate.contentVersion == universal.contentVersion else { return nil } + continue + } + guard file( + atPath: universal.contentURL.path, + stablyContainsPrefixAtPath: candidate.contentURL.path, + fileManager: fileManager + ) else { + return nil + } + } + for candidate in candidates { + guard stableRegularFileVersion( + atPath: candidate.url.path, + fileManager: fileManager + ) == candidate.authorityVersion, + stableRegularFileVersion( + atPath: candidate.contentURL.path, + fileManager: fileManager + ) == candidate.contentVersion else { + return nil + } + } + + return [universal] + candidates + .filter { $0.url.path != universal.url.path } + .sorted(by: recoveryCandidateIsNewer) + } + + static func transcriptCandidates(projectRoot: String, sessionId: String) -> [String] { + let directPath = (projectRoot as NSString).appendingPathComponent("\(sessionId).jsonl") + let nestedPath = (((projectRoot as NSString).appendingPathComponent(sessionId) as NSString).appendingPathComponent("messages") as NSString).appendingPathComponent("\(sessionId).jsonl") + return [directPath, nestedPath] + } + + private static func isSafeSessionIdPathComponent(_ sessionId: String) -> Bool { + !sessionId.isEmpty + && sessionId != "." + && sessionId != ".." + && !sessionId.contains("/") + && !sessionId.contains("\\") + && !sessionId.unicodeScalars.contains { + $0.properties.generalCategory == .control + } + } + + // Mirrors regularNonEmptyFileExists in RestorableAgentSession.swift: an empty + // recorded/derived file must not shadow a populated transcript elsewhere. + static func isRegularFile(atPath path: String, fileManager: FileManager) -> Bool { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), + !isDirectory.boolValue, + let attributes = try? fileManager.attributesOfItem(atPath: path), + let fileType = attributes[.type] as? FileAttributeType, + fileType == .typeRegular else { + return false + } + return ((attributes[.size] as? NSNumber)?.int64Value ?? 0) > 0 + } + + private static func directoryExists(atPath path: String, fileManager: FileManager) -> Bool { + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue + } + + static func recordedTranscriptPath( + agent: SessionRestorableAgentSnapshot, + panelKey: AgentHibernationPanelKey?, + homeDirectory: String, + fileManager: FileManager + ) -> (path: String?, isAmbiguous: Bool) { + let environment = ProcessInfo.processInfo.environment + let storeURL = RestorableAgentKind.claude.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ) + let recordData: [Data] + if let exact = AgentHookSessionRegistryReader.recordData( + provider: RestorableAgentKind.claude.rawValue, + sessionID: agent.sessionId, + legacyURL: storeURL, + environment: environment, + fileManager: fileManager + ) { + recordData = [exact] + } else if let records = AgentHookSessionRegistryReader.records( + provider: RestorableAgentKind.claude.rawValue, + legacyURL: storeURL, + environment: environment, + fileManager: fileManager + ) { + recordData = Array(records.values) + } else { + return (nil, false) + } + + var paths: [String] = [] + var seenPaths: Set = [] + for data in recordData { + guard let record = try? JSONDecoder().decode( + AgentHibernationTranscriptHookStoreRecord.self, + from: data + ) else { continue } + guard normalized(record.sessionId) == agent.sessionId, + panelKey.map({ record.matches(panelKey: $0) }) ?? true, + let transcriptPath = normalized(record.transcriptPath) else { + continue + } + let expandedPath = expandTilde(in: transcriptPath, homeDirectory: homeDirectory) + let standardizedPath = (expandedPath as NSString).standardizingPath + if seenPaths.insert(standardizedPath).inserted, + isRegularFile(atPath: expandedPath, fileManager: fileManager) { + paths.append(expandedPath) + } + } + guard let path = paths.first else { return (nil, false) } + return paths.count == 1 ? (path, false) : (nil, true) + } + + static func claudeConfigRoots( + for agent: SessionRestorableAgentSnapshot, homeDirectory: String, fileManager: FileManager ) -> [String] { @@ -291,141 +2036,1850 @@ enum AgentHibernationTranscriptGuard { return [ClaudeConfigDirectoryPath.preferredPath(expanded, fileManager: fileManager, homeDirectory: homeDirectory)] } - var roots: [String] = [] - var seen: Set = [] - func appendRoot(_ path: String) { - let standardized = (path as NSString).standardizingPath - guard seen.insert(standardized).inserted else { return } - roots.append(standardized) + var roots: [String] = [] + var seen: Set = [] + func appendRoot(_ path: String) { + let standardized = (path as NSString).standardizingPath + guard seen.insert(standardized).inserted else { return } + roots.append(standardized) + } + + let accountRoot = (homeDirectory as NSString).appendingPathComponent(".codex-accounts/claude") + for accountPath in boundedOwnedChildDirectories(atPath: accountRoot) { + appendRoot(accountPath) + } + appendRoot((homeDirectory as NSString).appendingPathComponent(".claude")) + appendRoot(ClaudeConfigDirectoryPath.preferredPath( + (homeDirectory as NSString).appendingPathComponent(".subrouter/codex/claude"), + fileManager: fileManager, + homeDirectory: homeDirectory) + ) + return roots + } + + private static func boundedOwnedChildDirectories( + atPath rootPath: String, + maximumEntries: Int = 256, + maximumDirectories: Int = 64 + ) -> [String] { + let descriptor = open( + rootPath, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard descriptor >= 0, let stream = fdopendir(descriptor) else { + if descriptor >= 0 { Darwin.close(descriptor) } + return [] + } + defer { closedir(stream) } + var examinedEntries = 0 + var directories: [String] = [] + while examinedEntries < max(0, maximumEntries), + directories.count < max(0, maximumDirectories), + let entry = readdir(stream) { + let name = withUnsafePointer(to: &entry.pointee.d_name) { namePointer in + namePointer.withMemoryRebound( + to: CChar.self, + capacity: Int(entry.pointee.d_namlen) + 1 + ) { String(cString: $0) } + } + guard name != ".", name != ".." else { continue } + examinedEntries += 1 + guard !name.isEmpty, !name.contains("/") else { continue } + var status = stat() + guard fstatat( + dirfd(stream), + name, + &status, + AT_SYMLINK_NOFOLLOW + ) == 0, + status.st_mode & S_IFMT == S_IFDIR, + status.st_uid == geteuid() else { + continue + } + directories.append((rootPath as NSString).appendingPathComponent(name)) + } + return directories.sorted() + } + + private static func capturedGuardedProcessIdentities( + from processIDs: Set, + maximumIdentities: Int + ) -> (identities: [AgentPIDProcessIdentity], hasUncaptured: Bool) { + let validProcessIDs = processIDs.filter { + $0 > 0 && $0 <= Int(Int32.max) + } + let rootProcessIDs = validProcessIDs.filter { processID in + guard let parentProcessID = parentProcessID(of: processID) else { + return true + } + return !validProcessIDs.contains(parentProcessID) + }.sorted() + let rootSet = Set(rootProcessIDs) + let orderedProcessIDs = rootProcessIDs + + validProcessIDs.filter { !rootSet.contains($0) }.sorted() + var identities: [AgentPIDProcessIdentity] = [] + var hasUncaptured = false + for processID in orderedProcessIDs { + guard identities.count < maximumIdentities else { + if processIsAlive(pid_t(processID)) { hasUncaptured = true } + continue + } + if let identity = AgentPIDProcessIdentity(pid: pid_t(processID)) { + identities.append(identity) + } else if processIsAlive(pid_t(processID)) { + hasUncaptured = true + } + } + return (identities, hasUncaptured) + } + + private static func parentProcessID(of processID: Int) -> Int? { + var info = proc_bsdinfo() + let expectedSize = MemoryLayout.stride + let size = proc_pidinfo( + pid_t(processID), + PROC_PIDTBSDINFO, + 0, + &info, + Int32(expectedSize) + ) + guard size == expectedSize else { return nil } + return Int(info.pbi_ppid) + } + + private static func processIsAlive(_ processID: pid_t) -> Bool { + if kill(processID, 0) == 0 { return true } + return errno == EPERM + } + + private static func persistRecoveryMetadata( + for snapshot: TeardownTranscriptSnapshot, + sessionId: String, + capturedAt: Date, + liveFileVersion: TeardownTranscriptFileVersion? = nil, + ownerProcessIdentity: AgentPIDProcessIdentity? = AgentPIDProcessIdentity(pid: getpid()), + ownerRuntimeMetadata: RecoveryOwnerRuntimeMetadata? = nil, + guardedProcessIdentities: [AgentPIDProcessIdentity] = [], + hasUncapturedGuardedProcesses: Bool = false, + candidateId: String? = nil, + candidateState: String = "recoverable", + externalCandidatePath: String? = nil, + externalFileDevice: UInt64? = nil, + externalFileNumber: UInt64? = nil, + destinationDescriptor: Int32? = nil + ) -> Bool { + let ownerRuntimeId: String? + let ownerBundleIdentifier: String? + if let ownerRuntimeMetadata { + ownerRuntimeId = ownerRuntimeMetadata.runtimeId + ownerBundleIdentifier = ownerRuntimeMetadata.bundleIdentifier + } else { + ownerRuntimeId = ownerProcessIdentity.flatMap { _ in + normalized(ProcessInfo.processInfo.environment["CMUX_RUNTIME_ID"]) + } + ownerBundleIdentifier = ownerProcessIdentity == nil + ? nil + : Bundle.main.bundleIdentifier + } + let resolvedCandidateId = candidateId + ?? recoveryMetadata(atSnapshotPath: snapshot.snapshotPath)?.candidateId + ?? UUID().uuidString + guard isSafeSessionIdPathComponent(sessionId), + UUID(uuidString: resolvedCandidateId) != nil, + let data = try? JSONEncoder().encode(RecoveryMetadata( + version: 2, + sessionId: sessionId, + transcriptPath: snapshot.transcriptPath, + snapshotPath: nil, + candidateId: resolvedCandidateId, + candidateState: candidateState, + externalCandidatePath: externalCandidatePath, + externalFileDevice: externalFileDevice, + externalFileNumber: externalFileNumber, + capturedAt: capturedAt, + liveFileNumber: liveFileVersion?.fileNumber, + liveFileSize: liveFileVersion?.size, + liveFileModificationDate: liveFileVersion?.modificationDate, + ownerProcessId: ownerProcessIdentity?.pid, + ownerProcessStartSeconds: ownerProcessIdentity?.startSeconds, + ownerProcessStartMicroseconds: ownerProcessIdentity?.startMicroseconds, + ownerRuntimeId: ownerRuntimeId, + ownerBundleIdentifier: ownerBundleIdentifier, + guardedProcesses: Array(guardedProcessIdentities.prefix(64)).map(RecoveryProcessIdentity.init), + hasUncapturedGuardedProcesses: hasUncapturedGuardedProcesses + )), + !data.isEmpty, + data.count <= maxRecoveryMetadataBytes else { + return false + } + if let destinationDescriptor { + return writeRecoveryMetadataData( + data, + toDescriptor: destinationDescriptor, + expectedPath: snapshot.snapshotPath + ) + } + return writeRecoveryMetadataData(data, atPath: snapshot.snapshotPath) + } + + /// Removes only this process generation's ownership after its in-memory + /// monitor is exhausted. Every byte-authority field remains unchanged, so + /// startup recovery can claim the candidate without waiting for an app + /// restart, while a replacement owner can never be retired accidentally. + static func retireCurrentRecoveryOwner( + for snapshot: TeardownTranscriptSnapshot, + expectedSnapshotVersion: TeardownTranscriptFileVersion, + fileManager: FileManager = .default + ) -> Bool { + let snapshotURL = URL(fileURLWithPath: snapshot.snapshotPath) + guard let lockDescriptor = acquireRecoveryDirectoryLockSynchronously( + in: snapshotURL.deletingLastPathComponent() + ) else { + return false + } + defer { releaseRecoveryDirectoryLock(lockDescriptor) } + guard stableRegularFileVersion( + atPath: snapshot.snapshotPath, + fileManager: fileManager + ) == expectedSnapshotVersion, + let metadata = recoveryMetadata( + atSnapshotPath: snapshot.snapshotPath + ), + metadata.version == 2, + recoveryMetadata(metadata, isValidAt: snapshotURL), + metadata.hasUncapturedGuardedProcesses != true, + metadata.guardedProcesses?.contains(where: { guarded in + guard let identity = guarded.processIdentity else { return true } + return AgentPIDProcessIdentity(pid: identity.pid) == identity + }) != true else { + return false + } + + var recognizedCurrentOwner = false + if metadata.ownerProcessId != nil + || metadata.ownerProcessStartSeconds != nil + || metadata.ownerProcessStartMicroseconds != nil { + guard let expectedOwner = metadata.ownerProcessIdentity, + let currentOwner = AgentPIDProcessIdentity(pid: expectedOwner.pid), + currentOwner == expectedOwner, + expectedOwner.pid == getpid() else { + return false + } + recognizedCurrentOwner = true + } + if let ownerRuntimeId = metadata.ownerRuntimeId { + guard ownerRuntimeId == normalized( + ProcessInfo.processInfo.environment["CMUX_RUNTIME_ID"] + ) else { + return false + } + recognizedCurrentOwner = true + } + if let ownerBundleIdentifier = metadata.ownerBundleIdentifier { + guard ownerBundleIdentifier == Bundle.main.bundleIdentifier else { + return false + } + recognizedCurrentOwner = true + } + guard recognizedCurrentOwner else { return false } + + let retiredMetadata = RecoveryMetadata( + version: metadata.version, + sessionId: metadata.sessionId, + transcriptPath: metadata.transcriptPath, + snapshotPath: metadata.snapshotPath, + candidateId: metadata.candidateId, + candidateState: metadata.candidateState, + externalCandidatePath: metadata.externalCandidatePath, + externalFileDevice: metadata.externalFileDevice, + externalFileNumber: metadata.externalFileNumber, + capturedAt: metadata.capturedAt, + liveFileNumber: metadata.liveFileNumber, + liveFileSize: metadata.liveFileSize, + liveFileModificationDate: metadata.liveFileModificationDate, + ownerProcessId: nil, + ownerProcessStartSeconds: nil, + ownerProcessStartMicroseconds: nil, + ownerRuntimeId: nil, + ownerBundleIdentifier: nil, + guardedProcesses: metadata.guardedProcesses, + hasUncapturedGuardedProcesses: + metadata.hasUncapturedGuardedProcesses + ) + guard let data = try? JSONEncoder().encode(retiredMetadata), + !data.isEmpty, + data.count <= maxRecoveryMetadataBytes, + writeRecoveryMetadataData( + data, + atPath: snapshot.snapshotPath + ), + synchronizeRegularFileAndContainingDirectory( + atPath: snapshot.snapshotPath + ), + recoveryMetadata(atSnapshotPath: snapshot.snapshotPath) + == retiredMetadata else { + return false + } + return true + } + + private static func recoveryMetadata(atSnapshotPath snapshotPath: String) -> RecoveryMetadata? { + recoveryMetadataEnvelope(atSnapshotPath: snapshotPath)?.metadata + } + + private static func recoveryMetadataEnvelope( + atSnapshotPath snapshotPath: String + ) -> RecoveryMetadataEnvelope? { + let descriptor = open( + snapshotPath, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + guard descriptor >= 0 else { return nil } + defer { Darwin.close(descriptor) } + return recoveryMetadataEnvelope( + forDescriptor: descriptor, + expectedPath: snapshotPath + ) + } + + private static func recoveryMetadataEnvelope( + forDescriptor descriptor: Int32, + expectedPath: String + ) -> RecoveryMetadataEnvelope? { + var initialStatus = stat() + guard fstat(descriptor, &initialStatus) == 0, + initialStatus.st_mode & S_IFMT == S_IFREG, + initialStatus.st_uid == geteuid(), + initialStatus.st_nlink == 1, + path(expectedPath, names: initialStatus) else { + return nil + } + let byteCount = fgetxattr( + descriptor, + recoveryMetadataName, + nil, + 0, + 0, + 0 + ) + guard byteCount > 0, byteCount <= maxRecoveryMetadataBytes else { return nil } + var data = Data(count: byteCount) + let bytesRead = data.withUnsafeMutableBytes { buffer in + fgetxattr( + descriptor, + recoveryMetadataName, + buffer.baseAddress, + buffer.count, + 0, + 0 + ) + } + var finalStatus = stat() + guard bytesRead == byteCount, + fstat(descriptor, &finalStatus) == 0, + sameStableFile(finalStatus, initialStatus), + path(expectedPath, names: finalStatus) else { + return nil + } + guard let metadata = try? JSONDecoder().decode(RecoveryMetadata.self, from: data) else { + return nil + } + return RecoveryMetadataEnvelope(metadata: metadata, byteCount: byteCount) + } + + private static func writeRecoveryMetadataData( + _ data: Data, + atPath path: String + ) -> Bool { + let descriptor = open( + path, + O_RDWR | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + guard descriptor >= 0 else { return false } + defer { Darwin.close(descriptor) } + return writeRecoveryMetadataData( + data, + toDescriptor: descriptor, + expectedPath: path + ) + } + + /// Descriptor-bound metadata commit primitive. Keeping this internal lets + /// callers that already hold an inode avoid reopening a mutable path. + static func writeRecoveryMetadataData( + _ data: Data, + toDescriptor descriptor: Int32, + expectedPath: String + ) -> Bool { + guard !data.isEmpty, data.count <= maxRecoveryMetadataBytes else { + return false + } + var initialStatus = stat() + guard fstat(descriptor, &initialStatus) == 0, + initialStatus.st_mode & S_IFMT == S_IFREG, + initialStatus.st_uid == geteuid(), + initialStatus.st_nlink == 1, + path(expectedPath, names: initialStatus), + data.withUnsafeBytes({ buffer in + fsetxattr( + descriptor, + recoveryMetadataName, + buffer.baseAddress, + buffer.count, + 0, + 0 + ) == 0 + }), + fsync(descriptor) == 0 else { + return false + } + var finalStatus = stat() + return fstat(descriptor, &finalStatus) == 0 + && sameStableFile(finalStatus, initialStatus) + && path(expectedPath, names: finalStatus) + } + + private static func path(_ path: String, names expectedStatus: stat) -> Bool { + var pathStatus = stat() + return lstat(path, &pathStatus) == 0 + && pathStatus.st_mode & S_IFMT == S_IFREG + && pathStatus.st_uid == geteuid() + && pathStatus.st_nlink == 1 + && sameStableFile(pathStatus, expectedStatus) + } + + private static func snapshotModificationDate( + _ snapshotURL: URL, + fileManager: FileManager + ) -> Date { + let attributes = try? fileManager.attributesOfItem(atPath: snapshotURL.path) + return attributes?[.modificationDate] as? Date ?? Date() + } + + private static func snapshotFilenameMatchesSession(_ snapshotURL: URL, sessionId: String) -> Bool { + let filename = snapshotURL.lastPathComponent + return filename == "\(sessionId)-retained.jsonl" || + (filename.hasPrefix("\(sessionId)-") && filename.hasSuffix(".jsonl")) + } + + private static func validatedRecoveryCandidates( + in directory: URL, + directoryStream: UnsafeMutablePointer, + maximumEntries: Int, + afterTranscriptPath: String?, + fileManager: FileManager, + processProbeBudget: inout RecoveryProcessProbeBudget, + cancellationCheck: @Sendable () -> Bool + ) -> RecoveryCandidateScan { + let standardizedSnapshotDirectory = (directory.path as NSString).standardizingPath + var candidates: [PendingRecoverySnapshot] = [] + var candidateMetadataBytes = 0 + var candidateContentBytes: UInt64 = 0 + var budgetBlockedTranscriptPaths: Set = [] + var invalidEntries: [URL] = [] + var examinedEntries = 0 + var reachedEnd = false + while examinedEntries < max(0, maximumEntries), + !cancellationCheck() { + guard let entry = readdir(directoryStream) else { + reachedEnd = true + break + } + let name = withUnsafePointer(to: &entry.pointee.d_name) { namePointer in + namePointer.withMemoryRebound( + to: CChar.self, + capacity: Int(entry.pointee.d_namlen) + 1 + ) { String(cString: $0) } + } + guard name != ".", name != ".." else { continue } + examinedEntries += 1 + var url = directory.appendingPathComponent(name, isDirectory: false) + var metadataEnvelope = recoveryMetadataEnvelope(atSnapshotPath: url.path) + if name.hasPrefix(".") { + guard name.contains("-capture-"), name.hasSuffix(".tmp") else { + continue + } + guard let stagedMetadata = metadataEnvelope?.metadata, + let publishedURL = publishStagedRecoveryCandidate( + url, + metadata: stagedMetadata, + in: directory + ) else { + if invalidEntries.count < maxStartupRecoveryInvalidMovesPerLaunch { + invalidEntries.append(url) + } + continue + } + url = publishedURL + metadataEnvelope = recoveryMetadataEnvelope( + atSnapshotPath: publishedURL.path + ) + } + if !name.hasPrefix("."), + (url.pathExtension != "jsonl" || metadataEnvelope == nil) { + if invalidEntries.count < maxStartupRecoveryInvalidMovesPerLaunch { + invalidEntries.append(url) + } + continue + } + var status = stat() + guard url.pathExtension == "jsonl", + lstat(url.path, &status) == 0, + status.st_mode & S_IFMT == S_IFREG, + status.st_uid == geteuid(), + status.st_nlink == 1, + status.st_size >= 0, + UInt64(status.st_size) <= maximumProtectedTranscriptBytes, + let metadataEnvelope else { + if !name.hasPrefix("."), + invalidEntries.count < maxStartupRecoveryInvalidMovesPerLaunch { + invalidEntries.append(url) + } + continue + } + if metadataEnvelope.metadata.version == 2, + (metadataEnvelope.metadata.candidateState == "external-staging" + || metadataEnvelope.metadata.candidateState + == "external-staging-pending") { + _ = cleanupExternalRestoreStagingAuthority( + at: url, + fileManager: fileManager + ) + continue + } + guard recoveryMetadata( + metadataEnvelope.metadata, + isValidAt: url + ) else { + if invalidEntries.count < maxStartupRecoveryInvalidMovesPerLaunch { + invalidEntries.append(url) + } + continue + } + let effectiveMetadataEnvelope: RecoveryMetadataEnvelope + if metadataEnvelope.metadata.version == 1 { + guard let upgraded = upgradeLegacyRecoveryMetadata( + metadataEnvelope.metadata, + at: url, + status: status + ) else { + continue + } + effectiveMetadataEnvelope = upgraded + } else { + effectiveMetadataEnvelope = metadataEnvelope + } + let metadata = effectiveMetadataEnvelope.metadata + guard + isSafeSessionIdPathComponent(metadata.sessionId), + snapshotFilenameMatchesSession(url, sessionId: metadata.sessionId), + metadata.transcriptPath.hasPrefix("/"), + !metadata.transcriptPath.contains("\0"), + (metadata.transcriptPath as NSString).standardizingPath != + (url.path as NSString).standardizingPath else { + if invalidEntries.count < maxStartupRecoveryInvalidMovesPerLaunch { + invalidEntries.append(url) + } + continue + } + let transcriptKey = (metadata.transcriptPath as NSString).standardizingPath + guard !budgetBlockedTranscriptPaths.contains(transcriptKey), + transcriptKey != standardizedSnapshotDirectory, + !transcriptKey.hasPrefix(standardizedSnapshotDirectory + "/"), + transcriptDestinationIsRegularOrMissing( + metadata.transcriptPath, + fileManager: fileManager + ) else { + continue + } + guard recoveryMetadataOwnerState( + metadata, + processProbeBudget: &processProbeBudget + ) == .retired else { + // A live or unprobed generation blocks every older generation + // for the same transcript. Dropping only that candidate would + // let stale bytes win merely because the syscall budget ended. + budgetBlockedTranscriptPaths.insert(transcriptKey) + candidates.removeAll { + ($0.metadata.transcriptPath as NSString).standardizingPath + == transcriptKey + } + candidateMetadataBytes = candidates.reduce(0) { + $0 + $1.metadataByteCount + } + candidateContentBytes = candidates.reduce(0) { + $0 + $1.contentByteCount + } + continue + } + guard let content = recoveryContent( + for: metadata, + authorityURL: url, + authorityStatus: status + ) else { + // A split cross-volume authority is valid only when both + // metadata copies still describe the exact same generation. + // Preserve a mismatched pointer in quarantine instead of + // silently retrying weakened external authority forever. + if invalidEntries.count < maxStartupRecoveryInvalidMovesPerLaunch { + invalidEntries.append(url) + } + continue + } + guard let authorityVersion = stableRegularFileVersion( + atPath: url.path, + fileManager: fileManager + ), + let contentVersion = content.url.path == url.path + ? authorityVersion + : stableRegularFileVersion( + atPath: content.url.path, + fileManager: fileManager + ), + recoveryMetadata(atSnapshotPath: url.path) == metadata else { + continue + } + let modificationDate = Date( + timeIntervalSince1970: TimeInterval(status.st_mtimespec.tv_sec) + + TimeInterval(status.st_mtimespec.tv_nsec) / 1_000_000_000 + ) + let capturedAt = recoveryOrderingDate( + metadata.capturedAt ?? modificationDate, + fallback: modificationDate + ) + insertRecoveryCandidate( + PendingRecoverySnapshot( + url: url, + contentURL: content.url, + authorityVersion: authorityVersion, + contentVersion: contentVersion, + metadata: metadata, + metadataByteCount: effectiveMetadataEnvelope.byteCount, + contentByteCount: UInt64(content.status.st_size), + capturedAt: capturedAt, + modificationDate: modificationDate + ), + into: &candidates, + metadataBytes: &candidateMetadataBytes, + contentBytes: &candidateContentBytes, + budgetBlockedTranscriptPaths: &budgetBlockedTranscriptPaths, + afterTranscriptPath: afterTranscriptPath + ) + } + return RecoveryCandidateScan( + candidates: candidates, + budgetBlockedTranscriptPaths: budgetBlockedTranscriptPaths, + invalidEntries: invalidEntries, + examinedEntries: examinedEntries, + reachedEnd: reachedEnd + ) + } + + private static func upgradeLegacyRecoveryMetadata( + _ metadata: RecoveryMetadata, + at candidateURL: URL, + status: stat + ) -> RecoveryMetadataEnvelope? { + guard metadata.version == 1, + recoveryMetadata(metadata, isValidAt: candidateURL) else { + return nil + } + let capturedAt = metadata.capturedAt ?? Date( + timeIntervalSince1970: TimeInterval(status.st_mtimespec.tv_sec) + + TimeInterval(status.st_mtimespec.tv_nsec) / 1_000_000_000 + ) + let snapshot = TeardownTranscriptSnapshot( + transcriptPath: metadata.transcriptPath, + snapshotPath: candidateURL.path, + liveFileVersion: metadata.liveFileVersion, + guardedProcessIdentities: metadata.guardedProcesses? + .compactMap(\.processIdentity) ?? [], + hasUncapturedGuardedProcesses: + metadata.hasUncapturedGuardedProcesses == true + ) + guard persistRecoveryMetadata( + for: snapshot, + sessionId: metadata.sessionId, + capturedAt: capturedAt, + liveFileVersion: metadata.liveFileVersion, + ownerProcessIdentity: metadata.ownerProcessIdentity, + ownerRuntimeMetadata: .init( + runtimeId: metadata.ownerRuntimeId, + bundleIdentifier: metadata.ownerBundleIdentifier + ), + guardedProcessIdentities: snapshot.guardedProcessIdentities, + hasUncapturedGuardedProcesses: + snapshot.hasUncapturedGuardedProcesses + ), synchronizeRegularFileAndContainingDirectory(atPath: candidateURL.path), + let upgraded = recoveryMetadataEnvelope( + atSnapshotPath: candidateURL.path + ), + upgraded.metadata.version == 2, + recoveryMetadata(upgraded.metadata, isValidAt: candidateURL) else { + return nil + } + return upgraded + } + + private static func validatedDurableRetainedMetadata( + at retainedURL: URL, + fileManager: FileManager + ) -> RecoveryMetadata? { + guard let envelope = recoveryMetadataEnvelope( + atSnapshotPath: retainedURL.path + ) else { + return nil + } + switch envelope.metadata.version { + case 1: + var status = stat() + guard lstat(retainedURL.path, &status) == 0 else { return nil } + return upgradeLegacyRecoveryMetadata( + envelope.metadata, + at: retainedURL, + status: status + )?.metadata + case 2: + guard recoveryMetadata(envelope.metadata, isValidAt: retainedURL), + synchronizeRegularFileAndContainingDirectory( + atPath: retainedURL.path + ), + recoveryMetadataEnvelope( + atSnapshotPath: retainedURL.path + )?.metadata == envelope.metadata else { + return nil + } + return envelope.metadata + default: + return nil + } + } + + private static func insertRecoveryCandidate( + _ candidate: PendingRecoverySnapshot, + into candidates: inout [PendingRecoverySnapshot], + metadataBytes: inout Int, + contentBytes: inout UInt64, + budgetBlockedTranscriptPaths: inout Set, + afterTranscriptPath: String? + ) { + let selection = selectRecoveryCandidatesUnderBudget( + candidates + [candidate], + transcriptKey: { + ($0.metadata.transcriptPath as NSString).standardizingPath + }, + metadataByteCount: \.metadataByteCount, + contentByteCount: \.contentByteCount, + isNewer: recoveryCandidateIsNewer, + maximumCount: maxStartupRecoverySnapshots, + maximumMetadataBytes: maxStartupRecoveryCandidateMetadataBytes, + maximumContentBytes: maxStartupRecoveryCandidateContentBytes, + previouslyBlockedTranscriptKeys: budgetBlockedTranscriptPaths, + afterTranscriptPath: afterTranscriptPath + ) + candidates = selection.candidates + budgetBlockedTranscriptPaths = selection.blockedTranscriptKeys + metadataBytes = candidates.reduce(0) { $0 + $1.metadataByteCount } + contentBytes = candidates.reduce(0) { $0 + $1.contentByteCount } + } + + /// Selects complete transcript-generation groups under startup's bounded + /// memory budget. If any generation of a transcript cannot be represented, + /// the whole transcript is deferred. This preserves the invariant that an + /// unresolved newer generation can never be forgotten while an older one is + /// admitted from a later directory batch. + static func selectRecoveryCandidatesUnderBudget( + _ input: [Candidate], + transcriptKey: (Candidate) -> String, + metadataByteCount: (Candidate) -> Int, + contentByteCount: (Candidate) -> UInt64, + isNewer: (Candidate, Candidate) -> Bool, + maximumCount: Int, + maximumMetadataBytes: Int, + maximumContentBytes: UInt64, + previouslyBlockedTranscriptKeys: Set = [], + afterTranscriptPath: String? = nil + ) -> (candidates: [Candidate], blockedTranscriptKeys: Set) { + var blockedTranscriptKeys = previouslyBlockedTranscriptKeys + var candidatesByTranscript: [String: [Candidate]] = [:] + for candidate in input { + let key = transcriptKey(candidate) + guard !blockedTranscriptKeys.contains(key) else { continue } + candidatesByTranscript[key, default: []].append(candidate) + } + for key in Array(candidatesByTranscript.keys) { + candidatesByTranscript[key]?.sort(by: isNewer) + } + let orderedKeys = candidatesByTranscript.keys.sorted { + recoveryTranscriptKey($0, precedes: $1, after: afterTranscriptPath) + } + + var selected: [(key: String, candidate: Candidate)] = [] + var selectedMetadataBytes = 0 + var selectedContentBytes: UInt64 = 0 + var generationIndex = 0 + while selected.count < max(0, maximumCount) { + var examinedGeneration = false + for key in orderedKeys + where selected.count < max(0, maximumCount) + && !blockedTranscriptKeys.contains(key) { + guard let values = candidatesByTranscript[key], + generationIndex < values.count else { + continue + } + examinedGeneration = true + let candidate = values[generationIndex] + let candidateMetadataBytes = metadataByteCount(candidate) + let candidateContentBytes = contentByteCount(candidate) + guard candidateMetadataBytes >= 0, + candidateMetadataBytes <= maximumMetadataBytes, + candidateContentBytes <= maximumContentBytes, + selectedMetadataBytes <= maximumMetadataBytes - candidateMetadataBytes, + selectedContentBytes <= maximumContentBytes - candidateContentBytes else { + blockedTranscriptKeys.insert(key) + selected.removeAll { selectedCandidate in + guard selectedCandidate.key == key else { return false } + selectedMetadataBytes -= metadataByteCount(selectedCandidate.candidate) + selectedContentBytes -= contentByteCount(selectedCandidate.candidate) + return true + } + continue + } + selected.append((key, candidate)) + selectedMetadataBytes += candidateMetadataBytes + selectedContentBytes += candidateContentBytes + } + guard examinedGeneration else { break } + generationIndex += 1 + } + + let selectedCountByTranscript = Dictionary( + grouping: selected, + by: \.key + ).mapValues(\.count) + for (key, values) in candidatesByTranscript + where selectedCountByTranscript[key, default: 0] < values.count { + blockedTranscriptKeys.insert(key) + } + selected.removeAll { blockedTranscriptKeys.contains($0.key) } + return (selected.map(\.candidate), blockedTranscriptKeys) + } + + private static func recoveryTranscriptKey( + _ lhs: String, + precedes rhs: String, + after cursor: String? + ) -> Bool { + guard lhs != rhs else { return false } + guard let cursor else { return lhs < rhs } + let lhsFollowsCursor = lhs > cursor + let rhsFollowsCursor = rhs > cursor + if lhsFollowsCursor != rhsFollowsCursor { + return lhsFollowsCursor + } + return lhs < rhs + } + + /// A completed hidden capture is already durable recovery authority. A + /// crash can occur after its metadata fsync but before the normal publish + /// rename, so startup must publish it instead of pruning it as a temp. + private static func publishStagedRecoveryCandidate( + _ stagedURL: URL, + metadata: RecoveryMetadata, + in directory: URL + ) -> URL? { + let standardizedDirectory = (directory.path as NSString).standardizingPath + guard isSafeSessionIdPathComponent(metadata.sessionId) else { return nil } + var effectiveMetadata = metadata + let intendedURL: URL + switch metadata.version { + case 1: + guard let snapshotPath = metadata.snapshotPath else { return nil } + intendedURL = URL(fileURLWithPath: snapshotPath) + guard (intendedURL.deletingLastPathComponent().path as NSString).standardizingPath + == standardizedDirectory, + snapshotFilenameMatchesSession( + intendedURL, + sessionId: metadata.sessionId + ) else { + return nil + } + case 2: + guard let candidateId = metadata.candidateId, + UUID(uuidString: candidateId) != nil, + metadata.candidateState == "recoverable" else { + return nil + } + intendedURL = directory.appendingPathComponent( + "\(metadata.sessionId)-\(candidateId).jsonl", + isDirectory: false + ) + default: + return nil } - - let accountRoot = (homeDirectory as NSString).appendingPathComponent(".codex-accounts/claude") - if directoryExists(atPath: accountRoot, fileManager: fileManager), - let accountDirs = try? fileManager.contentsOfDirectory(atPath: accountRoot) { - for accountDir in accountDirs.sorted() { - let accountPath = (accountRoot as NSString).appendingPathComponent(accountDir) - guard directoryExists(atPath: accountPath, fileManager: fileManager) else { continue } - appendRoot(accountPath) + var intendedStatus = stat() + let destinationURL: URL + if lstat(intendedURL.path, &intendedStatus) == 0 { + destinationURL = directory.appendingPathComponent( + "\(metadata.sessionId)-staged-\(UUID().uuidString).jsonl", + isDirectory: false + ) + } else { + guard errno == ENOENT else { return nil } + destinationURL = intendedURL + } + if metadata.version == 1, destinationURL.path != intendedURL.path { + let stagedSnapshot = TeardownTranscriptSnapshot( + transcriptPath: metadata.transcriptPath, + snapshotPath: stagedURL.path, + liveFileVersion: metadata.liveFileVersion, + guardedProcessIdentities: metadata.guardedProcesses?.compactMap(\.processIdentity) ?? [], + hasUncapturedGuardedProcesses: metadata.hasUncapturedGuardedProcesses == true + ) + guard persistRecoveryMetadata( + for: stagedSnapshot, + sessionId: metadata.sessionId, + capturedAt: metadata.capturedAt ?? Date(), + liveFileVersion: metadata.liveFileVersion, + ownerProcessIdentity: metadata.ownerProcessIdentity, + ownerRuntimeMetadata: .init( + runtimeId: metadata.ownerRuntimeId, + bundleIdentifier: metadata.ownerBundleIdentifier + ), + guardedProcessIdentities: stagedSnapshot.guardedProcessIdentities, + hasUncapturedGuardedProcesses: stagedSnapshot.hasUncapturedGuardedProcesses + ), synchronizeRegularFileAndContainingDirectory(atPath: stagedURL.path), + let upgraded = recoveryMetadata(atSnapshotPath: stagedURL.path) else { + return nil } + effectiveMetadata = upgraded } - appendRoot((homeDirectory as NSString).appendingPathComponent(".claude")) - appendRoot(ClaudeConfigDirectoryPath.preferredPath( - (homeDirectory as NSString).appendingPathComponent(".subrouter/codex/claude"), - fileManager: fileManager, - homeDirectory: homeDirectory) - ) - return roots + guard renamex_np( + stagedURL.path, + destinationURL.path, + UInt32(RENAME_EXCL) + ) == 0 else { + return nil + } + guard recoveryMetadata(effectiveMetadata, isValidAt: destinationURL), + synchronizeRegularFileAndContainingDirectory(atPath: destinationURL.path) else { + _ = renamex_np( + destinationURL.path, + stagedURL.path, + UInt32(RENAME_EXCL) + ) + return nil + } + return destinationURL } - private static func defaultSnapshotDirectoryURL() -> URL? { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? - .appendingPathComponent("cmux", isDirectory: true) - .appendingPathComponent("agent-transcript-teardown-snapshots", isDirectory: true) + private static func recoveryMetadata( + _ metadata: RecoveryMetadata, + isValidAt candidateURL: URL + ) -> Bool { + switch metadata.version { + case 1: + guard let snapshotPath = metadata.snapshotPath else { return false } + return (snapshotPath as NSString).standardizingPath == + (candidateURL.path as NSString).standardizingPath + && metadata.externalCandidatePath == nil + && metadata.externalFileDevice == nil + && metadata.externalFileNumber == nil + case 2: + guard let candidateId = metadata.candidateId else { return false } + let hasExternalPath = metadata.externalCandidatePath != nil + let hasExternalDevice = metadata.externalFileDevice != nil + let hasExternalFile = metadata.externalFileNumber != nil + return UUID(uuidString: candidateId) != nil + && metadata.candidateState == "recoverable" + && metadata.snapshotPath == nil + && (hasExternalPath == hasExternalDevice) + && (hasExternalPath == hasExternalFile) + default: + return false + } } - private static func pruneOldSnapshots(in directory: URL, fileManager: FileManager) { - guard let urls = try? fileManager.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { - return + private static func recoveryContent( + for metadata: RecoveryMetadata, + authorityURL: URL, + authorityStatus: stat + ) -> (url: URL, status: stat)? { + guard let externalPath = metadata.externalCandidatePath else { + return (authorityURL, authorityStatus) } - let cutoff = Date().addingTimeInterval(-14 * 24 * 60 * 60) - for url in urls { - guard (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) - .map({ $0 < cutoff }) == true else { - continue + guard let candidateId = metadata.candidateId, + UUID(uuidString: candidateId) != nil, + externalPath.hasPrefix("/"), + !externalPath.contains("\0"), + (externalPath as NSString).standardizingPath != + (authorityURL.path as NSString).standardizingPath, + let expectedDevice = metadata.externalFileDevice, + let expectedFile = metadata.externalFileNumber else { + return nil + } + let transcriptURL = URL(fileURLWithPath: metadata.transcriptPath) + let expectedExternalURL = transcriptURL.deletingLastPathComponent() + .appendingPathComponent( + ".\(transcriptURL.lastPathComponent).cmux-recovery-\(candidateId).jsonl", + isDirectory: false + ) + guard (externalPath as NSString).standardizingPath + == (expectedExternalURL.path as NSString).standardizingPath else { + return nil + } + let externalURL = URL(fileURLWithPath: externalPath) + var externalStatus = stat() + guard lstat(externalURL.path, &externalStatus) == 0, + externalStatus.st_mode & S_IFMT == S_IFREG, + externalStatus.st_uid == geteuid(), + externalStatus.st_nlink == 1, + externalStatus.st_size >= 0, + UInt64(externalStatus.st_size) <= maximumProtectedTranscriptBytes, + UInt64(externalStatus.st_dev) == expectedDevice, + UInt64(externalStatus.st_ino) == expectedFile, + let externalMetadata = recoveryMetadata( + atSnapshotPath: externalURL.path + ), + externalMetadata == metadata, + recoveryMetadata(externalMetadata, isValidAt: externalURL) else { + return nil + } + return (externalURL, externalStatus) + } + + private static func recoveryCandidateIsNewer( + _ lhs: PendingRecoverySnapshot, + _ rhs: PendingRecoverySnapshot + ) -> Bool { + if lhs.capturedAt != rhs.capturedAt { + return lhs.capturedAt > rhs.capturedAt + } + if lhs.modificationDate != rhs.modificationDate { + return lhs.modificationDate > rhs.modificationDate + } + return lhs.url.path > rhs.url.path + } + + private static func recoveryOrderingDate(_ date: Date, fallback: Date) -> Date { + let maximumMagnitude: TimeInterval = 100_000_000_000 + let fallbackSeconds = fallback.timeIntervalSinceReferenceDate + let seconds = date.timeIntervalSinceReferenceDate + let finiteSeconds = seconds.isFinite + ? seconds + : (fallbackSeconds.isFinite ? fallbackSeconds : 0) + return Date( + timeIntervalSinceReferenceDate: min( + maximumMagnitude, + max(-maximumMagnitude, finiteSeconds) + ) + ) + } + + private static func recoveryMetadataOwnerState( + _ metadata: RecoveryMetadata, + processProbeBudget: inout RecoveryProcessProbeBudget + ) -> RecoveryOwnerState { + if metadata.hasUncapturedGuardedProcesses == true { return .live } + if metadata.ownerProcessId != nil + || metadata.ownerProcessStartSeconds != nil + || metadata.ownerProcessStartMicroseconds != nil { + guard let ownerIdentity = metadata.ownerProcessIdentity else { + return .unknown } - try? fileManager.removeItem(at: url) + let current = processProbeBudget.identity(for: ownerIdentity.pid) + guard current.known else { return .unknown } + if current.identity == ownerIdentity { return .live } + } + if let ownerRuntimeId = metadata.ownerRuntimeId, + ownerRuntimeId == normalized( + ProcessInfo.processInfo.environment["CMUX_RUNTIME_ID"] + ), + metadata.ownerBundleIdentifier == Bundle.main.bundleIdentifier { + return .live + } + for guarded in (metadata.guardedProcesses ?? []).prefix(64) { + guard let identity = guarded.processIdentity else { return .unknown } + let current = processProbeBudget.identity(for: identity.pid) + guard current.known else { return .unknown } + if current.identity == identity { return .live } } + return .retired } - private static func lineDataHasConversationTurn(_ data: Data) -> Bool { - guard !data.isEmpty, - data.range(of: Data(#""type""#.utf8)) != nil, - (data.range(of: Data(#""user""#.utf8)) != nil || - data.range(of: Data(#""assistant""#.utf8)) != nil), - String(data: data, encoding: .utf8) != nil, - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let type = object["type"] as? String else { + private static func transcriptDestinationIsRegularOrMissing( + _ path: String, + fileManager: FileManager + ) -> Bool { + guard fileManager.fileExists(atPath: path) else { return true } + guard let attributes = try? fileManager.attributesOfItem(atPath: path), + let fileType = attributes[.type] as? FileAttributeType else { return false } - return type == "user" || type == "assistant" + return fileType == .typeRegular } - static func transcriptContainsOnlyNonProtectiveMetadata( - atPath path: String, - fileManager: FileManager, - maxScannedLineBytes: Int = Self.maxScannedLineBytes + private static func rotatedRecoveryTranscriptKeys( + _ sortedKeys: [String], + after cursor: String? + ) -> [String] { + guard let cursor, + let cursorIndex = sortedKeys.firstIndex(of: cursor), + cursorIndex + 1 < sortedKeys.count else { + return sortedKeys + } + return Array(sortedKeys[(cursorIndex + 1)...]) + Array(sortedKeys[...cursorIndex]) + } + + private static func acquireRecoveryDirectoryLock(in directory: URL) -> Int32? { + guard let descriptor = validatedRecoveryDirectoryLockDescriptor(in: directory) else { + return nil + } + guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { + close(descriptor) + return nil + } + _ = fchmod(descriptor, S_IRUSR | S_IWUSR) + return descriptor + } + + private static func acquireRecoveryDirectoryLockSynchronously( + in directory: URL + ) -> Int32? { + guard let descriptor = validatedRecoveryDirectoryLockDescriptor(in: directory) else { + return nil + } + while flock(descriptor, LOCK_EX) != 0 { + guard errno == EINTR else { + close(descriptor) + return nil + } + } + _ = fchmod(descriptor, S_IRUSR | S_IWUSR) + return descriptor + } + + private static func acquireRecoveryDirectoryLockAwaitingContention( + in directory: URL + ) async -> Int32? { + await withCheckedContinuation { continuation in + recoveryLockWaitQueue.async { + guard let descriptor = validatedRecoveryDirectoryLockDescriptor( + in: directory + ) else { + continuation.resume(returning: nil) + return + } + while flock(descriptor, LOCK_EX) != 0 { + guard errno == EINTR else { + close(descriptor) + continuation.resume(returning: nil) + return + } + } + _ = fchmod(descriptor, S_IRUSR | S_IWUSR) + continuation.resume(returning: descriptor) + } + } + } + + private static func validatedRecoveryDirectoryLockDescriptor( + in directory: URL + ) -> Int32? { + guard ensurePrivateRecoveryDirectory( + at: directory, + createIfMissing: false, + fileManager: .default + ) else { return nil } + let lockURL = directory.appendingPathComponent(recoveryLockFilename, isDirectory: false) + let descriptor = open( + lockURL.path, + O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, + S_IRUSR | S_IWUSR + ) + guard descriptor >= 0 else { return nil } + var status = stat() + guard fstat(descriptor, &status) == 0, + status.st_mode & S_IFMT == S_IFREG, + status.st_uid == geteuid(), + status.st_nlink == 1, + fchmod(descriptor, S_IRUSR | S_IWUSR) == 0 else { + close(descriptor) + return nil + } + return descriptor + } + + private static func releaseRecoveryDirectoryLock(_ descriptor: Int32) { + _ = flock(descriptor, LOCK_UN) + _ = close(descriptor) + } + + /// Counts every recovery file before admitting another durable authority. + /// The scan is complete when under quota and short-circuits as soon as a + /// limit is exceeded, so directory noise cannot force unbounded allocation. + /// Cross-volume content is counted by inode in addition to its pointer. + private static func recoveryStorageCanAdmit( + in directory: URL, + additionalFileCount: Int, + additionalBytes: UInt64, + maximumFileCount: Int, + maximumBytes: UInt64 ) -> Bool { - guard fileManager.fileExists(atPath: path), - let handle = FileHandle(forReadingAtPath: path) else { + guard maximumFileCount >= 0, + additionalFileCount >= 0, + additionalFileCount <= maximumFileCount, + additionalBytes <= maximumBytes else { return false } - defer { try? handle.close() } + var fileCount = additionalFileCount + var logicalBytes = additionalBytes + var countedFiles: Set = [] - var sawMetadata = false - var buffered = Data() - while true { - let chunk: Data - do { chunk = try handle.read(upToCount: 64 * 1024) ?? Data() } catch { return false } - guard !chunk.isEmpty else { - guard buffered.count <= maxScannedLineBytes, - lineDataIsNonProtectiveMetadata(buffered, sawMetadata: &sawMetadata) else { - return false - } + func addFile(_ status: stat) -> Bool { + guard status.st_mode & S_IFMT == S_IFREG, + status.st_uid == geteuid(), + status.st_nlink == 1, + status.st_size >= 0 else { + return false + } + let identity = RecoveryStorageFileIdentity(status) + guard countedFiles.insert(identity).inserted else { return true } + let size = UInt64(status.st_size) + guard size <= maximumBytes, + fileCount < maximumFileCount, + logicalBytes <= maximumBytes - size else { + return false + } + fileCount += 1 + logicalBytes += size + return true + } + + func addReferencedExternalFile( + metadata: RecoveryMetadata, + authorityURL: URL, + authorityStatus: stat + ) -> Bool { + guard let externalPath = metadata.externalCandidatePath else { return true } + if let content = recoveryContent( + for: metadata, + authorityURL: authorityURL, + authorityStatus: authorityStatus + ) { + return addFile(content.status) + } + + // Invalid split metadata is not recovery authority, but its exact + // referenced inode still consumes storage. Count it conservatively + // when the pointer's path, device, and inode tuple is self-consistent. + guard let candidateId = metadata.candidateId, + UUID(uuidString: candidateId) != nil, + externalPath.hasPrefix("/"), + !externalPath.contains("\0"), + let expectedDevice = metadata.externalFileDevice, + let expectedFile = metadata.externalFileNumber else { + return false + } + let transcriptURL = URL(fileURLWithPath: metadata.transcriptPath) + let expectedURL = transcriptURL.deletingLastPathComponent() + .appendingPathComponent( + ".\(transcriptURL.lastPathComponent).cmux-recovery-\(candidateId).jsonl", + isDirectory: false + ) + guard (externalPath as NSString).standardizingPath + == (expectedURL.path as NSString).standardizingPath else { + return false + } + var externalStatus = stat() + guard lstat(externalPath, &externalStatus) == 0, + UInt64(externalStatus.st_dev) == expectedDevice, + UInt64(externalStatus.st_ino) == expectedFile else { + return false + } + return addFile(externalStatus) + } + + func scanDirectory( + _ scanDirectoryURL: URL, + permitsQuarantineChild: Bool + ) -> Bool { + guard let stream = recoveryDirectoryStream(in: scanDirectoryURL) else { + return false + } + defer { closedir(stream) } + while let entry = readdir(stream) { + let name = withUnsafePointer(to: &entry.pointee.d_name) { namePointer in + namePointer.withMemoryRebound( + to: CChar.self, + capacity: Int(entry.pointee.d_namlen) + 1 + ) { String(cString: $0) } + } + guard name != ".", name != ".." else { continue } + if permitsQuarantineChild, name == recoveryLockFilename { continue } + guard !name.isEmpty, !name.contains("/") else { return false } + var status = stat() + guard fstatat( + dirfd(stream), + name, + &status, + AT_SYMLINK_NOFOLLOW + ) == 0 else { + return false + } + let url = scanDirectoryURL.appendingPathComponent( + name, + isDirectory: status.st_mode & S_IFMT == S_IFDIR + ) + if status.st_mode & S_IFMT == S_IFDIR { + guard permitsQuarantineChild, + name == ".recovery-quarantine", + status.st_uid == geteuid(), + scanDirectory(url, permitsQuarantineChild: false) else { + return false + } + continue + } + guard addFile(status) else { return false } - buffered.append(chunk) - while let newlineIndex = buffered.firstIndex(of: 10) { - let lineData = Data(buffered[.. maxScannedLineBytes { + return true + } + + return scanDirectory(directory, permitsQuarantineChild: true) + } + + private static func recoveryCursor(lockDescriptor: Int32) -> RecoveryCursorState { + let handle = FileHandle(fileDescriptor: lockDescriptor, closeOnDealloc: false) + do { + try handle.seek(toOffset: 0) + guard let data = try handle.read(upToCount: maxRecoveryCursorBytes + 1), + !data.isEmpty, + data.count <= maxRecoveryCursorBytes, + let value = String(data: data, encoding: .utf8), + !value.contains("\0") else { + return RecoveryCursorState(transcriptPath: nil) + } + if let state = try? JSONDecoder().decode(RecoveryCursorState.self, from: data) { + return state + } + // Version-one lock files stored only the transcript fairness key. + return RecoveryCursorState(transcriptPath: value) + } catch { + return RecoveryCursorState(transcriptPath: nil) + } + } + + private static func persistRecoveryCursor( + _ cursor: RecoveryCursorState, + lockDescriptor: Int32 + ) { + guard let data = try? JSONEncoder().encode(cursor), + !data.isEmpty, + data.count <= maxRecoveryCursorBytes else { + return + } + let handle = FileHandle(fileDescriptor: lockDescriptor, closeOnDealloc: false) + do { + try handle.truncate(atOffset: 0) + try handle.seek(toOffset: 0) + try handle.write(contentsOf: data) + try handle.synchronize() + } catch { + return + } + } + + private static func claimRecoveryCandidate( + _ candidate: PendingRecoverySnapshot, + fileManager: FileManager + ) -> PendingRecoverySnapshot? { + guard candidate.metadata.version == 2, + recoveryMetadata(candidate.metadata, isValidAt: candidate.url), + stableRegularFileVersion( + atPath: candidate.url.path, + fileManager: fileManager + ) == candidate.authorityVersion, + stableRegularFileVersion( + atPath: candidate.contentURL.path, + fileManager: fileManager + ) == candidate.contentVersion, + recoveryMetadata(atSnapshotPath: candidate.url.path) + == candidate.metadata else { + return nil + } + let claimedURL = candidate.url.deletingLastPathComponent() + .appendingPathComponent( + "\(candidate.metadata.sessionId)-processing-\(UUID().uuidString).jsonl", + isDirectory: false + ) + guard atomicallyRename(candidate.url, to: claimedURL) else { return nil } + let claimedContentURL = candidate.contentURL.path == candidate.url.path + ? claimedURL + : candidate.contentURL + var claimedAuthorityStatus = stat() + guard stableRegularFileVersion( + atPath: claimedURL.path, + fileManager: fileManager + ) == candidate.authorityVersion, + stableRegularFileVersion( + atPath: claimedContentURL.path, + fileManager: fileManager + ) == candidate.contentVersion, + recoveryMetadata(atSnapshotPath: claimedURL.path) + == candidate.metadata, + lstat(claimedURL.path, &claimedAuthorityStatus) == 0, + let validatedContent = recoveryContent( + for: candidate.metadata, + authorityURL: claimedURL, + authorityStatus: claimedAuthorityStatus + ), + validatedContent.url.path == claimedContentURL.path else { + _ = atomicallyRename(claimedURL, to: candidate.url) + return nil + } + return PendingRecoverySnapshot( + url: claimedURL, + contentURL: claimedContentURL, + authorityVersion: candidate.authorityVersion, + contentVersion: candidate.contentVersion, + metadata: candidate.metadata, + metadataByteCount: candidate.metadataByteCount, + contentByteCount: candidate.contentByteCount, + capturedAt: candidate.capturedAt, + modificationDate: candidate.modificationDate + ) + } + + private static func durablyRemoveClaimedRecoveryCandidate( + _ candidate: PendingRecoverySnapshot, + authorityVersion: TeardownTranscriptFileVersion, + contentVersion: TeardownTranscriptFileVersion, + afterSynchronizingLivePath livePath: String? + ) -> Bool { + if candidate.contentURL.path != candidate.url.path { + guard durablyRemoveRecoverySnapshot( + atPath: candidate.contentURL.path, + afterSynchronizingLivePath: livePath, + expectedSnapshotVersion: contentVersion + ) else { return false } } + return durablyRemoveRecoverySnapshot( + atPath: candidate.url.path, + afterSynchronizingLivePath: livePath, + expectedSnapshotVersion: authorityVersion + ) + } + + private static func preserveClaimedRecoveryCandidate( + _ candidate: PendingRecoverySnapshot, + at originalURL: URL, + fileManager: FileManager + ) { + let preservedURL: URL + if !fileManager.fileExists(atPath: originalURL.path), + atomicallyRename(candidate.url, to: originalURL) { + preservedURL = originalURL + } else { + preservedURL = candidate.url + } + _ = persistRecoveryMetadata( + for: TeardownTranscriptSnapshot( + transcriptPath: candidate.metadata.transcriptPath, + snapshotPath: preservedURL.path + ), + sessionId: candidate.metadata.sessionId, + capturedAt: candidate.capturedAt, + liveFileVersion: candidate.metadata.liveFileVersion, + ownerProcessIdentity: candidate.metadata.ownerProcessIdentity, + ownerRuntimeMetadata: .init( + runtimeId: candidate.metadata.ownerRuntimeId, + bundleIdentifier: candidate.metadata.ownerBundleIdentifier + ), + guardedProcessIdentities: candidate.metadata.guardedProcesses?.compactMap(\.processIdentity) ?? [], + hasUncapturedGuardedProcesses: + candidate.metadata.hasUncapturedGuardedProcesses == true, + candidateId: candidate.metadata.candidateId, + externalCandidatePath: candidate.metadata.externalCandidatePath, + externalFileDevice: candidate.metadata.externalFileDevice, + externalFileNumber: candidate.metadata.externalFileNumber + ) + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: preservedURL.path) + _ = synchronizeRegularFileAndContainingDirectory(atPath: preservedURL.path) + } + + private static func moveInvalidRecoveryEntriesAside( + _ entries: [URL], + in directory: URL, + fileManager: FileManager, + cancellationCheck: @Sendable () -> Bool + ) { + guard !entries.isEmpty, !cancellationCheck() else { return } + let quarantineDirectory = directory.appendingPathComponent( + ".recovery-quarantine", + isDirectory: true + ) + guard ensurePrivateRecoveryDirectory( + at: quarantineDirectory, + createIfMissing: true, + fileManager: fileManager + ) else { return } + var movedAny = false + for entry in entries.prefix(maxStartupRecoveryInvalidMovesPerLaunch) { + guard !cancellationCheck() else { break } + let name = entry.lastPathComponent + guard name != recoveryLockFilename, + name != ".recovery-quarantine", + !name.isEmpty, + !name.contains("/") else { + continue + } + var status = stat() + guard lstat(entry.path, &status) == 0, + status.st_mode & S_IFMT == S_IFREG, + status.st_uid == geteuid(), + status.st_nlink == 1 else { + continue + } + let destination = quarantineDirectory.appendingPathComponent( + "invalid-\(UUID().uuidString)-\(name)", + isDirectory: false + ) + if atomicallyRename(entry, to: destination) { movedAny = true } + } + if movedAny { + _ = synchronizeDirectory(atPath: directory.path) + _ = synchronizeDirectory(atPath: quarantineDirectory.path) + } } - private static func lineDataIsNonProtectiveMetadata(_ data: Data, sawMetadata: inout Bool) -> Bool { - guard let line = String(data: data, encoding: .utf8) else { return false } - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return true } - guard let object = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) as? [String: Any], - let type = object["type"] as? String else { + static func atomicallyRename(_ source: URL, to destination: URL) -> Bool { + renamex_np(source.path, destination.path, UInt32(RENAME_EXCL)) == 0 + } + + static func volumeSupportsAtomicSwap(in directory: URL) -> Bool { + let directoryDescriptor = open( + directory.path, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard directoryDescriptor >= 0 else { return false } + defer { Darwin.close(directoryDescriptor) } + var directoryStatus = stat() + guard fstat(directoryDescriptor, &directoryStatus) == 0, + directoryStatus.st_mode & S_IFMT == S_IFDIR, + directoryStatus.st_uid == geteuid() else { return false } - guard type == "last-prompt" || type == "ai-title" || type == "mode" else { return false } - sawMetadata = true - return true + let device = UInt64(directoryStatus.st_dev) + if let cached = atomicSwapCapabilityCache.withLock({ $0[device] }) { + return cached + } + + let token = UUID().uuidString + let firstName = ".cmux-swap-probe-a-\(token)" + let secondName = ".cmux-swap-probe-b-\(token)" + let firstURL = directory.appendingPathComponent(firstName) + let secondURL = directory.appendingPathComponent(secondName) + let firstDescriptor = openat( + directoryDescriptor, + firstName, + O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(S_IRUSR | S_IWUSR) + ) + guard firstDescriptor >= 0 else { return false } + defer { + Darwin.close(firstDescriptor) + _ = unlinkat(directoryDescriptor, firstName, 0) + } + let secondDescriptor = openat( + directoryDescriptor, + secondName, + O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(S_IRUSR | S_IWUSR) + ) + guard secondDescriptor >= 0 else { return false } + defer { + Darwin.close(secondDescriptor) + _ = unlinkat(directoryDescriptor, secondName, 0) + _ = fsync(directoryDescriptor) + } + var firstStatus = stat() + var secondStatus = stat() + guard fstat(firstDescriptor, &firstStatus) == 0, + fstat(secondDescriptor, &secondStatus) == 0, + fsync(firstDescriptor) == 0, + fsync(secondDescriptor) == 0, + fsync(directoryDescriptor) == 0, + renamex_np( + firstURL.path, + secondURL.path, + UInt32(RENAME_SWAP) + ) == 0 else { + return false + } + var swappedFirstStatus = stat() + var swappedSecondStatus = stat() + let supported = lstat(firstURL.path, &swappedFirstStatus) == 0 + && lstat(secondURL.path, &swappedSecondStatus) == 0 + && swappedFirstStatus.st_dev == secondStatus.st_dev + && swappedFirstStatus.st_ino == secondStatus.st_ino + && swappedSecondStatus.st_dev == firstStatus.st_dev + && swappedSecondStatus.st_ino == firstStatus.st_ino + && fsync(directoryDescriptor) == 0 + // Cache only a positive capability proof. Permission, fsync, and I/O + // failures are transient and must not disable recovery for the whole + // volume until process restart. Concurrent positive probes are + // idempotent because each uses unique names and the cache lock only + // publishes the final true result. + if supported { + atomicSwapCapabilityCache.withLock { $0[device] = true } + } + return supported + } + + private static func defaultSnapshotDirectoryURL() -> URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("agent-transcript-teardown-snapshots", isDirectory: true) + } + + private static func pruneOldSnapshots(in directory: URL, fileManager: FileManager) { + pruneAbandonedCaptureTemps(in: directory) + // Top-level snapshots are unresolved recovery authority. Age alone + // never proves their bytes reached the live transcript, so pruning is + // restricted to candidates already quarantined as invalid. + let quarantineDirectory = directory.appendingPathComponent( + ".recovery-quarantine", + isDirectory: true + ) + guard ensurePrivateRecoveryDirectory( + at: quarantineDirectory, + createIfMissing: false, + fileManager: fileManager + ) else { return } + let descriptor = open( + quarantineDirectory.path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard descriptor >= 0, let stream = fdopendir(descriptor) else { + if descriptor >= 0 { Darwin.close(descriptor) } + return + } + defer { closedir(stream) } + var examinedEntries = 0 + var removedAny = false + while examinedEntries < maxStartupRecoveryDirectoryEntries, + let entry = readdir(stream) { + let name = withUnsafePointer(to: &entry.pointee.d_name) { namePointer in + namePointer.withMemoryRebound( + to: CChar.self, + capacity: Int(entry.pointee.d_namlen) + 1 + ) { String(cString: $0) } + } + guard name != ".", name != ".." else { continue } + examinedEntries += 1 + let url = quarantineDirectory.appendingPathComponent(name, isDirectory: false) + var status = stat() + guard lstat(url.path, &status) == 0, + status.st_mode & S_IFMT == S_IFREG, + status.st_uid == geteuid(), + status.st_nlink == 1, + recoveryMetadataEnvelope(atSnapshotPath: url.path) == nil, + Darwin.unlink(url.path) == 0 else { + continue + } + // Only metadata-free directory noise is disposable. A quarantined + // recovery inode keeps its metadata because a late held-fd append + // can turn a previously empty stub into the sole surviving branch. + removedAny = true + } + if removedAny { _ = fsync(dirfd(stream)) } + } + + private static func pruneAbandonedCaptureTemps(in directory: URL) { + let descriptor = open( + directory.path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard descriptor >= 0, let stream = fdopendir(descriptor) else { + if descriptor >= 0 { Darwin.close(descriptor) } + return + } + defer { closedir(stream) } + let cutoff = Date().addingTimeInterval(-60 * 60).timeIntervalSince1970 + var examinedEntries = 0 + var removedAny = false + while examinedEntries < maxStartupRecoveryDirectoryEntries, + let entry = readdir(stream) { + let name = withUnsafePointer(to: &entry.pointee.d_name) { namePointer in + namePointer.withMemoryRebound( + to: CChar.self, + capacity: Int(entry.pointee.d_namlen) + 1 + ) { String(cString: $0) } + } + guard name != ".", name != ".." else { continue } + examinedEntries += 1 + guard name.hasPrefix("."), name.contains("-capture-"), name.hasSuffix(".tmp") else { + continue + } + let url = directory.appendingPathComponent(name, isDirectory: false) + // Metadata is written only after the stable copy is complete. Such + // a temp is durable recovery authority after a crash and must be + // published by startup recovery, never garbage-collected. + guard recoveryMetadata(atSnapshotPath: url.path) == nil else { continue } + var status = stat() + guard lstat(url.path, &status) == 0, + status.st_mode & S_IFMT == S_IFREG, + status.st_uid == geteuid(), + status.st_nlink == 1, + TimeInterval(status.st_mtimespec.tv_sec) < cutoff, + Darwin.unlink(url.path) == 0 else { + continue + } + removedAny = true + } + if removedAny { _ = fsync(dirfd(stream)) } + } + + private static func ensurePrivateRecoveryDirectory( + at directory: URL, + createIfMissing: Bool, + fileManager: FileManager + ) -> Bool { + var initialStatus = stat() + let existed = lstat(directory.path, &initialStatus) == 0 + if !existed { + guard createIfMissing else { return false } + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] + ) + } catch { + return false + } + } + let descriptor = open( + directory.path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard descriptor >= 0 else { return false } + defer { Darwin.close(descriptor) } + var status = stat() + var pathStatus = stat() + guard fstat(descriptor, &status) == 0, + status.st_mode & S_IFMT == S_IFDIR, + status.st_uid == geteuid(), + fchmod(descriptor, mode_t(S_IRWXU)) == 0, + lstat(directory.path, &pathStatus) == 0, + pathStatus.st_mode & S_IFMT == S_IFDIR, + pathStatus.st_uid == geteuid(), + pathStatus.st_dev == status.st_dev, + pathStatus.st_ino == status.st_ino else { + return false + } + return existed || synchronizeContainingDirectory(atPath: directory.path) + } + + static func transcriptContainsOnlyNonProtectiveMetadata( + atPath path: String, + fileManager: FileManager, + maxScannedLineBytes: Int = Self.maxScannedLineBytes + ) -> Bool { + boundedTranscriptContainsOnlyNonProtectiveMetadata( + atPath: path, + fileManager: fileManager, + maxScannedLineBytes: maxScannedLineBytes + ) } - private static func appendLiveStubIfPresent( + static func appendLiveStubIfPresent( from stubURL: URL, toRestoreFile restoreURL: URL, fileManager: FileManager ) throws { - guard isRegularFile(atPath: stubURL.path, fileManager: fileManager) else { return } + var initialPathStatus = stat() + guard lstat(stubURL.path, &initialPathStatus) == 0 else { + if errno == ENOENT { return } + throw POSIXError(.EIO) + } + guard initialPathStatus.st_mode & S_IFMT == S_IFREG, + initialPathStatus.st_size >= 0, + UInt64(initialPathStatus.st_size) <= maximumLiveStubBytes else { + throw POSIXError(.EFBIG) + } + let inputDescriptor = open( + stubURL.path, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + guard inputDescriptor >= 0 else { throw POSIXError(.EIO) } + let input = FileHandle(fileDescriptor: inputDescriptor, closeOnDealloc: true) + defer { try? input.close() } + var initialDescriptorStatus = stat() + guard fstat(inputDescriptor, &initialDescriptorStatus) == 0, + stableFileStatus(initialDescriptorStatus, matches: initialPathStatus) else { + throw POSIXError(.EIO) + } - let output = try FileHandle(forUpdating: restoreURL) + var initialRestorePathStatus = stat() + guard lstat(restoreURL.path, &initialRestorePathStatus) == 0, + initialRestorePathStatus.st_mode & S_IFMT == S_IFREG, + initialRestorePathStatus.st_uid == geteuid(), + initialRestorePathStatus.st_nlink == 1 else { + throw POSIXError(.EIO) + } + let outputDescriptor = open( + restoreURL.path, + O_RDWR | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + guard outputDescriptor >= 0 else { throw POSIXError(.EIO) } + let output = FileHandle(fileDescriptor: outputDescriptor, closeOnDealloc: true) defer { try? output.close() } + var initialRestoreDescriptorStatus = stat() + guard fstat(outputDescriptor, &initialRestoreDescriptorStatus) == 0, + stableFileStatus( + initialRestoreDescriptorStatus, + matches: initialRestorePathStatus + ), + initialRestoreDescriptorStatus.st_uid == geteuid(), + initialRestoreDescriptorStatus.st_nlink == 1 else { + throw POSIXError(.EIO) + } let endOffset = try output.seekToEnd() let trimmedOffset = try offsetByTrimmingTrailingNewlines(handle: output, endOffset: endOffset) try output.truncate(atOffset: trimmedOffset) try output.seekToEnd() try output.write(contentsOf: Data([10])) - let input = try FileHandle(forReadingFrom: stubURL) - defer { try? input.close() } var skippingLeadingNewlines = true - while let chunk = try input.read(upToCount: 64 * 1024), - !chunk.isEmpty { + var remainingBytes = UInt64(initialDescriptorStatus.st_size) + while remainingBytes > 0 { + let chunk = try input.read( + upToCount: Int(min(UInt64(64 * 1_024), remainingBytes)) + ) ?? Data() + guard !chunk.isEmpty else { throw POSIXError(.EIO) } + remainingBytes -= UInt64(chunk.count) var bytes = chunk[chunk.startIndex.. Bool { + lhs.st_mode & S_IFMT == S_IFREG + && rhs.st_mode & S_IFMT == S_IFREG + && lhs.st_dev == rhs.st_dev + && lhs.st_ino == rhs.st_ino + } + + private static func stableFileStatus(_ lhs: stat, matches rhs: stat) -> Bool { + lhs.st_mode & S_IFMT == S_IFREG + && lhs.st_dev == rhs.st_dev + && lhs.st_ino == rhs.st_ino + && lhs.st_size == rhs.st_size + && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec + && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec } private static func offsetByTrimmingTrailingNewlines(handle: FileHandle, endOffset: UInt64) throws -> UInt64 { diff --git a/Sources/App/StartupBreadcrumbLog.swift b/Sources/App/StartupBreadcrumbLog.swift index 21d25957cb08..76604ad848f8 100644 --- a/Sources/App/StartupBreadcrumbLog.swift +++ b/Sources/App/StartupBreadcrumbLog.swift @@ -2,6 +2,9 @@ import Darwin import Foundation import os +typealias StartupBreadcrumbEvent = (event: String, fields: [String: String]) +typealias StartupBreadcrumbBatchWriter = ([StartupBreadcrumbEvent]) -> Void + enum StartupBreadcrumbLog { private static let maxFieldLength = 240 private nonisolated static let logger = Logger(subsystem: "com.cmuxterm.app", category: "StartupBreadcrumbLog") @@ -15,23 +18,33 @@ enum StartupBreadcrumbLog { ] static func append(_ event: String, fields: [String: String] = [:]) { - guard isEnabled else { return } - - var payload: [String: Any] = [ - "timestamp": ISO8601DateFormatter().string(from: Date()), - "event": event, - "pid": ProcessInfo.processInfo.processIdentifier, - "bundleIdentifier": Bundle.main.bundleIdentifier ?? "unknown", - "appVersion": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown", - "build": Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" - ] - - for (key, value) in fields { - let payloadKey = reservedFieldKeys.contains(key) ? "custom_\(key)" : key - payload[payloadKey] = sanitized(value) - } + append([(event: event, fields: fields)]) + } + static func append(_ events: [StartupBreadcrumbEvent]) { + guard isEnabled, !events.isEmpty else { return } do { + let timestamp = ISO8601DateFormatter().string(from: Date()) + let basePayload: [String: Any] = [ + "timestamp": timestamp, + "pid": ProcessInfo.processInfo.processIdentifier, + "bundleIdentifier": Bundle.main.bundleIdentifier ?? "unknown", + "appVersion": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown", + "build": Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" + ] + var output = Data() + for event in events { + var payload = basePayload + payload["event"] = event.event + for (key, value) in event.fields { + let payloadKey = reservedFieldKeys.contains(key) ? "custom_\(key)" : key + payload[payloadKey] = sanitized(value) + } + let line = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + output.append(line) + output.append(0x0A) + } + let url = logURL try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), @@ -40,7 +53,6 @@ enum StartupBreadcrumbLog { if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) } - let line = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) let handle = try FileHandle(forWritingTo: url) defer { try? handle.close() } guard flock(handle.fileDescriptor, LOCK_EX) == 0 else { @@ -50,8 +62,7 @@ enum StartupBreadcrumbLog { defer { flock(handle.fileDescriptor, LOCK_UN) } // Startup breadcrumbs are synchronous so the last edge survives immediate launch aborts. try handle.seekToEnd() - try handle.write(contentsOf: line) - try handle.write(contentsOf: Data([0x0A])) + try handle.write(contentsOf: output) } catch { logger.fault("cmux startup breadcrumb failed: \(String(describing: error), privacy: .public)") } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index fb399e3ea937..b904eeb58a74 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3182,8 +3182,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent didPrepareStartupSessionSnapshot = true Self.removeLegacyPersistedWindowGeometry() syncManualRestoreSnapshotCachePruningCrashDiagnostics() - let sanitizedStartupSnapshot = loadStartupSessionSnapshotPruningCrashDiagnostics() + var sanitizedStartupSnapshot = loadStartupSessionSnapshotPruningCrashDiagnostics() guard SessionRestorePolicy.shouldAttemptRestore() else { return } + if var snapshot = sanitizedStartupSnapshot { + RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore(&snapshot) + sanitizedStartupSnapshot = snapshot + } startupSessionSnapshot = sanitizedStartupSnapshot } @@ -3387,9 +3391,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } flushPendingStartupNavigationURLRequests() if Self.shouldSaveSessionSnapshotOnRestoreCompletion(isManualReopen: isManualReopen) { - // Auto-resume input can be queued before tmux has spawned; preserve - // restored process-detected bindings until a later live scan. - _ = saveSessionSnapshot(includeScrollback: false) + // Restored bindings already live in workspace state. A cold durable-agent + // scan here would block the main actor while the shared cache is filling. + let restorableAgentIndex = SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh() + ?? .empty + _ = saveSessionSnapshot( + includeScrollback: false, + restorableAgentIndex: restorableAgentIndex + ) } } @@ -3406,9 +3415,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent _ snapshot: AppSessionSnapshot, shouldActivate: Bool = true ) -> Bool { - guard let snapshot = SessionPersistencePolicy.pruningCmuxCrashDiagnosticWindows(from: snapshot).snapshot else { + guard var snapshot = SessionPersistencePolicy.pruningCmuxCrashDiagnosticWindows(from: snapshot).snapshot else { return false } + RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore(&snapshot) let snapshotWindows = Array( snapshot.windows.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) ) @@ -16243,8 +16253,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent !isApplyingSessionRestore else { return } + // Window close is interactive, so let the shared cache refresh asynchronously + // instead of scanning durable agent state on the main actor when it is cold. let restorableAgentIndex = SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh() - ?? RestorableAgentSessionIndex.load() + ?? .empty guard let snapshot = closeWindowSnapshotPruningCrashDiagnostics( for: context, includeScrollback: true, diff --git a/Sources/ClosedItemHistory.swift b/Sources/ClosedItemHistory.swift index d7c3ff12ad21..c8e4c67363f0 100644 --- a/Sources/ClosedItemHistory.swift +++ b/Sources/ClosedItemHistory.swift @@ -178,15 +178,15 @@ final class ClosedItemHistoryStore: ObservableObject { !records.isEmpty } - func push(_ entry: ClosedItemHistoryEntry) { + @discardableResult func push(_ entry: ClosedItemHistoryEntry) -> UUID { push(ClosedItemHistoryRecord(entry: entry)) } - - func push(_ record: ClosedItemHistoryRecord) { + @discardableResult func push(_ record: ClosedItemHistoryRecord) -> UUID { records.append(record) trimToCapacityIfNeeded() revision &+= 1 persistRecords() + return record.id } @discardableResult diff --git a/Sources/ClosedItemHistoryStore+AgentEnrichment.swift b/Sources/ClosedItemHistoryStore+AgentEnrichment.swift new file mode 100644 index 000000000000..1c29235a49b1 --- /dev/null +++ b/Sources/ClosedItemHistoryStore+AgentEnrichment.swift @@ -0,0 +1,73 @@ +import Foundation + +extension ClosedItemHistoryStore { + /// Replaces one still-present close record while preserving its stable ID, + /// timestamp, and ordering. Removal plus reinsertion is synchronous on the + /// main actor; revision ordering prevents an intermediate save from winning. + @discardableResult + func enrichClosedPanelAgent(recordId: UUID, agent: SessionRestorableAgentSnapshot) -> Bool { + guard let removed = removeRecord(id: recordId) else { return false } + guard case .panel(let panelEntry) = removed.record.entry, + panelEntry.snapshot.terminal?.agent == nil else { + insert(removed.record, at: removed.index) + return false + } + var snapshot = panelEntry.snapshot + snapshot.terminal?.agent = agent + let enriched = ClosedPanelHistoryEntry( + workspaceId: panelEntry.workspaceId, + paneId: panelEntry.paneId, + paneAnchorPanelId: panelEntry.paneAnchorPanelId, + restoreInOriginalPane: panelEntry.restoreInOriginalPane, + tabIndex: panelEntry.tabIndex, + snapshot: snapshot, + fallbackSplitPlacement: panelEntry.fallbackSplitPlacement + ) + insert(ClosedItemHistoryRecord( + id: removed.record.id, + closedAt: removed.record.closedAt, + entry: .panel(enriched) + ), at: removed.index) + return true + } + + /// Enriches terminal panels in a workspace record after the shared agent + /// index finishes its off-main cold load. + @discardableResult + func enrichClosedWorkspaceAgents( + recordId: UUID, + agentsByPanelId: [UUID: SessionRestorableAgentSnapshot] + ) -> Bool { + guard !agentsByPanelId.isEmpty, + let removed = removeRecord(id: recordId) else { return false } + guard case .workspace(let workspaceEntry) = removed.record.entry else { + insert(removed.record, at: removed.index) + return false + } + var snapshot = workspaceEntry.snapshot + var changed = false + for index in snapshot.panels.indices { + let panelId = snapshot.panels[index].id + guard snapshot.panels[index].terminal?.agent == nil, + let agent = agentsByPanelId[panelId] else { continue } + snapshot.panels[index].terminal?.agent = agent + changed = true + } + guard changed else { + insert(removed.record, at: removed.index) + return false + } + let enriched = ClosedWorkspaceHistoryEntry( + workspaceId: workspaceEntry.workspaceId, + windowId: workspaceEntry.windowId, + workspaceIndex: workspaceEntry.workspaceIndex, + snapshot: snapshot + ) + insert(ClosedItemHistoryRecord( + id: removed.record.id, + closedAt: removed.record.closedAt, + entry: .workspace(enriched) + ), at: removed.index) + return true + } +} diff --git a/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift b/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift index 33e1e9d0da45..990fd7b16ff3 100644 --- a/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift +++ b/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift @@ -47,6 +47,14 @@ extension CmuxTaskManagerCodingAgentDefinition { launchKinds: ["factory"], directBasenames: ["droid", "factory"], argumentNeedles: ["factory"]), .init(id: "qoder", displayName: "Qoder", assetName: nil, launchKinds: ["qoder"], directBasenames: ["qoder", "qodercli"], argumentNeedles: ["qoder", "qodercli"]), + .init( + id: "kimi", + displayName: String(localized: "agent.kimi.displayName", defaultValue: "Kimi Code"), + assetName: nil, + launchKinds: ["kimi"], + directBasenames: ["kimi", "kimi-cli", "kimi-code"], + argumentNeedles: ["kimi-cli", "kimi-code"] + ), .init( id: "ollama", displayName: String(localized: "agent.ollama.displayName", defaultValue: "Ollama"), diff --git a/Sources/CmuxTopProcessArguments.swift b/Sources/CmuxTopProcessArguments.swift index 9f353d0116fa..1c1727fe4861 100644 --- a/Sources/CmuxTopProcessArguments.swift +++ b/Sources/CmuxTopProcessArguments.swift @@ -7,6 +7,15 @@ struct CmuxTopProcessArguments: Sendable { } extension CmuxTopProcessSnapshot { + static func processExecutablePath(for pid: Int) -> String? { + guard pid > 0, pid <= Int(Int32.max) else { return nil } + var buffer = [CChar](repeating: 0, count: 4_096) + let length = proc_pidpath(pid_t(pid), &buffer, UInt32(buffer.count)) + guard length > 0 else { return nil } + let path = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) + return path.isEmpty ? nil : path + } + static func processArgumentsAndEnvironment(for pid: Int) -> CmuxTopProcessArguments? { guard pid > 0, pid <= Int(Int32.max), let bytes = kernProcArgsBytes(for: pid) else { diff --git a/Sources/CmuxTopProcessEnumeration.swift b/Sources/CmuxTopProcessEnumeration.swift index cfce03779a57..1a97018307e3 100644 --- a/Sources/CmuxTopProcessEnumeration.swift +++ b/Sources/CmuxTopProcessEnumeration.swift @@ -3,6 +3,11 @@ import Foundation private nonisolated let cmuxTopPIDPathBufferSize = 4096 +enum CmuxTopTargetedPIDEnumeration: Sendable, Equatable { + case complete(Set) + case incomplete +} + extension CmuxTopProcessSnapshot { static func allProcesses(includeProcessDetails: Bool, includeCMUXScope: Bool) -> [CmuxTopProcessInfo] { let sampledProcesses = allBSDProcesses() @@ -75,6 +80,58 @@ extension CmuxTopProcessSnapshot { return Int64(statInfo.st_rdev) } + /// `proc_listpids(PROC_TTY_ONLY)` scopes completeness to one terminal. A + /// process elsewhere on the machine that denies `proc_pidinfo` therefore + /// cannot prevent an otherwise safe pane from hibernating. + static func processIDs(forTTYDevice ttyDevice: Int64) -> CmuxTopTargetedPIDEnumeration { + guard ttyDevice > 0, ttyDevice <= Int64(UInt32.max) else { return .incomplete } + let pidStride = MemoryLayout.stride + var capacity = 16 + for _ in 0..<4 { + var pids = Array(repeating: pid_t(), count: capacity) + let returnedBytes = pids.withUnsafeMutableBufferPointer { buffer in + proc_listpids( + UInt32(PROC_TTY_ONLY), + UInt32(ttyDevice), + buffer.baseAddress, + Int32(buffer.count * pidStride) + ) + } + guard returnedBytes >= 0 else { return .incomplete } + let returnedCount = Int(returnedBytes) / pidStride + guard returnedCount <= pids.count else { return .incomplete } + if returnedCount < pids.count { + return .complete(Set(pids.prefix(returnedCount).map(Int.init).filter { $0 > 0 })) + } + capacity *= 2 + } + return .incomplete + } + + /// Direct-child enumeration is sufficient for a process-free proof: every + /// descendant has a direct child on the path from the terminal shell. + static func childProcessIDs(of pid: Int) -> CmuxTopTargetedPIDEnumeration { + guard pid > 0, pid <= Int(Int32.max) else { return .incomplete } + let pidStride = MemoryLayout.stride + var capacity = 8 + for _ in 0..<4 { + var pids = Array(repeating: pid_t(), count: capacity) + let returnedCount = pids.withUnsafeMutableBufferPointer { buffer in + proc_listchildpids( + pid_t(pid), + buffer.baseAddress, + Int32(buffer.count * pidStride) + ) + } + guard returnedCount >= 0, Int(returnedCount) <= pids.count else { return .incomplete } + if Int(returnedCount) < pids.count { + return .complete(Set(pids.prefix(Int(returnedCount)).map(Int.init).filter { $0 > 0 })) + } + capacity *= 2 + } + return .incomplete + } + private static func processInfo( from bsdInfo: proc_bsdinfo, includeProcessDetails: Bool, @@ -150,7 +207,12 @@ extension CmuxTopProcessSnapshot { residentBytes: residentBytes, residentMemorySource: residentMemorySource, virtualBytes: int64Clamped(taskInfo?.pti_virtual_size ?? 0), - threadCount: Int(taskInfo?.pti_threadnum ?? 0) + threadCount: Int(taskInfo?.pti_threadnum ?? 0), + generationIdentity: AgentPIDProcessIdentity( + pid: pid_t(pid), + startSeconds: Int64(bsdInfo.pbi_start_tvsec), + startMicroseconds: Int64(bsdInfo.pbi_start_tvusec) + ) ), cpuSampleKey) } diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 61ebc43325e0..e65a06a0f813 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -71,6 +71,9 @@ struct CmuxTopProcessInfo: Sendable { let residentMemorySource: CmuxTopProcessMemorySource let virtualBytes: Int64 let threadCount: Int + /// PID generation captured with the topology fields above. Lifecycle + /// authorization must compare this value, because a PID can be reused. + let generationIdentity: AgentPIDProcessIdentity? init( pid: Int, @@ -89,7 +92,8 @@ struct CmuxTopProcessInfo: Sendable { residentBytes: Int64, residentMemorySource: CmuxTopProcessMemorySource = .residentSize, virtualBytes: Int64, - threadCount: Int + threadCount: Int, + generationIdentity: AgentPIDProcessIdentity? = nil ) { self.pid = pid self.parentPID = parentPID @@ -109,6 +113,7 @@ struct CmuxTopProcessInfo: Sendable { self.residentMemorySource = residentMemorySource self.virtualBytes = virtualBytes self.threadCount = threadCount + self.generationIdentity = generationIdentity } var isTerminalForegroundProcessGroup: Bool { diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index ecdc1988f6d2..7f299f3fba59 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -471,7 +471,10 @@ extension FeedCoordinator { event: WorkstreamEvent ) -> (workspaceId: UUID, surfaceId: UUID?)? { let sessionMatch: (workspaceId: UUID, surfaceId: UUID?)? = { - guard let parsed = FeedJumpResolver.parse(event.sessionId), + guard let parsed = FeedJumpResolver.parse( + event.sessionId, + source: event.source + ), let resolved = FeedJumpResolver.lookup(agent: parsed.agent, sessionId: parsed.sessionId), let workspaceId = UUID(uuidString: resolved.workspaceId) else { return nil } @@ -632,35 +635,75 @@ enum FeedJumpResolver { let surfaceId: String } - static func parse(_ workstreamId: String) -> (agent: String, sessionId: String)? { + static func parse( + _ workstreamId: String, + source explicitSource: String? = nil + ) -> (agent: String, sessionId: String)? { + if let explicitSource { + let source = explicitSource.trimmingCharacters(in: .whitespacesAndNewlines) + guard !source.isEmpty else { return nil } + return parse(workstreamId, provider: source) + } + + // Wire ids are `-`, but provider ids may themselves + // contain dashes (`hermes-agent`). Prefer the longest registered prefix + // before retaining the legacy first-dash fallback for custom sources. + for source in WorkstreamSource.allCases + .map(\.rawValue) + .sorted(by: { $0.count > $1.count }) { + if workstreamId.hasPrefix(source + "-") { + return parse(workstreamId, provider: source) + } + } + guard let dash = workstreamId.firstIndex(of: "-") else { return nil } let agent = String(workstreamId[.. Target? { - let home = FileManager.default.homeDirectoryForCurrentUser - let file = home - .appendingPathComponent(".cmuxterm", isDirectory: true) - .appendingPathComponent("\(agent)-hook-sessions.json", isDirectory: false) - guard let data = try? Data(contentsOf: file), - let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { return nil } - // Stores have a consistent shape: top-level `sessions` dict keyed - // by sessionId. Tolerate older flat layouts too. - let sessions: [String: Any] - if let nested = root["sessions"] as? [String: Any] { - sessions = nested - } else { - sessions = root - } - guard let entry = sessions[sessionId] as? [String: Any], + private static func parse( + _ workstreamId: String, + provider: String + ) -> (agent: String, sessionId: String)? { + guard CmuxVaultAgentRegistration.isValidID(provider) else { return nil } + let prefix = provider + "-" + guard workstreamId.hasPrefix(prefix) else { return nil } + let sessionId = String(workstreamId.dropFirst(prefix.count)) + guard !sessionId.isEmpty else { return nil } + return (provider, sessionId) + } + + static func lookup( + agent: String, + sessionId: String, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) -> Target? { + guard let file = AgentHookSessionRegistryReader.legacyURL( + provider: agent, + homeDirectory: homeDirectory, + environment: environment + ), + let data = AgentHookSessionRegistryReader.recordData( + provider: agent, + sessionID: sessionId, + legacyURL: file, + environment: environment, + fileManager: fileManager + ), + let entry = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let workspaceId = entry["workspaceId"] as? String, let surfaceId = entry["surfaceId"] as? String, !workspaceId.isEmpty, !surfaceId.isEmpty else { return nil } + if let embeddedSessionID = entry["sessionId"] as? String, + embeddedSessionID != sessionId { + return nil + } return Target(workspaceId: workspaceId, surfaceId: surfaceId) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 803e696feb65..9e3178811319 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -380,6 +380,18 @@ class GhosttyApp { /// The process-wide paced native-surface creation queue for session restore. @MainActor static let terminalSurfaceRestoreSpawnScheduler = TerminalSurfaceRestoreSpawnScheduler() + /// Content-free agent observations are owned by the app composition root + /// and injected into the terminal-state runtime that publishes them. + nonisolated private let agentTerminalObservationCache: AgentTerminalObservationCache + /// Process-wide coding-agent terminal-state runtime, constructed at the app composition root. + @MainActor + private let agentTerminalStateRuntime: AgentTerminalStateRuntime + + /// Copies cached live-agent metadata without triggering terminal capture. + nonisolated + func agentTerminalObservationsSnapshot() -> [CmuxAgentTerminalObservation] { + agentTerminalObservationCache.snapshot() + } /// Snapshotted once per app session so all workspaces use consistent values. static let terminalSessionPortBase: Int = { let val = UserDefaults.standard.integer(forKey: AutomationSettings.portBaseKey) @@ -399,7 +411,7 @@ class GhosttyApp { engine: GhosttyApp.shared, viewProvider: TerminalSurfaceViewFactory(), spawnPolicy: TerminalSurfaceSpawnPolicyBridge(), - byteTee: TerminalOutputByteTeeBridge(), + byteTee: TerminalOutputByteTeeBridge(agentStateRuntime: GhosttyApp.shared.agentTerminalStateRuntime), rendererRealization: RendererRealizationController.shared, hibernationRecorder: TerminalAgentHibernationRecorder(), runtimeTeardown: GhosttyApp.terminalSurfaceRuntimeTeardown, @@ -739,6 +751,9 @@ class GhosttyApp { } private init() { + let observationCache = AgentTerminalObservationCache() + agentTerminalObservationCache = observationCache + agentTerminalStateRuntime = AgentTerminalStateRuntime(observationCache: observationCache) initializeGhostty() } diff --git a/Sources/Mobile/AgentChat/AgentChatHookSessionStore.swift b/Sources/Mobile/AgentChat/AgentChatHookSessionStore.swift index 8bf35cf55fd1..b0421c3361bc 100644 --- a/Sources/Mobile/AgentChat/AgentChatHookSessionStore.swift +++ b/Sources/Mobile/AgentChat/AgentChatHookSessionStore.swift @@ -9,6 +9,9 @@ import Foundation /// dict with a flat-layout fallback) but surfaces the additional fields the /// chat service needs (`cwd`, `transcriptPath`, `pid`). struct AgentChatHookSessionStore: Sendable { + static let maximumSeedRecords = 512 + static let maximumSeedBytes: Int64 = 16 * 1_024 * 1_024 + /// One hook-store entry's chat-relevant fields. struct Entry: Sendable { /// The agent's session identifier (the store key). @@ -28,41 +31,42 @@ struct AgentChatHookSessionStore: Sendable { } private let homeDirectory: URL + private let environment: [String: String] /// Creates a store reader. /// /// - Parameter homeDirectory: The home directory containing /// `.cmuxterm/`; injectable for tests. - init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) { + init( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + environment: [String: String] = ProcessInfo.processInfo.environment + ) { self.homeDirectory = homeDirectory + self.environment = environment } - /// Reads one agent's hook session store. + /// Reads active sessions plus bounded recent history from one hook store. /// /// - Parameter agentSource: The agent's `_source` name (`claude`, /// `codex`, ...), which names the store file. - /// - Returns: All entries, or empty when the store is absent/malformed. + /// - Returns: The bounded seed entries, or empty when the store is absent/malformed. func entries(agentSource: String) -> [Entry] { - let file = homeDirectory - .appendingPathComponent(".cmuxterm", isDirectory: true) - .appendingPathComponent("\(agentSource)-hook-sessions.json", isDirectory: false) - guard let data = try? Data(contentsOf: file), - let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + guard let file = AgentHookSessionRegistryReader.legacyURL( + provider: agentSource, + homeDirectory: homeDirectory, + environment: environment + ), + let records = AgentHookSessionRegistryReader.recentRecordData( + provider: agentSource, + legacyURL: file, + environment: environment, + maximumRecords: Self.maximumSeedRecords, + maximumBytes: Self.maximumSeedBytes + ) else { return [] } - let sessions = (root["sessions"] as? [String: Any]) ?? root - return sessions.compactMap { key, value in - guard let entry = value as? [String: Any] else { return nil } - let updatedAt = (entry["updatedAt"] as? TimeInterval).map(Date.init(timeIntervalSince1970:)) - return Entry( - sessionID: key, - workspaceID: Self.nonEmpty(entry["workspaceId"] as? String), - surfaceID: Self.nonEmpty(entry["surfaceId"] as? String), - workingDirectory: Self.nonEmpty(entry["cwd"] as? String), - transcriptPath: Self.nonEmpty(entry["transcriptPath"] as? String), - pid: entry["pid"] as? Int, - updatedAt: updatedAt - ) + return records.compactMap { record in + Self.entry(sessionID: record.sessionID, data: record.data) } } @@ -73,7 +77,41 @@ struct AgentChatHookSessionStore: Sendable { /// - sessionID: The session to look up. /// - Returns: The entry, or `nil` when absent. func entry(agentSource: String, sessionID: String) -> Entry? { - entries(agentSource: agentSource).first { $0.sessionID == sessionID } + guard let file = AgentHookSessionRegistryReader.legacyURL( + provider: agentSource, + homeDirectory: homeDirectory, + environment: environment + ), + let data = AgentHookSessionRegistryReader.recordData( + provider: agentSource, + sessionID: sessionID, + legacyURL: file, + environment: environment + ) else { + return nil + } + return Self.entry(sessionID: sessionID, data: data) + } + + private static func entry(sessionID: String, data: Data) -> Entry? { + guard let entry = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + if let embeddedSessionID = entry["sessionId"] as? String, + embeddedSessionID != sessionID { + return nil + } + let updatedAt = (entry["updatedAt"] as? TimeInterval) + .map(Date.init(timeIntervalSince1970:)) + return Entry( + sessionID: sessionID, + workspaceID: nonEmpty(entry["workspaceId"] as? String), + surfaceID: nonEmpty(entry["surfaceId"] as? String), + workingDirectory: nonEmpty(entry["cwd"] as? String), + transcriptPath: nonEmpty(entry["transcriptPath"] as? String), + pid: entry["pid"] as? Int, + updatedAt: updatedAt + ) } private static func nonEmpty(_ value: String?) -> String? { diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 6c4bef203b53..00aefdb33f81 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -31,6 +31,20 @@ enum AgentHibernationResumePreparation: Equatable { } } +struct AgentHibernationResumePlan: Sendable { + let kind: RestorableAgentKind + let sessionId: String + let hibernatedAt: Date + let temporaryDirectory: URL + let executableResolution: AgentCommandExecutableResolution + + func matches(_ state: AgentHibernationPanelState) -> Bool { + kind == state.agent.kind + && sessionId == state.agent.sessionId + && hibernatedAt == state.hibernatedAt + } +} + /// TerminalPanel wraps an existing TerminalSurface and conforms to the Panel protocol. /// This allows TerminalSurface to be used within the bonsplit-based layout system. @MainActor @@ -686,35 +700,140 @@ final class TerminalPanel: Panel, ObservableObject { surface.teardownSurface() } + /// Restores an already-persisted hibernated model before its native surface + /// is created. This path never tears down a live runtime. + @discardableResult func enterAgentHibernation( agent: SessionRestorableAgentSnapshot, lastActivityAt: Date, hibernatedAt: Date = Date() + ) -> Bool { + guard surface.markRuntimeSurfaceSuspendedForRestoredAgentHibernation() else { return false } + commitAgentHibernationState( + agent: agent, + lastActivityAt: lastActivityAt, + hibernatedAt: hibernatedAt + ) + return true + } + + /// Suspends a live runtime only after the serialized native teardown lane + /// accepts its final validation and finishes freeing callback userdata. + func enterAgentHibernation( + agent: SessionRestorableAgentSnapshot, + lastActivityAt: Date, + hibernatedAt: Date = Date(), + finalValidation: @escaping @Sendable () async -> Bool, + finalTeardownPreparation: @escaping @Sendable () -> (@Sendable () -> Void)? = { {} }, + finalCommit: @escaping @Sendable () -> Bool = { true }, + executableResolver: AgentCommandExecutableResolver = AgentCommandExecutableResolver() + ) async -> Bool { + guard agentHibernationState == nil, + let descriptor = agent.resumeExecutionDescriptor, + let executableResolution = executableResolver.resolve(descriptor), + await surface.suspendRuntimeSurfaceForAgentHibernation( + reason: "agentHibernation", + finalValidation: finalValidation, + finalTeardownPreparation: { + guard AgentCommandExecutableResolver.revalidate(executableResolution) else { + return nil + } + return finalTeardownPreparation() + }, + finalCommit: finalCommit + ) else { + return false + } + commitAgentHibernationState( + agent: agent, + lastActivityAt: lastActivityAt, + hibernatedAt: hibernatedAt + ) + return true + } + + private func commitAgentHibernationState( + agent: SessionRestorableAgentSnapshot, + lastActivityAt: Date, + hibernatedAt: Date ) { agentHibernationState = AgentHibernationPanelState( agent: agent, hibernatedAt: hibernatedAt, lastActivityAt: lastActivityAt ) + surface.commitAgentHibernationOwnerState() unfocus() searchState = nil hostedView.setVisibleInUI(false) TerminalWindowPortalRegistry.detach(hostedView: hostedView) - surface.suspendRuntimeSurfaceForAgentHibernation(reason: "agentHibernation") requestViewReattach() } @discardableResult func prepareAgentHibernationResume() -> AgentHibernationResumePreparation { - guard let state = agentHibernationState else { + guard let plan = agentHibernationResumePlan() else { + return .unavailable + } + return applyAgentHibernationResume(plan) + } + + func agentHibernationResumePlan( + temporaryDirectory: URL = FileManager.default.temporaryDirectory, + executableResolver: AgentCommandExecutableResolver = AgentCommandExecutableResolver() + ) -> AgentHibernationResumePlan? { + guard let state = agentHibernationState, + surface.canPrepareAgentHibernationResume, + let descriptor = state.agent.resumeExecutionDescriptor, + let executableResolution = executableResolver.resolve(descriptor) else { + return nil + } + return AgentHibernationResumePlan( + kind: state.agent.kind, + sessionId: state.agent.sessionId, + hibernatedAt: state.hibernatedAt, + temporaryDirectory: temporaryDirectory, + executableResolution: executableResolution + ) + } + + @discardableResult + func applyAgentHibernationResume( + _ plan: AgentHibernationResumePlan, + hibernationResumeAttemptId: UUID? = nil + ) -> AgentHibernationResumePreparation { + guard let state = agentHibernationState, + plan.matches(state), + surface.canPrepareAgentHibernationResume, + AgentCommandExecutableResolver.revalidate(plan.executableResolution), + let startupInput = state.agent.resumeStartupInput( + temporaryDirectory: plan.temporaryDirectory, + hibernationResumeAttemptId: hibernationResumeAttemptId + ) else { return .unavailable } - let resumeStartupInput = state.agent.resumeStartupInput() agentHibernationState = nil - surface.prepareAgentHibernationResume(initialInput: resumeStartupInput) + surface.prepareAgentHibernationResume(initialInput: startupInput) + requestViewReattach() + surface.requestBackgroundSurfaceStartIfNeeded() + return .resumed(queuedStartupInput: true) + } + + var canPrepareAgentHibernationResume: Bool { + agentHibernationState != nil && surface.canPrepareAgentHibernationResume + } + + /// Abandons a persisted hibernation placeholder whose durable session + /// binding could not be adopted. Unlike resume, this never queues the + /// agent's resume command, so a newer live owner cannot be duplicated. + @discardableResult + func discardRestoredAgentHibernation() -> Bool { + guard agentHibernationState != nil else { return false } + agentHibernationState = nil + surface.prepareAgentHibernationResume(initialInput: nil) requestViewReattach() surface.requestBackgroundSurfaceStartIfNeeded() - return .resumed(queuedStartupInput: resumeStartupInput != nil) + return true } func requestViewReattach() { diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 3f59df90674b..7b2cc51e9f34 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -44,24 +44,20 @@ struct TerminalPanelView: View { @ViewBuilder private func hibernationBody(_ hibernationState: AgentHibernationPanelState) -> some View { - if isVisibleInUI { - Color(nsColor: appearance.contentBackgroundColor) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .id("hibernated-resuming-\(panel.id.uuidString)") - .onAppear { - onAutoResumeAgentHibernation() - } - } else { - AgentHibernationPlaceholderView( - state: hibernationState, - appearance: appearance, - onResume: onResumeAgentHibernation - ) - .id("hibernated-\(panel.id.uuidString)") - .onChange(of: isVisibleInUI) { _, visible in - if visible { - onAutoResumeAgentHibernation() - } + AgentHibernationPlaceholderView( + state: hibernationState, + appearance: appearance, + onResume: onResumeAgentHibernation + ) + .id("hibernated-\(panel.id.uuidString)") + .onAppear { + if isVisibleInUI { + onAutoResumeAgentHibernation() + } + } + .onChange(of: isVisibleInUI) { _, visible in + if visible { + onAutoResumeAgentHibernation() } } } diff --git a/Sources/RemoteInteractiveShellBootstrapBuilder.swift b/Sources/RemoteInteractiveShellBootstrapBuilder.swift index 196cff6c1460..024ccc33f207 100644 --- a/Sources/RemoteInteractiveShellBootstrapBuilder.swift +++ b/Sources/RemoteInteractiveShellBootstrapBuilder.swift @@ -202,7 +202,9 @@ enum RemoteInteractiveShellBootstrapBuilder { "case \"$cmux_workspace_id\" in \"\"|'__CMUX_''WORKSPACE_ID__') ;; *) export CMUX_WORKSPACE_ID=\"$cmux_workspace_id\"; export CMUX_TAB_ID=\"$cmux_workspace_id\" ;; esac", "cmux_surface_id='__CMUX_SURFACE_ID__'", "case \"$cmux_surface_id\" in \"\"|'__CMUX_''SURFACE_ID__') ;; *) export CMUX_SURFACE_ID=\"$cmux_surface_id\"; export CMUX_PANEL_ID=\"$cmux_surface_id\" ;; esac", - "unset cmux_workspace_id cmux_surface_id", + "cmux_runtime_id='__CMUX_RUNTIME_ID__'", + "case \"$cmux_runtime_id\" in \"\"|'__CMUX_''RUNTIME_ID__') ;; *) export CMUX_RUNTIME_ID=\"$cmux_runtime_id\" ;; esac", + "unset cmux_workspace_id cmux_surface_id cmux_runtime_id", "hash -r >/dev/null 2>&1 || true", "rehash >/dev/null 2>&1 || true", ]) diff --git a/Sources/RestorableAgentHookSessionRecord.swift b/Sources/RestorableAgentHookSessionRecord.swift index c314d101653b..af10908c4599 100644 --- a/Sources/RestorableAgentHookSessionRecord.swift +++ b/Sources/RestorableAgentHookSessionRecord.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import Foundation struct RestorableAgentHookSessionRecord: Codable, Sendable { @@ -11,6 +12,32 @@ struct RestorableAgentHookSessionRecord: Codable, Sendable { /// Last hook-observed agent permission mode (e.g. Claude's `permission_mode`). var lastPermissionMode: String? var isRestorable: Bool? + /// False for a session observed beneath another agent on the same surface. + /// Child sessions remain visible in history but never become restoration candidates. + var restoreAuthority: Bool? = nil + var relationship: CmuxAgentSessionRunAuthorityProjection.Relationship? = nil + var authorityEvidence: CmuxAgentSessionRunAuthorityProjection.AuthorityEvidence? = nil + /// Canonical process generations supersede the compatibility authority bit. + var runs: [CmuxAgentSessionRunAuthorityProjection.Run]? = nil + var activeRunId: String? = nil + var completedAt: TimeInterval? = nil + var workloads: [AgentWorkloadRecord]? = nil + var sessionState: AgentSessionLifecycleState? = nil var agentLifecycle: AgentHibernationLifecycleState? var updatedAt: TimeInterval + + var effectiveHibernationLifecycle: AgentHibernationLifecycleState? { + workloads?.contains { $0.keepsSessionBusy && $0.phase.isActive } == true ? .running : agentLifecycle + } + + var projectedRestoreAuthority: Bool { + CmuxAgentSessionRunAuthorityProjection().projectedRestoreAuthority( + recordRestoreAuthority: restoreAuthority, + runs: runs, + activeRunId: activeRunId, + recordRelationship: relationship, + recordAuthorityEvidence: authorityEvidence, + recordCompletedAt: completedAt + ) + } } diff --git a/Sources/RestorableAgentHookSessionStoreFile.swift b/Sources/RestorableAgentHookSessionStoreFile.swift index 466c2e24c9f7..9ec695d9b704 100644 --- a/Sources/RestorableAgentHookSessionStoreFile.swift +++ b/Sources/RestorableAgentHookSessionStoreFile.swift @@ -1,6 +1,88 @@ +import CmuxFoundation +import Darwin import Foundation struct RestorableAgentHookSessionStoreFile: Codable, Sendable { var version: Int = 1 var sessions: [String: RestorableAgentHookSessionRecord] = [:] } + +extension RestorableAgentHookSessionStoreFile { + static func decode( + snapshot: CmuxAgentSessionRegistry.Snapshot, + decoder: JSONDecoder + ) throws -> Self { + var state = Self() + for stored in snapshot.records { + let record = try decoder.decode(RestorableAgentHookSessionRecord.self, from: stored.json) + guard record.sessionId == stored.sessionID else { + throw ProjectionError.recordIdentityMismatch + } + state.sessions[stored.sessionID] = record + } + return state + } + + private enum ProjectionError: Error { + case recordIdentityMismatch + } + + /// Loads the authoritative registry snapshot, importing changed legacy JSON + /// first. The import is raw so fields unknown to this app model survive. + static func load( + provider: String, + legacyURL: URL, + environment: [String: String], + fileManager: FileManager, + decoder: JSONDecoder + ) -> Self? { + let registryURL: URL + if let explicit = environment["CMUX_AGENT_SESSION_REGISTRY_PATH"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !explicit.isEmpty { + registryURL = URL(fileURLWithPath: NSString(string: explicit).expandingTildeInPath) + } else { + registryURL = legacyURL.deletingLastPathComponent() + .appendingPathComponent(CmuxAgentSessionRegistry.filename, isDirectory: false) + } + let registry = CmuxAgentSessionRegistry(url: registryURL) + do { + let snapshot = try registry.snapshotImportingLegacy( + provider: provider, + legacyURL: legacyURL, + fileManager: fileManager + ) + return try decode(snapshot: snapshot, decoder: decoder) + } catch { + do { + let snapshot = try registry.snapshot(provider: provider) + return try? decode(snapshot: snapshot, decoder: decoder) + } catch { + guard registryStorageIsTrulyAbsent(at: registryURL), + fileManager.fileExists(atPath: legacyURL.path), + let data = try? registry.readHookLegacySourceData(at: legacyURL), + registryStorageIsTrulyAbsent(at: registryURL) else { + return nil + } + return try? decoder.decode(Self.self, from: data) + } + } + } + + /// Legacy JSON is only authoritative before the SQLite registry exists. + /// Treat permission errors, dangling symlinks, and SQLite sidecars as + /// possible registry state so an unreadable canonical store fails closed. + private static func registryStorageIsTrulyAbsent(at registryURL: URL) -> Bool { + let paths = [ + registryURL.path, + registryURL.path + "-wal", + registryURL.path + "-shm", + registryURL.path + "-journal", + ] + return paths.allSatisfy { path in + var metadata = stat() + guard lstat(path, &metadata) != 0 else { return false } + return errno == ENOENT || errno == ENOTDIR + } + } +} diff --git a/Sources/RestorableAgentRegistrySnapshots.swift b/Sources/RestorableAgentRegistrySnapshots.swift new file mode 100644 index 000000000000..e7bc6f5ef020 --- /dev/null +++ b/Sources/RestorableAgentRegistrySnapshots.swift @@ -0,0 +1,937 @@ +import CmuxFoundation +import Foundation + +extension RestorableAgentSessionIndex { + private struct StartupRestoreCandidate { + var workspaceIndex: Int + var panelIndex: Int + var workspaceID: UUID? + var panelID: UUID + var agent: SessionRestorableAgentSnapshot + var wasHibernated: Bool + + var kind: RestorableAgentKind { agent.kind } + var sessionID: String { agent.sessionId } + } + + private struct DeadRestoringAttempt { + var agent: SessionRestorableAgentSnapshot + var provider: String + var sessionID: String + var workspaceID: UUID + var panelID: UUID + var attemptID: String + var fingerprint: Data + var updatedAt: TimeInterval + } + + private struct StartupRegistryProjection { + var recordsBySessionID: [String: CmuxAgentSessionRegistry.Record] + var surfaceSlotsByID: [UUID: CmuxAgentSessionRegistry.ActiveSlot] + } + + struct AgentRegistryHibernationSnapshotResult { + var snapshots: [String: CmuxAgentSessionRegistry.Snapshot] + var failedProviders: Set + } + + static let maximumHibernationRegistryProviders = 64 + static let maximumHibernationPanelContexts = 4_096 + static let maximumHibernationRegistryRecords = 12_288 + static let maximumHibernationRegistryBytes: Int64 = 64 * 1_024 * 1_024 + + /// Reconciles all bounded workspace snapshots through one registry + /// projection before the restore path can construct any terminal panels. + @discardableResult + static func prepareAgentRegistryForSessionRestore( + _ snapshot: inout AppSessionSnapshot, + homeDirectory: String = NSHomeDirectory(), + fileManager: FileManager = .default, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Set { + var workspaceLocations: [(windowIndex: Int, workspaceIndex: Int)] = [] + var workspaces: [SessionWorkspaceSnapshot] = [] + for windowIndex in snapshot.windows.indices + .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { + for workspaceIndex in snapshot.windows[windowIndex] + .tabManager.workspaces.indices + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) { + workspaceLocations.append((windowIndex, workspaceIndex)) + workspaces.append( + snapshot.windows[windowIndex].tabManager.workspaces[workspaceIndex] + ) + } + } + let failures = prepareAgentRegistryForSessionRestore( + &workspaces, + homeDirectory: homeDirectory, + fileManager: fileManager, + environment: environment + ) + for (offset, location) in workspaceLocations.enumerated() { + snapshot.windows[location.windowIndex] + .tabManager.workspaces[location.workspaceIndex] = workspaces[offset] + } + return failures + } + + /// Reconciles one closed-history workspace through the same bounded + /// projection used by full app-session restore. + @discardableResult + static func prepareAgentRegistryForSessionRestore( + _ snapshot: inout SessionWorkspaceSnapshot, + homeDirectory: String = NSHomeDirectory(), + fileManager: FileManager = .default, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Set { + var workspaces = [snapshot] + let failures = prepareAgentRegistryForSessionRestore( + &workspaces, + homeDirectory: homeDirectory, + fileManager: fileManager, + environment: environment + ) + if let reconciled = workspaces.first { + snapshot = reconciled + } + return failures + } + + private static func prepareAgentRegistryForSessionRestore( + _ workspaces: inout [SessionWorkspaceSnapshot], + homeDirectory: String, + fileManager: FileManager, + environment: [String: String] + ) -> Set { + var candidates: [StartupRestoreCandidate] = [] + var kinds = Set() + var restoreOwners = Set() + for workspaceIndex in workspaces.indices { + for panelIndex in workspaces[workspaceIndex].panels.indices + .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) { + let panel = workspaces[workspaceIndex].panels[panelIndex] + guard let terminal = panel.terminal, + let agent = terminal.agent else { continue } + let sessionID = agent.sessionId + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !sessionID.isEmpty else { continue } + kinds.insert(agent.kind) + var normalizedAgent = agent + normalizedAgent.sessionId = sessionID + let candidate = StartupRestoreCandidate( + workspaceIndex: workspaceIndex, + panelIndex: panelIndex, + workspaceID: workspaces[workspaceIndex].workspaceId, + panelID: panel.id, + agent: normalizedAgent, + wasHibernated: terminal.hibernation != nil + ) + candidates.append(candidate) + if let workspaceID = candidate.workspaceID { + restoreOwners.insert(.init( + provider: candidate.kind.rawValue, + sessionID: candidate.sessionID, + workspaceID: workspaceID.uuidString, + surfaceID: candidate.panelID.uuidString + )) + } + } + } + guard !candidates.isEmpty else { return [] } + guard candidates.count <= maximumHibernationPanelContexts, + kinds.count <= maximumHibernationRegistryProviders else { + suppressAutomaticStartup(for: candidates, in: &workspaces) + return kinds + } + + let orderedKinds = kinds.sorted { $0.rawValue < $1.rawValue } + let sources = orderedKinds.map { + CmuxAgentSessionRegistry.LegacySource( + provider: $0.rawValue, + url: $0.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ) + ) + } + let registry = CmuxAgentSessionRegistry( + url: CmuxAgentSessionRegistry.defaultURL( + homeDirectory: homeDirectory, + environment: environment + ), + busyTimeoutMilliseconds: 25 + ) + var failedProviders: Set + let verifiedCanonicalRestoreOwners: Set + let registryProjectionAvailable: Bool + do { + let refresh = try registry.refreshLegacySources( + sources, + preservingCanonicalRestoreOwners: restoreOwners, + fileManager: fileManager + ) + failedProviders = refresh.failedProviders + verifiedCanonicalRestoreOwners = refresh.verifiedCanonicalRestoreOwners + registryProjectionAvailable = true + } catch { + failedProviders = Set(kinds.map(\.rawValue)) + verifiedCanonicalRestoreOwners = [] + registryProjectionAvailable = false + } + + let candidatesByProvider = Dictionary(grouping: candidates) { + $0.kind.rawValue + } + var projections: [String: StartupRegistryProjection] = [:] + var remainingRecords = maximumHibernationRegistryRecords + var remainingBytes = maximumHibernationRegistryBytes + for kind in orderedKinds where registryProjectionAvailable { + let provider = kind.rawValue + let providerCandidates = candidatesByProvider[provider] ?? [] + let panelContexts = Set(providerCandidates.compactMap { candidate in + candidate.workspaceID.map { + CmuxAgentSessionRegistry.HookHibernationPanelContext( + workspaceID: $0.uuidString, + surfaceID: candidate.panelID.uuidString + ) + } + }) + do { + let projection = try registry.hookHibernationSnapshot( + provider: provider, + panelContexts: panelContexts, + exactSessionIDs: Set(providerCandidates.map { $0.sessionID }), + maximumRecords: remainingRecords, + maximumBytes: remainingBytes + ) + let bytes = try projectedRegistryBytes(projection) + remainingRecords -= projection.records.count + remainingBytes -= bytes + projections[provider] = StartupRegistryProjection( + recordsBySessionID: Dictionary( + projection.records.map { ($0.sessionID, $0) }, + uniquingKeysWith: { current, replacement in + current.updatedAt >= replacement.updatedAt + ? current + : replacement + } + ), + surfaceSlotsByID: Dictionary( + projection.activeSlots.compactMap { slot in + guard slot.scope == .surface, + let surfaceID = normalizedRegistryUUID(slot.scopeID) else { + return nil + } + return (surfaceID, slot) + }, + uniquingKeysWith: { current, replacement in + current.updatedAt >= replacement.updatedAt + ? current + : replacement + } + ) + ) + } catch { + failedProviders.insert(provider) + } + } + + var runtimeOwnershipProbe = AgentRuntimeOwnershipProbe( + environment: environment, + currentSocketStateResolver: { + AgentHookRuntimeSocketState.resolve(preferredPath: $0) + }, + processIdentityResolver: { AgentPIDProcessIdentity(pid: $0) } + ) + var deadRestoringAttempts: [DeadRestoringAttempt] = [] + var reconciledCanonicalOwners = Set< + CmuxAgentSessionRegistry.RestoreOwnerContext + >() + for candidate in candidates { + guard let workspaceID = candidate.workspaceID else { + rejectStartupRestoreCandidate(candidate, in: &workspaces) + continue + } + guard let projection = projections[candidate.kind.rawValue] else { + continue + } + guard let record = projection.recordsBySessionID[candidate.sessionID] else { + rejectStartupRestoreCandidate(candidate, in: &workspaces) + continue + } + guard let recordObject = try? JSONSerialization.jsonObject( + with: record.json + ) as? [String: Any] else { + rejectStartupRestoreCandidate(candidate, in: &workspaces) + continue + } + let projectedRestoreAuthority = CmuxAgentSessionRunAuthorityProjection() + .projectedRestoreAuthority(recordJSON: record.json) + guard projectedRestoreAuthority == true else { + rejectStartupRestoreCandidate(candidate, in: &workspaces) + continue + } + guard startupRecord( + recordObject, + projectedRestoreAuthority: projectedRestoreAuthority == true, + matches: candidate, + workspaceID: workspaceID + ), startupRecordHasCanonicalSurfaceAuthority( + recordObject, + projection: projection, + candidate: candidate + ) else { + rejectStartupRestoreCandidate(candidate, in: &workspaces) + continue + } + let lifecycleValue = recordObject["sessionState"] + let lifecycle: String? + if lifecycleValue == nil || lifecycleValue is NSNull { + lifecycle = nil + } else if let lifecycleValue = lifecycleValue as? String { + lifecycle = lifecycleValue + } else { + rejectStartupRestoreCandidate(candidate, in: &workspaces) + continue + } + if lifecycle == nil + || lifecycle == AgentSessionLifecycleState.active.rawValue { + if candidate.wasHibernated { + rejectStartupRestoreCandidate(candidate, in: &workspaces) + } + continue + } + guard lifecycle == AgentSessionLifecycleState.hibernated.rawValue + || lifecycle == AgentSessionLifecycleState.restoring.rawValue else { + rejectStartupRestoreCandidate(candidate, in: &workspaces) + continue + } + reconciledCanonicalOwners.insert(.init( + provider: candidate.kind.rawValue, + sessionID: candidate.sessionID, + workspaceID: workspaceID.uuidString, + surfaceID: candidate.panelID.uuidString + )) + + if lifecycle == AgentSessionLifecycleState.restoring.rawValue, + runtimeOwnershipProbe.evidence(for: recordObject) == .provablyDeadForeign, + let attemptID = normalizedRegistryValue( + recordObject["cmuxHibernationResumeAttemptId"] as? String + ), + let fingerprint = startupRecordFingerprint(recordObject) { + deadRestoringAttempts.append(DeadRestoringAttempt( + agent: candidate.agent, + provider: candidate.kind.rawValue, + sessionID: candidate.sessionID, + workspaceID: workspaceID, + panelID: candidate.panelID, + attemptID: attemptID, + fingerprint: fingerprint, + updatedAt: record.updatedAt + )) + } + + var terminal = workspaces[candidate.workspaceIndex] + .panels[candidate.panelIndex].terminal + let hibernatedAt = registryTimeInterval( + recordObject["cmuxHibernatedAt"] + ) ?? record.updatedAt + let lastActivityAt = terminal?.hibernation?.lastActivityAt ?? hibernatedAt + terminal?.hibernation = SessionAgentHibernationSnapshot( + hibernatedAt: hibernatedAt, + lastActivityAt: lastActivityAt + ) + terminal?.agent = candidate.agent + terminal?.wasAgentRunning = false + workspaces[candidate.workspaceIndex] + .panels[candidate.panelIndex].terminal = terminal + } + + normalizeProvablyDeadRestoringAttempts( + deadRestoringAttempts, + registry: registry + ) + + for candidate in candidates where failedProviders.contains(candidate.kind.rawValue) { + guard var terminal = workspaces[candidate.workspaceIndex] + .panels[candidate.panelIndex].terminal else { continue } + if candidate.wasHibernated { + let owner = candidate.workspaceID.map { + CmuxAgentSessionRegistry.RestoreOwnerContext( + provider: candidate.kind.rawValue, + sessionID: candidate.sessionID, + workspaceID: $0.uuidString, + surfaceID: candidate.panelID.uuidString + ) + } + guard owner.map({ + verifiedCanonicalRestoreOwners.contains($0) + || reconciledCanonicalOwners.contains($0) + }) != true else { + continue + } + terminal.agent = nil + terminal.hibernation = nil + terminal.resumeBinding = nil + } + terminal.wasAgentRunning = false + workspaces[candidate.workspaceIndex] + .panels[candidate.panelIndex].terminal = terminal + } + return Set(failedProviders.compactMap(RestorableAgentKind.init(rawValue:))) + } + + private static func rejectStartupRestoreCandidate( + _ candidate: StartupRestoreCandidate, + in workspaces: inout [SessionWorkspaceSnapshot] + ) { + guard var terminal = workspaces[candidate.workspaceIndex] + .panels[candidate.panelIndex].terminal else { return } + terminal.agent = nil + terminal.hibernation = nil + terminal.resumeBinding = nil + terminal.wasAgentRunning = false + workspaces[candidate.workspaceIndex] + .panels[candidate.panelIndex].terminal = terminal + } + + private static func suppressAutomaticStartup( + for candidates: [StartupRestoreCandidate], + in workspaces: inout [SessionWorkspaceSnapshot] + ) { + for candidate in candidates { + workspaces[candidate.workspaceIndex] + .panels[candidate.panelIndex].terminal?.wasAgentRunning = false + } + } + + private static func projectedRegistryBytes( + _ snapshot: CmuxAgentSessionRegistry.Snapshot + ) throws -> Int64 { + var bytes: Int64 = 0 + for count in snapshot.records.map({ $0.json.count }) + + snapshot.activeSlots.map({ $0.json.count }) { + let next = bytes.addingReportingOverflow(Int64(count)) + guard !next.overflow else { throw CocoaError(.fileReadTooLarge) } + bytes = next.partialValue + } + return bytes + } + + private static func startupRecord( + _ record: [String: Any], + projectedRestoreAuthority: Bool, + matches candidate: StartupRestoreCandidate, + workspaceID: UUID + ) -> Bool { + guard record["sessionId"] as? String == candidate.sessionID, + projectedRestoreAuthority, + record["updatedAt"] is NSNumber, + normalizedRegistryUUID(record["workspaceId"] as? String) == workspaceID, + normalizedRegistryUUID(record["surfaceId"] as? String) == candidate.panelID else { + return false + } + guard let completedAt = record["completedAt"] else { return true } + return completedAt is NSNull + } + + private static func startupRecordHasCanonicalSurfaceAuthority( + _ record: [String: Any], + projection: StartupRegistryProjection, + candidate: StartupRestoreCandidate + ) -> Bool { + if let slot = projection.surfaceSlotsByID[candidate.panelID] { + guard slot.sessionID == candidate.sessionID, + let slotObject = try? JSONSerialization.jsonObject( + with: slot.json + ) as? [String: Any] else { + return false + } + return slotObject["sessionId"] as? String == candidate.sessionID + && slotObject["updatedAt"] is NSNumber + } + return record["cmuxHibernationDetached"] as? Bool == true + && record["sessionState"] as? String + == AgentSessionLifecycleState.hibernated.rawValue + } + + private static func normalizeProvablyDeadRestoringAttempts( + _ attempts: [DeadRestoringAttempt], + registry: CmuxAgentSessionRegistry + ) { + for (provider, providerAttempts) in Dictionary( + grouping: attempts, + by: { $0.provider } + ).sorted(by: { $0.key < $1.key }) { + var normalizedAgent: SessionRestorableAgentSnapshot? + do { + try registry.withRecordRebindBatch { batch in + for attempt in providerAttempts { + let result = try batch.patchRecordRebindingActiveSlots( + provider: provider, + sessionID: attempt.sessionID, + updatedAt: attempt.updatedAt, + previousSlots: [], + activeSlots: [.init( + scope: .surface, + scopeID: attempt.panelID.uuidString + )], + requireExistingActiveSlots: true, + monotonicUpdatedAt: true, + shouldMutate: { record in + startupRecordFingerprint(record) == attempt.fingerprint + && normalizedRegistryValue( + record["cmuxHibernationResumeAttemptId"] as? String + ) == attempt.attemptID + && record["sessionState"] as? String + == AgentSessionLifecycleState.restoring.rawValue + && normalizedRegistryUUID( + record["workspaceId"] as? String + ) == attempt.workspaceID + && normalizedRegistryUUID( + record["surfaceId"] as? String + ) == attempt.panelID + } + ) { record in + record["sessionState"] = AgentSessionLifecycleState.hibernated.rawValue + record.removeValue(forKey: "cmuxHibernationResumeAttemptId") + record.removeValue(forKey: "cmuxHibernationResumeStartedAt") + record.removeValue(forKey: "cmuxHibernationResumeFromAttemptId") + } + if result == .patched, normalizedAgent == nil { + normalizedAgent = attempt.agent + } + } + } + } catch { + continue + } + if let agent = normalizedAgent { + AgentHookSessionStateWriter.projectCanonicalLegacy(agent: agent) + } + } + } + + private static func startupRecordFingerprint( + _ record: [String: Any] + ) -> Data? { + guard JSONSerialization.isValidJSONObject(record) else { return nil } + return try? JSONSerialization.data( + withJSONObject: record, + options: [.sortedKeys] + ) + } + + private static func normalizedRegistryValue(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + + private static func normalizedRegistryUUID(_ value: String?) -> UUID? { + normalizedRegistryValue(value).flatMap(UUID.init(uuidString:)) + } + + private static func registryTimeInterval(_ value: Any?) -> TimeInterval? { + (value as? NSNumber)?.doubleValue + } + + static func agentRegistrySnapshots( + _ sources: [(kind: RestorableAgentKind, fileURL: URL)], + fileManager: FileManager, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> [String: CmuxAgentSessionRegistry.Snapshot]? { + guard let firstSource = sources.first else { + return nil + } + let registryURL: URL + if let explicit = environment["CMUX_AGENT_SESSION_REGISTRY_PATH"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !explicit.isEmpty { + registryURL = URL(fileURLWithPath: NSString(string: explicit).expandingTildeInPath) + } else { + registryURL = firstSource.fileURL.deletingLastPathComponent() + .appendingPathComponent(CmuxAgentSessionRegistry.filename, isDirectory: false) + } + let registry = CmuxAgentSessionRegistry(url: registryURL) + let legacySources = sources.map { + CmuxAgentSessionRegistry.LegacySource(provider: $0.kind.rawValue, url: $0.fileURL) + } + do { + return try registry.snapshotsImportingLegacy( + sources: legacySources, + fileManager: fileManager + ) + } catch { + var recovered: [String: CmuxAgentSessionRegistry.Snapshot] = [:] + for source in legacySources { + recovered[source.provider] = (try? registry.snapshotImportingLegacy( + provider: source.provider, + legacyURL: source.url, + fileManager: fileManager + )) ?? (try? registry.snapshot(provider: source.provider)) + } + return recovered.isEmpty ? nil : recovered + } + } + + /// Refreshes compatibility sources, then materializes only the active slot + /// owners for open panels and exact process-detected session identities. + /// Every failure produces an explicit empty provider snapshot so callers do + /// not fall back to full registry history or compatibility JSON. + static func agentRegistryHibernationSnapshots( + _ sources: [(kind: RestorableAgentKind, fileURL: URL)], + panelKeys: Set, + exactSessionIDsByProvider: [String: Set], + maximumProviders: Int = maximumHibernationRegistryProviders, + maximumPanelContexts: Int = maximumHibernationPanelContexts, + maximumRecords: Int = maximumHibernationRegistryRecords, + maximumBytes: Int64 = maximumHibernationRegistryBytes, + maximumLegacySourceReadBytes: Int64 = CmuxAgentSessionRegistry.maximumLegacyRefreshReadBytes, + fileManager: FileManager, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> AgentRegistryHibernationSnapshotResult { + let uniqueSources = Dictionary( + sources.map { ($0.kind.rawValue, $0) }, + uniquingKeysWith: { _, latest in latest } + ).values.sorted { $0.kind.rawValue < $1.kind.rawValue } + let emptySnapshots = Dictionary(uniqueKeysWithValues: uniqueSources.map { + ($0.kind.rawValue, CmuxAgentSessionRegistry.Snapshot(records: [], activeSlots: [])) + }) + let allProviders = Set(uniqueSources.map { $0.kind.rawValue }) + guard !uniqueSources.isEmpty else { + return AgentRegistryHibernationSnapshotResult(snapshots: [:], failedProviders: []) + } + guard panelKeys.count <= max(0, maximumPanelContexts), + maximumRecords >= 0, + maximumBytes >= 0, + maximumLegacySourceReadBytes >= 0 else { + return AgentRegistryHibernationSnapshotResult( + snapshots: emptySnapshots, + failedProviders: allProviders + ) + } + + let firstSource = uniqueSources[0] + let registryURL: URL + if let explicit = environment["CMUX_AGENT_SESSION_REGISTRY_PATH"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !explicit.isEmpty { + registryURL = URL(fileURLWithPath: NSString(string: explicit).expandingTildeInPath) + } else { + registryURL = firstSource.fileURL.deletingLastPathComponent() + .appendingPathComponent(CmuxAgentSessionRegistry.filename, isDirectory: false) + } + let registry = CmuxAgentSessionRegistry(url: registryURL) + let legacySources = uniqueSources.map { + CmuxAgentSessionRegistry.LegacySource(provider: $0.kind.rawValue, url: $0.fileURL) + } + let panelContexts = Set(panelKeys.map { + CmuxAgentSessionRegistry.HookHibernationPanelContext( + workspaceID: $0.workspaceId.uuidString, + surfaceID: $0.panelId.uuidString + ) + }) + let exactProviders = Set(exactSessionIDsByProvider.compactMap { entry in + allProviders.contains(entry.key) && !entry.value.isEmpty ? entry.key : nil + }) + let panelOwnerProviders: Set + do { + panelOwnerProviders = try registry.hookHibernationPanelOwnerProviders( + providers: allProviders, + panelContexts: panelContexts + ) + } catch { + return AgentRegistryHibernationSnapshotResult( + snapshots: emptySnapshots, + failedProviders: allProviders + ) + } + // Exact process evidence is strongest and consumes the bounded legacy + // read budget first. Existing panel owners follow; speculative adapters + // are admitted only from whatever budget remains. + let priorityProviders = exactProviders.sorted() + + panelOwnerProviders.subtracting(exactProviders).sorted() + let refresh: CmuxAgentSessionRegistry.LegacyRefreshResult + do { + refresh = try registry.refreshLegacySources( + legacySources, + prioritizingProviders: priorityProviders, + maximumReadBytes: maximumLegacySourceReadBytes, + fileManager: fileManager + ) + } catch { + return AgentRegistryHibernationSnapshotResult( + snapshots: emptySnapshots, + failedProviders: allProviders + ) + } + + var snapshots = emptySnapshots + var failedProviders = refresh.failedProviders + do { + let availableProviders = allProviders.subtracting(failedProviders) + let selected = try registry.hookHibernationSnapshots( + providers: availableProviders, + panelContexts: panelContexts, + exactSessionIDsByProvider: exactSessionIDsByProvider, + maximumProviders: maximumProviders, + maximumRecords: maximumRecords, + maximumBytes: maximumBytes + ) + snapshots.merge(selected.snapshots) { _, selected in selected } + failedProviders.formUnion(selected.failedProviders) + } catch { + return AgentRegistryHibernationSnapshotResult( + snapshots: emptySnapshots, + failedProviders: allProviders + ) + } + return AgentRegistryHibernationSnapshotResult( + snapshots: snapshots, + failedProviders: failedProviders + ) + } + + static func agentHookState( + kind: RestorableAgentKind, + fileURL: URL, + snapshots: [String: CmuxAgentSessionRegistry.Snapshot]?, + fileManager: FileManager, + decoder: JSONDecoder + ) -> RestorableAgentHookSessionStoreFile? { + if let snapshot = snapshots?[kind.rawValue] { + return try? RestorableAgentHookSessionStoreFile.decode( + snapshot: snapshot, + decoder: decoder + ) + } + return RestorableAgentHookSessionStoreFile.load( + provider: kind.rawValue, + legacyURL: fileURL, + environment: ProcessInfo.processInfo.environment, + fileManager: fileManager, + decoder: decoder + ) + } +} + +/// Shared canonical reader for app surfaces that consume hook-session state. +/// SQLite is authoritative and retains the full history; bounded legacy JSON +/// remains a compatibility fallback for stores written before the registry. +enum AgentHookSessionRegistryReader { + struct RecordData: Sendable { + let sessionID: String + let updatedAt: TimeInterval + let data: Data + } + + private static let maximumSessionIdentifierBytes = 16 * 1_024 + + static func legacyURL( + provider: String, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> URL? { + guard CmuxVaultAgentRegistration.isValidID(provider) else { return nil } + let directory: URL + if let override = normalized(environment["CMUX_AGENT_HOOK_STATE_DIR"]) { + directory = URL( + fileURLWithPath: NSString(string: override).expandingTildeInPath, + isDirectory: true + ) + } else { + directory = homeDirectory.appendingPathComponent(".cmuxterm", isDirectory: true) + } + return directory.appendingPathComponent("\(provider)-hook-sessions.json", isDirectory: false) + } + + static func recordData( + provider: String, + sessionID: String, + legacyURL: URL, + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) -> Data? { + guard CmuxVaultAgentRegistration.isValidID(provider), + !sessionID.isEmpty, + sessionID.utf8.count <= maximumSessionIdentifierBytes else { + return nil + } + let registry = registry(legacyURL: legacyURL, environment: environment) + let existingRecord = try? registry.hookRecord( + provider: provider, + sessionID: sessionID + ) + if let existingRecord, + existingRecord.writerGeneration > 0 { + return existingRecord.json + } + + // Generation-zero rows came from compatibility JSON. Refresh its + // stamp before returning one so a concurrently running older cmux can + // update the same session without this app serving stale bindings. + let refresh = try? registry.refreshLegacySources( + [.init(provider: provider, url: legacyURL)], + fileManager: fileManager + ) + if let record = try? registry.hookRecord(provider: provider, sessionID: sessionID) { + return record.json + } + if refresh?.failedProviders.contains(provider) == false { + // A successful refresh can legitimately delete a generation-zero + // row that disappeared from an older writer's complete store. + return nil + } + if let existingRecord { + return existingRecord.json + } + return legacyRecords( + registry: registry, + legacyURL: legacyURL, + fileManager: fileManager + )?[sessionID] + } + + static func records( + provider: String, + legacyURL: URL, + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + maximumRecords: Int = 20_000 + ) -> [String: Data]? { + guard CmuxVaultAgentRegistration.isValidID(provider) else { return nil } + let registry = registry(legacyURL: legacyURL, environment: environment) + let refresh = try? registry.refreshLegacySources( + [.init(provider: provider, url: legacyURL)], + fileManager: fileManager + ) + if let snapshot = try? registry.hookBoundedSnapshot( + provider: provider, + maximumRecords: maximumRecords + ), !snapshot.records.isEmpty || refresh?.failedProviders.contains(provider) == false { + var result: [String: Data] = [:] + result.reserveCapacity(snapshot.records.count) + for record in snapshot.records { + result[record.sessionID] = record.json + } + return result + } + return legacyRecords( + registry: registry, + legacyURL: legacyURL, + fileManager: fileManager + ) + } + + /// Reads active owners plus bounded recent history without materializing + /// the provider's full canonical store. Exact lookups remain available for + /// older rows omitted from this seed-oriented view. + static func recentRecordData( + provider: String, + legacyURL: URL, + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + maximumRecords: Int, + maximumBytes: Int64 + ) -> [RecordData]? { + guard CmuxVaultAgentRegistration.isValidID(provider) else { return nil } + let registry = registry(legacyURL: legacyURL, environment: environment) + let refresh = try? registry.refreshLegacySources( + [.init(provider: provider, url: legacyURL)], + fileManager: fileManager + ) + do { + let records = try registry.hookBoundedRecentRecords( + provider: provider, + maximumRecords: maximumRecords, + maximumBytes: maximumBytes + ) + if !records.isEmpty || refresh?.failedProviders.contains(provider) == false { + return records.map { + RecordData(sessionID: $0.sessionID, updatedAt: $0.updatedAt, data: $0.json) + } + } + } catch { + // A populated canonical store that exceeds the caller's seed + // budget must fail closed. Falling back to its compatibility + // projection could silently omit active owners. + if let metrics = try? registry.hookStorageMetrics(provider: provider), + metrics.recordCount > 0 { + return nil + } + } + + guard let legacy = legacyRecords( + registry: registry, + legacyURL: legacyURL, + fileManager: fileManager + ) else { return nil } + let ordered = legacy.map { sessionID, data -> RecordData in + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + return RecordData( + sessionID: sessionID, + updatedAt: object?["updatedAt"] as? TimeInterval ?? 0, + data: data + ) + }.sorted { + if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt } + return $0.sessionID < $1.sessionID + } + + let recordLimit = max(0, maximumRecords) + let byteLimit = max(0, maximumBytes) + var selected: [RecordData] = [] + selected.reserveCapacity(min(recordLimit, ordered.count)) + var selectedBytes: Int64 = 0 + for record in ordered.prefix(recordLimit) { + let nextBytes = selectedBytes.addingReportingOverflow(Int64(record.data.count)) + guard !nextBytes.overflow, nextBytes.partialValue <= byteLimit else { break } + selectedBytes = nextBytes.partialValue + selected.append(record) + } + return selected + } + + private static func registry( + legacyURL: URL, + environment: [String: String] + ) -> CmuxAgentSessionRegistry { + let registryURL: URL + if let explicit = normalized(environment["CMUX_AGENT_SESSION_REGISTRY_PATH"]) { + registryURL = URL(fileURLWithPath: NSString(string: explicit).expandingTildeInPath) + } else { + registryURL = legacyURL.deletingLastPathComponent() + .appendingPathComponent(CmuxAgentSessionRegistry.filename, isDirectory: false) + } + return CmuxAgentSessionRegistry(url: registryURL) + } + + private static func legacyRecords( + registry: CmuxAgentSessionRegistry, + legacyURL: URL, + fileManager: FileManager + ) -> [String: Data]? { + guard fileManager.fileExists(atPath: legacyURL.path), + let data = try? registry.readHookLegacySourceData(at: legacyURL), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + let sessions = (root["sessions"] as? [String: Any]) ?? root + var result: [String: Data] = [:] + result.reserveCapacity(sessions.count) + for (sessionID, value) in sessions { + guard JSONSerialization.isValidJSONObject(value), + let encoded = try? JSONSerialization.data( + withJSONObject: value, + options: [.sortedKeys] + ) else { continue } + result[sessionID] = encoded + } + return result + } + + private static func normalized(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } +} diff --git a/Sources/RestorableAgentSession+Campfire.swift b/Sources/RestorableAgentSession+Campfire.swift index 0a1af9159d8b..7ccadede883f 100644 --- a/Sources/RestorableAgentSession+Campfire.swift +++ b/Sources/RestorableAgentSession+Campfire.swift @@ -1,20 +1,53 @@ -import CMUXAgentLaunch +import Foundation extension AgentResumeCommandBuilder { - static func campfireBuiltInResumeArguments( - customRegistration: CmuxVaultAgentRegistration, - sessionId: String, + /// Registry-owned built-ins use sanitizer-backed native argv only while the + /// configured verb still matches that built-in. Project template overrides + /// must remain authoritative. + static func matchingBuiltInResumeKind( + registration: CmuxVaultAgentRegistration + ) -> String? { + let builtIns: [(String, CmuxVaultAgentRegistration)] = [ + ("pi", .builtInPi), + ("omp", .builtInOmp), + ("grok", .builtInGrok), + ("campfire", .builtInCampfire), + ("antigravity", .builtInAntigravity), + ] + return builtIns.first { kind, builtIn in + registration.id.caseInsensitiveCompare(builtIn.id) == .orderedSame + && registration.resumeCommand == builtIn.resumeCommand + }?.0 + } + + static func matchingBuiltInForkKind( + registration: CmuxVaultAgentRegistration + ) -> String? { + let builtIns: [(String, CmuxVaultAgentRegistration)] = [ + ("pi", .builtInPi), + ("grok", .builtInGrok), + ("campfire", .builtInCampfire), + ] + return builtIns.first { kind, builtIn in + registration.id.caseInsensitiveCompare(builtIn.id) == .orderedSame + && registration.forkCommand == builtIn.forkCommand + }?.0 + } + + static func capturedOrRegisteredExecutablePath( + registration: CmuxVaultAgentRegistration, launchCommand: AgentLaunchCommandSnapshot? - ) -> [String]? { - guard customRegistration.id == CmuxVaultAgentRegistration.builtInCampfire.id, - customRegistration.resumeCommand == CmuxVaultAgentRegistration.builtInCampfire.resumeCommand else { + ) -> String? { + normalizedExecutable(launchCommand?.executablePath) + ?? normalizedExecutable(launchCommand?.arguments.first) + ?? registration.defaultExecutable + } + + private static func normalizedExecutable(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { return nil } - return AgentResumeArgv().builtInKind( - kind: "campfire", - sessionId: sessionId, - executablePath: launchCommand?.executablePath, - arguments: launchCommand?.arguments ?? [] - ) + return trimmed } } diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index d441561f59e9..ec82b7cf0f60 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -1,7 +1,7 @@ import Darwin import Foundation import CMUXAgentLaunch -import Darwin +import CmuxFoundation import os enum TerminalStartupShellQuoting { @@ -314,6 +314,7 @@ enum AgentResumeCommandBuilder { static func resumeShellCommand( kind: RestorableAgentKind, sessionId: String, + transcriptPath: String? = nil, launchCommand: AgentLaunchCommandSnapshot?, workingDirectory: String?, registrationOverride: CmuxVaultAgentRegistration? = nil, @@ -325,6 +326,7 @@ enum AgentResumeCommandBuilder { let argv = resumeArguments( kind: kind, sessionId: sessionId, + transcriptPath: transcriptPath, launchCommand: launchCommand, workingDirectory: workingDirectory, customRegistration: customRegistration, @@ -372,8 +374,136 @@ enum AgentResumeCommandBuilder { kind: kind, launchCommand: launchCommand, workingDirectory: workingDirectory, - customRegistration: customRegistration, - includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix + customRegistration: customRegistration, includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix, + additionalEnvironment: ["CMUX_AGENT_PARENT_SESSION_ID": sessionId, "CMUX_AGENT_RELATIONSHIP": "forked"] + ) + } + + static func resumeExecutionDescriptor( + kind: RestorableAgentKind, + sessionId: String, + transcriptPath: String? = nil, + launchCommand: AgentLaunchCommandSnapshot?, + workingDirectory: String?, + registrationOverride: CmuxVaultAgentRegistration? = nil, + observedPermissionMode: String? = nil + ) -> AgentCommandExecutionDescriptor? { + guard let argv = resumeArguments( + kind: kind, + sessionId: sessionId, + transcriptPath: transcriptPath, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + customRegistration: registrationOverride, + observedPermissionMode: observedPermissionMode + ) else { + return nil + } + return executionDescriptor( + argv: argv, + kind: kind, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + customRegistration: registrationOverride + ) + } + + static func forkExecutionDescriptor( + kind: RestorableAgentKind, + sessionId: String, + launchCommand: AgentLaunchCommandSnapshot?, + workingDirectory: String?, + registrationOverride: CmuxVaultAgentRegistration? = nil, + observedPermissionMode: String? = nil + ) -> AgentCommandExecutionDescriptor? { + guard let argv = forkArguments( + kind: kind, + sessionId: sessionId, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + customRegistration: registrationOverride, + observedPermissionMode: observedPermissionMode + ) else { + return nil + } + return executionDescriptor( + argv: argv, + kind: kind, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + customRegistration: registrationOverride + ) + } + + static func surfaceResumeBindingExecutionDescriptor( + command: String, + kind rawKind: String?, + environment: [String: String]?, + workingDirectory: String? + ) -> AgentCommandExecutionDescriptor? { + guard let rawKind = normalized(rawKind), + let kind = RestorableAgentKind(rawValue: rawKind) else { + return nil + } + let words = TerminalStartupWorkingDirectoryPrefix.shellWordRanges(command) + guard let executableIndex = SurfaceResumeCommandCanonicalizer.commandExecutableWordIndex( + in: words, + command: command + ) else { + return nil + } + let commandStartIndex = words[.. String { var commandParts: [String] = [] - let environmentParts = launchEnvironmentParts(kind: kind, environment: launchCommand?.environment) + var environmentParts = launchEnvironmentParts(kind: kind, environment: launchCommand?.environment) + environmentParts.append(contentsOf: additionalEnvironment.keys.sorted().compactMap { key in additionalEnvironment[key].map { "\(key)=\($0)" } }) if !environmentParts.isEmpty { commandParts.append("env") commandParts.append(contentsOf: environmentParts) } - commandParts.append(contentsOf: argv) + if commandParts.first == "env", argv.first == "env" { + commandParts.append(contentsOf: argv.dropFirst()) + } else { + commandParts.append(contentsOf: argv) + } let cwd = !includeWorkingDirectoryPrefix || customRegistration?.cwd == .ignore ? nil @@ -462,21 +597,9 @@ enum AgentResumeCommandBuilder { kind: RestorableAgentKind, environment: [String: String]? ) -> [String] { - guard let environment, !environment.isEmpty else { - return [] - } - var environmentParts: [String] = [] var preservedClaudeAuthSelectionEnvironmentKeys: [String] = [] - var selectedEnvironment = AgentLaunchEnvironmentPolicy().selectedEnvironment(from: environment, kind: kind.rawValue) - let piFamilyUsesCapturedPath = kind == .pi - || kind.customAgentID == "pi" - || kind.customAgentID == "omp" - if piFamilyUsesCapturedPath, - let path = environment["PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !path.isEmpty { - selectedEnvironment["PATH"] = path - } + let selectedEnvironment = launchEnvironment(kind: kind, environment: environment) for key in selectedEnvironment.keys.sorted() { guard let value = selectedEnvironment[key] else { continue } environmentParts.append("\(key)=\(value)") @@ -494,9 +617,127 @@ enum AgentResumeCommandBuilder { return environmentParts } + private static func launchEnvironment( + kind: RestorableAgentKind, + environment: [String: String]? + ) -> [String: String] { + guard let environment, !environment.isEmpty else { return [:] } + var selectedEnvironment = AgentLaunchEnvironmentPolicy().selectedEnvironment( + from: environment, + kind: kind.rawValue + ) + let piFamilyUsesCapturedPath = kind == .pi + || kind.customAgentID == "pi" + || kind.customAgentID == "omp" + if piFamilyUsesCapturedPath, + let path = normalized(environment["PATH"]) { + selectedEnvironment["PATH"] = path + } + return selectedEnvironment + } + + static func executionDescriptor( + argv: [String], + kind: RestorableAgentKind, + launchCommand: AgentLaunchCommandSnapshot?, + workingDirectory: String?, + customRegistration: CmuxVaultAgentRegistration?, + baseSearchPath: String? = nil + ) -> AgentCommandExecutionDescriptor? { + guard !argv.isEmpty else { return nil } + var searchPath = baseSearchPath ?? launchEnvironment( + kind: kind, + environment: launchCommand?.environment + )["PATH"] ?? ProcessInfo.processInfo.environment["PATH"] + var customCodexPath: String? + var index = 0 + + func consumeAssignment(_ argument: String) -> Bool { + guard let equals = argument.firstIndex(of: "="), + equals != argument.startIndex else { + return false + } + let key = argument[.. String? { startupInput( - command: resumeCommand, + command: resumeCommand.map { + commandWithHibernationResumeEvidence( + $0, + attemptId: hibernationResumeAttemptId + ) + }, fileManager: fileManager, temporaryDirectory: temporaryDirectory, allowLauncherScript: allowLauncherScript, @@ -784,6 +1048,17 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { ) } + private func commandWithHibernationResumeEvidence( + _ command: String, + attemptId: UUID? + ) -> String { + guard let attemptId else { return command } + let assignment = shellSingleQuoted( + "\(AgentHibernationResumeEvidence.environmentKey)=\(attemptId.uuidString)" + ) + return "/usr/bin/env \(assignment) /bin/zsh -c \(shellSingleQuoted(command))" + } + func resumeStartupCommand( fileManager: FileManager = .default, temporaryDirectory: URL = FileManager.default.temporaryDirectory @@ -864,63 +1139,36 @@ private enum AgentResumeScriptStore { returnToLoginShell: Bool = false, workingDirectory: String? = nil ) -> URL? { - let directoryURL = temporaryDirectory.appendingPathComponent(directoryName, isDirectory: true) - do { - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) - try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directoryURL.path) - pruneOldScripts(in: directoryURL, fileManager: fileManager) - - let safeSessionPrefix = sessionId - .prefix(12) - .map { character -> Character in - character.isLetter || character.isNumber || character == "-" ? character : "_" - } - let scriptURL = directoryURL.appendingPathComponent( - "\(kind.rawValue)-\(String(safeSessionPrefix))-\(UUID().uuidString).zsh", - isDirectory: false - ) - var lines = [ - "#!/bin/zsh", - "rm -f -- \"$0\" 2>/dev/null || true" - ] - if returnToLoginShell { - lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines( - command: command, - workingDirectory: workingDirectory - )) - } else { - lines.append(command) - } - let contents = lines.joined(separator: "\n") + "\n" - try contents.write(to: scriptURL, atomically: true, encoding: .utf8) - try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: scriptURL.path) - return scriptURL - } catch { - return nil - } - } - - private static func pruneOldScripts(in directoryURL: URL, fileManager: FileManager) { - guard let scriptURLs = try? fileManager.contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { - return - } - - let cutoff = Date().addingTimeInterval(-scriptTTL) - for scriptURL in scriptURLs where scriptURL.pathExtension == "zsh" { - let values = try? scriptURL.resourceValues(forKeys: [.contentModificationDateKey]) - if let modified = values?.contentModificationDate, modified < cutoff { - try? fileManager.removeItem(at: scriptURL) - } - } + var lines = [ + "#!/bin/zsh", + "rm -f -- \"$0\" 2>/dev/null || true" + ] + if returnToLoginShell { + lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines( + command: command, + workingDirectory: workingDirectory + )) + } else { + lines.append(command) + } + _ = fileManager + return PrivateLauncherScriptStore.write( + contents: lines.joined(separator: "\n") + "\n", + directoryName: directoryName, + filenamePrefix: "\(kind.rawValue)-\(sessionId)", + temporaryDirectory: temporaryDirectory, + scriptTTL: scriptTTL + ) } } struct RestorableAgentSessionIndex: Sendable { - static let empty = RestorableAgentSessionIndex(entriesByPanel: [:]) + static let empty = RestorableAgentSessionIndex(entriesByPanel: [:], processEvidenceByPanel: [:]) + + enum LoadMode: Sendable { + case standard + case hibernation(processSnapshot: CmuxTopProcessSnapshot) + } struct PanelKey: Hashable, Sendable { let workspaceId: UUID @@ -938,7 +1186,6 @@ struct RestorableAgentSessionIndex: Sendable { enum ProcessDetectedSessionIDSource: Equatable, Sendable { case explicit - case inferredLatestSessionFile case forkParentFallback case relaunchOnly } @@ -961,24 +1208,23 @@ struct RestorableAgentSessionIndex: Sendable { let kind: RestorableAgentKind } - private struct PanelIDKindKey: Hashable { - let panelId: UUID - let kind: RestorableAgentKind - } - - private struct PanelIDKindCandidate { - let panelKey: PanelKey - let entry: Entry - let isAmbiguous: Bool - } - private let entriesByPanel: [PanelKey: Entry] private let entriesByPanelId: [UUID: Entry] + private let processEvidenceByPanel: [PanelKey: AgentHibernationProcessEvidence] func entry(workspaceId: UUID, panelId: UUID) -> Entry? { entriesByPanel[PanelKey(workspaceId: workspaceId, panelId: panelId)] ?? entriesByPanelId[panelId] } + func exactEntry(workspaceId: UUID, panelId: UUID) -> Entry? { + entriesByPanel[PanelKey(workspaceId: workspaceId, panelId: panelId)] + } + + func processEvidence(workspaceId: UUID, panelId: UUID) -> AgentHibernationProcessEvidence { + processEvidenceByPanel[PanelKey(workspaceId: workspaceId, panelId: panelId)] + ?? .unverified(processIDs: []) + } + func snapshot(workspaceId: UUID, panelId: UUID) -> SessionRestorableAgentSnapshot? { entry(workspaceId: workspaceId, panelId: panelId)?.snapshot } @@ -1041,36 +1287,46 @@ struct RestorableAgentSessionIndex: Sendable { homeDirectory: homeDirectory, fileManager: fileManager, registry: registry, - detectedSnapshots: [:] + detectedSnapshots: [:], + mode: .standard ) } static func loadIncludingProcessDetectedSnapshots( homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default + fileManager: FileManager = .default, + hibernationPanelKeys: Set? = nil ) async -> RestorableAgentSessionIndex { await Task.detached(priority: .utility) { loadIncludingProcessDetectedSnapshotsSynchronously( homeDirectory: homeDirectory, - fileManager: fileManager + fileManager: fileManager, + hibernationPanelKeys: hibernationPanelKeys ) }.value } - static func loadIncludingProcessDetectedSnapshotsSynchronously( + private static func loadIncludingProcessDetectedSnapshotsSynchronously( homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default + fileManager: FileManager = .default, + hibernationPanelKeys: Set? = nil ) -> RestorableAgentSessionIndex { let registry = CmuxVaultAgentRegistry.load(homeDirectory: homeDirectory, fileManager: fileManager) + let capturedAt = Date().timeIntervalSince1970 + let processSnapshot = CmuxTopProcessSnapshot.capture(includeProcessDetails: true) let detectedSnapshots = processDetectedSnapshots( registry: registry, - fileManager: fileManager + fileManager: fileManager, + processSnapshot: processSnapshot, + capturedAt: capturedAt ) return load( homeDirectory: homeDirectory, fileManager: fileManager, registry: registry, - detectedSnapshots: detectedSnapshots + detectedSnapshots: detectedSnapshots, + mode: .hibernation(processSnapshot: processSnapshot), + hibernationPanelKeys: hibernationPanelKeys ) } @@ -1079,12 +1335,28 @@ struct RestorableAgentSessionIndex: Sendable { fileManager: FileManager, registry: CmuxVaultAgentRegistry, detectedSnapshots: [PanelKey: ProcessDetectedSnapshotEntry], + mode: LoadMode = .standard, + hibernationPanelKeys: Set? = nil, processArgumentsProvider: (Int) -> CmuxTopProcessArguments? = { CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: $0) }, processIdentityProvider: (Int) -> AgentPIDProcessIdentity? = { guard $0 > 0, $0 <= Int(Int32.max) else { return nil } return AgentPIDProcessIdentity(pid: pid_t($0)) + }, + processExecutablePathProvider: (Int) -> String? = { + CmuxTopProcessSnapshot.processExecutablePath(for: $0) + }, + processSessionIDProvider: (Int) -> pid_t? = { + guard $0 > 0, $0 <= Int(Int32.max) else { return nil } + let value = getsid(pid_t($0)) + return value > 0 ? value : nil + }, + ttyProcessIDsProvider: (Int64) -> CmuxTopTargetedPIDEnumeration = { + CmuxTopProcessSnapshot.processIDs(forTTYDevice: $0) + }, + childProcessIDsProvider: (Int) -> CmuxTopTargetedPIDEnumeration = { + CmuxTopProcessSnapshot.childProcessIDs(of: $0) } ) -> RestorableAgentSessionIndex { let decoder = JSONDecoder() @@ -1104,24 +1376,41 @@ struct RestorableAgentSessionIndex: Sendable { } var hookCandidatesBySession: [SessionKey: Entry] = [:] var hookCandidatesByPanelAndKind: [PanelKindKey: Entry] = [:] - var hookCandidatesByPanelIdAndKind: [PanelIDKindKey: PanelIDKindCandidate] = [:] - - for (kind, registration) in hookKinds { - let fileURL = kind.hookStoreFileURL(homeDirectory: homeDirectory) - guard fileManager.fileExists(atPath: fileURL.path), - let data = try? Data(contentsOf: fileURL), - let state = try? decoder.decode(RestorableAgentHookSessionStoreFile.self, from: data) else { - continue + var hookIdentityByPanelID: [UUID: SessionKey] = [:] + var ambiguousHookPanelIDs = Set() + let hookSources = hookKinds.map { + ($0.kind, $0.registration, $0.kind.hookStoreFileURL(homeDirectory: homeDirectory)) + } + let registrySources = hookSources.map { (kind: $0.0, fileURL: $0.2) } + let registrySnapshots: [String: CmuxAgentSessionRegistry.Snapshot]? + if let hibernationPanelKeys { + var exactSessionIDsByProvider: [String: Set] = [:] + for detected in detectedSnapshots.values where detected.sessionIDSource == .explicit { + let sessionID = detected.snapshot.sessionId + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !sessionID.isEmpty else { continue } + exactSessionIDsByProvider[detected.snapshot.kind.rawValue, default: []] + .insert(sessionID) } + registrySnapshots = agentRegistryHibernationSnapshots( + registrySources, + panelKeys: hibernationPanelKeys, + exactSessionIDsByProvider: exactSessionIDsByProvider, + fileManager: fileManager + ).snapshots + } else { + registrySnapshots = agentRegistrySnapshots( + registrySources, + fileManager: fileManager + ) + } + for (kind, registration, fileURL) in hookSources { + guard let state = agentHookState(kind: kind, fileURL: fileURL, + snapshots: registrySnapshots, fileManager: fileManager, + decoder: decoder) else { continue } - for record in state.sessions.values { - var effectiveRecord = kind == .claude - ? resolvedClaudeWorkflowRecord( - record, - fileManager: fileManager, - lookup: claudeTranscriptLookup - ) - : record + for record in state.sessions.values where record.projectedRestoreAuthority && record.completedAt == nil { + var effectiveRecord = record // Drop untrusted launch captures before ANY derivation: the // working directory below would otherwise inherit the foreign launch cwd. effectiveRecord.launchCommand = trustedLaunchCommand( @@ -1131,6 +1420,7 @@ struct RestorableAgentSessionIndex: Sendable { if kind == .codex, normalizedNonEmptyValue(effectiveRecord.launchCommand?.source)?.lowercased() == "environment", normalizedNonEmptyValue(effectiveRecord.launchCommand?.environment?["CODEX_HOME"]) == nil, (normalizedNonEmptyValue(effectiveRecord.launchCommand?.environment?["ANTHROPIC_BASE_URL"]) != nil || normalizedNonEmptyValue(effectiveRecord.launchCommand?.environment?["CLAUDE_CONFIG_DIR"]) != nil) { effectiveRecord.launchCommand = nil } let normalizedSessionId = effectiveRecord.sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedSessionId.isEmpty, + kind != .claude || normalizedSessionId == effectiveRecord.sessionId, let workspaceId = UUID(uuidString: effectiveRecord.workspaceId), let panelId = UUID(uuidString: effectiveRecord.surfaceId), hookRecordIsRestorable( @@ -1145,6 +1435,7 @@ struct RestorableAgentSessionIndex: Sendable { let snapshot = SessionRestorableAgentSnapshot( kind: kind, sessionId: normalizedSessionId, + transcriptPath: effectiveRecord.transcriptPath, workingDirectory: restorableWorkingDirectory( for: effectiveRecord, kind: kind, @@ -1157,10 +1448,23 @@ struct RestorableAgentSessionIndex: Sendable { registration: registration, permissionMode: effectiveRecord.lastPermissionMode ) + // A legacy record can predate the explicit `isRestorable` and + // rejected-source fields while still carrying one-shot argv. + // Command generation is the final replay authority, so keep + // snapshots with no safe resume command out of every restore, + // hibernation, closed-history, and fork consumer. + guard snapshot.resumeCommand != nil else { continue } let key = PanelKey(workspaceId: workspaceId, panelId: panelId) let sessionKey = SessionKey(kind: kind, sessionId: normalizedSessionId) let panelKindKey = PanelKindKey(panelKey: key, kind: kind) - let panelIDKindKey = PanelIDKindKey(panelId: panelId, kind: kind) + // Workspace UUIDs can rotate on restore, but a surface-only fallback is safe + // only when every surviving hook record agrees on the session identity. + if let existingIdentity = hookIdentityByPanelID[panelId], + existingIdentity != sessionKey { + ambiguousHookPanelIDs.insert(panelId) + } else { + hookIdentityByPanelID[panelId] = sessionKey + } let liveProcessID = liveScopedProcessID( for: effectiveRecord, kind: kind, @@ -1170,7 +1474,7 @@ struct RestorableAgentSessionIndex: Sendable { ) let entry = Entry( snapshot: snapshot, - lifecycle: effectiveRecord.agentLifecycle, + lifecycle: effectiveRecord.effectiveHibernationLifecycle, updatedAt: effectiveRecord.updatedAt, processIDs: liveProcessID.map { [$0] } ?? [], agentProcessIDs: liveProcessID.map { [$0] } ?? [], @@ -1185,25 +1489,6 @@ struct RestorableAgentSessionIndex: Sendable { ) { hookCandidatesByPanelAndKind[panelKindKey] = entry } - if let existingPanelIDCandidate = hookCandidatesByPanelIdAndKind[panelIDKindKey] { - let shouldReplace = shouldReplaceHookEntry( - existing: existingPanelIDCandidate.entry, - incoming: entry - ) - hookCandidatesByPanelIdAndKind[panelIDKindKey] = PanelIDKindCandidate( - panelKey: shouldReplace ? key : existingPanelIDCandidate.panelKey, - entry: shouldReplace ? entry : existingPanelIDCandidate.entry, - isAmbiguous: existingPanelIDCandidate.isAmbiguous || - existingPanelIDCandidate.panelKey != key || - existingPanelIDCandidate.entry.snapshot.sessionId != entry.snapshot.sessionId - ) - } else { - hookCandidatesByPanelIdAndKind[panelIDKindKey] = PanelIDKindCandidate( - panelKey: key, - entry: entry, - isAmbiguous: false - ) - } if shouldReplaceHookEntry( existing: hookCandidatesBySession[sessionKey], incoming: entry @@ -1234,17 +1519,6 @@ struct RestorableAgentSessionIndex: Sendable { let sameKindPanelCandidate = hookCandidatesByPanelAndKind[ PanelKindKey(panelKey: key, kind: detected.snapshot.kind) ] - let sameKindPanelIDCandidate = hookCandidatesByPanelIdAndKind[ - PanelIDKindKey(panelId: key.panelId, kind: detected.snapshot.kind) - ] - // Panel-only restore is safe only when this surface/kind maps back to exactly one - // old workspace/session pair. Stale hook stores can otherwise reuse a surface id - // across old workspaces, or record multiple sessions for the same old workspace and - // surface after an agent restart. In either case, shouldReplaceHookEntry would pick - // one session by recency, so the panel-only fallback must stay ambiguous. - let sameKindStablePanelCandidate = sameKindPanelCandidate ?? ( - sameKindPanelIDCandidate?.isAmbiguous == false ? sameKindPanelIDCandidate?.entry : nil - ) if detected.sessionIDSource == .forkParentFallback, let panelCandidate = sameKindPanelCandidate, Self.hookCandidateRepresentsDetectedProcess( @@ -1258,13 +1532,6 @@ struct RestorableAgentSessionIndex: Sendable { // A nested fork process inside another agent's pane must not displace // that pane's hook-backed identity. continue - } else if detected.sessionIDSource == .inferredLatestSessionFile, - let panelCandidate = sameKindStablePanelCandidate { - // Latest-file detection is ambiguous when multiple panels or restored workspaces share a - // cwd. Prefer the hook-store identity for this stable panel/surface while still carrying - // live process evidence for the restored panel. The workspace UUID can rotate during - // session restore, but the surface id is intentionally reused on the normal restore path. - resolved[key] = processDetectedEntry(snapshot: panelCandidate.snapshot, lifecycle: panelCandidate.lifecycle, updatedAt: panelCandidate.updatedAt, detected: detected) } else if let existing = Self.matchingHookEntry( for: detected.snapshot, resolved: resolved[key], @@ -1299,7 +1566,44 @@ struct RestorableAgentSessionIndex: Sendable { } } - return RestorableAgentSessionIndex(entriesByPanel: resolved) + let processEvidenceByPanel: [PanelKey: AgentHibernationProcessEvidence] + switch mode { + case .standard: + processEvidenceByPanel = [:] + case .hibernation(let processSnapshot): + let restorablePanelIDs = Set(resolved.keys.map(\.panelId)) + // Surface UUIDs can survive workspace restore and can also collide + // across concurrently running cmux runtimes. Any live resolved + // session for a surface revokes process-free authority for every + // workspace key carrying that surface UUID. + let liveResolvedPanelIDs = Set(resolved.compactMap { key, entry in + entry.processIDs.isEmpty ? nil : key.panelId + }) + let processFreeCandidateKeys = Set(resolved.compactMap { key, entry in + entry.processIDs.isEmpty && !liveResolvedPanelIDs.contains(key.panelId) ? key : nil + }) + let topology = AgentHibernationProcessTopologyIndex( + processSnapshot: processSnapshot, + targetPanelKeys: processFreeCandidateKeys, + targetPanelIDs: restorablePanelIDs.subtracting(liveResolvedPanelIDs), + processArguments: processArgumentsProvider, + processIdentity: processIdentityProvider, + processExecutablePath: processExecutablePathProvider, + processSessionID: processSessionIDProvider, + ttyProcessIDs: ttyProcessIDsProvider, + childProcessIDs: childProcessIDsProvider + ) + var evidence = topology.allEvidence + for (key, entry) in resolved where !entry.processIDs.isEmpty { + evidence[key] = .unverified(processIDs: entry.processIDs) + } + processEvidenceByPanel = evidence + } + return RestorableAgentSessionIndex( + entriesByPanel: resolved, + processEvidenceByPanel: processEvidenceByPanel, + ambiguousPanelIDs: ambiguousHookPanelIDs + ) } private static func matchingHookEntry( @@ -1352,8 +1656,19 @@ struct RestorableAgentSessionIndex: Sendable { kind: RestorableAgentKind ) -> AgentLaunchCommandSnapshot? { guard let launchCommand else { return nil } + // A canonical replay plan intentionally has no executable or argv. Any + // captured executable must independently prove the actual agent entrypoint; + // the launcher label alone can be inherited or forged by older records. + let isCanonicalCapture = launchCommand.arguments.isEmpty + && normalizedNonEmptyValue(launchCommand.executablePath) == nil guard AgentLaunchCaptureTrust.launcherDescribesKind(launchCommand.launcher, kind: kind.rawValue), - !AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(launchCommand.arguments) else { + !AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(launchCommand.arguments), + isCanonicalCapture || AgentLaunchCaptureTrust.capturedArgumentsDescribeKind( + launcher: launchCommand.launcher, + executablePath: launchCommand.executablePath, + arguments: launchCommand.arguments, + kind: kind.rawValue + ) else { return nil } return launchCommand @@ -1365,9 +1680,16 @@ struct RestorableAgentSessionIndex: Sendable { fileManager: FileManager, claudeTranscriptLookup: ClaudeTranscriptLookupCache ) -> Bool { + // Keep the app restore index on the same trust boundary as agents + // list/tree and fork diagnostics. `rejected` means the live process or + // captured argv proved that this launch shape is not safe to replay; + // an older sticky `isRestorable=true` bit must not resurrect it after + // an app restart. + guard normalizedNonEmptyValue(record.launchCommand?.source)?.lowercased() != "rejected" else { + return false + } if kind == .codex { guard record.isRestorable != false else { return false } - guard normalizedNonEmptyValue(record.launchCommand?.source)?.lowercased() != "rejected" else { return false } let launchSource = normalizedNonEmptyValue(record.launchCommand?.source)?.lowercased() if record.isRestorable == true || launchSource == "default" @@ -1383,157 +1705,33 @@ struct RestorableAgentSessionIndex: Sendable { fileManager: fileManager ) } + if kind == .gemini { + guard record.isRestorable != false, + let transcriptPath = normalizedNonEmptyValue(record.transcriptPath) else { + return false + } + return regularNonEmptyFileExists( + atPath: (transcriptPath as NSString).expandingTildeInPath, + fileManager: fileManager + ) + } guard kind == .claude else { return record.isRestorable != false } - if let transcriptPath = normalizedNonEmptyValue(record.transcriptPath), - regularNonEmptyFileExists( - atPath: (transcriptPath as NSString).expandingTildeInPath, - fileManager: fileManager - ) { - return true - } - return claudeTranscriptExists(for: record, fileManager: fileManager, lookup: claudeTranscriptLookup) - } - - private static func resolvedClaudeWorkflowRecord( - _ record: RestorableAgentHookSessionRecord, - fileManager: FileManager, - lookup: ClaudeTranscriptLookupCache - ) -> RestorableAgentHookSessionRecord { - guard let sessionId = normalizedNonEmptyValue(record.sessionId), - claudeSessionIdIsSafeFilename(sessionId) else { - return record - } - if let transcriptPath = normalizedNonEmptyValue(record.transcriptPath), - regularNonEmptyFileExists( - atPath: (transcriptPath as NSString).expandingTildeInPath, - fileManager: fileManager - ) { - return record - } - - let roots = lookup.configRoots(for: record) - guard !roots.isEmpty else { return record } - let candidateProjectDirs = claudeWorkflowProjectDirs( - for: record, - sessionId: sessionId, - roots: roots, - fileManager: fileManager, - lookup: lookup - ) - guard let resolved = singleClaudeSiblingTranscript( - in: candidateProjectDirs, - excludingSessionId: sessionId, - fileManager: fileManager - ) else { - return record - } - - var resolvedRecord = record - resolvedRecord.sessionId = resolved.sessionId - resolvedRecord.transcriptPath = resolved.path - return resolvedRecord - } - - private static func claudeWorkflowProjectDirs( - for record: RestorableAgentHookSessionRecord, - sessionId: String, - roots: [String], - fileManager: FileManager, - lookup: ClaudeTranscriptLookupCache - ) -> [String] { - var projectDirs: [String] = [] - var seen: Set = [] - - func appendIfWorkflowContainer(projectRoot: String) { - let workflowContainer = (projectRoot as NSString).appendingPathComponent(sessionId) - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: workflowContainer, isDirectory: &isDirectory), - isDirectory.boolValue else { - return - } - let standardized = (projectRoot as NSString).standardizingPath - guard seen.insert(standardized).inserted else { return } - projectDirs.append(standardized) + let sessionId = record.sessionId + guard claudeSessionIdIsSafeFilename(sessionId) else { + return false } - - let cwdCandidates = [ - normalizedWorkingDirectory(record.launchCommand?.workingDirectory), - normalizedWorkingDirectory(record.cwd), - ].compactMap { $0 } - for root in roots { - let projectsRoot = (root as NSString).appendingPathComponent("projects") - for cwd in cwdCandidates { - appendIfWorkflowContainer( - projectRoot: (projectsRoot as NSString).appendingPathComponent(encodeClaudeProjectDir(cwd)) - ) - } - for projectDir in lookup.projectDirs(configRoot: root) { - appendIfWorkflowContainer( - projectRoot: (projectsRoot as NSString).appendingPathComponent(projectDir) - ) + if let transcriptPath = normalizedNonEmptyValue(record.transcriptPath) { + let expandedTranscriptPath = (transcriptPath as NSString).expandingTildeInPath + guard claudeTranscriptPath(expandedTranscriptPath, matchesSessionId: sessionId) else { + return false } - } - return projectDirs - } - - private static func singleClaudeSiblingTranscript( - in projectDirs: [String], - excludingSessionId excludedSessionId: String, - fileManager: FileManager - ) -> (sessionId: String, path: String)? { - var matches: [(sessionId: String, path: String)] = [] - for projectDir in projectDirs { - guard matches.count < 2 else { break } - collectClaudeTranscripts( - inDirectory: projectDir, - excludingSessionId: excludedSessionId, - remainingDirectoryDepth: 4, - fileManager: fileManager, - matches: &matches - ) - } - guard matches.count == 1, let match = matches.first else { return nil } - return match - } - - private static func collectClaudeTranscripts( - inDirectory directory: String, - excludingSessionId excludedSessionId: String, - remainingDirectoryDepth: Int, - fileManager: FileManager, - matches: inout [(sessionId: String, path: String)] - ) { - guard matches.count < 2 else { return } - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: directory, isDirectory: &isDirectory), - isDirectory.boolValue, - let children = try? fileManager.contentsOfDirectory(atPath: directory) else { - return - } - for child in children { - let childPath = (directory as NSString).appendingPathComponent(child) - if child.hasSuffix(".jsonl") { - let sessionId = String(child.dropLast(".jsonl".count)) - guard sessionId != excludedSessionId, - claudeSessionIdIsSafeFilename(sessionId), - regularNonEmptyFileExists(atPath: childPath, fileManager: fileManager) else { - continue - } - matches.append((sessionId, childPath)) - if matches.count >= 2 { return } - } else if remainingDirectoryDepth > 0 { - collectClaudeTranscripts( - inDirectory: childPath, - excludingSessionId: excludedSessionId, - remainingDirectoryDepth: remainingDirectoryDepth - 1, - fileManager: fileManager, - matches: &matches - ) - if matches.count >= 2 { return } + if regularNonEmptyFileExists(atPath: expandedTranscriptPath, fileManager: fileManager) { + return true } } + return claudeTranscriptExists(for: record, fileManager: fileManager, lookup: claudeTranscriptLookup) } private static func claudeTranscriptExists( @@ -1541,30 +1739,30 @@ struct RestorableAgentSessionIndex: Sendable { fileManager: FileManager, lookup: ClaudeTranscriptLookupCache ) -> Bool { - guard let sessionId = normalizedNonEmptyValue(record.sessionId), - claudeSessionIdIsSafeFilename(sessionId) else { + let sessionId = record.sessionId + guard claudeSessionIdIsSafeFilename(sessionId) else { return false } let roots = lookup.configRoots(for: record) guard !roots.isEmpty else { return false } - let cwd = normalizedWorkingDirectory(record.cwd) - ?? normalizedWorkingDirectory(record.launchCommand?.workingDirectory) - for root in roots { - if let cwd, - lookup.transcriptPath( - configRoot: root, - projectDirName: encodeClaudeProjectDir(cwd), - sessionId: sessionId - ) != nil { - return true - } - if lookup.transcriptPathInAnyProject( - configRoot: root, - sessionId: sessionId - ) != nil { - return true + var seenProjectDirectories: Set = [] + let candidates = [ + normalizedWorkingDirectory(record.launchCommand?.workingDirectory), + normalizedWorkingDirectory(record.cwd), + ].compactMap { $0 } + for cwd in candidates { + let projectDirectory = encodeClaudeProjectDir(cwd) + guard seenProjectDirectories.insert(projectDirectory).inserted else { continue } + for root in roots { + if lookup.transcriptPath( + configRoot: root, + projectDirName: projectDirectory, + sessionId: sessionId + ) != nil { + return true + } } } return false @@ -1634,8 +1832,8 @@ struct RestorableAgentSessionIndex: Sendable { fileManager: FileManager, lookup: ClaudeTranscriptLookupCache ) -> String? { - guard let sessionId = normalizedNonEmptyValue(record.sessionId), - claudeSessionIdIsSafeFilename(sessionId) else { + let sessionId = record.sessionId + guard claudeSessionIdIsSafeFilename(sessionId) else { return nil } let candidates = [launchCwd, recordedCwd].compactMap { $0 } @@ -1644,17 +1842,20 @@ struct RestorableAgentSessionIndex: Sendable { // so the candidate whose encoding matches it is the one Claude can resume from. if let transcriptPath = normalizedNonEmptyValue(record.transcriptPath) { let expandedTranscriptPath = (transcriptPath as NSString).expandingTildeInPath - let roots = lookup.configRoots(for: record) - let expectedProjectDirName = claudeProjectDirName( - containingTranscriptPath: expandedTranscriptPath, - configRoots: roots - ) ?? (((expandedTranscriptPath as NSString).deletingLastPathComponent) as NSString) - .lastPathComponent - if !expectedProjectDirName.isEmpty, - let matched = candidates.first(where: { - encodeClaudeProjectDir($0) == expectedProjectDirName - }) { - return matched + if claudeTranscriptPath(expandedTranscriptPath, matchesSessionId: sessionId), + regularNonEmptyFileExists(atPath: expandedTranscriptPath, fileManager: fileManager) { + let roots = lookup.configRoots(for: record) + let expectedProjectDirName = claudeProjectDirName( + containingTranscriptPath: expandedTranscriptPath, + configRoots: roots + ) ?? (((expandedTranscriptPath as NSString).deletingLastPathComponent) as NSString) + .lastPathComponent + if !expectedProjectDirName.isEmpty, + let matched = candidates.first(where: { + encodeClaudeProjectDir($0) == expectedProjectDirName + }) { + return matched + } } } @@ -1680,6 +1881,12 @@ struct RestorableAgentSessionIndex: Sendable { && !sessionId.isEmpty && sessionId != "." && sessionId != ".." + && sessionId.trimmingCharacters(in: .whitespacesAndNewlines) == sessionId + && sessionId.rangeOfCharacter(from: .controlCharacters) == nil + } + + private static func claudeTranscriptPath(_ path: String, matchesSessionId sessionId: String) -> Bool { + (path as NSString).lastPathComponent == "\(sessionId).jsonl" } static func encodeClaudeProjectDir(_ path: String) -> String { @@ -1842,14 +2049,8 @@ struct RestorableAgentSessionIndex: Sendable { var lookups: [String: ClaudeTranscriptLookupResult] = [:] } - private struct ClaudeTranscriptProjectDirsCache: Sendable { - var stamp: ClaudeTranscriptDirectoryStamp? - var projectDirs: [String] - } - private struct ClaudeTranscriptSharedStore: Sendable { var projectRootCaches: [String: ClaudeTranscriptProjectRootCache] = [:] - var projectDirsByConfigRoot: [String: ClaudeTranscriptProjectDirsCache] = [:] } // load() is synchronous and can be invoked concurrently by the live index and @@ -1862,13 +2063,14 @@ struct RestorableAgentSessionIndex: Sendable { // nested `/messages/` layout (or on zero-byte files growing in place) // are marked `requiresPerLoadRecheck` and re-probed once per load instead of being // trusted across loads. - // Growth stays bounded: per-root session entries only exist for hook-store records - // the loader walks and are replaced wholesale when that directory's mtime changes, - // while caches for deleted project directories are pruned when the projects/ - // listing is revalidated (deletion bumps the projects/ root mtime). + // Growth is capped at both ownership levels without scanning unrelated project + // directories. Exact hook session IDs and recorded working directories are the + // only keys admitted to this cache. private nonisolated static let claudeTranscriptSharedStore = OSAllocatedUnfairLock( initialState: ClaudeTranscriptSharedStore() ) + private nonisolated static let maximumClaudeTranscriptProjectRootCacheCount = 512 + private nonisolated static let maximumClaudeTranscriptSessionCacheCountPerRoot = 2_048 private final class ClaudeTranscriptLookupCache { private let homeDirectory: String @@ -1876,13 +2078,9 @@ struct RestorableAgentSessionIndex: Sendable { private let usesSharedStore: Bool private var defaultRoots: [String]? private var validatedProjectRootStamps: [String: ClaudeTranscriptDirectoryValidation] = [:] - private var validatedProjectDirsStamps: [String: ClaudeTranscriptDirectoryValidation] = [:] - private var projectDirsByConfigRoot: [String: [String]] = [:] private var transcriptPathByProjectRootAndSession: [String: String] = [:] private var missingTranscriptPathByProjectRootAndSession: Set = [] private var volatileTranscriptLookupCheckedThisLoad: Set = [] - private var transcriptPathByConfigRootAndSession: [String: String] = [:] - private var missingTranscriptPathByConfigRootAndSession: Set = [] init(homeDirectory: String, fileManager: FileManager) { self.homeDirectory = homeDirectory @@ -1918,13 +2116,6 @@ struct RestorableAgentSessionIndex: Sendable { roots.append(standardized) } - let accountRoot = (homeDirectory as NSString).appendingPathComponent(".codex-accounts/claude") - if directoryExists(atPath: accountRoot), - let accountDirs = try? fileManager.contentsOfDirectory(atPath: accountRoot) { - for accountDir in accountDirs.sorted() { - appendRoot((accountRoot as NSString).appendingPathComponent(accountDir)) - } - } appendRoot((homeDirectory as NSString).appendingPathComponent(".claude")) appendRoot( ClaudeConfigDirectoryPath.preferredPath( @@ -1938,75 +2129,6 @@ struct RestorableAgentSessionIndex: Sendable { return roots } - func projectDirs(configRoot: String) -> [String] { - let standardizedRoot = (configRoot as NSString).standardizingPath - guard usesSharedStore else { - return uncachedProjectDirs(configRoot: standardizedRoot) - } - - let stamp = validatedProjectsRootStamp(configRoot: standardizedRoot) - if let cached = RestorableAgentSessionIndex.claudeTranscriptSharedStore.withLock({ store in - store.projectDirsByConfigRoot[standardizedRoot] - }), cached.stamp == stamp { - return cached.projectDirs - } - - let projectsRoot = (standardizedRoot as NSString).appendingPathComponent("projects") - let projectDirs: [String] - if directoryExists(atPath: projectsRoot) { - guard let listed = try? fileManager.contentsOfDirectory(atPath: projectsRoot) else { - return [] - } - projectDirs = listed - } else { - projectDirs = [] - } - - // Recomputing the listing is the eviction point for dead project roots: - // deleting a project directory (or the whole projects/ root) bumps the - // projects/ mtime, lands here, and drops the per-root lookup caches for - // directories that no longer exist. Per-root session entries are bounded - // by the hook-store records the loader walks and are replaced wholesale - // whenever that directory's own mtime changes. - let liveProjectRoots = Set(projectDirs.map { dirName in - ((projectsRoot as NSString).appendingPathComponent(dirName) as NSString).standardizingPath - }) - let projectsRootPrefix = projectsRoot.hasSuffix("/") ? projectsRoot : projectsRoot + "/" - RestorableAgentSessionIndex.claudeTranscriptSharedStore.withLock { store in - if let existing = store.projectDirsByConfigRoot[standardizedRoot], - existing.stamp != stamp { - return - } - store.projectDirsByConfigRoot[standardizedRoot] = ClaudeTranscriptProjectDirsCache( - stamp: stamp, - projectDirs: projectDirs - ) - let staleRoots = store.projectRootCaches.keys.filter { root in - root.hasPrefix(projectsRootPrefix) && !liveProjectRoots.contains(root) - } - for staleRoot in staleRoots { - store.projectRootCaches[staleRoot] = nil - } - } - return projectDirs - } - - private func uncachedProjectDirs(configRoot standardizedRoot: String) -> [String] { - if let cached = projectDirsByConfigRoot[standardizedRoot] { - return cached - } - - let projectsRoot = (standardizedRoot as NSString).appendingPathComponent("projects") - guard directoryExists(atPath: projectsRoot), - let projectDirs = try? fileManager.contentsOfDirectory(atPath: projectsRoot) else { - projectDirsByConfigRoot[standardizedRoot] = [] - return [] - } - - projectDirsByConfigRoot[standardizedRoot] = projectDirs - return projectDirs - } - func transcriptPath(configRoot: String, projectDirName: String, sessionId: String) -> String? { let standardizedRoot = (configRoot as NSString).standardizingPath let projectsRoot = (standardizedRoot as NSString).appendingPathComponent("projects") @@ -2044,7 +2166,17 @@ struct RestorableAgentSessionIndex: Sendable { RestorableAgentSessionIndex.claudeTranscriptSharedStore.withLock { store in var cache = store.projectRootCaches[projectRoot] ?? ClaudeTranscriptProjectRootCache(stamp: stamp) guard cache.stamp == stamp else { return } + if cache.lookups[sessionId] == nil, + cache.lookups.count >= RestorableAgentSessionIndex.maximumClaudeTranscriptSessionCacheCountPerRoot, + let evictedSessionID = cache.lookups.keys.first { + cache.lookups.removeValue(forKey: evictedSessionID) + } cache.lookups[sessionId] = result + if store.projectRootCaches[projectRoot] == nil, + store.projectRootCaches.count >= RestorableAgentSessionIndex.maximumClaudeTranscriptProjectRootCacheCount, + let evictedProjectRoot = store.projectRootCaches.keys.first { + store.projectRootCaches.removeValue(forKey: evictedProjectRoot) + } store.projectRootCaches[projectRoot] = cache } return result.path @@ -2072,47 +2204,6 @@ struct RestorableAgentSessionIndex: Sendable { return path } - func transcriptPathInAnyProject(configRoot: String, sessionId: String) -> String? { - let standardizedRoot = (configRoot as NSString).standardizingPath - let key = cacheKey(standardizedRoot, sessionId) - if let cached = transcriptPathByConfigRootAndSession[key] { - return cached - } - if missingTranscriptPathByConfigRootAndSession.contains(key) { - return nil - } - - for projectDir in projectDirs(configRoot: standardizedRoot) { - if let path = transcriptPath( - configRoot: standardizedRoot, - projectDirName: projectDir, - sessionId: sessionId - ) { - transcriptPathByConfigRootAndSession[key] = path - return path - } - } - missingTranscriptPathByConfigRootAndSession.insert(key) - return nil - } - - private func validatedProjectsRootStamp(configRoot standardizedRoot: String) -> ClaudeTranscriptDirectoryStamp? { - if let validation = validatedProjectDirsStamps[standardizedRoot] { - return validation.stamp - } - - let projectsRoot = (standardizedRoot as NSString).appendingPathComponent("projects") - let stamp = RestorableAgentSessionIndex.directoryStamp(atPath: projectsRoot) - RestorableAgentSessionIndex.claudeTranscriptSharedStore.withLock { store in - if let existing = store.projectDirsByConfigRoot[standardizedRoot], - existing.stamp != stamp { - store.projectDirsByConfigRoot[standardizedRoot] = nil - } - } - validatedProjectDirsStamps[standardizedRoot] = ClaudeTranscriptDirectoryValidation(stamp: stamp) - return stamp - } - private func validatedProjectRootStamp(_ projectRoot: String) -> ClaudeTranscriptDirectoryStamp? { if let validation = validatedProjectRootStamps[projectRoot] { return validation.stamp @@ -2124,6 +2215,11 @@ struct RestorableAgentSessionIndex: Sendable { existing.stamp == stamp { return } + if store.projectRootCaches[projectRoot] == nil, + store.projectRootCaches.count >= RestorableAgentSessionIndex.maximumClaudeTranscriptProjectRootCacheCount, + let evictedProjectRoot = store.projectRootCaches.keys.first { + store.projectRootCaches.removeValue(forKey: evictedProjectRoot) + } store.projectRootCaches[projectRoot] = ClaudeTranscriptProjectRootCache(stamp: stamp) } validatedProjectRootStamps[projectRoot] = ClaudeTranscriptDirectoryValidation(stamp: stamp) @@ -2226,10 +2322,32 @@ struct RestorableAgentSessionIndex: Sendable { return rawValue } - private init(entriesByPanel: [PanelKey: Entry]) { + private init( + entriesByPanel: [PanelKey: Entry], + processEvidenceByPanel: [PanelKey: AgentHibernationProcessEvidence], + ambiguousPanelIDs: Set = [] + ) { self.entriesByPanel = entriesByPanel + self.processEvidenceByPanel = processEvidenceByPanel var entriesByPanelId: [UUID: Entry] = [:] + var identityByPanelID: [UUID: SessionKey] = [:] + var ambiguousPanelIDs = ambiguousPanelIDs for (key, entry) in entriesByPanel { + // Never resolve a workspace-rotated lookup by recency when this surface has + // represented more than one conversation. + guard !ambiguousPanelIDs.contains(key.panelId) else { continue } + let identity = SessionKey( + kind: entry.snapshot.kind, + sessionId: entry.snapshot.sessionId + ) + if let existingIdentity = identityByPanelID[key.panelId], + existingIdentity != identity { + ambiguousPanelIDs.insert(key.panelId) + entriesByPanelId.removeValue(forKey: key.panelId) + identityByPanelID.removeValue(forKey: key.panelId) + continue + } + identityByPanelID[key.panelId] = identity let existing = entriesByPanelId[key.panelId] if existing == nil || entry.updatedAt >= (existing?.updatedAt ?? 0) { entriesByPanelId[key.panelId] = entry diff --git a/Sources/RestorableAgentTypes.swift b/Sources/RestorableAgentTypes.swift index 08cb25ca191f..eaf891d986d3 100644 --- a/Sources/RestorableAgentTypes.swift +++ b/Sources/RestorableAgentTypes.swift @@ -18,9 +18,44 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { case codebuddy case factory case qoder + case kimi case ollama case custom(String) + /// Native values that must never be reinterpreted as a custom provider + /// solely because persisted state changed their ASCII letter case. + private static let nativeRawValues: Set = [ + "claude", + "codex", + "grok", + "pi", + "amp", + "cursor", + "gemini", + "kiro", + "antigravity", + "opencode", + "rovodev", + "hermes-agent", + "copilot", + "codebuddy", + "factory", + "qoder", + "kimi", + "ollama", + ] + + /// Providers whose native enum case is only a decode compatibility shape. + /// Their embedded Vault registration owns resume and fork semantics. + static let registryOwnedRawValues: Set = [ + "grok", + "pi", + "antigravity", + "ollama", + "omp", + "campfire", + ] + static let allCases: [RestorableAgentKind] = [ .claude, .codex, @@ -39,6 +74,7 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { .codebuddy, .factory, .qoder, + .kimi, // Ollama is registry-owned like Pi/Grok/Antigravity: leaving it out // keeps the id available to pre-existing custom Vault registrations // while direct native values still encode. @@ -63,8 +99,10 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { case "codebuddy": self = .codebuddy case "factory": self = .factory case "qoder": self = .qoder + case "kimi": self = .kimi case "ollama": self = .ollama default: + guard !Self.nativeRawValues.contains(value.lowercased()) else { return nil } guard CmuxVaultAgentRegistration.isValidID(value) else { return nil } self = .custom(value) } @@ -88,6 +126,7 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { case .codebuddy: return "codebuddy" case .factory: return "factory" case .qoder: return "qoder" + case .kimi: return "kimi" case .ollama: return "ollama" case .custom(let id): return id } @@ -118,6 +157,8 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { case .codebuddy: return "CodeBuddy" case .factory: return "Factory" case .qoder: return "Qoder" + case .kimi: + return String(localized: "agent.kimi.displayName", defaultValue: "Kimi Code") case .ollama: return String(localized: "agent.ollama.displayName", defaultValue: "Ollama") case .custom(let id): return id diff --git a/Sources/SessionIndexRegisteredAgents.swift b/Sources/SessionIndexRegisteredAgents.swift index 1dbd6888dbd2..a4c97be9f2bc 100644 --- a/Sources/SessionIndexRegisteredAgents.swift +++ b/Sources/SessionIndexRegisteredAgents.swift @@ -5,22 +5,6 @@ struct GrokSessionRoot: Sendable, Hashable { let grokHomeForResume: String? } -private struct GrokHookObservedSessionStoreFile: Decodable { - var sessions: [String: GrokHookObservedSessionRecord] - - private enum CodingKeys: String, CodingKey { - case sessions - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - sessions = try container.decodeIfPresent( - [String: GrokHookObservedSessionRecord].self, - forKey: .sessions - ) ?? [:] - } -} - private struct GrokHookObservedSessionRecord: Decodable { var launchCommand: GrokHookObservedLaunchCommand? } @@ -30,6 +14,10 @@ private struct GrokHookObservedLaunchCommand: Decodable { } enum GrokSessionLocator { + static let maximumObservedGrokHomes = 16 + private static let maximumObservedHookRecords = 512 + private static let maximumObservedHookBytes: Int64 = 16 * 1_024 * 1_024 + static func defaultSessionsRoot(homeDirectory: String = NSHomeDirectory()) -> String { let standardizedHome = expandTilde(homeDirectory, homeDirectory: homeDirectory) return ((standardizedHome as NSString).appendingPathComponent(".grok") as NSString) @@ -116,7 +104,7 @@ enum GrokSessionLocator { ) var roots = [root] if registrationUsesDefaultGrokRoot(registration: registration, homeDirectory: homeDirectory) { - for grokHome in observedGrokHomes { + for grokHome in observedGrokHomes.prefix(maximumObservedGrokHomes) { guard let candidate = sessionRoot( grokHome: grokHome, homeDirectory: homeDirectory @@ -148,21 +136,31 @@ enum GrokSessionLocator { homeDirectory: homeDirectory, environment: environment ) - guard fileManager.fileExists(atPath: storeURL.path), - let data = try? Data(contentsOf: storeURL), - let state = try? JSONDecoder().decode(GrokHookObservedSessionStoreFile.self, from: data) else { + guard let storedRecords = AgentHookSessionRegistryReader.recentRecordData( + provider: RestorableAgentKind.grok.rawValue, + legacyURL: storeURL, + environment: environment, + fileManager: fileManager, + maximumRecords: maximumObservedHookRecords, + maximumBytes: maximumObservedHookBytes + ) else { return [] } var seen = Set() var homes: [String] = [] - for record in state.sessions.values { + for stored in storedRecords { + guard let record = try? JSONDecoder().decode( + GrokHookObservedSessionRecord.self, + from: stored.data + ) else { continue } guard let rawHome = normalized(record.launchCommand?.environment?["GROK_HOME"]) else { continue } let home = expandTilde(rawHome, homeDirectory: homeDirectory) guard seen.insert(home).inserted else { continue } homes.append(home) + if homes.count == maximumObservedGrokHomes { break } } return homes } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index f719b55e2242..282726814708 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1314,65 +1314,26 @@ enum SurfaceResumeBindingScriptStore { temporaryDirectory: URL, returnToLoginShell: Bool = false ) -> URL? { - let directoryURL = temporaryDirectory.appendingPathComponent(directoryName, isDirectory: true) - do { - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) - try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directoryURL.path) - pruneOldScripts(in: directoryURL, fileManager: fileManager) - - let prefix = safeFilenamePrefix(binding: binding) - let scriptURL = directoryURL.appendingPathComponent( - "\(prefix)-\(UUID().uuidString).zsh", - isDirectory: false - ) - var lines = [ - "#!/bin/zsh", - "rm -f -- \"$0\" 2>/dev/null || true" - ] - if returnToLoginShell { - lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines( - command: inlineInput, - workingDirectory: binding.cwd - )) - } else { - lines.append(inlineInput) - } - let contents = lines.joined(separator: "\n") + "\n" - try contents.write(to: scriptURL, atomically: true, encoding: .utf8) - try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) - return scriptURL - } catch { - return nil - } - } - - private static func safeFilenamePrefix(binding: SurfaceResumeBindingSnapshot) -> String { - let rawPrefix = binding.kind ?? binding.source ?? "surface-resume" - let safePrefix = rawPrefix - .prefix(24) - .map { character -> Character in - character.isLetter || character.isNumber || character == "-" ? character : "_" - } - return safePrefix.isEmpty ? "surface-resume" : String(safePrefix) - } - - private static func pruneOldScripts(in directoryURL: URL, fileManager: FileManager) { - guard let scriptURLs = try? fileManager.contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { - return - } - let cutoff = Date().addingTimeInterval(-scriptTTL) - for scriptURL in scriptURLs where scriptURL.pathExtension == "zsh" { - guard let values = try? scriptURL.resourceValues(forKeys: [.contentModificationDateKey]), - let modifiedAt = values.contentModificationDate, - modifiedAt < cutoff else { - continue - } - try? fileManager.removeItem(at: scriptURL) - } + var lines = [ + "#!/bin/zsh", + "rm -f -- \"$0\" 2>/dev/null || true" + ] + if returnToLoginShell { + lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines( + command: inlineInput, + workingDirectory: binding.cwd + )) + } else { + lines.append(inlineInput) + } + _ = fileManager + return PrivateLauncherScriptStore.write( + contents: lines.joined(separator: "\n") + "\n", + directoryName: directoryName, + filenamePrefix: binding.kind ?? binding.source ?? "surface-resume", + temporaryDirectory: temporaryDirectory, + scriptTTL: scriptTTL + ) } } diff --git a/Sources/SessionRestorableAgentSnapshot+Commands.swift b/Sources/SessionRestorableAgentSnapshot+Commands.swift index 53aa9d3d2ab3..fa91f5973140 100644 --- a/Sources/SessionRestorableAgentSnapshot+Commands.swift +++ b/Sources/SessionRestorableAgentSnapshot+Commands.swift @@ -4,6 +4,7 @@ extension SessionRestorableAgentSnapshot { private enum SnapshotCodingKeys: String, CodingKey { case kind case sessionId + case transcriptPath case workingDirectory case launchCommand case registration @@ -17,20 +18,33 @@ extension SessionRestorableAgentSnapshot { CmuxVaultAgentRegistration.self, forKey: .registration )?.migratedPersistedBuiltInRegistration - // Registry-detected snapshots persist `.custom(id)`, whose raw string - // collapses to the native case on decode when the id matches a - // built-in raw value. Restore the write-side identity whenever that - // collapse would change command semantics (registry-owned Pi or - // relaunch-only natives such as Ollama), so the stored registration - // keeps owning resume and fork behavior. - if (kind.restoreMode == .relaunchCommand || kind == .pi), - let registration, - registration.id == kind.rawValue { - kind = .custom(registration.id) + if let registration { + guard registration.id == kind.rawValue else { + throw DecodingError.dataCorruptedError( + forKey: .registration, + in: container, + debugDescription: "Embedded Vault registration id '\(registration.id)' does not match restorable agent kind '\(kind.rawValue)'" + ) + } + // Registry snapshots encode `.custom(id)` as the same string as a + // native compatibility case. Restore custom ownership for every + // registry-owned id so its persisted registration continues to + // define resume and fork behavior after app relaunch. + if kind.customAgentID == nil { + guard RestorableAgentKind.registryOwnedRawValues.contains(registration.id) else { + throw DecodingError.dataCorruptedError( + forKey: .registration, + in: container, + debugDescription: "Embedded Vault registration cannot override native agent kind '\(kind.rawValue)'" + ) + } + kind = .custom(registration.id) + } } self.init( kind: kind, sessionId: try container.decode(String.self, forKey: .sessionId), + transcriptPath: try container.decodeIfPresent(String.self, forKey: .transcriptPath), workingDirectory: try container.decodeIfPresent(String.self, forKey: .workingDirectory), launchCommand: try container.decodeIfPresent( AgentLaunchCommandSnapshot.self, @@ -53,6 +67,26 @@ extension SessionRestorableAgentSnapshot { return AgentResumeCommandBuilder.resumeShellCommand( kind: kind, sessionId: sessionId, + transcriptPath: transcriptPath, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + registrationOverride: registration, + observedPermissionMode: permissionMode + ) + } + + var resumeExecutionDescriptor: AgentCommandExecutionDescriptor? { + if kind.restoreMode == .relaunchCommand { + return AgentRelaunchCommandBuilder().executionDescriptor( + kind: kind, + launchCommand: launchCommand, + workingDirectory: workingDirectory + ) + } + return AgentResumeCommandBuilder.resumeExecutionDescriptor( + kind: kind, + sessionId: sessionId, + transcriptPath: transcriptPath, launchCommand: launchCommand, workingDirectory: workingDirectory, registrationOverride: registration, @@ -72,6 +106,18 @@ extension SessionRestorableAgentSnapshot { ) } + var forkExecutionDescriptor: AgentCommandExecutionDescriptor? { + guard kind.restoreMode == .resumeSession else { return nil } + return AgentResumeCommandBuilder.forkExecutionDescriptor( + kind: kind, + sessionId: sessionId, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + registrationOverride: registration, + observedPermissionMode: permissionMode + ) + } + var agentDisplayName: String { if let name = registration?.name.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { @@ -80,3 +126,15 @@ extension SessionRestorableAgentSnapshot { return kind.displayName } } + +extension SurfaceResumeBindingSnapshot { + var agentHookExecutionDescriptor: AgentCommandExecutionDescriptor? { + guard isAgentHookBinding else { return nil } + return AgentResumeCommandBuilder.surfaceResumeBindingExecutionDescriptor( + command: command, + kind: kind, + environment: environment, + workingDirectory: cwd + ) + } +} diff --git a/Sources/SessionRestoredTerminalCommandStore.swift b/Sources/SessionRestoredTerminalCommandStore.swift index 8a860817e1c5..7db777d73380 100644 --- a/Sources/SessionRestoredTerminalCommandStore.swift +++ b/Sources/SessionRestoredTerminalCommandStore.swift @@ -1,7 +1,352 @@ +import Darwin import Foundation +import os + +/// Publishes short-lived shell launchers without trusting path-based temporary +/// directory operations. Every caller gets the same private-directory, +/// bounded-pruning, and atomic-publication invariants. +enum PrivateLauncherScriptStore { + static let maximumDirectoryEntriesPerPass = 256 + + private static let stagingNamePrefix = ".cmux-private-launcher-" + private static let maximumScriptBytes = 1 * 1_024 * 1_024 + private static let inProcessDirectoryClaims = OSAllocatedUnfairLock( + initialState: Set() + ) + + private struct DirectoryIdentity: Hashable, Sendable { + let device: UInt64 + let inode: UInt64 + + init(_ status: stat) { + device = UInt64(bitPattern: Int64(status.st_dev)) + inode = UInt64(status.st_ino) + } + } + + private enum PruneResult { + case ready + case overflow + case failed + } + + static func write( + contents: String, + directoryName: String, + filenamePrefix: String, + temporaryDirectory: URL, + scriptTTL: TimeInterval + ) -> URL? { + guard isSafePathComponent(directoryName), + let data = contents.data(using: .utf8), + !data.isEmpty, + data.count <= maximumScriptBytes else { + return nil + } + + let parentDescriptor = open( + temporaryDirectory.path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard parentDescriptor >= 0 else { return nil } + defer { Darwin.close(parentDescriptor) } + + var parentStatus = stat() + guard fstat(parentDescriptor, &parentStatus) == 0, + ownedDirectoryStatusIsSafe(parentStatus) else { + return nil + } + + var createdDirectory = false + if mkdirat(parentDescriptor, directoryName, mode_t(S_IRWXU)) == 0 { + createdDirectory = true + } else if errno != EEXIST { + return nil + } + + let directoryDescriptor = openat( + parentDescriptor, + directoryName, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard directoryDescriptor >= 0 else { return nil } + defer { Darwin.close(directoryDescriptor) } + + var directoryStatus = stat() + guard fstat(directoryDescriptor, &directoryStatus) == 0, + ownedDirectoryStatusIsSafe(directoryStatus), + fchmod(directoryDescriptor, mode_t(S_IRWXU)) == 0, + fstat(directoryDescriptor, &directoryStatus) == 0, + ownedPrivateDirectoryStatusIsSafe(directoryStatus) else { + return nil + } + if createdDirectory, fsync(parentDescriptor) != 0 { return nil } + + let directoryIdentity = DirectoryIdentity(directoryStatus) + let claimedInProcess = inProcessDirectoryClaims.withLock { claims in + claims.insert(directoryIdentity).inserted + } + guard claimedInProcess else { return nil } + defer { + _ = inProcessDirectoryClaims.withLock { claims in + claims.remove(directoryIdentity) + } + } + guard flock(directoryDescriptor, LOCK_EX | LOCK_NB) == 0 else { return nil } + defer { _ = flock(directoryDescriptor, LOCK_UN) } + + guard pruneExpiredScripts( + directoryDescriptor: directoryDescriptor, + cutoff: Date().addingTimeInterval(-scriptTTL) + ) == .ready else { + return nil + } + + let safePrefix = sanitizedFilenamePrefix(filenamePrefix) + let finalName = "\(safePrefix)-\(UUID().uuidString).zsh" + let stagingName = "\(stagingNamePrefix)\(UUID().uuidString).tmp" + let stagingDescriptor = openat( + directoryDescriptor, + stagingName, + O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW | O_CLOEXEC, + mode_t(S_IRUSR | S_IWUSR) + ) + guard stagingDescriptor >= 0 else { return nil } + var openStagingDescriptor = stagingDescriptor + var stagingExists = true + var removePublishedFile = false + defer { + if openStagingDescriptor >= 0 { Darwin.close(openStagingDescriptor) } + var removedFile = false + if stagingExists, unlinkat(directoryDescriptor, stagingName, 0) == 0 { + removedFile = true + } + if removePublishedFile, unlinkat(directoryDescriptor, finalName, 0) == 0 { + removedFile = true + } + if removedFile { _ = fsync(directoryDescriptor) } + } + + var stagingStatus = stat() + guard fstat(stagingDescriptor, &stagingStatus) == 0, + ownedSingleLinkRegularFileStatusIsSafe(stagingStatus), + fchmod(stagingDescriptor, mode_t(S_IRUSR | S_IWUSR)) == 0, + writeAll(data, to: stagingDescriptor), + fsync(stagingDescriptor) == 0 else { + return nil + } + guard Darwin.close(stagingDescriptor) == 0 else { + openStagingDescriptor = -1 + return nil + } + openStagingDescriptor = -1 + + guard renameatx_np( + directoryDescriptor, + stagingName, + directoryDescriptor, + finalName, + UInt32(RENAME_EXCL) + ) == 0 else { + return nil + } + stagingExists = false + removePublishedFile = true + guard fsync(directoryDescriptor) == 0 else { return nil } + + var finalStatus = stat() + guard fstatat( + directoryDescriptor, + finalName, + &finalStatus, + AT_SYMLINK_NOFOLLOW + ) == 0, + ownedSingleLinkRegularFileStatusIsSafe(finalStatus), + finalStatus.st_dev == stagingStatus.st_dev, + finalStatus.st_ino == stagingStatus.st_ino, + finalStatus.st_size == off_t(data.count), + finalStatus.st_mode & mode_t(0o777) == mode_t(0o600), + directoryPathStillMatches( + parentDescriptor: parentDescriptor, + directoryName: directoryName, + expected: directoryStatus + ), + pathStillMatchesDescriptor( + temporaryDirectory.path, + expected: parentStatus + ) else { + return nil + } + + removePublishedFile = false + return temporaryDirectory + .appendingPathComponent(directoryName, isDirectory: true) + .appendingPathComponent(finalName, isDirectory: false) + } + + private static func pruneExpiredScripts( + directoryDescriptor: Int32, + cutoff: Date + ) -> PruneResult { + let streamDescriptor = dup(directoryDescriptor) + guard streamDescriptor >= 0 else { return .failed } + _ = fcntl(streamDescriptor, F_SETFD, FD_CLOEXEC) + guard let stream = fdopendir(streamDescriptor) else { + Darwin.close(streamDescriptor) + return .failed + } + defer { closedir(stream) } + + var examinedEntries = 0 + var removedAny = false + while examinedEntries < maximumDirectoryEntriesPerPass { + errno = 0 + guard let entry = readdir(stream) else { + guard errno == 0 else { return .failed } + if removedAny, fsync(directoryDescriptor) != 0 { return .failed } + return .ready + } + let name = directoryEntryName(entry) + guard name != ".", name != ".." else { continue } + examinedEntries += 1 + guard shouldPrune(name: name) else { continue } + + var status = stat() + guard fstatat( + directoryDescriptor, + name, + &status, + AT_SYMLINK_NOFOLLOW + ) == 0, + ownedSingleLinkRegularFileStatusIsSafe(status), + modificationDate(status) < cutoff, + unlinkat(directoryDescriptor, name, 0) == 0 else { + continue + } + removedAny = true + } + + if removedAny, fsync(directoryDescriptor) != 0 { return .failed } + while true { + errno = 0 + guard let entry = readdir(stream) else { + return errno == 0 ? .ready : .failed + } + let name = directoryEntryName(entry) + if name != ".", name != ".." { return .overflow } + } + } + + private static func shouldPrune(name: String) -> Bool { + (!name.hasPrefix(".") && name.hasSuffix(".zsh")) || + (name.hasPrefix(stagingNamePrefix) && name.hasSuffix(".tmp")) + } + + private static func directoryEntryName(_ entry: UnsafeMutablePointer) -> String { + withUnsafePointer(to: &entry.pointee.d_name) { pointer in + pointer.withMemoryRebound( + to: CChar.self, + capacity: Int(entry.pointee.d_namlen) + 1 + ) { String(cString: $0) } + } + } + + private static func modificationDate(_ status: stat) -> Date { + Date( + timeIntervalSince1970: TimeInterval(status.st_mtimespec.tv_sec) + + TimeInterval(status.st_mtimespec.tv_nsec) / 1_000_000_000 + ) + } + + private static func writeAll(_ data: Data, to descriptor: Int32) -> Bool { + data.withUnsafeBytes { bytes in + guard let baseAddress = bytes.baseAddress else { return data.isEmpty } + var offset = 0 + while offset < bytes.count { + let count = Darwin.write( + descriptor, + baseAddress.advanced(by: offset), + bytes.count - offset + ) + if count < 0 { + if errno == EINTR { continue } + return false + } + guard count > 0 else { return false } + offset += count + } + return true + } + } + + private static func directoryPathStillMatches( + parentDescriptor: Int32, + directoryName: String, + expected: stat + ) -> Bool { + var current = stat() + return fstatat( + parentDescriptor, + directoryName, + ¤t, + AT_SYMLINK_NOFOLLOW + ) == 0 && sameFileIdentity(current, expected: expected) && + ownedPrivateDirectoryStatusIsSafe(current) + } + + private static func pathStillMatchesDescriptor(_ path: String, expected: stat) -> Bool { + var current = stat() + return lstat(path, ¤t) == 0 && + sameFileIdentity(current, expected: expected) && + ownedDirectoryStatusIsSafe(current) + } + + private static func sameFileIdentity(_ status: stat, expected: stat) -> Bool { + status.st_dev == expected.st_dev && status.st_ino == expected.st_ino + } + + private static func ownedDirectoryStatusIsSafe(_ status: stat) -> Bool { + status.st_mode & S_IFMT == S_IFDIR && + status.st_uid == geteuid() && + status.st_nlink > 0 + } + + private static func ownedPrivateDirectoryStatusIsSafe(_ status: stat) -> Bool { + ownedDirectoryStatusIsSafe(status) && + status.st_mode & mode_t(0o777) == mode_t(0o700) + } + + private static func ownedSingleLinkRegularFileStatusIsSafe(_ status: stat) -> Bool { + status.st_mode & S_IFMT == S_IFREG && + status.st_uid == geteuid() && + status.st_nlink == 1 && + status.st_size >= 0 + } + + private static func isSafePathComponent(_ value: String) -> Bool { + !value.isEmpty && value != "." && value != ".." && + !value.contains("/") && !value.contains("\0") + } + + private static func sanitizedFilenamePrefix(_ rawValue: String) -> String { + let bytes = rawValue.utf8.prefix(48) + let sanitized = bytes.map { byte -> Character in + switch byte { + case 48...57, 65...90, 97...122: + Character(UnicodeScalar(byte)) + case 45, 95: + Character(UnicodeScalar(byte)) + default: + "_" + } + } + return sanitized.isEmpty ? "launcher" : String(sanitized) + } +} enum SessionRestoredTerminalCommandStore { private static let directoryName = "cmux-session-terminal-command" + private static let scriptTTL: TimeInterval = 24 * 60 * 60 static func writeLauncherScript( command: String, @@ -11,36 +356,27 @@ enum SessionRestoredTerminalCommandStore { ) -> URL? { let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedCommand.isEmpty else { return nil } - - let directoryURL = temporaryDirectory.appendingPathComponent(directoryName, isDirectory: true) - do { - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) - try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directoryURL.path) - - let scriptURL = directoryURL.appendingPathComponent( - "\(UUID().uuidString).zsh", - isDirectory: false - ) - var lines = [ - "#!/bin/zsh", - "rm -f -- \"$0\" 2>/dev/null || true" - ] - if let workingDirectory = normalized(workingDirectory) { - let quotedDirectory = shellSingleQuoted(workingDirectory) - lines.append("{ cd -- \(quotedDirectory) 2>/dev/null || [ ! -d \(quotedDirectory) ]; } || exit $?") - } - lines.append("exec \"${SHELL:-/bin/zsh}\" -lc \(shellSingleQuoted(trimmedCommand))") - - try (lines.joined(separator: "\n") + "\n").write( - to: scriptURL, - atomically: true, - encoding: .utf8 - ) - try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) - return scriptURL - } catch { - return nil + var lines = [ + "#!/bin/zsh", + "rm -f -- \"$0\" 2>/dev/null || true" + ] + if let workingDirectory = normalized(workingDirectory) { + let quotedDirectory = shellSingleQuoted(workingDirectory) + lines.append("{ cd -- \(quotedDirectory) 2>/dev/null || [ ! -d \(quotedDirectory) ]; } || exit $?") } + lines.append("exec \"${SHELL:-/bin/zsh}\" -lc \(shellSingleQuoted(trimmedCommand))") + _ = fileManager + return PrivateLauncherScriptStore.write( + contents: lines.joined(separator: "\n") + "\n", + directoryName: directoryName, + filenamePrefix: "session-terminal", + temporaryDirectory: temporaryDirectory, + scriptTTL: scriptTTL + ) + } + + static func launcherCommand(for scriptURL: URL) -> String { + "/bin/zsh \(shellSingleQuoted(scriptURL.path))" } private static func normalized(_ value: String?) -> String? { diff --git a/Sources/SharedLiveAgentIndex.swift b/Sources/SharedLiveAgentIndex.swift index cbf035157f7e..b4d50982584c 100644 --- a/Sources/SharedLiveAgentIndex.swift +++ b/Sources/SharedLiveAgentIndex.swift @@ -438,6 +438,17 @@ final class SharedLiveAgentIndex { return index } + /// Waits for the already scheduled cold-cache fill without blocking the + /// main actor. Callers capture UI state first, then enrich durable records. + func currentIndexAfterRefreshing() async -> RestorableAgentSessionIndex? { + scheduleRefreshIfStale() + let scheduledRefresh = refreshTask + let scheduledForkRefresh = forkAvailabilityRefreshTask + await scheduledRefresh?.value + await scheduledForkRefresh?.value + return index + } + func scheduleRefreshIfStale( validating panelKey: RestorableAgentSessionIndex.PanelKey? = nil, isRemoteContext: Bool = false @@ -1285,11 +1296,28 @@ final class SharedLiveAgentIndex { watchGeneration = nil refreshBeforeReuse = false case "unresolved": + let watchGeneration = await updateForkExecutableWatch( + for: resolvedProbeKey, + requestingPanelKey: panelKey, + lookupPath: executableResolutionBeforeProbe.lookupPath, + realPath: nil, + watchDirectories: executableResolutionBeforeProbe.watchDirectories + ) + guard !Task.isCancelled else { + markCancelledForkValidationRequests(pendingRequestIDsToRemoveOnCancellation) + removeForkSupportValidation(for: resolvedProbeKey) + restorePendingForkValidationsAfterCancellation( + unprocessedRequestsByProbeKey, + dropping: pendingRequestIDsToRemoveOnCancellation + ) + return processedPanelIdsByWorkspaceId + } storeRejectedForkSupportValidation( identity: identity, for: resolvedProbeKey, requiresLiveIndexPanel: validationRequiresLiveIndexPanel, - refreshBeforeReuse: false + refreshBeforeReuse: watchGeneration == nil, + preserveExecutableWatch: watchGeneration != nil ) continue case "resolved": @@ -1502,9 +1530,12 @@ final class SharedLiveAgentIndex { identity: String, for probeKey: ForkProbeKey, requiresLiveIndexPanel: Bool, - refreshBeforeReuse: Bool = false + refreshBeforeReuse: Bool = false, + preserveExecutableWatch: Bool = false ) { - clearForkExecutableWatch(for: probeKey) + if !preserveExecutableWatch { + clearForkExecutableWatch(for: probeKey) + } validatedForkSupport[probeKey] = ForkSupportValidation( identity: identity, executableFingerprint: nil, @@ -1610,7 +1641,7 @@ final class SharedLiveAgentIndex { watchDirectories: [String] ) async -> UUID? { clearForkExecutableWatch(for: probeKey) - guard let lookupPath, let realPath else { return nil } + guard let lookupPath else { return nil } let resolvedWatchPaths = await resolveForkExecutableWatchPaths( lookupPath: lookupPath, realPath: realPath, @@ -1714,7 +1745,7 @@ final class SharedLiveAgentIndex { private func resolveForkExecutableWatchPaths( lookupPath: String, - realPath: String, + realPath: String?, watchDirectories: [String] ) async -> ForkExecutableWatchKey? { let key = Self.forkExecutableWatchPathTaskKey( @@ -1824,10 +1855,10 @@ final class SharedLiveAgentIndex { nonisolated private static func forkExecutableWatchPathTaskKey( lookupPath: String, - realPath: String, + realPath: String?, watchDirectories: [String] ) -> String { - ([lookupPath, realPath] + watchDirectories.sorted()).joined(separator: "\u{1f}") + ([lookupPath, realPath ?? ""] + watchDirectories.sorted()).joined(separator: "\u{1f}") } nonisolated private static func forkExecutableWatchOpenTaskKey( @@ -1856,13 +1887,21 @@ final class SharedLiveAgentIndex { nonisolated private static func forkExecutableWatchPaths( lookupPath: String, - realPath: String, + realPath: String?, watchDirectories: [String] ) -> [String]? { var watchPaths = Set() - watchPaths.insert(realPath) + if let realPath, + FileManager.default.fileExists(atPath: realPath) { + watchPaths.insert(realPath) + } let lookupDirectory = URL(fileURLWithPath: lookupPath).deletingLastPathComponent().path - watchPaths.insert(lookupDirectory) + guard let watchableLookupDirectory = watchableDirectoryPath( + forDirectoryPath: lookupDirectory + ) else { + return nil + } + watchPaths.insert(watchableLookupDirectory) guard insertForkExecutableSymlinkRetargetWatchPaths( forPath: lookupDirectory, into: &watchPaths diff --git a/Sources/SidebarAgentActivitySummary.swift b/Sources/SidebarAgentActivitySummary.swift index ccafebaafd94..2d54232496e9 100644 --- a/Sources/SidebarAgentActivitySummary.swift +++ b/Sources/SidebarAgentActivitySummary.swift @@ -13,7 +13,8 @@ enum SidebarAgentActivitySummary { statesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] ) -> Int { statesByPanelId.values.reduce(0) { partial, panelStates in - partial + panelStates.values.reduce(0) { $1 == .running ? $0 + 1 : $0 } + partial + AgentHibernationLifecycleStatusKeys.resolvedStates(panelStates) + .reduce(0) { $1 == .running ? $0 + 1 : $0 } } } } diff --git a/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift b/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift index d5182da0b378..2a1acebb37e3 100644 --- a/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift +++ b/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift @@ -1,5 +1,4 @@ import CMUXAgentLaunch -import Darwin import Foundation extension SurfaceResumeBindingSnapshot { @@ -119,16 +118,20 @@ extension SurfaceResumeCommandCanonicalizer { for: kind, executableBasename: executableBasename ), - executableBasename == executableName, - isPATHManagedAgentExecutablePath(executable, executableName: executableName) else { + executableBasename == executableName else { return command } - guard !isExecutableFile(atPath: executable) else { - return command - } - + let isPATHManaged = isPATHManagedAgentExecutablePath( + executable, + executableName: executableName + ) + guard executableName == "codex" || isPATHManaged else { return command } if executableName == "claude" { - return replacingStaleWrapperRoutedExecutable( + // A live PATH-managed executable still must not be invoked directly. + // Surface restoration otherwise bypasses the per-surface wrapper shim, + // so the resumed root never emits SessionStart and disappears from the + // runtime-scoped agent tree. Custom absolute paths were rejected above. + return replacingWrapperRoutedExecutable( in: command, words: words, executableIndex: executableIndex, @@ -138,18 +141,18 @@ extension SurfaceResumeCommandCanonicalizer { wrapInPortableShell: { AgentResumeArgv.portableClaudeResumeShellCommand(posixCommand: $0) } ) } else if executableName == "codex" { - // Mirror claude: route a stale codex executable (a PATH-managed path - // whose file is gone) through the codex wrapper token instead of a - // bare `codex`, so the restored codex surface keeps cmux hooks. + // Mirror Claude: both live and stale managed Codex paths route through + // the wrapper token so restored surfaces keep cmux hooks. // https://github.com/manaflow-ai/cmux/issues/5639 - return replacingStaleWrapperRoutedExecutable( + return replacingWrapperRoutedExecutable( in: command, words: words, executableIndex: executableIndex, executableName: "codex", wrapperToken: AgentResumeArgv.codexWrapperShellExecutableToken, renderPortable: { AgentResumeArgv.renderedPortableCodexResumeShellCommand(parts: $0, quote: $1) }, - wrapInPortableShell: { AgentResumeArgv.portableCodexResumeShellCommand(posixCommand: $0) } + wrapInPortableShell: { AgentResumeArgv.portableCodexResumeShellCommand(posixCommand: $0) }, + customExecutableEnvironmentVariable: "CMUX_CUSTOM_CODEX_PATH" ) } else { return replacingExecutableOnly( @@ -236,18 +239,15 @@ extension SurfaceResumeCommandCanonicalizer { } } - private static func isExecutableFile(atPath path: String) -> Bool { - path.withCString { access($0, X_OK) == 0 } - } - - private static func replacingStaleWrapperRoutedExecutable( + private static func replacingWrapperRoutedExecutable( in command: String, words: [TerminalStartupWorkingDirectoryPrefix.ShellWordRange], executableIndex: Int, executableName: String, wrapperToken: String, renderPortable: ([String], (String) -> String) -> String, - wrapInPortableShell: (String) -> String + wrapInPortableShell: (String) -> String, + customExecutableEnvironmentVariable: String? = nil ) -> String { let commandStartIndex = commandStartWordIndex(in: words) guard commandStartIndex < words.count, @@ -260,46 +260,68 @@ extension SurfaceResumeCommandCanonicalizer { command: command, commandStartIndex: commandStartIndex ) else { - return replacingStaleExecutableWithWrapperShellCommand( + return replacingExecutableWithWrapperShellCommand( in: command, words: words, commandStartIndex: commandStartIndex, executableIndex: executableIndex, wrapperToken: wrapperToken, - wrapInPortableShell: wrapInPortableShell + wrapInPortableShell: wrapInPortableShell, + customExecutableEnvironmentVariable: customExecutableEnvironmentVariable ) } - guard canRenderStaleCommandAsPortableArgv( + guard canRenderCommandAsPortableArgv( words: words, command: command, commandStartIndex: commandStartIndex, executableIndex: executableIndex ) else { - return replacingStaleExecutableWithWrapperShellCommand( + return replacingExecutableWithWrapperShellCommand( in: command, words: words, commandStartIndex: commandStartIndex, executableIndex: executableIndex, wrapperToken: wrapperToken, - wrapInPortableShell: wrapInPortableShell + wrapInPortableShell: wrapInPortableShell, + customExecutableEnvironmentVariable: customExecutableEnvironmentVariable ) } - parts[executableIndex - commandStartIndex] = executableName + let relativeExecutableIndex = executableIndex - commandStartIndex + parts[relativeExecutableIndex] = executableName + if let customExecutableEnvironmentVariable { + let assignment = "\(customExecutableEnvironmentVariable)=\(words[executableIndex].value)" + if parts.first == "env" || parts.first == "/usr/bin/env" { + parts.insert(assignment, at: relativeExecutableIndex) + } else { + parts.insert(contentsOf: ["env", assignment], at: relativeExecutableIndex) + } + } let renderedCommand = renderPortable(parts, shellQuoted) let commandStart = words[commandStartIndex].range.lowerBound return String(command[.. String + wrapInPortableShell: (String) -> String, + customExecutableEnvironmentVariable: String? ) -> String { + let hasEnvCommand = words[commandStartIndex.. = [] + ) { + let recordId = ClosedItemHistoryStore.shared.push(.workspace(entry)) + let missingPanelIds = entry.snapshot.panels.compactMap { panel in + panel.terminal?.agent == nil && !excludedPanelIds.contains(panel.id) ? panel.id : nil + } + guard !missingPanelIds.isEmpty else { return } + let workspaceId = entry.workspaceId + Task { @MainActor in + guard let index = await SharedLiveAgentIndex.shared.currentIndexAfterRefreshing() else { return } + let agents = Dictionary(uniqueKeysWithValues: missingPanelIds.compactMap { panelId in + index.snapshot(workspaceId: workspaceId, panelId: panelId).map { (panelId, $0) } + }) + ClosedItemHistoryStore.shared.enrichClosedWorkspaceAgents( + recordId: recordId, + agentsByPanelId: agents + ) + } + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 28626883b5df..8dec8b01de01 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2017,19 +2017,24 @@ class TabManager: ObservableObject { let index = tabs.firstIndex(where: { $0.id == workspace.id }) { // Prefer the warm cached agent index over a synchronous // RestorableAgentSessionIndex.load() (sysctl-per-record + disk) so closing a - // workspace does not freeze the main thread; fall back to a fresh load only - // while the cache has not loaded yet. See closedPanelHistoryEntry. + // workspace does not freeze the main thread. During a cold-cache window, + // durable history asynchronously backfills agents after the shared load. let snapshot = workspace.sessionSnapshot( includeScrollback: true, restorableAgentIndex: SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh() - ?? RestorableAgentSessionIndex.load() + ?? .empty ) - ClosedItemHistoryStore.shared.push(.workspace(ClosedWorkspaceHistoryEntry( + let completedAgentPanelIds = Set( + workspace.restoredAgentResumeStatesByPanelId.compactMap { panelId, state in + state == .completedAgentExit ? panelId : nil + } + ) + pushClosedWorkspaceHistoryEntryWithAgentEnrichment(ClosedWorkspaceHistoryEntry( workspaceId: workspace.id, windowId: AppDelegate.shared?.windowId(for: self), workspaceIndex: index, snapshot: snapshot - ))) + ), excludedPanelIds: completedAgentPanelIds) } sidebarGitMetadataService.clearWorkspaceGitProbes(workspaceId: workspace.id) pullRequestProbing.clearWorkspacePullRequestTracking(workspaceId: workspace.id) @@ -4161,14 +4166,21 @@ class TabManager: ObservableObject { @discardableResult func restoreClosedWorkspace(_ entry: ClosedWorkspaceHistoryEntry) -> Bool { let preRestoreFocus = currentFocusHistoryEntry + var reconciledSnapshot = entry.snapshot + RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore( + &reconciledSnapshot + ) let workspace = addWorkspace( - title: entry.snapshot.customTitle ?? entry.snapshot.processTitle, - workingDirectory: entry.snapshot.currentDirectory, + title: reconciledSnapshot.customTitle ?? reconciledSnapshot.processTitle, + workingDirectory: reconciledSnapshot.currentDirectory, select: false, autoWelcomeIfNeeded: false ) - let restoredPanelIds = workspace.restoreSessionSnapshot(entry.snapshot, excludingStableIdentities: liveStableIdentitySet()) - guard !entry.snapshot.hasRestorablePanels || !restoredPanelIds.isEmpty else { + let restoredPanelIds = workspace.restoreSessionSnapshot( + reconciledSnapshot, + excludingStableIdentities: liveStableIdentitySet() + ) + guard !reconciledSnapshot.hasRestorablePanels || !restoredPanelIds.isEmpty else { closeWorkspace(workspace, recordHistory: false) return false } @@ -5959,7 +5971,9 @@ extension TabManager { @discardableResult func restoreSessionSnapshot( _ snapshot: SessionTabManagerSnapshot, - remapClosedPanelHistory: Bool = true, excludingStableIdentities: Set = [] + remapClosedPanelHistory: Bool = true, + excludingStableIdentities: Set = [], + restoredAgentHibernationAdoptionBatch providedAdoptionBatch: RestoredAgentHibernationAdoptionBatch? = nil ) -> [[UUID: UUID]] { isRestoringSessionSnapshot = true defer { isRestoringSessionSnapshot = false } @@ -5989,6 +6003,8 @@ extension TabManager { // mountedWorkspaceIds empty and cause a frozen blank launch state (#399). var newTabs: [Workspace] = [] var restoredPanelIdsByWorkspaceIndex: [[UUID: UUID]] = [] + let restoredAgentHibernationAdoptionBatch = providedAdoptionBatch + ?? RestoredAgentHibernationAdoptionBatch() let (normalizedWorkspaceSnapshots, selectedWorkspaceIndex) = Self.normalizedCloudVMSessionRestoreWorkspaces( snapshot.workspaces.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow), selectedWorkspaceIndex: snapshot.selectedWorkspaceIndex @@ -5996,6 +6012,7 @@ extension TabManager { let workspaceSnapshots = normalizedWorkspaceSnapshots .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) var restoredOriginalWorkspaceIds: [UUID?] = [] + let agentCommandExecutableResolver = AgentCommandExecutableResolver() for workspaceSnapshot in workspaceSnapshots { let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 @@ -6007,12 +6024,18 @@ extension TabManager { nativeSSHConnectionBroker: nativeSSHConnectionBroker ) workspace.owningTabManager = self - let restoredPanelIds = workspace.restoreSessionSnapshot(workspaceSnapshot, excludingStableIdentities: excludingStableIdentities) + let restoredPanelIds = workspace.restoreSessionSnapshot( + workspaceSnapshot, + excludingStableIdentities: excludingStableIdentities, + restoredAgentHibernationAdoptionBatch: restoredAgentHibernationAdoptionBatch, + agentCommandExecutableResolver: agentCommandExecutableResolver + ) wireClosedBrowserTracking(for: workspace) newTabs.append(workspace) restoredPanelIdsByWorkspaceIndex.append(restoredPanelIds) restoredOriginalWorkspaceIds.append(workspaceSnapshot.workspaceId) } + restoredAgentHibernationAdoptionBatch.finalize() if newTabs.isEmpty { let ordinal = Self.nextPortOrdinal diff --git a/Sources/TerminalController+AgentObservations.swift b/Sources/TerminalController+AgentObservations.swift new file mode 100644 index 000000000000..945151e2db7b --- /dev/null +++ b/Sources/TerminalController+AgentObservations.swift @@ -0,0 +1,22 @@ +import CmuxFoundation +import CmuxTerminal +import Foundation + +extension TerminalController { + /// Copies cached terminal classifications entirely on the socket worker. + /// This never hops to the UI thread, captures terminal text, or scans processes. + nonisolated func v2AgentObservations() -> [String: Any] { + let observations = GhosttyApp.shared.agentTerminalObservationsSnapshot() + guard let data = try? JSONEncoder().encode(observations), + let payload = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return [ + "runtime_id": TerminalSurface.managedCmuxRuntimeId, + "observations": [], + ] + } + return [ + "runtime_id": TerminalSurface.managedCmuxRuntimeId, + "observations": payload, + ] + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0c5bdca7bd94..a59fc372c4e0 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -383,6 +383,7 @@ class TerminalController { events: Self.makeSocketServerEvents(target: serverEventTarget) ) self.socketServer = socketServer + AgentHookRuntimeSocketState.install(socketServer: socketServer) // Single consumer of the accepted-connection stream, detached so // accepts never funnel through the main actor. Each connection still // gets a dedicated thread: command bodies block (main-thread sync @@ -1276,6 +1277,8 @@ class TerminalController { return v2Ok(id: request.id, result: ["pong": true]) case "system.capabilities": return v2Ok(id: request.id, result: v2CapabilitiesWithBrowserDesignMode()) + case "agents.observations": + return v2Ok(id: request.id, result: v2AgentObservations()) case "system.top": return v2Result(id: request.id, v2SystemTop(params: request.params)) case "system.memory": @@ -2315,6 +2318,7 @@ class TerminalController { "system.capabilities", "system.identify", "system.tree", + "agents.observations", "sidebar.custom.open", "system.top", "system.memory", @@ -2555,9 +2559,9 @@ class TerminalController { #endif return [ - "protocol": "cmux-socket", - "version": 2, + "protocol": "cmux-socket", "version": 2, "socket_path": socketServer.currentSocketPath, + "runtime_id": TerminalSurface.managedCmuxRuntimeId, "bundle_identifier": Bundle.main.bundleIdentifier ?? "", "access_mode": socketServer.accessMode.rawValue, "methods": methods.sorted() ] @@ -4965,6 +4969,7 @@ class TerminalController { let requestedWorkingDirectory = workspace?.allowsLocalDirectoryFallback(panelId: panelId) == false ? nil : nonEmpty(terminalSurface.requestedWorkingDirectory) let teardownRequest = terminalSurface.debugTeardownRequest() let lastKnownWorkspaceId = terminalSurface.debugLastKnownWorkspaceId() + let detectedAgentState = workspace?.observedAgentTerminalState(panelId: panelId) var item: [String: Any] = [ "index": index, @@ -5046,6 +5051,10 @@ class TerminalController { "teardown_requested_reason": v2OrNull(nonEmpty(teardownRequest.reason)) ] + item["agent_terminal_family"] = v2OrNull(detectedAgentState?.family) + item["agent_terminal_state"] = detectedAgentState?.state ?? "unknown" + item["agent_terminal_state_source"] = detectedAgentState?.source ?? "none" + if title == nil, let fallbackTitle = mapped?.terminalPanel.displayTitle, !fallbackTitle.isEmpty { item["surface_title"] = fallbackTitle } diff --git a/Sources/TerminalOutputTeeContext.swift b/Sources/TerminalOutputTeeContext.swift index bf30485c1174..541490df8070 100644 --- a/Sources/TerminalOutputTeeContext.swift +++ b/Sources/TerminalOutputTeeContext.swift @@ -43,6 +43,7 @@ final class TerminalOutputTeeContext: @unchecked Sendable { let workspaceID: UUID let surfaceID: UUID + let agentStateSignal: AgentTerminalDirtySignal private let clock = ContinuousClock() private let notificationHandler: PromptTurnNotificationHandler private var detectors: [DetectorBinding] @@ -51,10 +52,12 @@ final class TerminalOutputTeeContext: @unchecked Sendable { init( workspaceID: UUID, surfaceID: UUID, - agentDefinitions: [CmuxTaskManagerCodingAgentDefinition] + agentDefinitions: [CmuxTaskManagerCodingAgentDefinition], + agentStateSignal: AgentTerminalDirtySignal ) { self.workspaceID = workspaceID self.surfaceID = surfaceID + self.agentStateSignal = agentStateSignal self.notificationHandler = PromptTurnNotificationHandler( workspaceID: workspaceID, surfaceID: surfaceID @@ -70,6 +73,10 @@ final class TerminalOutputTeeContext: @unchecked Sendable { } func consume(_ bytes: UnsafeBufferPointer) { + // The synchronous PTY lane only flips an atomic revision and yields + // into a buffering-newest stream. Snapshotting and classification run + // later in the bounded scheduler. + agentStateSignal.markDirty() let now = clock.now for index in detectors.indices { if let confirmation = detectors[index].detector.pendingConfirmation, diff --git a/Sources/TerminalSurfaceRuntimeWiring.swift b/Sources/TerminalSurfaceRuntimeWiring.swift index e21c60f7d856..75db28a7a4b5 100644 --- a/Sources/TerminalSurfaceRuntimeWiring.swift +++ b/Sources/TerminalSurfaceRuntimeWiring.swift @@ -73,6 +73,17 @@ final class TerminalSurfaceSpawnPolicyBridge: TerminalSurfaceSpawnPolicyProvidin /// drop/replay state by surface id (the legacy inline /// `ghostty_surface_set_pty_tee_cb` + `MobileTerminalByteTee.shared` calls). final class TerminalOutputByteTeeBridge: TerminalByteTeeBinding { + private let agentStateRuntime: AgentTerminalStateRuntime + + private static var isRunningTests: Bool { + SessionRestorePolicy.isRunningUnderAutomatedTests() + } + + @MainActor + init(agentStateRuntime: AgentTerminalStateRuntime) { + self.agentStateRuntime = agentStateRuntime + } + /// Wraps the retained tee userdata; `release()` runs exactly where the /// surface released the legacy `Unmanaged` context. /// @unchecked Sendable: the Unmanaged box is exclusively owned by this @@ -94,24 +105,42 @@ final class TerminalOutputByteTeeBridge: TerminalByteTeeBinding { func installTee( on surface: ghostty_surface_t, workspaceID: UUID, - surfaceID: UUID + surfaceID: UUID, + surfaceGeneration: UInt64 ) -> any TerminalByteTeeLease { + let agentStateSignal = AgentTerminalDirtySignal() let teeContext = Unmanaged.passRetained(TerminalOutputTeeContext( workspaceID: workspaceID, surfaceID: surfaceID, - agentDefinitions: CmuxTaskManagerCodingAgentDefinition.builtIns + agentDefinitions: CmuxTaskManagerCodingAgentDefinition.builtIns, + agentStateSignal: agentStateSignal )) ghostty_surface_set_pty_tee_cb( surface, cmuxTerminalOutputTeeCallback, teeContext.toOpaque() ) + // App-host tests construct hundreds of short-lived terminal surfaces in + // one process. The production detector owns persistent per-surface tasks + // and mutates shared workspace lifecycle state, so installing it for + // those fixtures leaks work across otherwise isolated test cases. Its + // scheduler and composition adapter have focused tests with injected + // signals instead. + if !Self.isRunningTests { + agentStateRuntime.install( + workspaceID: workspaceID, + surfaceID: surfaceID, + expectedRuntimeGeneration: surfaceGeneration, + signal: agentStateSignal + ) + } return Lease(context: teeContext) } @MainActor - func dropSurface(surfaceID: UUID) { + func dropSurface(surfaceID: UUID, surfaceGeneration: UInt64) { MobileTerminalByteTee.shared.dropSurface(surfaceID: surfaceID) + agentStateRuntime.drop(surfaceID: surfaceID, surfaceGeneration: surfaceGeneration) } } diff --git a/Sources/VaultAgentProcessScanner+CodexSessionCwd.swift b/Sources/VaultAgentProcessScanner+CodexSessionCwd.swift index 32df57c124b1..e0774e6afb1f 100644 --- a/Sources/VaultAgentProcessScanner+CodexSessionCwd.swift +++ b/Sources/VaultAgentProcessScanner+CodexSessionCwd.swift @@ -2,8 +2,29 @@ import Foundation import SQLite3 final class CodexSessionCwdLookupCache { + private final class DatabaseLookup { + let database: OpaquePointer + let statement: OpaquePointer + + init(database: OpaquePointer, statement: OpaquePointer) { + self.database = database + self.statement = statement + } + + deinit { + sqlite3_finalize(statement) + sqlite3_close(database) + } + } + + private enum CachedDatabase { + case unavailable + case available(DatabaseLookup) + } + private let fileManager: FileManager private var cwdByDatabaseAndSession: [String: String?] = [:] + private var databaseByPath: [String: CachedDatabase] = [:] init(fileManager: FileManager) { self.fileManager = fileManager @@ -21,32 +42,27 @@ final class CodexSessionCwdLookupCache { let dbPath = URL(fileURLWithPath: codexHome, isDirectory: true) .appendingPathComponent("state_5.sqlite", isDirectory: false) .path - guard fileManager.fileExists(atPath: dbPath) else { return nil } let cacheKey = dbPath + "\u{0}" + sessionId // dict[key] is String?? here: .some(nil) is a memoized negative result. if let cached = cwdByDatabaseAndSession[cacheKey] { return cached } - var db: OpaquePointer? - guard sqlite3_open_v2(dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else { - sqlite3_close(db) - return nil - } - defer { sqlite3_close(db) } - - let sql = "SELECT cwd FROM threads WHERE id = ? AND archived = 0 LIMIT 1" - var stmt: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { - sqlite3_finalize(stmt) + guard let lookup = databaseLookup(at: dbPath) else { + cwdByDatabaseAndSession.updateValue(nil, forKey: cacheKey) return nil } - defer { sqlite3_finalize(stmt) } let SQLITE_TRANSIENT_FN = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) - sqlite3_bind_text(stmt, 1, sessionId, -1, SQLITE_TRANSIENT_FN) - guard sqlite3_step(stmt) == SQLITE_ROW, - let cwd = normalizedCodexCwdValue(SessionIndexStore.sqliteText(stmt, 0)) else { + sqlite3_reset(lookup.statement) + sqlite3_clear_bindings(lookup.statement) + sqlite3_bind_text(lookup.statement, 1, sessionId, -1, SQLITE_TRANSIENT_FN) + defer { + sqlite3_reset(lookup.statement) + sqlite3_clear_bindings(lookup.statement) + } + guard sqlite3_step(lookup.statement) == SQLITE_ROW, + let cwd = normalizedCodexCwdValue(SessionIndexStore.sqliteText(lookup.statement, 0)) else { // updateValue stores .some(nil); subscript nil-assignment would remove the key. cwdByDatabaseAndSession.updateValue(nil, forKey: cacheKey) return nil @@ -55,6 +71,44 @@ final class CodexSessionCwdLookupCache { return cwd } + private func databaseLookup(at dbPath: String) -> DatabaseLookup? { + if let cached = databaseByPath[dbPath] { + switch cached { + case .unavailable: + return nil + case .available(let lookup): + return lookup + } + } + + guard fileManager.fileExists(atPath: dbPath) else { + databaseByPath[dbPath] = .unavailable + return nil + } + + var database: OpaquePointer? + guard sqlite3_open_v2(dbPath, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, + let database else { + sqlite3_close(database) + databaseByPath[dbPath] = .unavailable + return nil + } + + let sql = "SELECT cwd FROM threads WHERE id = ? AND archived = 0 LIMIT 1" + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, + let statement else { + sqlite3_finalize(statement) + sqlite3_close(database) + databaseByPath[dbPath] = .unavailable + return nil + } + + let lookup = DatabaseLookup(database: database, statement: statement) + databaseByPath[dbPath] = .available(lookup) + return lookup + } + private func normalizedCodexCwdValue(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index a30b5c3a873a..6a5be1a5c93e 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -79,20 +79,14 @@ extension RestorableAgentSessionIndex { )) { existing, _ in existing } resolved.merge(processDetectedForkParentFallbackSnapshots(processSnapshot: processSnapshot, capturedAt: capturedAt, scopedProcessIDsByPanelKey: scopedProcessIDsByPanelKey, processArgumentsProvider: cachedProcessArguments)) { existing, _ in existing } guard !registry.registrations.isEmpty else { return resolved } - var registriesByWorkingDirectory: [String: CmuxVaultAgentRegistry] = [:] + var projectConfigCache = CmuxVaultAgentRegistry.ProjectConfigCache(base: registry) + var piSessionDirectoryIndex = PiSessionDirectoryIndex(fileManager: fileManager) func registryForWorkingDirectory(_ workingDirectory: String?) -> CmuxVaultAgentRegistry { - guard let workingDirectory else { return registry } - let key = (workingDirectory as NSString).standardizingPath - if let cached = registriesByWorkingDirectory[key] { - return cached - } - let resolved = registry.mergingProjectConfig( - workingDirectory: key, + projectConfigCache.registry( + forWorkingDirectory: workingDirectory, fileManager: fileManager ) - registriesByWorkingDirectory[key] = resolved - return resolved } for process in processSnapshot.cmuxScopedProcesses() { @@ -109,12 +103,13 @@ extension RestorableAgentSessionIndex { ) let cwd = normalized(observed.environment["CMUX_AGENT_LAUNCH_CWD"] ?? observed.environment["PWD"]) let processRegistry = registryForWorkingDirectory(cwd) - guard let registration = processRegistry.registrations.first(where: { $0.detect.matches(observed) }), + guard let registration = processRegistry.matchingRegistration(for: observed), registration.processDetectedSnapshotIsRestorable(for: observed), let sessionIDResolution = registration.sessionIdSource.sessionIDResolution( from: observed, registration: registration, - fileManager: fileManager + fileManager: fileManager, + piSessionDirectoryIndex: &piSessionDirectoryIndex ) else { continue } @@ -272,7 +267,11 @@ extension RestorableAgentSessionIndex { arguments: processArguments.arguments, environment: processArguments.environment ) - guard observed.isOpenCodeProcess else { continue } + guard observed.isOpenCodeProcess, + CmuxVaultAgentRegistration.processDetectedSnapshotIsRestorable( + kind: "opencode", + for: observed + ) else { continue } let cwd = openCodeWorkingDirectory(observed: observed) let cwdKey = cwd.map { ($0 as NSString).standardizingPath } ?? "" @@ -669,7 +668,8 @@ private extension CmuxVaultAgentSessionIDSource { func sessionIDResolution( from process: VaultObservedAgentProcess, registration: CmuxVaultAgentRegistration, - fileManager: FileManager + fileManager: FileManager, + piSessionDirectoryIndex: inout PiSessionDirectoryIndex ) -> VaultAgentSessionIDResolution? { switch self { case .argvOption(let option): @@ -677,29 +677,18 @@ private extension CmuxVaultAgentSessionIDSource { return VaultAgentSessionIDResolution(sessionId: sessionId, source: registration.processArgumentsCarryForkParentFlag(process.arguments) ? .forkParentFallback : .explicit) case .piSessionFile: let carriesForkParentFlag = registration.processArgumentsCarryForkParentFlag(process.arguments) - if let session = process.piCompatibleSessionID { - let sessionId = PiSessionLocator.resolvedSessionPath( - session, - for: process, - registration: registration, - fileManager: fileManager - ) ?? session - return VaultAgentSessionIDResolution( - sessionId: sessionId, - source: carriesForkParentFlag ? .forkParentFallback : .explicit - ) - } - if carriesForkParentFlag { - return nil - } - guard let sessionId = PiSessionLocator.latestSessionPath( + guard let session = process.piCompatibleSessionID else { return nil } + let sessionId = PiSessionLocator.resolvedSessionPath( + session, for: process, registration: registration, - fileManager: fileManager - ) else { - return nil - } - return VaultAgentSessionIDResolution(sessionId: sessionId, source: .inferredLatestSessionFile) + fileManager: fileManager, + piSessionDirectoryIndex: &piSessionDirectoryIndex + ) ?? session + return VaultAgentSessionIDResolution( + sessionId: sessionId, + source: carriesForkParentFlag ? .forkParentFallback : .explicit + ) case .grokSessionDirectory: if let session = process.arguments.grokResumeSessionID { return VaultAgentSessionIDResolution(sessionId: session, source: .explicit) @@ -808,19 +797,12 @@ enum PiSessionLocator { return "--\(sanitized)--" } - fileprivate static func latestSessionPath( - for process: VaultObservedAgentProcess, - registration: CmuxVaultAgentRegistration, - fileManager: FileManager - ) -> String? { - newestJSONLFile(in: candidateSessionDirectory(for: process, registration: registration), fileManager: fileManager)?.path - } - fileprivate static func resolvedSessionPath( _ session: String, for process: VaultObservedAgentProcess, registration: CmuxVaultAgentRegistration, - fileManager: FileManager + fileManager: FileManager, + piSessionDirectoryIndex: inout PiSessionDirectoryIndex ) -> String? { let trimmed = session.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } @@ -829,34 +811,10 @@ enum PiSessionLocator { return fileManager.fileExists(atPath: expanded) ? expanded : trimmed } - let directory = candidateSessionDirectory(for: process, registration: registration) - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: directory, isDirectory: &isDirectory), - isDirectory.boolValue, - let enumerator = fileManager.enumerator( - at: URL(fileURLWithPath: directory, isDirectory: true), - includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], - options: [.skipsHiddenFiles] - ) else { - return nil - } - - var exactNewest: (url: URL, modified: Date)? - var partialNewest: (url: URL, modified: Date)? - for case let url as URL in enumerator where url.pathExtension == "jsonl" { - let basename = url.deletingPathExtension().lastPathComponent - guard basename == trimmed || basename.contains(trimmed) else { continue } - let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) - guard values?.isRegularFile == true, let modified = values?.contentModificationDate else { continue } - if basename == trimmed { - if exactNewest == nil || modified > exactNewest!.modified { - exactNewest = (url, modified) - } - } else if partialNewest == nil || modified > partialNewest!.modified { - partialNewest = (url, modified) - } - } - return exactNewest?.url.path ?? partialNewest?.url.path + return piSessionDirectoryIndex.resolvedSessionPath( + trimmed, + in: candidateSessionDirectory(for: process, registration: registration) + ) } } diff --git a/Sources/VaultAgentProcessScannerDetectRules.swift b/Sources/VaultAgentProcessScannerDetectRules.swift index 99782a7984ae..24443d0541e5 100644 --- a/Sources/VaultAgentProcessScannerDetectRules.swift +++ b/Sources/VaultAgentProcessScannerDetectRules.swift @@ -1,13 +1,135 @@ +import CMUXAgentLaunch import Foundation +private let knownVaultProcessRestoreKinds = Set(RestorableAgentKind.allCases.map(\.rawValue)) + .union(RestorableAgentKind.registryOwnedRawValues) + extension CmuxVaultAgentRegistration { func processDetectedSnapshotIsRestorable(for process: VaultObservedAgentProcess) -> Bool { - guard id == "campfire" else { return true } - return process.environment["CAMPFIRE_SESSION_ROLE"] == "host" + Self.processDetectedSnapshotIsRestorable(kind: id, for: process) + } + + static func processDetectedSnapshotIsRestorable( + kind rawKind: String, + for process: VaultObservedAgentProcess + ) -> Bool { + let registeredKind = rawKind.lowercased() + let liveProcessName = process.processPath ?? process.processName + let kind = AgentLaunchCaptureTrust.nativeAgentKind( + processName: liveProcessName, + arguments: process.arguments + ) ?? registeredKind + if kind == "campfire", + process.environment["CAMPFIRE_SESSION_ROLE"] != "host" { + return false + } + guard knownVaultProcessRestoreKinds.contains(kind) else { return true } + + let capturedArguments = Self.decodedCapturedArguments( + process.environment["CMUX_AGENT_LAUNCH_ARGV_B64"] + ) + let capturedKind = process.environment["CMUX_AGENT_LAUNCH_KIND"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let capturedExecutable = process.environment["CMUX_AGENT_LAUNCH_EXECUTABLE"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let trustedCapture = capturedArguments.flatMap { arguments -> [String]? in + AgentLaunchCaptureTrust.capturedArgumentsDescribeKind( + launcher: capturedKind, + executablePath: capturedExecutable, + arguments: arguments, + kind: kind + ) ? arguments : nil + } + let classifier = AgentLaunchModeClassifier() + let liveMode = classifier.processMode( + processName: liveProcessName, + arguments: process.arguments, + kind: kind + ) + let interpreterNames: Set = [ + "node", "nodejs", "bun", "deno", "tsx", "ts-node", "ts_node", + ] + let observedIsInterpreterHost = process.executableBasenames.contains { + interpreterNames.contains($0.lowercased()) + } + let canUseCapturedInterpreterLaunch = ["pi", "omp", "campfire"].contains(kind) + && observedIsInterpreterHost + && liveMode == .unknown + let mode: AgentProcessLaunchMode + if canUseCapturedInterpreterLaunch, let trustedCapture { + mode = classifier.processMode( + processName: capturedExecutable?.isEmpty == false + ? capturedExecutable + : trustedCapture.first, + arguments: trustedCapture, + kind: kind + ) + } else { + mode = liveMode + } + switch mode { + case .oneShot, .nonSession: + return false + case .interactive, .unknown: + return true + } + } + + private static func decodedCapturedArguments(_ rawValue: String?) -> [String]? { + let maximumEncodedBytes = 1_400_000 + let maximumArgumentCount = 4_096 + guard let rawValue, + rawValue.utf8.count <= maximumEncodedBytes, + let data = Data(base64Encoded: rawValue) else { + return nil + } + var arguments: [String] = [] + var start = data.startIndex + for index in data.indices where data[index] == 0 { + guard arguments.count < maximumArgumentCount else { return nil } + guard index > start, + let argument = String(data: data[start.. Bool { let expectedNames = primaryProcessNames let hasPrimaryCriteria = !expectedNames.isEmpty || !argvContains.isEmpty diff --git a/Sources/VaultAgentProcessScannerSessionDirectories.swift b/Sources/VaultAgentProcessScannerSessionDirectories.swift index 0ca392b8a3f5..4f133e6b6a03 100644 --- a/Sources/VaultAgentProcessScannerSessionDirectories.swift +++ b/Sources/VaultAgentProcessScannerSessionDirectories.swift @@ -1,5 +1,213 @@ import Foundation +/// Per-process-snapshot index of Pi-family session files. A refresh sees a +/// coherent directory snapshot and never walks the same project tree once per +/// live agent process. The next refresh constructs a fresh index, so newly +/// written sessions are visible without a TTL or invalidation timer. +struct PiSessionDirectoryIndex { + private struct Candidate { + let url: URL + } + + private enum CandidateResolution { + case none + case unique(Candidate) + case ambiguous + + var candidate: Candidate? { + guard case .unique(let candidate) = self else { return nil } + return candidate + } + + static func merging(_ lhs: CandidateResolution, _ rhs: CandidateResolution) -> CandidateResolution { + switch (lhs, rhs) { + case (.ambiguous, _), (_, .ambiguous): + return .ambiguous + case (.none, let resolution), (let resolution, .none): + return resolution + case (.unique(let lhs), .unique(let rhs)): + return lhs.url.path == rhs.url.path ? .unique(lhs) : .ambiguous + } + } + } + + private struct DirectorySnapshot { + let exactByBasename: [String: CandidateResolution] + let prefixLookup: PrefixLookupIndex + } + + /// Pi accepts a prefix of its UUID session ID. Session filenames add + /// a timestamp before that UUID, so both the basename and UUID suffix are + /// searchable keys. A segment tree resolves a prefix only when every + /// matching key names the same file, without scanning every candidate. + private struct PrefixLookupIndex { + private struct Entry { + let key: String + let candidate: Candidate + } + + private let entries: [Entry] + private let leafBase: Int + private let resolutionByTreeNode: [CandidateResolution] + + init(candidates: [Candidate]) { + var entries: [Entry] = [] + entries.reserveCapacity(candidates.count * 2) + for candidate in candidates { + let basename = candidate.url.deletingPathExtension().lastPathComponent + entries.append(Entry(key: basename, candidate: candidate)) + if let sessionID = Self.uuidSuffix(in: basename) { + entries.append(Entry(key: sessionID, candidate: candidate)) + } + } + entries.sort { lhs, rhs in + if lhs.key != rhs.key { return lhs.key < rhs.key } + return lhs.candidate.url.path < rhs.candidate.url.path + } + self.entries = entries + + var leafBase = 1 + while leafBase < entries.count { leafBase *= 2 } + self.leafBase = leafBase + var tree = [CandidateResolution](repeating: .none, count: leafBase * 2) + for (index, entry) in entries.enumerated() { + tree[leafBase + index] = .unique(entry.candidate) + } + if leafBase > 1 { + for index in stride(from: leafBase - 1, through: 1, by: -1) { + tree[index] = CandidateResolution.merging(tree[index * 2], tree[index * 2 + 1]) + } + } + self.resolutionByTreeNode = tree + } + + func uniqueCandidate(forPrefix prefix: String) -> (candidate: Candidate?, visitCount: Int) { + var lower = 0 + var upper = entries.count + while lower < upper { + let middle = lower + (upper - lower) / 2 + if entries[middle].key < prefix { + lower = middle + 1 + } else { + upper = middle + } + } + guard lower < entries.count, entries[lower].key.hasPrefix(prefix) else { + return (nil, 0) + } + + let rangeStart = lower + upper = entries.count + while lower < upper { + let middle = lower + (upper - lower) / 2 + if entries[middle].key.hasPrefix(prefix) { + lower = middle + 1 + } else { + upper = middle + } + } + + var left = leafBase + rangeStart + var right = leafBase + lower + var resolution = CandidateResolution.none + var visitCount = 0 + while left < right { + if left % 2 == 1 { + resolution = CandidateResolution.merging(resolution, resolutionByTreeNode[left]) + visitCount += 1 + left += 1 + } + if right % 2 == 1 { + right -= 1 + resolution = CandidateResolution.merging(resolution, resolutionByTreeNode[right]) + visitCount += 1 + } + left /= 2 + right /= 2 + } + return (resolution.candidate, visitCount) + } + + private static func uuidSuffix(in basename: String) -> String? { + guard let separator = basename.lastIndex(of: "_") else { return nil } + let suffix = String(basename[basename.index(after: separator)...]) + return UUID(uuidString: suffix) == nil ? nil : suffix + } + } + + private enum CachedDirectory { + case unavailable + case files(DirectorySnapshot) + } + + private let fileManager: FileManager + private var cachedDirectories: [String: CachedDirectory] = [:] + private(set) var directoryEnumerationCount = 0 + private(set) var candidateQueryVisitCount = 0 + + init(fileManager: FileManager) { + self.fileManager = fileManager + } + + mutating func resolvedSessionPath(_ session: String, in directory: String) -> String? { + guard let snapshot = directorySnapshot(in: directory) else { return nil } + if let exact = snapshot.exactByBasename[session] { + return exact.candidate?.url.path + } + + let lookup = snapshot.prefixLookup.uniqueCandidate(forPrefix: session) + candidateQueryVisitCount += lookup.visitCount + return lookup.candidate?.url.path + } + + private mutating func directorySnapshot(in directory: String) -> DirectorySnapshot? { + let standardizedDirectory = (directory as NSString).standardizingPath + if let cached = cachedDirectories[standardizedDirectory] { + switch cached { + case .unavailable: + return nil + case .files(let snapshot): + return snapshot + } + } + + directoryEnumerationCount += 1 + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: standardizedDirectory, isDirectory: &isDirectory), + isDirectory.boolValue, + let enumerator = fileManager.enumerator( + at: URL(fileURLWithPath: standardizedDirectory, isDirectory: true), + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + cachedDirectories[standardizedDirectory] = .unavailable + return nil + } + + var candidates: [Candidate] = [] + var exactByBasename: [String: CandidateResolution] = [:] + for case let url as URL in enumerator where url.pathExtension == "jsonl" { + let values = try? url.resourceValues(forKeys: [.isRegularFileKey]) + guard values?.isRegularFile == true else { + continue + } + let candidate = Candidate(url: url) + candidates.append(candidate) + let basename = url.deletingPathExtension().lastPathComponent + exactByBasename[basename] = CandidateResolution.merging( + exactByBasename[basename] ?? .none, + .unique(candidate) + ) + } + let snapshot = DirectorySnapshot( + exactByBasename: exactByBasename, + prefixLookup: PrefixLookupIndex(candidates: candidates) + ) + cachedDirectories[standardizedDirectory] = .files(snapshot) + return snapshot + } +} + extension PiSessionLocator { static func candidateSessionDirectory( for process: VaultObservedAgentProcess, @@ -94,28 +302,6 @@ extension PiSessionLocator { return trimmed.isEmpty ? nil : trimmed } - static func newestJSONLFile(in directory: String, fileManager: FileManager = .default) -> URL? { - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: directory, isDirectory: &isDirectory), - isDirectory.boolValue, - let enumerator = fileManager.enumerator( - at: URL(fileURLWithPath: directory, isDirectory: true), - includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], - options: [.skipsHiddenFiles] - ) else { - return nil - } - - var newest: (url: URL, modified: Date)? - for case let url as URL in enumerator where url.pathExtension == "jsonl" { - let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) - guard values?.isRegularFile == true, let modified = values?.contentModificationDate else { continue } - if newest == nil || modified > newest!.modified { - newest = (url, modified) - } - } - return newest?.url - } } private extension Array where Element == String { diff --git a/Sources/VaultAgentRegistry+Campfire.swift b/Sources/VaultAgentRegistry+Campfire.swift index c5a473b59e3b..83938365486b 100644 --- a/Sources/VaultAgentRegistry+Campfire.swift +++ b/Sources/VaultAgentRegistry+Campfire.swift @@ -13,6 +13,7 @@ extension CmuxVaultAgentRegistration { ), sessionIdSource: .argvOption("--session"), resumeCommand: "{{executable}} --session {{sessionId}}", + forkCommand: "{{executable}} --fork {{sessionId}}", cwd: .preserve, sessionDirectory: "~/.campfire/agent/sessions" ) diff --git a/Sources/VaultAgentRegistry.swift b/Sources/VaultAgentRegistry.swift index 30498883f2f4..6c66fca74e2c 100644 --- a/Sources/VaultAgentRegistry.swift +++ b/Sources/VaultAgentRegistry.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import Foundation import OSLog @@ -56,7 +57,7 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { throw DecodingError.dataCorruptedError( forKey: .id, in: container, - debugDescription: "Vault agent id must contain only letters, numbers, dots, underscores, and hyphens" + debugDescription: "Vault agent id must contain only letters, numbers, dots, underscores, and hyphens and must not exceed 128 UTF-8 bytes" ) } @@ -106,8 +107,7 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { } static func isValidID(_ value: String) -> Bool { - guard !value.isEmpty else { return false } - return value.range(of: #"^[A-Za-z0-9._-]+$"#, options: .regularExpression) != nil + CmuxAgentSessionRegistry.isSafeProviderIdentifier(value) } private static func normalizedOptional(_ value: String?) -> String? { @@ -116,7 +116,13 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { } private static func isReservedID(_ value: String) -> Bool { - RestorableAgentKind.allCases.contains { $0.rawValue == value } + let caseFolded = value.lowercased() + if RestorableAgentKind.registryOwnedRawValues.contains(caseFolded) { + return value != caseFolded + } + return RestorableAgentKind.allCases.contains { + $0.rawValue.caseInsensitiveCompare(value) == .orderedSame + } } var defaultExecutable: String { @@ -155,8 +161,7 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { alternateArgvContains: ["@oh-my-pi/pi-coding-agent"] ), sessionIdSource: .piSessionFile, - resumeCommand: "{{executable}} --session {{sessionId}}", - forkCommand: "{{executable}} --fork {{sessionId}}", + resumeCommand: "{{executable}} --resume {{sessionId}}", cwd: .preserve, sessionDirectory: "~/.omp/agent/sessions" ) @@ -166,9 +171,15 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { if matchesPersistedBuiltInHistory(current: Self.builtInPi) { return Self.builtInPi } - if matchesPersistedBuiltInHistory(current: Self.builtInOmp) { + if matchesPersistedBuiltInOmpHistory() { return Self.builtInOmp } + if matchesPersistedBuiltInWithoutFork(current: Self.builtInGrok) { + return Self.builtInGrok + } + if matchesPersistedBuiltInWithoutFork(current: Self.builtInCampfire) { + return Self.builtInCampfire + } return self } @@ -184,6 +195,33 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { return candidate == current } + private func matchesPersistedBuiltInOmpHistory() -> Bool { + let current = Self.builtInOmp + let legacyResumeCommand = "{{executable}} --session {{sessionId}}" + let legacyForkCommands: Set = [ + "{{executable}} --fork {{sessionId}}", + "{{executable}} --session {{sessionId}} --fork", + ] + let hasKnownForkCommand = forkCommand.map(legacyForkCommands.contains) ?? true + guard resumeCommand == legacyResumeCommand, + hasKnownForkCommand, + iconAssetName == nil || iconAssetName == current.iconAssetName else { + return false + } + var candidate = self + candidate.iconAssetName = current.iconAssetName + candidate.resumeCommand = current.resumeCommand + candidate.forkCommand = current.forkCommand + return candidate == current + } + + private func matchesPersistedBuiltInWithoutFork(current: CmuxVaultAgentRegistration) -> Bool { + guard forkCommand == nil else { return false } + var candidate = self + candidate.forkCommand = current.forkCommand + return candidate == current + } + static var builtInAntigravity: CmuxVaultAgentRegistration { CmuxVaultAgentRegistration( id: "antigravity", @@ -204,6 +242,7 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { detect: CmuxVaultAgentDetectRule(processNames: ["grok", "grok-macos-aarch64", "grok-macos-aarch"]), sessionIdSource: .grokSessionDirectory, resumeCommand: "{{executable}} -r {{sessionId}}", + forkCommand: "{{executable}} --resume {{sessionId}} --fork-session", cwd: .preserve, sessionDirectory: "~/.grok/sessions" ) @@ -390,8 +429,75 @@ enum CmuxVaultAgentCWDPolicy: String, Codable, Hashable, Sendable { struct CmuxVaultAgentRegistry: Sendable { private static let logger = Logger(subsystem: "ai.manaflow.cmux", category: "VaultAgentRegistry") + private static let maximumConfigBytes = 1_024 * 1_024 + private static let maximumConfigAncestorCount = 64 + private static let maximumDynamicRegistrationCount = + CmuxAgentSessionRegistry.maximumProviderEnumerationCount + private static let builtInRegistrationIDs: Set = [ + "pi", "omp", "campfire", "antigravity", "grok", + ] var registrations: [CmuxVaultAgentRegistration] + private var detectionIndexesByExecutableName: [String: [Int]] + private var fallbackDetectionIndexes: [Int] + + struct ProjectConfigCache { + private let base: CmuxVaultAgentRegistry + private var registryByDirectory: [String: CmuxVaultAgentRegistry] = [:] + private(set) var directoryProbeCount = 0 + private(set) var configDecodeCount = 0 + + init(base: CmuxVaultAgentRegistry) { + self.base = base + } + + mutating func registry( + forWorkingDirectory workingDirectory: String?, + fileManager: FileManager + ) -> CmuxVaultAgentRegistry { + guard let workingDirectory = workingDirectory? + .trimmingCharacters(in: .whitespacesAndNewlines), + !workingDirectory.isEmpty else { + return base + } + var isDirectory: ObjCBool = false + let start = fileManager.fileExists( + atPath: workingDirectory, + isDirectory: &isDirectory + ) && isDirectory.boolValue + ? workingDirectory + : (workingDirectory as NSString).deletingLastPathComponent + var current = (start as NSString).standardizingPath + var visited: [String] = [] + visited.reserveCapacity(CmuxVaultAgentRegistry.maximumConfigAncestorCount) + for _ in 0..] = [:] + for registration in ordered { + exactIDsByCaseFold[registration.id.lowercased(), default: []] + .insert(registration.id) + } + let conflictingCaseFolds = Set(exactIDsByCaseFold.compactMap { key, exactIDs in + exactIDs.count > 1 ? key : nil + }) + for caseFold in conflictingCaseFolds.sorted() { + let exactIDs = exactIDsByCaseFold[caseFold, default: []].sorted() + Self.logger.fault( + "Ignoring Vault registrations with case-colliding ids: \(exactIDs.joined(separator: ", "), privacy: .public)" + ) + } + let filtered = ordered.filter { + !conflictingCaseFolds.contains($0.id.lowercased()) + } + var indexesByExecutableName: [String: [Int]] = [:] + var fallbackIndexes: [Int] = [] + for (index, registration) in filtered.enumerated() { + var requiresFallback = registration.detect.needsUnindexedDetectionFallback + for processName in registration.detect.detectionIndexProcessNames { + guard let key = Self.detectionIndexKey(processName) else { + requiresFallback = true + continue + } + indexesByExecutableName[key, default: []].append(index) + } + if requiresFallback { + guard fallbackIndexes.count < Self.maximumDynamicRegistrationCount else { + continue + } + fallbackIndexes.append(index) + } + } + if filtered.filter({ $0.detect.needsUnindexedDetectionFallback }).count + > fallbackIndexes.count { + Self.logger.fault( + "Vault argv-only detection exceeded the bounded fallback catalog" + ) + } + self.registrations = filtered + detectionIndexesByExecutableName = indexesByExecutableName + fallbackDetectionIndexes = fallbackIndexes } func registration(id: String) -> CmuxVaultAgentRegistration? { registrations.first { $0.id == id } } + func matchingRegistration( + for process: VaultObservedAgentProcess + ) -> CmuxVaultAgentRegistration? { + detectionCandidateIndexes(for: process).lazy + .map { registrations[$0] } + .first { $0.detect.matches(process) } + } + + func detectionCandidateCount(for process: VaultObservedAgentProcess) -> Int { + detectionCandidateIndexes(for: process).count + } + func mergingProjectConfig( workingDirectory: String?, fileManager: FileManager = .default ) -> CmuxVaultAgentRegistry { guard let workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !workingDirectory.isEmpty, - let path = Self.findLocalConfig(startingAt: workingDirectory, fileManager: fileManager), - let config = Self.decodeConfig(at: path, fileManager: fileManager), + let path = Self.findLocalConfig(startingAt: workingDirectory, fileManager: fileManager) else { + return self + } + return mergingProjectConfig(at: path, fileManager: fileManager) + } + + private func mergingProjectConfig( + at path: String, + fileManager: FileManager + ) -> CmuxVaultAgentRegistry { + guard let config = Self.decodeConfig(at: path, fileManager: fileManager), let agents = config.vault?.agents, - !agents.isEmpty else { + !agents.isEmpty, + Self.dynamicRegistrationIDs(in: registrations) + .union(Self.dynamicRegistrationIDs(in: agents)).count + <= Self.maximumDynamicRegistrationCount else { return self } return CmuxVaultAgentRegistry(registrations: registrations + agents) @@ -439,9 +613,21 @@ struct CmuxVaultAgentRegistry: Sendable { CmuxVaultAgentRegistration.builtInAntigravity, CmuxVaultAgentRegistration.builtInGrok, ] + var dynamicRegistrationIDs = Set() for path in configPaths(homeDirectory: homeDirectory, workingDirectory: workingDirectory, environment: environment, fileManager: fileManager) { guard let config = decodeConfig(at: path, fileManager: fileManager) else { continue } - registrations.append(contentsOf: config.vault?.agents ?? []) + let agents = config.vault?.agents ?? [] + let candidateIDs = dynamicRegistrationIDs.union( + Self.dynamicRegistrationIDs(in: agents) + ) + guard candidateIDs.count <= maximumDynamicRegistrationCount else { + logger.fault( + "Ignoring Vault config whose merged dynamic catalog exceeds \(maximumDynamicRegistrationCount) registrations: \(path, privacy: .public)" + ) + continue + } + dynamicRegistrationIDs = candidateIDs + registrations.append(contentsOf: agents) } return CmuxVaultAgentRegistry(registrations: registrations) } @@ -470,7 +656,7 @@ struct CmuxVaultAgentRegistry: Sendable { ? path : (path as NSString).deletingLastPathComponent var current = (start as NSString).standardizingPath - while true { + for _ in 0.. CmuxConfigFile? { guard fileManager.fileExists(atPath: path), - let data = fileManager.contents(atPath: path), - !data.isEmpty else { + let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { return nil } + defer { try? handle.close() } do { + guard let data = try handle.read(upToCount: maximumConfigBytes + 1), + !data.isEmpty, + data.count <= maximumConfigBytes else { + return nil + } let sanitized = try JSONCParser.preprocess(data: data) - return try JSONDecoder().decode(CmuxConfigFile.self, from: sanitized) + let config = try JSONDecoder().decode(CmuxConfigFile.self, from: sanitized) + guard (config.vault?.agents.count ?? 0) <= maximumDynamicRegistrationCount else { + return nil + } + return config } catch { logger.fault( "Failed to decode config at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)" @@ -500,4 +696,32 @@ struct CmuxVaultAgentRegistry: Sendable { return nil } } + + private func detectionCandidateIndexes( + for process: VaultObservedAgentProcess + ) -> [Int] { + var indexes = Set(fallbackDetectionIndexes) + for basename in process.executableBasenames { + guard let key = Self.detectionIndexKey(basename) else { continue } + indexes.formUnion(detectionIndexesByExecutableName[key] ?? []) + } + return indexes.sorted() + } + + private static func detectionIndexKey(_ value: String) -> String? { + let basename = (value as NSString).lastPathComponent + guard !basename.isEmpty, + basename.unicodeScalars.allSatisfy({ $0.isASCII }) else { + return nil + } + return basename.lowercased() + } + + private static func dynamicRegistrationIDs( + in registrations: [CmuxVaultAgentRegistration] + ) -> Set { + Set(registrations.compactMap { + builtInRegistrationIDs.contains($0.id) ? nil : $0.id + }) + } } diff --git a/Sources/Workspace+AgentLifecycle.swift b/Sources/Workspace+AgentLifecycle.swift index b2fed9a5556d..2a0ee8ff1724 100644 --- a/Sources/Workspace+AgentLifecycle.swift +++ b/Sources/Workspace+AgentLifecycle.swift @@ -2,6 +2,33 @@ import CmuxWorkspaces import Foundation extension Workspace { + func agentRootExitCandidate( + panelId: UUID, + previousState: PanelShellActivityState, + state: PanelShellActivityState + ) -> SurfaceResumeBindingSnapshot? { + // Resume-state handlers can clear the binding on the same transition, + // so capture the owner before those handlers run. + AgentHookSessionStateWriter.rootExitCandidate( + previousWasRunning: previousState == .commandRunning, + isPromptIdle: state == .promptIdle, + isHibernated: (panels[panelId] as? TerminalPanel)?.isAgentHibernated == true, + binding: surfaceResumeBindingsByPanelId[panelId] + ) + } + + func recordAgentRootExit( + panelId: UUID, + binding: SurfaceResumeBindingSnapshot?, + isPromptIdle: Bool + ) { + if let binding { + markAgentRootExitLocally(panelId: panelId, binding: binding) + AgentHookSessionStateWriter.recordRootExitIfNeeded(binding: binding) + } + if isPromptIdle { clearStaleAgentPIDs(panelId: panelId, refreshPorts: true) } + } + func allowsAgentContinuation(forPanelId panelId: UUID) -> Bool { restoredAgentResumeStatesByPanelId[panelId] != .completedAgentExit || restoredAgentSnapshotForContinuation(panelId: panelId) != nil @@ -48,6 +75,21 @@ extension Workspace { ) } + func markAgentRootExitLocally( + panelId: UUID, + binding: SurfaceResumeBindingSnapshot + ) { + // Disk completion is deliberately queued off the main thread. Mark the + // in-memory owner ended first so a close snapshot taken before that write + // cannot persist the old session as restorable. + restoredAgentResumeStatesByPanelId[panelId] = .completedAgentExit + guard let storedBinding = surfaceResumeBindingsByPanelId[panelId], + storedBinding.isAgentHookBinding, + storedBinding.kind == binding.kind, + storedBinding.checkpointId == binding.checkpointId else { return } + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + } + func restoredAgentResumeStateForAcceptedSnapshot(panelId: UUID) -> RestoredAgentResumeState { panelShellActivityStates[panelId] == .commandRunning ? .observedAgentCommandRunning @@ -157,6 +199,16 @@ extension Workspace { invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) } + func discardRejectedRestoredAgentHibernation( + panelId: UUID, + restoredAgent: SessionRestorableAgentSnapshot + ) { + _ = (panels[panelId] as? TerminalPanel)?.discardRestoredAgentHibernation() + invalidateRestoredAgentSnapshot(panelId: panelId, restoredAgent: restoredAgent) + clearAgentLifecycleStates(panelId: panelId) + clearRejectedRestoredAgentHibernationMetadata(panelId: panelId) + } + func seedDetachedRestoredAgentState(from detached: DetachedSurfaceTransfer) { if let shellActivityState = detached.shellActivityState { panelShellActivityStates[detached.panelId] = shellActivityState @@ -245,23 +297,36 @@ extension Workspace { panelId: UUID, fallback: AgentHibernationLifecycleState? ) -> AgentHibernationLifecycleState { - let states = (agentLifecycleStatesByPanelId[panelId] ?? [:]) - .filter { !AgentHibernationLifecycleStatusKeys.isManualKey($0.key) } - .map(\.value) + let states = AgentHibernationLifecycleStatusKeys.resolvedStates( + agentLifecycleStatesByPanelId[panelId] ?? [:] + ) guard !states.isEmpty else { return fallback ?? .unknown } - if states.contains(.running) { return .running } - if states.contains(.needsInput) { return .needsInput } - if states.contains(.unknown) { return .unknown } - if states.contains(.idle) { return .idle } - return fallback ?? .unknown + return AgentHibernationLifecycleState.effective(states) } - private func recordAgentLifecycleChange(panelId: UUID) { + func recordAgentHibernationLifecycleChange(panelId: UUID) { + invalidateProvisionalAgentHibernation(panelId: panelId) AgentHibernationController.shared.recordAgentLifecycleChange( workspaceId: id, panelId: panelId ) } + + func recordAgentHibernationProcessChange(panelId: UUID) { + invalidateProvisionalAgentHibernation(panelId: panelId) + AgentHibernationController.shared.recordAgentProcessChange( + workspaceId: id, + panelId: panelId + ) + } + + private func invalidateProvisionalAgentHibernation(panelId: UUID) { + (panels[panelId] as? TerminalPanel)?.surface.invalidateProvisionalAgentHibernation() + } + + private func recordAgentLifecycleChange(panelId: UUID) { + recordAgentHibernationLifecycleChange(panelId: panelId) + } } diff --git a/Sources/Workspace+AgentTerminalStateDetection.swift b/Sources/Workspace+AgentTerminalStateDetection.swift new file mode 100644 index 000000000000..71bcd0cc3cdd --- /dev/null +++ b/Sources/Workspace+AgentTerminalStateDetection.swift @@ -0,0 +1,100 @@ +import CmuxTerminalCore +import Foundation + +extension AgentHibernationLifecycleStatusKeys { + static func resolvedStates( + _ panelStates: [String: AgentHibernationLifecycleState] + ) -> [AgentHibernationLifecycleState] { + var lifecycle = panelStates.filter { + !isManualKey($0.key) && !isDetectionKey($0.key) + } + var screen: [AgentHibernationLifecycleState] = [] + for (key, state) in panelStates where isDetectionKey(key) { + guard let familyID = detectionFamilyID(key: key), + let profile = AgentTerminalProfileCatalog.builtIn.profile(id: familyID) else { + screen.append(state) + continue + } + if profile.lifecycleAuthoritative { + if lifecycle[profile.statusKey] == nil { screen.append(state) } + } else { + lifecycle.removeValue(forKey: profile.statusKey) + screen.append(state) + } + } + return Array(lifecycle.values) + screen + } +} + +extension Workspace { + func setDetectedAgentLifecycle( + statusKey: String?, + familyID: String?, + panelId: UUID, + state: AgentTerminalSemanticState + ) { + guard panels[panelId] != nil else { return } + let oldEffective = agentHibernationLifecycleState(panelId: panelId, fallback: nil) + var states = agentLifecycleStatesByPanelId[panelId] ?? [:] + states = states.filter { !AgentHibernationLifecycleStatusKeys.isDetectionKey($0.key) } + if state != .unknown, let familyID, statusKey != nil { + states[AgentHibernationLifecycleStatusKeys.detectionKey(familyID: familyID)] = state.hibernationLifecycleState + } + if states.isEmpty { + agentLifecycleStatesByPanelId.removeValue(forKey: panelId) + } else { + agentLifecycleStatesByPanelId[panelId] = states + } + let newEffective = agentHibernationLifecycleState(panelId: panelId, fallback: nil) + if oldEffective != newEffective { recordDetectedAgentLifecycleChange(panelId: panelId) } + } + + func clearDetectedAgentLifecycle(panelId: UUID) { + setDetectedAgentLifecycle(statusKey: nil, familyID: nil, panelId: panelId, state: .unknown) + } + + func observedAgentTerminalState(panelId: UUID) -> (family: String?, state: String, source: String) { + let states = agentLifecycleStatesByPanelId[panelId] ?? [:] + let lifecycle = states.filter { + !AgentHibernationLifecycleStatusKeys.isManualKey($0.key) && + !AgentHibernationLifecycleStatusKeys.isDetectionKey($0.key) + } + if let detected = states.first(where: { AgentHibernationLifecycleStatusKeys.isDetectionKey($0.key) }) { + let familyID = AgentHibernationLifecycleStatusKeys.detectionFamilyID(key: detected.key) + let profile = familyID.flatMap { AgentTerminalProfileCatalog.builtIn.profile(id: $0) } + if profile?.lifecycleAuthoritative != true || lifecycle[profile?.statusKey ?? ""] == nil { + return (familyID, detected.value.semanticState.rawValue, "screen") + } + } + if !lifecycle.isEmpty { + return (lifecycle.keys.sorted().first, AgentHibernationLifecycleState.effective(lifecycle.values).rawValue, "lifecycle") + } + return (nil, AgentTerminalSemanticState.unknown.rawValue, "none") + } + + private func recordDetectedAgentLifecycleChange(panelId: UUID) { + recordAgentHibernationLifecycleChange(panelId: panelId) + } +} + +private extension AgentTerminalSemanticState { + var hibernationLifecycleState: AgentHibernationLifecycleState { + switch self { + case .unknown: .unknown + case .idle: .idle + case .working: .running + case .blocked: .needsInput + } + } +} + +private extension AgentHibernationLifecycleState { + var semanticState: AgentTerminalSemanticState { + switch self { + case .unknown: .unknown + case .idle: .idle + case .running: .working + case .needsInput: .blocked + } + } +} diff --git a/Sources/Workspace+ClosedPanelAgentHistory.swift b/Sources/Workspace+ClosedPanelAgentHistory.swift new file mode 100644 index 000000000000..6a3af34909e3 --- /dev/null +++ b/Sources/Workspace+ClosedPanelAgentHistory.swift @@ -0,0 +1,31 @@ +import Foundation + +extension Workspace { + /// Captures close history immediately, then fills a missing agent snapshot + /// after the shared index's off-main cold load finishes. + func pushClosedPanelHistoryEntryWithAgentEnrichment(_ entry: ClosedPanelHistoryEntry) { + let recordId = ClosedItemHistoryStore.shared.push(.panel(entry)) + guard entry.snapshot.terminal?.agent == nil else { return } + let workspaceId = entry.workspaceId + let panelId = entry.snapshot.id + // Root exit is recorded in memory before its queued disk write. Do not + // let a cold index refresh reattach the stale pre-exit record after the + // panel lifecycle cleanup removes this completion marker. + guard Self.closedPanelAgentEnrichmentAllowed( + resumeState: restoredAgentResumeStatesByPanelId[panelId] + ) else { return } + Task { @MainActor in + guard let index = await SharedLiveAgentIndex.shared.currentIndexAfterRefreshing(), + let agent = index.snapshot(workspaceId: workspaceId, panelId: panelId) else { + return + } + ClosedItemHistoryStore.shared.enrichClosedPanelAgent(recordId: recordId, agent: agent) + } + } + + static func closedPanelAgentEnrichmentAllowed( + resumeState: RestoredAgentResumeState? + ) -> Bool { + resumeState != .completedAgentExit + } +} diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 3d45434b0cdc..364b41393031 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -137,7 +137,7 @@ extension Workspace { if let panelId { recordAgentPIDOwnership(key: key, panelId: panelId) } else { removeAgentPIDOwnership(key: key) } if previous.pid != pid || previous.panelId != panelId || previous.identity != processIdentity { for changedPanelId in (previous.panelId == panelId ? [panelId] : [previous.panelId, panelId]).compactMap({ $0 }) { - AgentHibernationController.shared.recordAgentProcessChange(workspaceId: id, panelId: changedPanelId) + recordAgentHibernationProcessChange(panelId: changedPanelId) } } if refreshPorts { refreshTrackedAgentPorts() } @@ -271,7 +271,9 @@ extension Workspace { removeAgentPIDOwnership(key: key) didChange = true } - if let changedPanelId = ownedPanelId ?? panelId, didChange { AgentHibernationController.shared.recordAgentProcessChange(workspaceId: id, panelId: changedPanelId) } + if let changedPanelId = ownedPanelId ?? panelId, didChange { + recordAgentHibernationProcessChange(panelId: changedPanelId) + } if let lifecyclePanelId = ownedPanelId ?? panelId { let lifecycleStatusKey = agentStatusKey(forAgentPIDKey: key) if clearAgentLifecycle(key: lifecycleStatusKey, panelId: lifecyclePanelId) { @@ -291,6 +293,7 @@ extension Workspace { /// Clears a panel's restored agent snapshot and resume metadata. func clearRestoredAgentSnapshot(panelId: UUID) { + clearPendingRestoredAgentHibernationAdoption(panelId: panelId) restoredAgentSnapshotsByPanelId.removeValue(forKey: panelId) restoredAgentResumeStatesByPanelId.removeValue(forKey: panelId) restoredResumeSessionWorkingDirectoriesByPanelId.removeValue(forKey: panelId) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 0c4f0cf702e1..52960c17b7bf 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -48,6 +48,130 @@ private struct SessionPaneRestoreEntry { let snapshot: SessionPaneLayoutSnapshot } +fileprivate struct PendingRestoredAgentHibernationAdoption: Sendable { + let request: AgentHookSessionStateWriter.RestoredHibernationAdoptionRequest + let resumeWorkingDirectory: String? +} + +private struct RestoredAgentHibernationAdoptionIdentity: Hashable, Sendable { + let kind: String + let sessionId: String + let previousWorkspaceId: UUID? + let previousSurfaceId: UUID + let workspaceId: UUID + let surfaceId: UUID + let adoptionId: UUID + let resumeWorkingDirectory: String? + + init(_ adoption: PendingRestoredAgentHibernationAdoption) { + kind = adoption.request.agent.kind.rawValue + sessionId = adoption.request.agent.sessionId + previousWorkspaceId = adoption.request.previousWorkspaceId + previousSurfaceId = adoption.request.previousSurfaceId + workspaceId = adoption.request.workspaceId + surfaceId = adoption.request.surfaceId + adoptionId = adoption.request.adoptionId + resumeWorkingDirectory = adoption.resumeWorkingDirectory + } +} + +typealias RestoredAgentHibernationAdoptionWaitHandler = @Sendable ( + [AgentHookSessionStateWriter.RestoredHibernationAdoptionRequest] +) async -> [UUID: AgentHookSessionStateWriter.RestoredHibernationAdoptionOutcome] + +fileprivate struct AgentHibernationRestoreSuppressionState { + let wasRestoring: Bool + let presentationVisible: Bool + let presentationVisibilityWasUpdated: Bool +} + +private enum AgentHibernationResumeAuthority { + case untracked + case claim(AgentHookSessionStateWriter.HibernatedResumeAuthorityRequest) + case rejected +} + +private struct AgentHibernationResumeCandidate { + let panelId: UUID + let panel: TerminalPanel + let agent: SessionRestorableAgentSnapshot + let plan: AgentHibernationResumePlan + let authority: AgentHibernationResumeAuthority +} + +/// Owns one window restore's hibernation transfer. Every workspace remains +/// suppressed until the complete request set has crossed one adoption call, +/// which the writer then groups into one SQLite transaction per provider. +@MainActor +final class RestoredAgentHibernationAdoptionBatch { + typealias AdoptionHandler = ( + [AgentHookSessionStateWriter.RestoredHibernationAdoptionRequest] + ) -> [UUID: AgentHookSessionStateWriter.RestoredHibernationAdoptionOutcome] + + private struct Entry { + let workspace: Workspace + let pending: [PendingRestoredAgentHibernationAdoption] + let suppression: AgentHibernationRestoreSuppressionState + } + + private let adoptionHandler: AdoptionHandler + private var entries: [Entry] = [] + private var isFinalized = false + private(set) var adoptionOperationCount = 0 + + init( + adoptionHandler: @escaping AdoptionHandler = { + AgentHookSessionStateWriter.recordRestoredHibernationOutcomes($0) + } + ) { + self.adoptionHandler = adoptionHandler + } + + @discardableResult + fileprivate func append( + workspace: Workspace, + pending: [PendingRestoredAgentHibernationAdoption], + suppression: AgentHibernationRestoreSuppressionState + ) -> Bool { + guard !isFinalized else { return false } + entries.append(Entry( + workspace: workspace, + pending: pending, + suppression: suppression + )) + return true + } + + func finalize() { + guard !isFinalized else { return } + isFinalized = true + let entries = entries + self.entries.removeAll(keepingCapacity: false) + defer { + for entry in entries { + entry.workspace.endAgentHibernationRestoreSuppression(entry.suppression) + } + } + + let requests = entries.flatMap { $0.pending.map(\.request) } + let adoptionOutcomes: [ + UUID: AgentHookSessionStateWriter.RestoredHibernationAdoptionOutcome + ] + if requests.isEmpty { + adoptionOutcomes = [:] + } else { + adoptionOperationCount += 1 + adoptionOutcomes = adoptionHandler(requests) + } + for entry in entries { + entry.workspace.applyRestoredAgentHibernationAdoptions( + entry.pending, + outcomes: adoptionOutcomes + ) + } + } +} + extension Workspace { func sessionSnapshot( includeScrollback: Bool, @@ -150,10 +274,22 @@ extension Workspace { } @discardableResult - func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot, excludingStableIdentities: Set = []) -> [UUID: UUID] { + func restoreSessionSnapshot( + _ snapshot: SessionWorkspaceSnapshot, + excludingStableIdentities: Set = [], + restoredAgentHibernationAdoptionBatch: RestoredAgentHibernationAdoptionBatch? = nil, + agentCommandExecutableResolver providedAgentCommandExecutableResolver: AgentCommandExecutableResolver? = nil + ) -> [UUID: UUID] { let previousSuppressClosedPanelHistory = suppressClosedPanelHistory + let hibernationSuppression = beginAgentHibernationRestoreSuppression() + var transferredHibernationSuppression = false suppressClosedPanelHistory = true - defer { suppressClosedPanelHistory = previousSuppressClosedPanelHistory } + defer { + suppressClosedPanelHistory = previousSuppressClosedPanelHistory + if !transferredHibernationSuppression { + endAgentHibernationRestoreSuppression(hibernationSuppression) + } + } sessionRestoreIdentityExclusions.beginRestore(excluding: excludingStableIdentities) defer { sessionRestoreIdentityExclusions.endRestore() } @@ -172,6 +308,8 @@ extension Workspace { restoredAgentResumeStatesByPanelId.removeAll(keepingCapacity: false) invalidatedRestoredAgentFingerprintsByPanelId.removeAll(keepingCapacity: false) surfaceResumeBindingsByPanelId.removeAll(keepingCapacity: false) + cancelAllRestoredAgentHibernationAdoptionWaits() + pendingRestoredAgentHibernationAdoptionsByPanelId.removeAll(keepingCapacity: false) restoredGuardedWorkingDirectoriesByPanelId.removeAll(keepingCapacity: false) restoredResumeSessionWorkingDirectoriesByPanelId.removeAll(keepingCapacity: false) @@ -212,6 +350,10 @@ extension Workspace { return restoreSessionLayout(snapshot.layout) }() var oldToNewPanelIds: [UUID: UUID] = [:] + var pendingAgentHibernationAdoptions: [PendingRestoredAgentHibernationAdoption] = [] + var startupBreadcrumbEvents: [StartupBreadcrumbEvent] = [] + let agentCommandExecutableResolver = providedAgentCommandExecutableResolver + ?? AgentCommandExecutableResolver() for entry in leafEntries { restorePane( @@ -220,8 +362,27 @@ extension Workspace { panelSnapshotsById: panelSnapshotsById, snapshotWorkspaceId: snapshot.workspaceId, shouldRestoreSingleDefaultCloudTerminal: shouldRestoreSingleDefaultCloudTerminal, - oldToNewPanelIds: &oldToNewPanelIds + oldToNewPanelIds: &oldToNewPanelIds, + startupBreadcrumbEvents: &startupBreadcrumbEvents, + pendingAgentHibernationAdoptions: &pendingAgentHibernationAdoptions, + agentCommandExecutableResolver: agentCommandExecutableResolver + ) + } + if !startupBreadcrumbEvents.isEmpty { + startupBreadcrumbBatchWriter(startupBreadcrumbEvents) + } + + if let restoredAgentHibernationAdoptionBatch { + transferredHibernationSuppression = restoredAgentHibernationAdoptionBatch.append( + workspace: self, + pending: pendingAgentHibernationAdoptions, + suppression: hibernationSuppression ) + if !transferredHibernationSuppression { + finalizeRestoredAgentHibernationAdoptions(pendingAgentHibernationAdoptions) + } + } else { + finalizeRestoredAgentHibernationAdoptions(pendingAgentHibernationAdoptions) } pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) @@ -294,6 +455,275 @@ extension Workspace { return oldToNewPanelIds } + private func finalizeRestoredAgentHibernationAdoptions( + _ pending: [PendingRestoredAgentHibernationAdoption] + ) { + guard !pending.isEmpty else { return } + let outcomes = AgentHookSessionStateWriter.recordRestoredHibernationOutcomes( + pending.map(\.request) + ) + applyRestoredAgentHibernationAdoptions( + pending, + outcomes: outcomes + ) + } + + fileprivate func applyRestoredAgentHibernationAdoptions( + _ pending: [PendingRestoredAgentHibernationAdoption], + outcomes: [UUID: AgentHookSessionStateWriter.RestoredHibernationAdoptionOutcome] + ) { + for adoption in pending { + let request = adoption.request + switch outcomes[request.surfaceId] ?? .unavailable { + case .adopted: + pendingRestoredAgentHibernationAdoptionsByPanelId.removeValue( + forKey: request.surfaceId + ) + if let resumeDirectory = adoption.resumeWorkingDirectory? + .trimmingCharacters(in: .whitespacesAndNewlines), + !resumeDirectory.isEmpty { + restoredResumeSessionWorkingDirectoriesByPanelId[request.surfaceId] = resumeDirectory + } else { + restoredResumeSessionWorkingDirectoriesByPanelId.removeValue(forKey: request.surfaceId) + } + AgentChatTranscriptService.recordResumeIntent( + sessionID: request.agent.sessionId, + source: request.agent.kind.rawValue, + surfaceID: request.surfaceId.uuidString, + workspaceID: request.workspaceId.uuidString, + workingDirectory: adoption.resumeWorkingDirectory + ) + case .rejected: + pendingRestoredAgentHibernationAdoptionsByPanelId.removeValue( + forKey: request.surfaceId + ) + discardRejectedRestoredAgentHibernation( + panelId: request.surfaceId, + restoredAgent: request.agent + ) + case .unavailable: + pendingRestoredAgentHibernationAdoptionsByPanelId[request.surfaceId] = adoption + } + } + } + + private func retryPendingRestoredAgentHibernationAdoptions(panelIds: Set) { + let pending: [PendingRestoredAgentHibernationAdoption] = panelIds + .sorted { $0.uuidString < $1.uuidString } + .compactMap { panelId in + guard restoredAgentHibernationAdoptionWaitGroupIdByPanelId[panelId] == nil else { + return nil + } + return pendingRestoredAgentHibernationAdoptionsByPanelId[panelId] + } + guard !pending.isEmpty else { return } + applyRestoredAgentHibernationAdoptions( + pending, + outcomes: AgentHookSessionStateWriter.recordRestoredHibernationOutcomes( + pending.map { $0.request } + ) + ) + } + + /// Gives a transient SQLite writer one bounded chance to finish without + /// blocking the main actor. One task preserves the provider-batched restore + /// transaction for all newly visible panels, and panel/group ownership + /// prevents repeated visibility callbacks from scheduling duplicate claims. + private func scheduleRestoredAgentHibernationAdoptionWait(panelIds: Set) { + let visiblePanelIds = agentHibernationVisiblePanelIdsForCurrentLayout() + let pending: [PendingRestoredAgentHibernationAdoption] = panelIds + .intersection(visiblePanelIds) + .sorted { $0.uuidString < $1.uuidString } + .compactMap { panelId in + guard restoredAgentHibernationAdoptionWaitGroupIdByPanelId[panelId] == nil else { + return nil + } + return pendingRestoredAgentHibernationAdoptionsByPanelId[panelId] + } + guard !pending.isEmpty else { return } + + let groupId = UUID() + for adoption in pending { + restoredAgentHibernationAdoptionWaitGroupIdByPanelId[adoption.request.surfaceId] = groupId + } +#if DEBUG + debugRestoredAgentHibernationAdoptionWaitOperationCount += 1 + let waitHandler = debugRestoredAgentHibernationAdoptionWaitHandler ?? { + await AgentHookSessionStateWriter.waitForRestoredHibernationOutcomes($0) + } + let ownerReleasedHandler = debugRestoredAgentHibernationAdoptionWaitOwnerReleased +#else + let waitHandler: RestoredAgentHibernationAdoptionWaitHandler = { + await AgentHookSessionStateWriter.waitForRestoredHibernationOutcomes($0) + } +#endif + let requests = pending.map { $0.request } + let task = Task { @MainActor [weak self] in + let outcomes = await waitHandler(requests) + let wasCancelled = Task.isCancelled + if let self { + await self.finishRestoredAgentHibernationAdoptionWait( + groupId: groupId, + capturedPending: pending, + outcomes: outcomes, + wasCancelled: wasCancelled + ) + } else { + // Workspace teardown is also the app/window shutdown path. An + // adoption that already committed remains the next launch's + // restore authority. Explicit panel/workspace closes remove + // the panel while this owner is alive, then release the exact + // adoption generation through the normal finish path. +#if DEBUG + ownerReleasedHandler?() +#endif + } + } + restoredAgentHibernationAdoptionWaitTasksByGroupId[groupId] = task + } + + private func finishRestoredAgentHibernationAdoptionWait( + groupId: UUID, + capturedPending: [PendingRestoredAgentHibernationAdoption], + outcomes: [UUID: AgentHookSessionStateWriter.RestoredHibernationAdoptionOutcome], + wasCancelled: Bool + ) async { + guard restoredAgentHibernationAdoptionWaitTasksByGroupId.removeValue( + forKey: groupId + ) != nil else { return } + let groupWasCanceled = canceledRestoredAgentHibernationAdoptionWaitGroupIds.remove(groupId) != nil + + var applicable: [PendingRestoredAgentHibernationAdoption] = [] + var adoptedButNoLongerOwned: [ + AgentHookSessionStateWriter.RestoredHibernationAdoptionRequest + ] = [] + applicable.reserveCapacity(capturedPending.count) + for captured in capturedPending { + let panelId = captured.request.surfaceId + guard restoredAgentHibernationAdoptionWaitGroupIdByPanelId[panelId] == groupId else { + if outcomes[panelId] == .adopted { + adoptedButNoLongerOwned.append(captured.request) + } + continue + } + restoredAgentHibernationAdoptionWaitGroupIdByPanelId.removeValue(forKey: panelId) + guard panels[panelId] != nil, + let current = pendingRestoredAgentHibernationAdoptionsByPanelId[panelId], + RestoredAgentHibernationAdoptionIdentity(current) + == RestoredAgentHibernationAdoptionIdentity(captured) else { + if outcomes[panelId] == .adopted { + adoptedButNoLongerOwned.append(captured.request) + } + continue + } + applicable.append(current) + } + + applyRestoredAgentHibernationAdoptions(applicable, outcomes: outcomes) + let visiblePanelIds = agentHibernationVisiblePanelIdsForCurrentLayout() + let adoptedPanelIds = Set(applicable.compactMap { adoption in + outcomes[adoption.request.surfaceId] == .adopted + && visiblePanelIds.contains(adoption.request.surfaceId) + ? adoption.request.surfaceId + : nil + }) + if !adoptedPanelIds.isEmpty { + _ = resumeAgentHibernationPanels( + panelIds: adoptedPanelIds, + focusPanelId: nil + ) + } + await AgentHookSessionStateWriter.releaseCanceledRestoredHibernations( + adoptedButNoLongerOwned + ) + if wasCancelled || groupWasCanceled { + scheduleRestoredAgentHibernationAdoptionWait( + panelIds: Set(capturedPending.compactMap { adoption in + let panelId = adoption.request.surfaceId + return pendingRestoredAgentHibernationAdoptionsByPanelId[panelId] != nil + ? panelId + : nil + }) + ) + } +#if DEBUG + debugRestoredAgentHibernationAdoptionWaitDidFinish?(groupId) +#endif + } + + private func cancelRestoredAgentHibernationAdoptionWait( + containing panelId: UUID + ) { + guard let groupId = restoredAgentHibernationAdoptionWaitGroupIdByPanelId[panelId] else { + return + } + restoredAgentHibernationAdoptionWaitGroupIdByPanelId.removeValue(forKey: panelId) + canceledRestoredAgentHibernationAdoptionWaitGroupIds.insert(groupId) + restoredAgentHibernationAdoptionWaitTasksByGroupId[groupId]?.cancel() +#if DEBUG + debugRestoredAgentHibernationAdoptionWaitCancellationCount += 1 +#endif + // Remaining group members are rescheduled only after the canceled + // transaction finishes or rolls back, preventing an old cleanup from + // racing a replacement adoption generation. + } + + private func cancelAllRestoredAgentHibernationAdoptionWaits() { + let tasks = restoredAgentHibernationAdoptionWaitTasksByGroupId + canceledRestoredAgentHibernationAdoptionWaitGroupIds.formUnion(tasks.keys) + restoredAgentHibernationAdoptionWaitGroupIdByPanelId.removeAll(keepingCapacity: false) + for task in tasks.values { task.cancel() } + } + + private func beginAgentHibernationRestoreSuppression() -> AgentHibernationRestoreSuppressionState { + let state = AgentHibernationRestoreSuppressionState( + wasRestoring: isRestoringSessionSnapshot, + presentationVisible: agentHibernationAutoResumePresentationVisible, + presentationVisibilityWasUpdated: agentHibernationPresentationVisibilityWasUpdatedDuringSessionRestore + ) + isRestoringSessionSnapshot = true + agentHibernationAutoResumePresentationVisible = false + agentHibernationPresentationVisibilityWasUpdatedDuringSessionRestore = false + return state + } + + fileprivate func endAgentHibernationRestoreSuppression( + _ state: AgentHibernationRestoreSuppressionState + ) { + let presentationVisible = agentHibernationPresentationVisibilityWasUpdatedDuringSessionRestore + ? agentHibernationAutoResumePresentationVisible + : state.presentationVisible + isRestoringSessionSnapshot = state.wasRestoring + agentHibernationPresentationVisibilityWasUpdatedDuringSessionRestore = + state.presentationVisibilityWasUpdated + if state.wasRestoring { + agentHibernationAutoResumePresentationVisible = presentationVisible + agentHibernationPresentationVisibilityWasUpdatedDuringSessionRestore = true + } else { + setAgentHibernationAutoResumePresentationVisible( + presentationVisible, + retryPendingAdoptions: false + ) + } + } + + func clearRejectedRestoredAgentHibernationMetadata(panelId: UUID) { + cancelRestoredAgentHibernationAdoptionWait( + containing: panelId + ) + pendingRestoredAgentHibernationAdoptionsByPanelId.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + restoredGuardedWorkingDirectoriesByPanelId.removeValue(forKey: panelId) + restoredResumeSessionWorkingDirectoriesByPanelId.removeValue(forKey: panelId) + } + + func clearPendingRestoredAgentHibernationAdoption(panelId: UUID) { + cancelRestoredAgentHibernationAdoptionWait( + containing: panelId + ) + pendingRestoredAgentHibernationAdoptionsByPanelId.removeValue(forKey: panelId) + } + private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { switch node { case .pane(let pane): @@ -742,15 +1172,16 @@ extension Workspace { anchorPanelId: fallbackAnchorPanelId ) } - // Prefer the warm cached agent index over a synchronous `RestorableAgentSessionIndex.load()` - // (sysctl-per-record + disk, ~350ms-1.8s on machines with large agent history) so closing a - // tab does not freeze the main thread. Fall back to a fresh load only when the cache has not - // loaded yet (the brief window after launch before the first refresh completes; the cache is - // prewarmed at launch so this is rare). A cached entry at most one refresh stale is acceptable - // here because restore prefers the always-fresh in-memory resumeBinding and only consults this - // agent snapshot when no binding exists, so cmux-launched agents reopen correctly regardless of cache freshness. + // Prefer the warm cached agent index over a synchronous + // `RestorableAgentSessionIndex.load()` (sysctl-per-record + disk, ~350ms-1.8s on + // machines with large agent history) so closing a tab does not freeze the main + // thread. An empty index is safer than a synchronous cold load before + // the prewarmed cache finishes. A cached entry at most one refresh stale + // is acceptable here because restore prefers the always-fresh in-memory + // resumeBinding and only consults this agent snapshot when no binding exists, so + // cmux-launched agents reopen correctly regardless of cache freshness. let agentIndex = SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh() - ?? RestorableAgentSessionIndex.load() + ?? .empty let restorableAgentObservation = agentIndex.entry(workspaceId: id, panelId: panelId) guard let snapshot = sessionPanelSnapshot( panelId: panelId, @@ -795,7 +1226,7 @@ extension Workspace { guard let entry = closedPanelHistoryEntry(panelId: panelId, tabId: tab.id, pane: pane) else { return false } - ClosedItemHistoryStore.shared.push(.panel(entry)) + pushClosedPanelHistoryEntryWithAgentEnrichment(entry) return true } @@ -821,12 +1252,21 @@ extension Workspace { @discardableResult private func restoreClosedPanel(_ entry: ClosedPanelHistoryEntry, inPane pane: PaneID) -> UUID? { + let hibernationSuppression = beginAgentHibernationRestoreSuppression() + defer { endAgentHibernationRestoreSuppression(hibernationSuppression) } + var pendingAgentHibernationAdoptions: [PendingRestoredAgentHibernationAdoption] = [] + var discardedStartupBreadcrumbEvents: [StartupBreadcrumbEvent] = [] guard let panelId = createPanel( from: entry.snapshot, inPane: pane, - snapshotWorkspaceId: nil, - shouldRestoreSingleDefaultCloudTerminal: false + snapshotWorkspaceId: entry.workspaceId, + shouldRestoreSingleDefaultCloudTerminal: false, + recordsStartupBreadcrumb: false, + startupBreadcrumbEvents: &discardedStartupBreadcrumbEvents, + pendingAgentHibernationAdoptions: &pendingAgentHibernationAdoptions, + agentCommandExecutableResolver: AgentCommandExecutableResolver() ) else { return nil } + finalizeRestoredAgentHibernationAdoptions(pendingAgentHibernationAdoptions) let maxIndex = max(0, bonsplitController.tabs(inPane: pane).count - 1) _ = reorderSurface(panelId: panelId, toIndex: min(max(entry.tabIndex, 0), maxIndex)) @@ -841,6 +1281,8 @@ extension Workspace { @discardableResult private func restoreClosedPanelInFallbackSplit(_ entry: ClosedPanelHistoryEntry) -> UUID? { + let hibernationSuppression = beginAgentHibernationRestoreSuppression() + defer { endAgentHibernationRestoreSuppression(hibernationSuppression) } guard let placement = entry.fallbackSplitPlacement, let anchorPanelId = placement.anchorPanelId, panels[anchorPanelId] != nil else { @@ -860,15 +1302,22 @@ extension Workspace { return nil } + var pendingAgentHibernationAdoptions: [PendingRestoredAgentHibernationAdoption] = [] + var discardedStartupBreadcrumbEvents: [StartupBreadcrumbEvent] = [] guard let panelId = createPanel( from: entry.snapshot, inPane: pane, - snapshotWorkspaceId: nil, - shouldRestoreSingleDefaultCloudTerminal: false + snapshotWorkspaceId: entry.workspaceId, + shouldRestoreSingleDefaultCloudTerminal: false, + recordsStartupBreadcrumb: false, + startupBreadcrumbEvents: &discardedStartupBreadcrumbEvents, + pendingAgentHibernationAdoptions: &pendingAgentHibernationAdoptions, + agentCommandExecutableResolver: AgentCommandExecutableResolver() ) else { _ = closePanel(placeholderPanel.id, force: true) return nil } + finalizeRestoredAgentHibernationAdoptions(pendingAgentHibernationAdoptions) _ = closePanel(placeholderPanel.id, force: true) guard panels[panelId] != nil else { @@ -965,9 +1414,7 @@ extension Workspace { guard binding.checkpointId?.trimmingCharacters(in: .whitespacesAndNewlines) == restorableAgent.sessionId else { return binding } - if let bindingKind = binding.kind?.trimmingCharacters(in: .whitespacesAndNewlines), - !bindingKind.isEmpty, - RestorableAgentKind(rawValue: bindingKind) != restorableAgent.kind { + if !resumeBindingProviderMatches(binding.kind, agent: restorableAgent) { return binding } @@ -999,15 +1446,20 @@ extension Workspace { checkpointId != restorableAgent.sessionId { return nil } - if let kindValue = normalizedResumeBindingValue(resumeBinding.kind) { - guard let bindingKind = RestorableAgentKind(rawValue: kindValue), - bindingKind == restorableAgent.kind else { - return nil - } + if !resumeBindingProviderMatches(resumeBinding.kind, agent: restorableAgent) { + return nil } return restorableAgent } + nonisolated private static func resumeBindingProviderMatches( + _ rawKind: String?, + agent: SessionRestorableAgentSnapshot + ) -> Bool { + guard let kind = normalizedResumeBindingValue(rawKind) else { return true } + return kind == agent.kind.rawValue + } + nonisolated private static func normalizedResumeBindingValue(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { @@ -1155,7 +1607,10 @@ extension Workspace { panelSnapshotsById: [UUID: SessionPanelSnapshot], snapshotWorkspaceId: UUID?, shouldRestoreSingleDefaultCloudTerminal: Bool, - oldToNewPanelIds: inout [UUID: UUID] + oldToNewPanelIds: inout [UUID: UUID], + startupBreadcrumbEvents: inout [StartupBreadcrumbEvent], + pendingAgentHibernationAdoptions: inout [PendingRestoredAgentHibernationAdoption], + agentCommandExecutableResolver: AgentCommandExecutableResolver ) { let existingPanelIds = bonsplitController .tabs(inPane: paneId) @@ -1166,12 +1621,24 @@ extension Workspace { var createdPanelIds: [UUID] = [] for oldPanelId in desiredOldPanelIds { guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } - guard let createdPanelId = createPanel( + let createdPanelId = createPanel( from: panelSnapshot, inPane: paneId, snapshotWorkspaceId: snapshotWorkspaceId, - shouldRestoreSingleDefaultCloudTerminal: shouldRestoreSingleDefaultCloudTerminal - ) else { continue } + shouldRestoreSingleDefaultCloudTerminal: shouldRestoreSingleDefaultCloudTerminal, + recordsStartupBreadcrumb: true, + startupBreadcrumbEvents: &startupBreadcrumbEvents, + pendingAgentHibernationAdoptions: &pendingAgentHibernationAdoptions, + agentCommandExecutableResolver: agentCommandExecutableResolver + ) + if panelSnapshot.type != .terminal { + recordSessionRestorePanelBreadcrumb( + snapshot: panelSnapshot, + outcome: createdPanelId == nil ? "failed" : "created", + events: &startupBreadcrumbEvents + ) + } + guard let createdPanelId else { continue } createdPanelIds.append(createdPanelId) oldToNewPanelIds[oldPanelId] = createdPanelId } @@ -1250,7 +1717,11 @@ extension Workspace { from snapshot: SessionPanelSnapshot, inPane paneId: PaneID, snapshotWorkspaceId: UUID?, - shouldRestoreSingleDefaultCloudTerminal: Bool + shouldRestoreSingleDefaultCloudTerminal: Bool, + recordsStartupBreadcrumb: Bool, + startupBreadcrumbEvents: inout [StartupBreadcrumbEvent], + pendingAgentHibernationAdoptions: inout [PendingRestoredAgentHibernationAdoption], + agentCommandExecutableResolver: AgentCommandExecutableResolver ) -> UUID? { let restoresUntrustedSavedDirectory = snapshot.directoryIsTrustedRemoteReport != true && (snapshot.directoryRequiresRemoteTrust == true || @@ -1287,7 +1758,24 @@ extension Workspace { let restoresRemoteWorkspaceTerminalSnapshot = remoteStartupCommand != nil && (snapshot.terminal?.isRemoteTerminal != false || shouldRestoreSingleDefaultCloudTerminal) - let restoredBindingLaunch: SurfaceResumeStartupLaunch? = if restoresRemoteWorkspaceTerminalSnapshot { + let selectedAgentHookBinding = effectiveResumeBindingForStartup.flatMap { + $0.isAgentHookBinding ? $0 : nil + } + let shouldPreflightResumeExecutable = !restoresRemoteWorkspaceTerminalSnapshot + && shouldAutoResumeAgent + && restoredHibernation == nil + let selectedAgentHookBindingExecutableUnavailable = selectedAgentHookBinding.map { binding in + guard let descriptor = binding.agentHookExecutionDescriptor else { return true } + return agentCommandExecutableResolver.resolve(descriptor) == nil + } ?? false + let selectedBindingExecutableUnavailable = shouldPreflightResumeExecutable + && selectedAgentHookBinding != nil + && selectedAgentHookBindingExecutableUnavailable + let candidateRestoredBindingLaunch: SurfaceResumeStartupLaunch? = if selectedBindingExecutableUnavailable { + // Do not write an oversized launcher script for a command that + // cannot pass local executable preflight. + nil + } else if restoresRemoteWorkspaceTerminalSnapshot { effectiveResumeBindingForStartup?.remoteStartupInputWithLauncherScript(allowLauncherScript: false) .map(SurfaceResumeStartupLaunch.input) } else { @@ -1298,6 +1786,18 @@ extension Workspace { ) } } + let restorableAgentExecutableUnavailable = restorableAgent.map { agent in + guard let descriptor = agent.resumeExecutionDescriptor else { return true } + return agentCommandExecutableResolver.resolve(descriptor) == nil + } ?? false + let selectedRestorableAgentExecutableUnavailable = shouldPreflightResumeExecutable + && selectedAgentHookBinding == nil + && candidateRestoredBindingLaunch == nil + && restorableAgent != nil + && restorableAgentExecutableUnavailable + let resumeExecutableUnavailable = selectedBindingExecutableUnavailable + || selectedRestorableAgentExecutableUnavailable + let restoredBindingLaunch = candidateRestoredBindingLaunch let effectiveResumeBinding = restoredBindingLaunch == nil ? nil : resumeBinding let savedWorkingDirectory = effectiveResumeBinding?.cwd ?? (restoresUntrustedSavedDirectory ? nil : snapshot.terminal?.workingDirectory) @@ -1332,7 +1832,11 @@ extension Workspace { } let restoredTmuxStartCommand = restoredTmuxStartupScript == nil ? nil : restorableTmuxStartCommand let restoredAgentResumeLaunch: SurfaceResumeStartupLaunch? = - if shouldAutoResumeAgent && restoredHibernation == nil && restoredBindingLaunch == nil { + if shouldAutoResumeAgent + && restoredHibernation == nil + && restoredBindingLaunch == nil + && selectedAgentHookBinding == nil + && !resumeExecutableUnavailable { if restoresRemoteWorkspaceTerminalSnapshot { restorableAgent?.resumeStartupInput(allowLauncherScript: false, allowOversizedInlineInput: true) .map(SurfaceResumeStartupLaunch.input) @@ -1393,7 +1897,9 @@ extension Workspace { } let restoredStartupCommand = restoredRemotePTYAttachCommand - ?? restoredTmuxStartupScript?.path + ?? restoredTmuxStartupScript.map( + SessionRestoredTerminalCommandStore.launcherCommand(for:) + ) ?? restoredBindingLaunch?.initialCommand ?? restoredAgentResumeLaunch?.initialCommand let restoredStartupInput = restoredRemotePTYAttachCommand == nil @@ -1426,6 +1932,76 @@ extension Workspace { let restoredAgentWillRunStartupInput = restoredAgentResumeLaunch?.initialInput != nil || (restoredBindingLaunch?.initialInput != nil && resumeBinding?.isAgentHookBinding == true) + let bindingStatus: String + let bindingReason: String + if snapshot.terminal?.resumeBinding == nil { + bindingStatus = "missing" + bindingReason = "absent" + } else if restoredHibernation != nil { + bindingStatus = "found" + bindingReason = "hibernated" + } else if resumeBinding?.isProcessDetected == true, resumeBinding?.autoResume != true { + bindingStatus = "found" + bindingReason = "process_detected_manual" + } else if effectiveResumeBindingForStartup == nil { + bindingStatus = "rejected" + if !autoResumeAgentSessions { + bindingReason = "auto_resume_disabled" + } else if !agentWasRunningAtQuit { + bindingReason = "agent_not_running" + } else { + bindingReason = "policy_denied" + } + } else { + bindingStatus = "found" + bindingReason = "approved" + } + let resumeAction = restoredBindingLaunch != nil || restoredAgentResumeLaunch != nil + ? "issued" + : "suppressed" + let resumeMode: String = { + if restoredBindingLaunch?.initialCommand != nil || restoredAgentResumeLaunch?.initialCommand != nil { + return "command" + } + if restoredBindingLaunch?.initialInput != nil || restoredAgentResumeLaunch?.initialInput != nil { + return "input" + } + return "none" + }() + let resumeReason: String = { + if restoredBindingLaunch != nil { return "binding" } + if restoredAgentResumeLaunch != nil { return "agent" } + if restoredHibernation != nil { return "hibernated" } + if resumeExecutableUnavailable { return "resume_executable_unavailable" } + if snapshot.terminal?.resumeBinding != nil { + switch bindingReason { + case "auto_resume_disabled": return "auto_resume_disabled" + case "agent_not_running": return "agent_not_running" + case "process_detected_manual": return "process_detected" + case "policy_denied": return "binding_rejected" + default: return "binding_unlaunchable" + } + } + if restorableAgent != nil { + if !autoResumeAgentSessions { return "auto_resume_disabled" } + if !agentWasRunningAtQuit { return "agent_not_running" } + return "resume_unavailable" + } + return "no_candidate" + }() + let provider: String = { + let kind = restorableAgent?.kind ?? resumeBinding?.kind.flatMap(RestorableAgentKind.init(rawValue:)) + guard let kind else { return "unknown" } + return kind.customAgentID == nil ? kind.rawValue : "custom" + }() + let terminalBreadcrumbFields = [ + "binding": bindingStatus, + "bindingReason": bindingReason, + "resume": resumeAction, + "resumeReason": resumeReason, + "resumeMode": resumeMode, + "provider": provider, + ] #if DEBUG if let restorableAgent { let sessionPreview = String(restorableAgent.sessionId.prefix(8)) @@ -1475,35 +2051,25 @@ extension Workspace { restoredSurfaceId: reusableSurfaceId ) else { if let replayFileURL { try? FileManager.default.removeItem(at: replayFileURL) } + if recordsStartupBreadcrumb { + recordSessionRestorePanelBreadcrumb( + snapshot: snapshot, + outcome: "failed", + terminalFields: terminalBreadcrumbFields, + events: &startupBreadcrumbEvents + ) + } return nil } - terminalPanel.adoptOwnedSessionScrollbackReplayArtifact(replayFileURL) - // Re-bind the resumed agent session from cmux's own authority, keyed - // on the surface that was actually created. `terminalPanel.id` equals - // `snapshot.id` on the normal path, but on a surface-id collision - // (restore-into-live / duplicate-workspace) `newTerminalSurface` - // minted a fresh id, so keying on `snapshot.id` would bind to a - // surface that does not exist and the GUI would never find the - // session. This is unconditional on whether cmux runs the resume - // command itself: a restored surface that CARRIES a resumable agent - // binding must flip its registry record to live/.idle so the iOS GUI - // is editable, even when auto-resume is off and the user resumes - // manually (e.g. `sr codex resume`). Recording .idle here is the safe - // direction per the spec — never invent `ended`. - if let resumeReboundSession { - // The chat record's cwd feeds transcript-path resolution - // (Claude transcripts live under the project the agent ran - // in), so it must be the resume launcher's real target, not - // the persisted terminal cwd a stray report may have parked - // on home (#7155). - AgentChatTranscriptService.recordResumeIntent( - sessionID: resumeReboundSession.sessionID, - source: resumeReboundSession.source, - surfaceID: terminalPanel.id.uuidString, - workspaceID: id.uuidString, - workingDirectory: resumeSessionWorkingDirectory + if recordsStartupBreadcrumb { + recordSessionRestorePanelBreadcrumb( + snapshot: snapshot, + outcome: "created", + terminalFields: terminalBreadcrumbFields, + events: &startupBreadcrumbEvents ) } + terminalPanel.adoptOwnedSessionScrollbackReplayArtifact(replayFileURL) if let restoredRemotePTYSessionID { registerRemoteRelayIDAliases( remotePTYSessionID: restoredRemotePTYSessionID, @@ -1552,6 +2118,8 @@ extension Workspace { } else { restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) } + let shouldRecordResumeIntent = restoredHibernation == nil + && (restoredBindingLaunch != nil || restoredAgentResumeLaunch != nil) if let restorableAgent { seedSessionRestoredAgentState( panelId: terminalPanel.id, @@ -1559,13 +2127,32 @@ extension Workspace { willRunStartupCommand: restoredAgentWillRunStartupCommand, willRunStartupInput: restoredAgentWillRunStartupInput ) - if let restoredHibernation, - restorableAgent.resumeCommand != nil { - terminalPanel.enterAgentHibernation( - agent: restorableAgent, - lastActivityAt: Date(timeIntervalSince1970: restoredHibernation.lastActivityAt), - hibernatedAt: Date(timeIntervalSince1970: restoredHibernation.hibernatedAt) - ) + if let restoredHibernation { + let didEnterHibernation = restorableAgent.resumeCommand != nil && + terminalPanel.enterAgentHibernation( + agent: restorableAgent, + lastActivityAt: Date(timeIntervalSince1970: restoredHibernation.lastActivityAt), + hibernatedAt: Date(timeIntervalSince1970: restoredHibernation.hibernatedAt) + ) + if didEnterHibernation { + pendingAgentHibernationAdoptions.append( + PendingRestoredAgentHibernationAdoption( + request: .init( + agent: restorableAgent, + previousWorkspaceId: snapshotWorkspaceId, + previousSurfaceId: snapshot.id, + workspaceId: id, + surfaceId: terminalPanel.id + ), + resumeWorkingDirectory: resumeSessionWorkingDirectory + ) + ) + } else { + discardRejectedRestoredAgentHibernation( + panelId: terminalPanel.id, + restoredAgent: restorableAgent + ) + } } } else { seedSessionRestoredAgentState( @@ -1575,6 +2162,17 @@ extension Workspace { willRunStartupInput: restoredAgentWillRunStartupInput ) } + if shouldRecordResumeIntent, let resumeReboundSession { + // The chat record's cwd feeds Claude transcript resolution, so + // use the resume launcher's target rather than a stray shell cwd. + AgentChatTranscriptService.recordResumeIntent( + sessionID: resumeReboundSession.sessionID, + source: resumeReboundSession.source, + surfaceID: terminalPanel.id.uuidString, + workspaceID: id.uuidString, + workingDirectory: resumeSessionWorkingDirectory + ) + } // While an auto-resumed agent-hook or restorable-agent launcher // holds the pane's foreground no prompt runs, so a stray // post-restore report can park the tracked cwd on the surface @@ -1675,6 +2273,22 @@ extension Workspace { } } + private func recordSessionRestorePanelBreadcrumb( + snapshot: SessionPanelSnapshot, + outcome: String, + terminalFields: [String: String] = [:], + events: inout [StartupBreadcrumbEvent] + ) { + var fields = terminalFields + fields["panel"] = String(snapshot.id.uuidString.lowercased().prefix(8)) + fields["type"] = snapshot.type.rawValue + fields["outcome"] = outcome + if outcome == "failed" { + fields["failureReason"] = "panel_creation_failed" + } + events.append((event: "session.restore.panel", fields: fields)) + } + func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) { adoptPersistedStableSurfaceId(from: snapshot, panelId: panelId) @@ -2389,6 +3003,24 @@ final class Workspace: Identifiable, ObservableObject { set { restoredAgentLifecycle.snapshotsByPanelId = newValue } } var surfaceResumeBindingsByPanelId: [UUID: SurfaceResumeBindingSnapshot] = [:] + fileprivate var pendingRestoredAgentHibernationAdoptionsByPanelId: [ + UUID: PendingRestoredAgentHibernationAdoption + ] = [:] + private var restoredAgentHibernationAdoptionWaitTasksByGroupId: [ + UUID: Task + ] = [:] + private var restoredAgentHibernationAdoptionWaitGroupIdByPanelId: [UUID: UUID] = [:] + private var canceledRestoredAgentHibernationAdoptionWaitGroupIds: Set = [] +#if DEBUG + var debugRestoredAgentHibernationAdoptionWaitHandler: RestoredAgentHibernationAdoptionWaitHandler? + var debugRestoredAgentHibernationAdoptionWaitDidFinish: (@MainActor @Sendable (UUID) -> Void)? + var debugRestoredAgentHibernationAdoptionWaitOwnerReleased: (@MainActor @Sendable () -> Void)? + private(set) var debugRestoredAgentHibernationAdoptionWaitOperationCount = 0 + private(set) var debugRestoredAgentHibernationAdoptionWaitCancellationCount = 0 + var debugRestoredAgentHibernationAdoptionWaitInFlightCount: Int { + restoredAgentHibernationAdoptionWaitTasksByGroupId.count + } +#endif private var restoredGuardedWorkingDirectoriesByPanelId: [UUID: String] = [:] /// The session directory each restored auto-resume launcher targets, kept /// for the lifetime of the resumed run (unlike the one-shot report guard @@ -2410,6 +3042,7 @@ final class Workspace: Identifiable, ObservableObject { } private var pendingTerminalInputObserversByPanelId: [UUID: [WorkspacePendingTerminalInputObserver]] = [:] private let sessionRestorePolicy: WorkspaceSessionRestorePolicyService + private let startupBreadcrumbBatchWriter: StartupBreadcrumbBatchWriter typealias SurfaceResumeStartupLaunch = WorkspaceSurfaceResumeStartupLaunch @@ -2910,11 +3543,13 @@ final class Workspace: Identifiable, ObservableObject { agentSessionAutoResumeDefaults: UserDefaults = .standard, initialDetachedSurface: DetachedSurfaceTransfer? = nil, sessionRestorePolicy: WorkspaceSessionRestorePolicyService? = nil, + startupBreadcrumbBatchWriter: @escaping StartupBreadcrumbBatchWriter = { StartupBreadcrumbLog.append($0) }, sidebarProcessTitleObservation: WorkspaceSidebarProcessTitleObservationModel? = nil, nativeSSHConnectionBroker: NativeSSHConnectionBroker = NativeSSHConnectionBroker() ) { self.id = UUID() self.sessionRestorePolicy = sessionRestorePolicy ?? Self.makeSessionRestorePolicyService() + self.startupBreadcrumbBatchWriter = startupBreadcrumbBatchWriter self.sidebarProcessTitleObservation = sidebarProcessTitleObservation ?? WorkspaceSidebarProcessTitleObservationModel() self.nativeSSHConnectionBroker = nativeSSHConnectionBroker self.closeTabWarningDefaults = closeTabWarningDefaults @@ -3188,6 +3823,9 @@ final class Workspace: Identifiable, ObservableObject { private var sharedLiveAgentIndexObserver: NSObjectProtocol? deinit { + for task in restoredAgentHibernationAdoptionWaitTasksByGroupId.values { + task.cancel() + } for registrations in pendingTerminalInputObserversByPanelId.values { for registration in registrations { if let observer = registration.observer { @@ -3417,7 +4055,9 @@ final class Workspace: Identifiable, ObservableObject { private var layoutFollowUpStalledAttemptCount = 0 private var pendingReparentFocusSuppressionViews: [ObjectIdentifier: GhosttySurfaceScrollView] = [:] private var portalRenderingEnabled = true - private var agentHibernationAutoResumePresentationVisible = true + private var agentHibernationAutoResumePresentationVisible = false + private var isRestoringSessionSnapshot = false + private var agentHibernationPresentationVisibilityWasUpdatedDuringSessionRestore = false private var isAttemptingLayoutFollowUp = false private var isNormalizingPinnedTabOrder = false /// The pending non-focusing-split focus re-assert request (the value @@ -4575,6 +5215,11 @@ final class Workspace: Identifiable, ObservableObject { if let terminalPanel = panels[panelId] as? TerminalPanel { terminalPanel.updateShellActivityState(state) } + // Shell integration is one of the exact hibernation commit gates. + // Invalidate an in-flight teardown synchronously before any async + // native-free validation can claim it. + recordAgentHibernationLifecycleChange(panelId: panelId) + let rootExitCandidate = agentRootExitCandidate(panelId: panelId, previousState: previousState, state: state) if let restoredAgent = restoredAgentSnapshotsByPanelId[panelId] { updateRestoredAgentResumeState( panelId: panelId, @@ -4584,7 +5229,11 @@ final class Workspace: Identifiable, ObservableObject { } else { updateBindingOnlyRestoredAgentResumeState(panelId: panelId, shellState: state) } - if state == .promptIdle { _ = clearStaleAgentPIDs(panelId: panelId, refreshPorts: true) } + recordAgentRootExit(panelId: panelId, binding: rootExitCandidate, isPromptIdle: state == .promptIdle) + if state == .promptIdle { + _ = clearStaleAgentPIDs(panelId: panelId, refreshPorts: true) + clearDetectedAgentLifecycle(panelId: panelId) + } #if DEBUG cmuxDebugLog( "surface.shellState workspace=\(id.uuidString.prefix(5)) " + @@ -4613,16 +5262,105 @@ final class Workspace: Identifiable, ObservableObject { return snapshot } + /// Restores hibernation state for a surface whose native runtime has not + /// been created. Live hibernation uses the async overload below. + @discardableResult func enterAgentHibernation( panelId: UUID, agent: SessionRestorableAgentSnapshot, lastActivityAt: Date - ) { + ) -> Bool { guard let terminalPanel = panels[panelId] as? TerminalPanel, - !terminalPanel.isAgentHibernated else { - return + !terminalPanel.isAgentHibernated, + agent.resumeCommand != nil, + terminalPanel.enterAgentHibernation(agent: agent, lastActivityAt: lastActivityAt) else { + return false + } + commitAgentHibernationMetadata( + panelId: panelId, + agent: agent + ) + return true + } + + func enterAgentHibernation( + panelId: UUID, + agent: SessionRestorableAgentSnapshot, + lastActivityAt: Date, + finalValidation: @escaping @Sendable () async -> Bool, + finalTeardownPreparation: @escaping @Sendable () -> (@Sendable () -> Void)? = { {} }, + executableResolver: AgentCommandExecutableResolver = AgentCommandExecutableResolver() + ) async -> Bool { + guard let terminalPanel = panels[panelId] as? TerminalPanel, + !terminalPanel.isAgentHibernated, + agent.resumeCommand != nil, + let resumeExecutionDescriptor = agent.resumeExecutionDescriptor, + executableResolver.resolve(resumeExecutionDescriptor) != nil else { + return false + } + let agentHookBinding = surfaceResumeBindingsByPanelId[panelId].flatMap { + $0.isAgentHookBinding ? $0 : nil + } + if let agentHookBinding, + !agentHookBindingMatchesAgent(agentHookBinding, agent: agent) { + return false + } + let workspaceId = id + let requiresDurableAuthority = agentHookBinding != nil + let hibernationAttemptId = UUID() + let hibernatedAtTimestamp = max( + Date().timeIntervalSince1970, + agentHookBinding?.updatedAt ?? -.infinity + ) + let hibernatedAt = Date(timeIntervalSince1970: hibernatedAtTimestamp) + let didHibernate = await terminalPanel.enterAgentHibernation( + agent: agent, + lastActivityAt: lastActivityAt, + hibernatedAt: hibernatedAt, + finalValidation: finalValidation, + finalTeardownPreparation: finalTeardownPreparation, + finalCommit: { + guard requiresDurableAuthority else { return true } + return AgentHookSessionStateWriter.establishHibernatedAuthority( + agent: agent, + workspaceId: workspaceId, + surfaceId: panelId, + attemptId: hibernationAttemptId, + now: hibernatedAtTimestamp + ) == .acquired + }, + executableResolver: executableResolver + ) + guard didHibernate else { + if requiresDurableAuthority { + await AgentHookSessionStateWriter.releaseFailedHibernationAuthority( + agent: agent, + workspaceId: workspaceId, + surfaceId: panelId, + attemptId: hibernationAttemptId + ) + } + return false + } + commitAgentHibernationMetadata( + panelId: panelId, + agent: agent, + recordLifecycle: !requiresDurableAuthority + ) + if requiresDurableAuthority { + AgentHookSessionStateWriter.projectCanonicalLegacy(agent: agent) + } + if terminalPanel.surface.hasPendingInputForAgentHibernationResume { + _ = resumeAgentHibernation(panelId: panelId, focus: false) } - guard agent.resumeCommand != nil else { return } + return true + } + + private func commitAgentHibernationMetadata( + panelId: UUID, + agent: SessionRestorableAgentSnapshot, + recordLifecycle: Bool = true + ) { restoredAgentSnapshotsByPanelId[panelId] = agent restoredAgentResumeStatesByPanelId[panelId] = .manualResumeAvailable invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) @@ -4633,40 +5371,198 @@ final class Workspace: Identifiable, ObservableObject { if !keys.isEmpty { refreshTrackedAgentPorts() } - terminalPanel.enterAgentHibernation(agent: agent, lastActivityAt: lastActivityAt) + if recordLifecycle { + AgentHookSessionStateWriter.recordLifecycle(agent: agent, state: .hibernated) + } } @discardableResult func resumeAgentHibernation(panelId: UUID, focus: Bool) -> Bool { - guard let terminalPanel = panels[panelId] as? TerminalPanel, - terminalPanel.isAgentHibernated else { - return false + retryPendingRestoredAgentHibernationAdoptions(panelIds: [panelId]) + let didResume = resumeAgentHibernationPanels( + panelIds: [panelId], + focusPanelId: focus ? panelId : nil + ) + scheduleRestoredAgentHibernationAdoptionWait(panelIds: [panelId]) + return didResume + } + + @discardableResult + func resumeVisibleAgentHibernationPanels( + panelIds: Set, + retryPendingAdoptions: Bool = true, + preclaimedResumeAuthorityOutcomes: [ + UUID: AgentHookSessionStateWriter.HibernatedResumeAuthorityOutcome + ]? = nil, + authorityClaimHandler: ( + [AgentHookSessionStateWriter.HibernatedResumeAuthorityRequest] + ) -> [UUID: AgentHookSessionStateWriter.HibernatedResumeAuthorityOutcome] = { + AgentHookSessionStateWriter.acquireHibernatedResumeAuthorities($0) } - let preparation = terminalPanel.prepareAgentHibernationResume() - guard preparation.didResume else { return false } - if restoredAgentSnapshotsByPanelId[panelId] != nil { - restoredAgentResumeStatesByPanelId[panelId] = preparation.queuedStartupInput - ? .awaitingAutoResumeCommand - : .manualResumeAvailable - invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) + ) -> Bool { + if retryPendingAdoptions { + retryPendingRestoredAgentHibernationAdoptions(panelIds: panelIds) } - clearAgentLifecycleStates(panelId: panelId) - AgentHibernationController.shared.recordTerminalFocus(workspaceId: id, panelId: panelId) - if focus { - focusPanel(panelId) + let didResume = resumeAgentHibernationPanels( + panelIds: panelIds, + focusPanelId: nil, + preclaimedResumeAuthorityOutcomes: preclaimedResumeAuthorityOutcomes, + authorityClaimHandler: authorityClaimHandler + ) + scheduleRestoredAgentHibernationAdoptionWait(panelIds: panelIds) + return didResume + } + + private func agentHibernationResumeCandidate( + panelId: UUID, + executableResolver: AgentCommandExecutableResolver + ) -> AgentHibernationResumeCandidate? { + guard pendingRestoredAgentHibernationAdoptionsByPanelId[panelId] == nil, + let terminalPanel = panels[panelId] as? TerminalPanel, + terminalPanel.isAgentHibernated, + let hibernatedAgent = terminalPanel.agentHibernationState?.agent, + let plan = terminalPanel.agentHibernationResumePlan( + executableResolver: executableResolver + ) else { + return nil } - return true + guard let agentHookBinding = surfaceResumeBindingsByPanelId[panelId].flatMap({ + $0.isAgentHookBinding ? $0 : nil + }) else { + return AgentHibernationResumeCandidate( + panelId: panelId, + panel: terminalPanel, + agent: hibernatedAgent, + plan: plan, + authority: .untracked + ) + } + + let authority: AgentHibernationResumeAuthority = agentHookBindingMatchesAgent( + agentHookBinding, + agent: hibernatedAgent + ) + ? .claim(.init(agent: hibernatedAgent, workspaceId: id, surfaceId: panelId)) + : .rejected + return AgentHibernationResumeCandidate( + panelId: panelId, + panel: terminalPanel, + agent: hibernatedAgent, + plan: plan, + authority: authority + ) + } + + private func agentHookBindingMatchesAgent( + _ binding: SurfaceResumeBindingSnapshot, + agent: SessionRestorableAgentSnapshot + ) -> Bool { + let checkpointId = binding.checkpointId? + .trimmingCharacters(in: .whitespacesAndNewlines) + let kind = binding.kind? + .trimmingCharacters(in: .whitespacesAndNewlines) + return (checkpointId == nil || checkpointId == agent.sessionId) + && Self.resumeBindingProviderMatches(kind, agent: agent) } @discardableResult - func resumeVisibleAgentHibernationPanels(panelIds: Set) -> Bool { + private func resumeAgentHibernationPanels( + panelIds: Set, + focusPanelId: UUID?, + preclaimedResumeAuthorityOutcomes: [ + UUID: AgentHookSessionStateWriter.HibernatedResumeAuthorityOutcome + ]? = nil, + authorityClaimHandler: ( + [AgentHookSessionStateWriter.HibernatedResumeAuthorityRequest] + ) -> [UUID: AgentHookSessionStateWriter.HibernatedResumeAuthorityOutcome] = { + AgentHookSessionStateWriter.acquireHibernatedResumeAuthorities($0) + } + ) -> Bool { + let executableResolver = AgentCommandExecutableResolver() + let candidates = panelIds + .sorted { $0.uuidString < $1.uuidString } + .compactMap { + agentHibernationResumeCandidate( + panelId: $0, + executableResolver: executableResolver + ) + } + let authorityRequests = candidates.compactMap { candidate in + if case .claim(let request) = candidate.authority { return request } + return nil + } + let authorityOutcomes: [ + UUID: AgentHookSessionStateWriter.HibernatedResumeAuthorityOutcome + ] + if let preclaimedResumeAuthorityOutcomes { + authorityOutcomes = preclaimedResumeAuthorityOutcomes + } else if authorityRequests.isEmpty { + authorityOutcomes = [:] + } else { + authorityOutcomes = authorityClaimHandler(authorityRequests) + } + var didResume = false - for panelId in panelIds { - guard let terminalPanel = panels[panelId] as? TerminalPanel, - terminalPanel.isAgentHibernated else { + for candidate in candidates { + let requiresAuthority: Bool + let claimedRequest: AgentHookSessionStateWriter.HibernatedResumeAuthorityRequest? + let authorityOutcome: AgentHookSessionStateWriter.HibernatedResumeAuthorityOutcome + switch candidate.authority { + case .untracked: + requiresAuthority = false + claimedRequest = nil + authorityOutcome = .acquired + case .claim(let request): + requiresAuthority = true + claimedRequest = request + authorityOutcome = authorityOutcomes[candidate.panelId] ?? .unavailable + case .rejected: + requiresAuthority = true + claimedRequest = nil + authorityOutcome = .rejected + } + if authorityOutcome == .unavailable { + continue + } + guard authorityOutcome == .acquired else { + discardRejectedRestoredAgentHibernation( + panelId: candidate.panelId, + restoredAgent: candidate.agent + ) + if focusPanelId == candidate.panelId { focusPanel(candidate.panelId) } + continue + } + let preparation = candidate.panel.applyAgentHibernationResume( + candidate.plan, + hibernationResumeAttemptId: claimedRequest?.attemptId + ) + guard preparation.didResume else { + if let claimedRequest { + AgentHookSessionStateWriter.releaseFailedHibernatedResumeAuthority( + claimedRequest + ) + } continue } - didResume = resumeAgentHibernation(panelId: panelId, focus: false) || didResume + if !requiresAuthority { + AgentHookSessionStateWriter.recordLifecycle( + agent: candidate.agent, + state: .restoring + ) + } + if restoredAgentSnapshotsByPanelId[candidate.panelId] != nil { + restoredAgentResumeStatesByPanelId[candidate.panelId] = preparation.queuedStartupInput + ? .awaitingAutoResumeCommand + : .manualResumeAvailable + invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: candidate.panelId) + } + clearAgentLifecycleStates(panelId: candidate.panelId) + AgentHibernationController.shared.recordTerminalFocus( + workspaceId: id, + panelId: candidate.panelId + ) + if focusPanelId == candidate.panelId { focusPanel(candidate.panelId) } + didResume = true } return didResume } @@ -9489,10 +10385,13 @@ final class Workspace: Identifiable, ObservableObject { if let targetPaneId { let activationIntent = focusIntent ?? panels[panelId]?.preferredFocusIntentForActivation() + let suppressRestoredHibernationFocus = + isRestoringSessionSnapshot && + (panels[panelId] as? TerminalPanel)?.isAgentHibernated == true applyTabSelection( tabId: tabId, inPane: targetPaneId, - reassertAppKitFocus: !shouldSuppressReentrantRefocus, + reassertAppKitFocus: !shouldSuppressReentrantRefocus && !suppressRestoredHibernationFocus, focusIntent: activationIntent, resumeHibernatedAgent: true, previousTerminalHostedView: previousTerminalHostedView @@ -9724,11 +10623,28 @@ final class Workspace: Identifiable, ObservableObject { } } - func setAgentHibernationAutoResumePresentationVisible(_ isVisible: Bool) { - guard agentHibernationAutoResumePresentationVisible != isVisible else { return } + func setAgentHibernationAutoResumePresentationVisible( + _ isVisible: Bool, + retryPendingAdoptions: Bool = true + ) { agentHibernationAutoResumePresentationVisible = isVisible + if isRestoringSessionSnapshot { + agentHibernationPresentationVisibilityWasUpdatedDuringSessionRestore = true + return + } guard isVisible else { return } - _ = resumeVisibleAgentHibernationPanels(panelIds: agentHibernationVisiblePanelIdsForCurrentLayout()) + // Visibility can become true before a restore installs its hibernated + // placeholders. Treat every visible update as an idempotent sweep so + // that lifecycle ordering cannot strand a visible agent asleep. + _ = resumeVisibleAgentHibernationPanels( + panelIds: agentHibernationVisiblePanelIdsForCurrentLayout(), + retryPendingAdoptions: retryPendingAdoptions + ) + // Restored focus requests were intentionally suppressed while the + // selected terminal was hibernated. Resume (or authority-loss fallback) + // changes the hosted view, so converge model focus and first responder + // synchronously after that lifecycle transition. + reconcileFocusState() } // MARK: - Utility @@ -9849,8 +10765,14 @@ final class Workspace: Identifiable, ObservableObject { panel.unfocus() } - targetPanel.focus() - if let terminalPanel = targetPanel as? TerminalPanel { + let suppressHibernatedPanelFocus = + (targetPanel as? TerminalPanel)?.isAgentHibernated == true && + !agentHibernationAutoResumePresentationVisible + if !suppressHibernatedPanelFocus { + targetPanel.focus() + } + if let terminalPanel = targetPanel as? TerminalPanel, + !suppressHibernatedPanelFocus { terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId) } if let dir = panelDirectories[targetPanelId] { @@ -10438,7 +11360,14 @@ final class Workspace: Identifiable, ObservableObject { terminalPanel.hostedView.setVisibleInUI(shouldBeVisible) didChange = true } - let shouldBeActive = shouldBeVisible && focusedPanelId == terminalPanel.id && !rightSidebarOwnsFocus + let suppressHibernatedPanelActivation = + terminalPanel.isAgentHibernated && + !agentHibernationAutoResumePresentationVisible + let shouldBeActive = + shouldBeVisible && + focusedPanelId == terminalPanel.id && + !rightSidebarOwnsFocus && + !suppressHibernatedPanelActivation if terminalPanel.hostedView.debugPortalActive != shouldBeActive { terminalPanel.hostedView.setActive(shouldBeActive) didChange = true @@ -11393,6 +12322,9 @@ extension Workspace: BonsplitDelegate { guard let panel = panels[effectiveFocusedPanelId] else { return } + let suppressRestoredHibernationActivation = + isRestoringSessionSnapshot && + (panel as? TerminalPanel)?.isAgentHibernated == true if debugStressPreloadSelectionDepth > 0 { if let terminalPanel = panel as? TerminalPanel { @@ -11409,7 +12341,7 @@ extension Workspace: BonsplitDelegate { } // Selecting a hibernated tab means the user is visiting it again. Resume by // default so sidebar/tab selection behaves the same as pressing Resume. - let shouldResumeHibernatedAgent = resumeHibernatedAgent ?? true + let shouldResumeHibernatedAgent = (resumeHibernatedAgent ?? true) && !isRestoringSessionSnapshot let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation() panel.prepareFocusIntentForActivation(activationIntent) let panelId = effectiveFocusedPanelId @@ -11440,7 +12372,8 @@ extension Workspace: BonsplitDelegate { hideBrowserPortalsForDeselectedTabs(inPane: focusedPane, selectedTabId: selectedTabId) reconcileTerminalPortalVisibilityForCurrentRenderedLayout() - if let focusWindow = activationWindow(for: panel) { + if !suppressRestoredHibernationActivation, + let focusWindow = activationWindow(for: panel) { yieldForeignOwnedFocusIfNeeded( in: focusWindow, targetPanelId: panelId, @@ -11448,11 +12381,13 @@ extension Workspace: BonsplitDelegate { ) } - activatePanel( - panel, - focusIntent: activationIntent, - reassertAppKitFocus: reassertAppKitFocus - ) + if !suppressRestoredHibernationActivation { + activatePanel( + panel, + focusIntent: activationIntent, + reassertAppKitFocus: reassertAppKitFocus + ) + } let focusIntentAllowsBrowserOmnibarAutofocus = explicitFocusIntent || TerminalController.socketCommandAllowsInAppFocusMutations() @@ -11467,7 +12402,9 @@ extension Workspace: BonsplitDelegate { // Converge AppKit first responder with bonsplit's selected tab in the focused pane. // Without this, keyboard input can remain on a different terminal than the blue tab indicator. - if reassertAppKitFocus, let terminalPanel = panel as? TerminalPanel { + if reassertAppKitFocus, + !suppressRestoredHibernationActivation, + let terminalPanel = panel as? TerminalPanel { if shouldMoveTerminalSurfaceFocus(for: activationIntent) { if !terminalPanel.hostedView.isSurfaceViewFirstResponder() { #if DEBUG @@ -11491,7 +12428,8 @@ extension Workspace: BonsplitDelegate { } } - if shouldRestoreFocusIntentAfterActivation(activationIntent) { + if !suppressRestoredHibernationActivation, + shouldRestoreFocusIntentAfterActivation(activationIntent) { _ = panel.restoreFocusIntent(activationIntent) } @@ -12175,7 +13113,7 @@ extension Workspace: BonsplitDelegate { if !closedPanelIds.isEmpty { if !isDetachingCloseTransaction && !suppressClosedPanelHistory { for entry in closedHistoryEntries { - ClosedItemHistoryStore.shared.push(.panel(entry)) + pushClosedPanelHistoryEntryWithAgentEnrichment(entry) } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 0407b009ee4d..76b80ebe52be 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -242,9 +242,11 @@ struct cmuxApp: App { NSLog("%@", message) Darwin.exit(64) } - private static func configureGhosttyEnvironment() { let fileManager = FileManager.default + if !SessionRestorePolicy.isRunningUnderAutomatedTests() { + setenv("CMUX_RUNTIME_ID", TerminalSurface.managedCmuxRuntimeId, 1) + } let currentResourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap { String(cString: $0) } if let resolvedResourcesDir = resolvedGhosttyResourcesDirectory( currentValue: currentResourcesDir, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 075c905d233d..6bc1e8568511 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ A11CE0010000000000000001 /* AboutLicensesResourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11CE0020000000000000001 /* AboutLicensesResourceTests.swift */; }; A9E020000000000000000006 /* agent-session-react in Resources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000006 /* agent-session-react */; }; A9E020000000000000000007 /* agent-session-solid in Resources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000007 /* agent-session-solid */; }; + A78670140000000000000001 /* AgentActivitySnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670140000000000000002 /* AgentActivitySnapshot.swift */; }; + A78670600000000000000001 /* AgentActivitySnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670140000000000000002 /* AgentActivitySnapshot.swift */; }; C7AF10000000000000000005 /* AgentChatArtifactGalleryBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AF10000000000000000006 /* AgentChatArtifactGalleryBuilder.swift */; }; C7AF10000000000000000001 /* AgentChatArtifactIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AF10000000000000000002 /* AgentChatArtifactIndex.swift */; }; C7A52E000000000000000002 /* AgentChatEndedTranscriptListabilityCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A52E000000000000000001 /* AgentChatEndedTranscriptListabilityCache.swift */; }; @@ -37,6 +39,8 @@ C7A52F000000000000000002 /* AgentChatTranscriptService+Wire.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A52F000000000000000001 /* AgentChatTranscriptService+Wire.swift */; }; ACA7C4A7000000000000000B /* AgentChatTranscriptService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */; }; ACA7C4A70000000000000009 /* AgentChatTranscriptTailer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */; }; + A78670320000000000000001 /* AgentCmuxRuntimeIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670320000000000000002 /* AgentCmuxRuntimeIdentity.swift */; }; + A78670610000000000000001 /* AgentCmuxRuntimeIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670320000000000000002 /* AgentCmuxRuntimeIdentity.swift */; }; 79390005AA11BB22CC33DD05 /* AgentDeliveryTargetResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79390006AA11BB22CC33DD06 /* AgentDeliveryTargetResolution.swift */; }; A9F200000000000000000001 /* AgentExecutableResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000001 /* AgentExecutableResolver.swift */; }; A9F200000000000000000002 /* AgentExecutableResolverError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000002 /* AgentExecutableResolverError.swift */; }; @@ -59,6 +63,8 @@ D36A00010000000000000001 /* AgentHibernationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00010000000000000002 /* AgentHibernationController.swift */; }; D36A00030000000000000001 /* AgentHibernationLifecycleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00030000000000000002 /* AgentHibernationLifecycleState.swift */; }; D36A00030000000000000003 /* AgentHibernationLifecycleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00030000000000000002 /* AgentHibernationLifecycleState.swift */; }; + A79700060000000000000001 /* AgentHibernationLifecycleStateTestAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700060000000000000002 /* AgentHibernationLifecycleStateTestAlias.swift */; }; + A7867B010000000000000001 /* AgentHibernationOwnedLiveProcessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7867B010000000000000002 /* AgentHibernationOwnedLiveProcessTests.swift */; }; F65760160000000000000001 /* AgentHibernationPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F65760160000000000000002 /* AgentHibernationPlanner.swift */; }; F65760170000000000000001 /* AgentHibernationPlannerInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = F65760170000000000000002 /* AgentHibernationPlannerInput.swift */; }; F65760010000000000000001 /* AgentHibernationPlannerSwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F65760010000000000000002 /* AgentHibernationPlannerSwiftTests.swift */; }; @@ -81,12 +87,25 @@ A76110010000000000000001 /* AgentHookNotificationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76110010000000000000002 /* AgentHookNotificationPolicy.swift */; }; A76110010000000000000003 /* AgentHookNotificationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76110010000000000000002 /* AgentHookNotificationPolicy.swift */; }; A76110020000000000000001 /* AgentHookNotificationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76110020000000000000002 /* AgentHookNotificationPolicyTests.swift */; }; + A78670040000000000000001 /* AgentHookSessionLineage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670040000000000000002 /* AgentHookSessionLineage.swift */; }; + A78670620000000000000001 /* AgentHookSessionLineage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670040000000000000002 /* AgentHookSessionLineage.swift */; }; + A78670050000000000000001 /* AgentHookSessionLineageResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670050000000000000002 /* AgentHookSessionLineageResolver.swift */; }; + A78670630000000000000001 /* AgentHookSessionLineageResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670050000000000000002 /* AgentHookSessionLineageResolver.swift */; }; + A79700100000000000000001 /* AgentHookSessionRegistryBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700100000000000000002 /* AgentHookSessionRegistryBridge.swift */; }; + A79700100000000000000003 /* AgentHookSessionRegistryBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700100000000000000002 /* AgentHookSessionRegistryBridge.swift */; }; + A78670180000000000000003 /* AgentHookSessionStateWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670180000000000000002 /* AgentHookSessionStateWriter.swift */; }; + A78670520000000000000001 /* AgentHookSessionStoreCompletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670520000000000000002 /* AgentHookSessionStoreCompletion.swift */; }; + A79701110000000000000003 /* AgentHookSessionStoreCompletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670520000000000000002 /* AgentHookSessionStoreCompletion.swift */; }; + A78670240000000000000001 /* AgentHookSessionStoreModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670240000000000000002 /* AgentHookSessionStoreModels.swift */; }; + A78670640000000000000001 /* AgentHookSessionStoreModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670240000000000000002 /* AgentHookSessionStoreModels.swift */; }; + B7970F010000000000000001 /* AgentHookSessionStoreScaleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7970F010000000000000002 /* AgentHookSessionStoreScaleTests.swift */; }; 0A1107110000000000000010 /* AgentNotificationDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1107110000000000000011 /* AgentNotificationDelivery.swift */; }; A5D41234A1B2C3D4E5F60718 /* AgentNotificationGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41235A1B2C3D4E5F60718 /* AgentNotificationGate.swift */; }; A5D41230A1B2C3D4E5F60718 /* AgentNotificationGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41231A1B2C3D4E5F60718 /* AgentNotificationGateTests.swift */; }; 79390003AA11BB22CC33DD03 /* AgentNotificationLiveRetargetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79390004AA11BB22CC33DD04 /* AgentNotificationLiveRetargetTests.swift */; }; 7939000FAA11BB22CC33DD0F /* AgentNotificationMoveRaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79390010AA11BB22CC33DD10 /* AgentNotificationMoveRaceTests.swift */; }; 79460001AA11BB22CC33EE01 /* AgentNotificationMutationBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79460002AA11BB22CC33EE02 /* AgentNotificationMutationBoundaryTests.swift */; }; + A79700020000000000000001 /* AgentNotificationOwnershipRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700020000000000000002 /* AgentNotificationOwnershipRegressionTests.swift */; }; C3744E030000000000000001 /* AgentPIDProcessIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3744E040000000000000001 /* AgentPIDProcessIdentity.swift */; }; B79500130000000000000001 /* AgentPortPublicationHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500130000000000000002 /* AgentPortPublicationHistory.swift */; }; B79500120000000000000001 /* AgentPortRootIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500120000000000000002 /* AgentPortRootIdentity.swift */; }; @@ -95,26 +114,59 @@ B79500040000000000000001 /* AgentPortScanRootInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500040000000000000002 /* AgentPortScanRootInput.swift */; }; B79500140000000000000001 /* AgentPortSnapshotReplacementState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500140000000000000002 /* AgentPortSnapshotReplacementState.swift */; }; B79500110000000000000001 /* AgentPortTrackingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500110000000000000002 /* AgentPortTrackingState.swift */; }; + A78670010000000000000001 /* AgentProcessIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670010000000000000002 /* AgentProcessIdentity.swift */; }; + A78670650000000000000001 /* AgentProcessIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670010000000000000002 /* AgentProcessIdentity.swift */; }; 0A1107110000000000000012 /* AgentRelaunchCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1107110000000000000013 /* AgentRelaunchCommandBuilder.swift */; }; 0A1107110000000000000014 /* AgentRestoreMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1107110000000000000015 /* AgentRestoreMode.swift */; }; + A78670510000000000000001 /* AgentResumeExecutableAvailabilityRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670510000000000000002 /* AgentResumeExecutableAvailabilityRegressionTests.swift */; }; + B78672A00000000000000001 /* AgentRuntimeOwnershipProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = B78672A00000000000000002 /* AgentRuntimeOwnershipProbe.swift */; }; + A79700040000000000000001 /* AgentSessionAuthorityEvidence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700040000000000000002 /* AgentSessionAuthorityEvidence.swift */; }; + A79700050000000000000001 /* AgentSessionAuthorityEvidence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700040000000000000002 /* AgentSessionAuthorityEvidence.swift */; }; D3610B010000000000000001 /* AgentSessionAutoResumeSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3610B010000000000000002 /* AgentSessionAutoResumeSettingsTests.swift */; }; D3610B020000000000000001 /* AgentSessionAutoResumeSwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3610B020000000000000002 /* AgentSessionAutoResumeSwiftTests.swift */; }; A9E020000000000000000008 /* AgentSessionBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000008 /* AgentSessionBridge.swift */; }; A9F20000000000000000000B /* AgentSessionBridgeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F10000000000000000000B /* AgentSessionBridgeError.swift */; }; A9F20000000000000000000C /* AgentSessionBridgeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F10000000000000000000C /* AgentSessionBridgeRequest.swift */; }; + A78670220000000000000001 /* AgentSessionCLIRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670220000000000000002 /* AgentSessionCLIRegressionTests.swift */; }; A9F200000000000000000003 /* AgentSessionDebugMenuButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000003 /* AgentSessionDebugMenuButtons.swift */; }; + A78670070000000000000001 /* AgentSessionGraphEdge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670070000000000000002 /* AgentSessionGraphEdge.swift */; }; + A78671200000000000000003 /* AgentSessionGraphEdge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670070000000000000002 /* AgentSessionGraphEdge.swift */; }; + A78671300000000000000003 /* AgentSessionGraphEdgeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700090000000000000002 /* AgentSessionGraphEdgeResolver.swift */; }; + A79700090000000000000001 /* AgentSessionGraphEdgeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700090000000000000002 /* AgentSessionGraphEdgeResolver.swift */; }; + A78670060000000000000001 /* AgentSessionGraphNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670060000000000000002 /* AgentSessionGraphNode.swift */; }; + A78671400000000000000003 /* AgentSessionGraphNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670060000000000000002 /* AgentSessionGraphNode.swift */; }; + A78670080000000000000001 /* AgentSessionGraphSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670080000000000000002 /* AgentSessionGraphSnapshot.swift */; }; + A78671500000000000000003 /* AgentSessionGraphSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670080000000000000002 /* AgentSessionGraphSnapshot.swift */; }; + A78670230000000000000001 /* AgentSessionHibernationLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670230000000000000002 /* AgentSessionHibernationLifecycleTests.swift */; }; A9F20000000000000000001B /* AgentSessionInputWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F10000000000000000001B /* AgentSessionInputWriter.swift */; }; A9F200000000000000000004 /* AgentSessionLaunchPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000004 /* AgentSessionLaunchPlan.swift */; }; + A78670300000000000000001 /* AgentSessionLifecycleAuthorityRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670300000000000000002 /* AgentSessionLifecycleAuthorityRegressionTests.swift */; }; + A78670270000000000000001 /* AgentSessionLifecycleFlowDocumentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670270000000000000002 /* AgentSessionLifecycleFlowDocumentation.swift */; }; + A79700030000000000000001 /* AgentSessionLifecycleRaceRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700030000000000000002 /* AgentSessionLifecycleRaceRegressionTests.swift */; }; + A79701020000000000000001 /* AgentSessionLineageAuthorityRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79701020000000000000002 /* AgentSessionLineageAuthorityRegressionTests.swift */; }; A9F20000000000000000000E /* AgentSessionOutputLineBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F10000000000000000000E /* AgentSessionOutputLineBuffer.swift */; }; A9E020000000000000000002 /* AgentSessionPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000002 /* AgentSessionPanel.swift */; }; A9E020000000000000000004 /* AgentSessionPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000004 /* AgentSessionPanelView.swift */; }; A9F20000000000000000000F /* AgentSessionPermissionMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F10000000000000000000F /* AgentSessionPermissionMode.swift */; }; A9E020000000000000000009 /* AgentSessionProcessStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000009 /* AgentSessionProcessStore.swift */; }; + A7867A100000000000000001 /* AgentSessionProjectionParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7867A100000000000000002 /* AgentSessionProjectionParityTests.swift */; }; A9E020000000000000000001 /* AgentSessionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000001 /* AgentSessionProvider.swift */; }; + A78670020000000000000001 /* AgentSessionRelationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670020000000000000002 /* AgentSessionRelationship.swift */; }; + A78670660000000000000001 /* AgentSessionRelationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670020000000000000002 /* AgentSessionRelationship.swift */; }; A9E02000000000000000000F /* AgentSessionRendererKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E01000000000000000000F /* AgentSessionRendererKind.swift */; }; A9F200000000000000000010 /* AgentSessionRunningSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000010 /* AgentSessionRunningSession.swift */; }; + A78670030000000000000001 /* AgentSessionRunRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670030000000000000002 /* AgentSessionRunRecord.swift */; }; + A78670670000000000000001 /* AgentSessionRunRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670030000000000000002 /* AgentSessionRunRecord.swift */; }; + A79701030000000000000001 /* AgentSessionRuntimeIdentityRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79701030000000000000002 /* AgentSessionRuntimeIdentityRegressionTests.swift */; }; + A78670310000000000000001 /* AgentSessionRuntimeScopingRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670310000000000000002 /* AgentSessionRuntimeScopingRegressionTests.swift */; }; + A79701040000000000000001 /* AgentSessionSocketRuntimeScopingRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79701040000000000000002 /* AgentSessionSocketRuntimeScopingRegressionTests.swift */; }; A9E030000000000000000002 /* AgentSessionSocketSurfaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E030000000000000000001 /* AgentSessionSocketSurfaceTests.swift */; }; A9F200000000000000000011 /* AgentSessionStartedSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000011 /* AgentSessionStartedSession.swift */; }; + A78670130000000000000001 /* AgentSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670130000000000000002 /* AgentSessionState.swift */; }; + A78670130000000000000003 /* AgentSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670130000000000000002 /* AgentSessionState.swift */; }; + A78670700000000000000001 /* AgentSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670130000000000000002 /* AgentSessionState.swift */; }; + A78670150000000000000001 /* AgentSessionStateProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670150000000000000002 /* AgentSessionStateProjection.swift */; }; + A78670680000000000000001 /* AgentSessionStateProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670150000000000000002 /* AgentSessionStateProjection.swift */; }; A9E02000000000000000000A /* AgentSessionStreamAccumulators.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E01000000000000000000A /* AgentSessionStreamAccumulators.swift */; }; A9F200000000000000000012 /* AgentSessionWebHostGeometryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000012 /* AgentSessionWebHostGeometryState.swift */; }; A9E02000000000000000000B /* AgentSessionWebHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E01000000000000000000B /* AgentSessionWebHostView.swift */; }; @@ -124,6 +176,37 @@ A9E050000000000000000002 /* AgentSessionWebRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E050000000000000000001 /* AgentSessionWebRendererTests.swift */; }; A9E02000000000000000000D /* AgentSessionWebTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E01000000000000000000D /* AgentSessionWebTheme.swift */; }; A9F200000000000000000014 /* AgentSessionWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000014 /* AgentSessionWebView.swift */; }; + A78670160000000000000001 /* AgentSessionWorkloadReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670160000000000000002 /* AgentSessionWorkloadReconciler.swift */; }; + A78670690000000000000001 /* AgentSessionWorkloadReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670160000000000000002 /* AgentSessionWorkloadReconciler.swift */; }; + A79701050000000000000001 /* AgentSessionWriterGenerationRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79701050000000000000002 /* AgentSessionWriterGenerationRegressionTests.swift */; }; + A71860010000000000000001 /* AgentStagedOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71860010000000000000002 /* AgentStagedOutput.swift */; }; + A71860010000000000000003 /* AgentStagedOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71860010000000000000002 /* AgentStagedOutput.swift */; }; + A78670210000000000000001 /* AgentStopStateAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670210000000000000002 /* AgentStopStateAdapter.swift */; }; + A786706A0000000000000001 /* AgentStopStateAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670210000000000000002 /* AgentStopStateAdapter.swift */; }; + A78670200000000000000001 /* AgentSubtreeActivitySnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670200000000000000002 /* AgentSubtreeActivitySnapshot.swift */; }; + A78671800000000000000003 /* AgentSubtreeActivitySnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670200000000000000002 /* AgentSubtreeActivitySnapshot.swift */; }; + FEA700020000000000000001 /* AgentTerminalClassificationWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA700020000000000000002 /* AgentTerminalClassificationWorker.swift */; }; + A78671600000000000000003 /* AgentTerminalObservationJoiner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A83100010000000000000002 /* AgentTerminalObservationJoiner.swift */; }; + A83100010000000000000001 /* AgentTerminalObservationJoiner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A83100010000000000000002 /* AgentTerminalObservationJoiner.swift */; }; + FEA700040000000000000001 /* AgentTerminalStateRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA700040000000000000002 /* AgentTerminalStateRuntime.swift */; }; + A57A7E010000000000000001 /* AgentTerminalStateRuntimeLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57A7E010000000000000002 /* AgentTerminalStateRuntimeLifecycleTests.swift */; }; + FEA700030000000000000001 /* AgentTerminalStateSurfaceObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA700030000000000000002 /* AgentTerminalStateSurfaceObserver.swift */; }; + C386C7FA4103F812B0A00001 /* AgentTerminalSurfaceTaskSequencer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C386C7FA4103F812B0A00002 /* AgentTerminalSurfaceTaskSequencer.swift */; }; + A78671900000000000000001 /* AgentTreeTextLineSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78671900000000000000002 /* AgentTreeTextLineSequence.swift */; }; + A78671900000000000000003 /* AgentTreeTextLineSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78671900000000000000002 /* AgentTreeTextLineSequence.swift */; }; + A79701140000000000000001 /* AgentVisibleMutationOwnershipAgentName.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79701140000000000000002 /* AgentVisibleMutationOwnershipAgentName.swift */; }; + A79701140000000000000003 /* AgentVisibleMutationOwnershipAgentName.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79701140000000000000002 /* AgentVisibleMutationOwnershipAgentName.swift */; }; + A78670100000000000000001 /* AgentWorkloadKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670100000000000000002 /* AgentWorkloadKind.swift */; }; + A78670100000000000000003 /* AgentWorkloadKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670100000000000000002 /* AgentWorkloadKind.swift */; }; + A78670710000000000000001 /* AgentWorkloadKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670100000000000000002 /* AgentWorkloadKind.swift */; }; + A78670110000000000000001 /* AgentWorkloadPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670110000000000000002 /* AgentWorkloadPhase.swift */; }; + A78670110000000000000003 /* AgentWorkloadPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670110000000000000002 /* AgentWorkloadPhase.swift */; }; + A78670720000000000000001 /* AgentWorkloadPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670110000000000000002 /* AgentWorkloadPhase.swift */; }; + A78670120000000000000001 /* AgentWorkloadRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670120000000000000002 /* AgentWorkloadRecord.swift */; }; + A78670120000000000000003 /* AgentWorkloadRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670120000000000000002 /* AgentWorkloadRecord.swift */; }; + A78670730000000000000001 /* AgentWorkloadRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670120000000000000002 /* AgentWorkloadRecord.swift */; }; + A78670190000000000000001 /* AgentWorkloadSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670190000000000000002 /* AgentWorkloadSnapshot.swift */; }; + A78671700000000000000003 /* AgentWorkloadSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670190000000000000002 /* AgentWorkloadSnapshot.swift */; }; REE0CA0000000000000000F2 /* AIAccountCredentialSources.swift in Sources */ = {isa = PBXBuildFile; fileRef = REE0CA0000000000000000F1 /* AIAccountCredentialSources.swift */; }; REE0CA0000000000000000E2 /* AIAccountsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = REE0CA0000000000000000E1 /* AIAccountsClient.swift */; }; A115C0DE0000000000000002 /* AllShortcutsPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = A115C0DE0000000000000001 /* AllShortcutsPopover.swift */; }; @@ -200,6 +283,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 7C592156DCB3419BB95383E5 /* AutoNamingEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7752D68A758642EFA282C208 /* AutoNamingEngineTests.swift */; }; A4ECF22C62724853A72D6BDE /* AutoNamingGrokAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07EC52007B2144C59C047774 /* AutoNamingGrokAdapterTests.swift */; }; 74AB3F34FE3B4974A3A5D264 /* AutoNamingHookPayloadAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46792DAA478444ED87B17B31 /* AutoNamingHookPayloadAdapterTests.swift */; }; + A78670500000000000000001 /* AutoNamingTranscriptMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670500000000000000002 /* AutoNamingTranscriptMessage.swift */; }; + A78670500000000000000003 /* AutoNamingTranscriptMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670500000000000000002 /* AutoNamingTranscriptMessage.swift */; }; C0DE35860000000000000001 /* BackgroundWorkspacePrimeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35860000000000000002 /* BackgroundWorkspacePrimeCoordinator.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; B057B0017E57B0017E57B001 /* Bonsplit in Frameworks */ = {isa = PBXBuildFile; productRef = A5001262 /* Bonsplit */; }; @@ -405,13 +490,17 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources ED226C59A21547BB962DB2A5 /* ChecklistSummaryPopoverModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 319C469164C74F2F8E41D2C3 /* ChecklistSummaryPopoverModifier.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */; }; + A78670170000000000000001 /* ClaudeAgentWorkloadAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670170000000000000002 /* ClaudeAgentWorkloadAdapter.swift */; }; A5D41232A1B2C3D4E5F60718 /* ClaudeBackgroundWorkNotifyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41233A1B2C3D4E5F60718 /* ClaudeBackgroundWorkNotifyTests.swift */; }; + A78670280000000000000001 /* ClaudeChildSessionObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670280000000000000002 /* ClaudeChildSessionObserver.swift */; }; C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */; }; C7A534000000000000000002 /* ClaudeHookFeedTelemetrySwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A534000000000000000001 /* ClaudeHookFeedTelemetrySwiftTests.swift */; }; 7939000BAA11BB22CC33DD0B /* ClaudeHookLifecycleCleanupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7939000CAA11BB22CC33DD0C /* ClaudeHookLifecycleCleanupTests.swift */; }; 79390001AA11BB22CC33DD01 /* ClaudeHookLiveDeliveryTargetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79390002AA11BB22CC33DD02 /* ClaudeHookLiveDeliveryTargetTests.swift */; }; 79390009AA11BB22CC33DD09 /* ClaudeHookLiveDeliveryTargetTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7939000AAA11BB22CC33DD0A /* ClaudeHookLiveDeliveryTargetTestSupport.swift */; }; 79390011AA11BB22CC33DD11 /* ClaudeHookPIDAuthenticationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79390012AA11BB22CC33DD12 /* ClaudeHookPIDAuthenticationTests.swift */; }; + A79701100000000000000001 /* ClaudeHookSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79701100000000000000002 /* ClaudeHookSessionStore.swift */; }; + A79701100000000000000003 /* ClaudeHookSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79701100000000000000002 /* ClaudeHookSessionStore.swift */; }; A5D41220A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */; }; A5D41222A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */; }; C7A533000000000000000002 /* ClaudeSessionCanonicalizationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A533000000000000000001 /* ClaudeSessionCanonicalizationContext.swift */; }; @@ -420,10 +509,11 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */; }; 805500000000000000000001 /* CLIBrowserEvalOutputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805500000000000000000002 /* CLIBrowserEvalOutputTests.swift */; }; CA11E4D0FA017DE500000B101 /* CLICallerWorkspaceDefaultTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E4D0FA017DE500000F102 /* CLICallerWorkspaceDefaultTests.swift */; }; + C0D3F1F20000000000000103 /* CLICodexHookSessionStartRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F20000000000000104 /* CLICodexHookSessionStartRegressionTests.swift */; }; C0D3F1F00000000000000103 /* CLICodexHookTimeoutRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F00000000000000104 /* CLICodexHookTimeoutRegressionTests.swift */; }; C0D3F1F10000000000000103 /* CLICodexHookTimeoutRegressionTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F10000000000000104 /* CLICodexHookTimeoutRegressionTestSupport.swift */; }; C6711A010000000000000001 /* CLICodexWeakEnvironmentRestoreBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6711B010000000000000001 /* CLICodexWeakEnvironmentRestoreBindingTests.swift */; }; - C0D3F1F20000000000000103 /* CLICodexYoloResumePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F20000000000000104 /* CLICodexYoloResumePersistenceTests.swift */; }; + C0D3F1F30000000000000103 /* CLICodexYoloResumePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F30000000000000104 /* CLICodexYoloResumePersistenceTests.swift */; }; E295EA3753206FE3FFCA6C0A /* CLIExplicitSurfaceRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1590EE4F01FEF02D40A48698 /* CLIExplicitSurfaceRoutingTests.swift */; }; C46790000000000000000001 /* CLIForwardingLaunchArgumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46790000000000000000002 /* CLIForwardingLaunchArgumentTests.swift */; }; C46790000000000000000003 /* CLIForwardingLaunchRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */; }; @@ -432,6 +522,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A5D41211A1B2C3D4E5F60718 /* CLIHookNoResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41212A1B2C3D4E5F60718 /* CLIHookNoResponseTests.swift */; }; A5D4120BA1B2C3D4E5F60718 /* CLILegacyHookAliasTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4120CA1B2C3D4E5F60718 /* CLILegacyHookAliasTests.swift */; }; A5D41330A1B2C3D4E5F60718 /* CLIMockSocketServerSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41331A1B2C3D4E5F60718 /* CLIMockSocketServerSupport.swift */; }; + A79701010000000000000001 /* CLINestedAgentLifecycleRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79701010000000000000002 /* CLINestedAgentLifecycleRegressionTests.swift */; }; F0F0CF0E0000000000000001 /* CLINotifyClaudeForkOfForkRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F0CF0E0000000000000002 /* CLINotifyClaudeForkOfForkRegressionTests.swift */; }; C72280000000000000000004 /* CLINotifyClaudeHookWorkspaceRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72280000000000000000003 /* CLINotifyClaudeHookWorkspaceRoutingTests.swift */; }; A5D41203A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41204A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift */; }; @@ -451,7 +542,9 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 773600000000000000000001 /* CLIWindowCommandMockServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 773600000000000000000002 /* CLIWindowCommandMockServer.swift */; }; 773600000000000000000003 /* CLIWindowHandleRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 773600000000000000000004 /* CLIWindowHandleRoutingTests.swift */; }; C10D51700000000000000002 /* ClosedItemHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D51700000000000000001 /* ClosedItemHistory.swift */; }; + A797000B0000000000000001 /* ClosedItemHistoryStore+AgentEnrichment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A797000B0000000000000002 /* ClosedItemHistoryStore+AgentEnrichment.swift */; }; 7375A0037375A0037375A003 /* ClosedMainWindowRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7375A0047375A0047375A004 /* ClosedMainWindowRoutingTests.swift */; }; + A79700080000000000000001 /* ClosedPanelAgentHistoryRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700080000000000000002 /* ClosedPanelAgentHistoryRegressionTests.swift */; }; B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */; }; B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */; }; @@ -492,6 +585,9 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B9000063A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */; }; 6D9C51AB30A64B1ABC819146 /* CMUXCLI+AgentHookPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5257257034CA4729B1211170 /* CMUXCLI+AgentHookPayload.swift */; }; C6711A050000000000000001 /* CMUXCLI+AgentHookRestoreEvidence.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6711B050000000000000001 /* CMUXCLI+AgentHookRestoreEvidence.swift */; }; + A79700070000000000000001 /* CMUXCLI+AgentHookRuntimeIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700070000000000000002 /* CMUXCLI+AgentHookRuntimeIdentity.swift */; }; + A79700010000000000000001 /* CMUXCLI+AgentNotificationOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700010000000000000002 /* CMUXCLI+AgentNotificationOwnership.swift */; }; + A78670250000000000000001 /* CMUXCLI+Agents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670250000000000000002 /* CMUXCLI+Agents.swift */; }; B9000068A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000069A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift */; }; 0CB4E9797AD54D3BB9CF06F9 /* CMUXCLI+AutoNaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5257257034CA4729B1211166 /* CMUXCLI+AutoNaming.swift */; }; 6D9C51AB30A64B1ABC819142 /* CMUXCLI+AutoNaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5257257034CA4729B1211166 /* CMUXCLI+AutoNaming.swift */; }; @@ -517,6 +613,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B9000064A1B2C3D4E5F60719 /* CMUXCLI+Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000065A1B2C3D4E5F60719 /* CMUXCLI+Events.swift */; }; B9000046A1B2C3D4E5F60719 /* CMUXCLI+ExecutableResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000047A1B2C3D4E5F60719 /* CMUXCLI+ExecutableResolution.swift */; }; B9000061A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */; }; + A79700110000000000000001 /* CMUXCLI+HooksUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700110000000000000002 /* CMUXCLI+HooksUsage.swift */; }; B9000052A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */; }; 489F4CF9B768C42D87B5EB2F /* CMUXCLI+KimiHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3D532FF0A00E20DA31667F /* CMUXCLI+KimiHooks.swift */; }; B9000071A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000070A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift */; }; @@ -531,10 +628,11 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources REE0CA0000000000000000C2 /* CMUXCLI+Remotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */; }; 888222D96880953F33F554F1 /* CMUXCLI+RovoDevHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF43B7BD28A755A6280428D6 /* CMUXCLI+RovoDevHooks.swift */; }; C0DE64950000000000000001 /* CMUXCLI+SessionsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE64950000000000000002 /* CMUXCLI+SessionsList.swift */; }; - C0DE64960000000000000005 /* CMUXCLI+SessionsListClaudeWorkflow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE64960000000000000006 /* CMUXCLI+SessionsListClaudeWorkflow.swift */; }; C0DE64950000000000000007 /* CMUXCLI+SessionsListForkDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE64950000000000000008 /* CMUXCLI+SessionsListForkDiagnostics.swift */; }; C0DE64960000000000000003 /* CMUXCLI+SessionsListForkStartupInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE64960000000000000004 /* CMUXCLI+SessionsListForkStartupInput.swift */; }; C0DE64960000000000000001 /* CMUXCLI+SessionsListProcessArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE64960000000000000002 /* CMUXCLI+SessionsListProcessArguments.swift */; }; + A78670400000000000000001 /* CMUXCLI+SessionsListRendering.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670400000000000000002 /* CMUXCLI+SessionsListRendering.swift */; }; + A78670090000000000000001 /* CMUXCLI+SessionsTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670090000000000000002 /* CMUXCLI+SessionsTree.swift */; }; C12984000000000000000002 /* CMUXCLI+SIGPIPEProbes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12984000000000000000001 /* CMUXCLI+SIGPIPEProbes.swift */; }; C79470010000000000000001 /* CMUXCLI+SocketClientCapability.swift in Sources */ = {isa = PBXBuildFile; fileRef = C79470010000000000000002 /* CMUXCLI+SocketClientCapability.swift */; }; B9000041A1B2C3D4E5F60719 /* CMUXCLI+SSHCommandSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000040A1B2C3D4E5F60719 /* CMUXCLI+SSHCommandSupport.swift */; }; @@ -551,6 +649,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D1FF52000000000000000001 /* CMUXCLI+TypedDiffViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FF52000000000000000002 /* CMUXCLI+TypedDiffViewer.swift */; }; 06CC2F6C1340C7424D1C7E0A /* CMUXCLI+WorkspaceTodo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */; }; C0DE31390000000000000105 /* CMUXCLIErrorOutputRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */; }; + A78670260000000000000001 /* CMUXCLIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78670260000000000000002 /* CMUXCLIModels.swift */; }; A72C9F4179B54DF38E99A021 /* CmuxCLIPathInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4FE96C3F394FC6A6D4B018 /* CmuxCLIPathInstaller.swift */; }; C12985000000000000000004 /* CMUXCLISentryTelemetryRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12985000000000000000003 /* CMUXCLISentryTelemetryRegressionTests.swift */; }; C0DE64950000000000000009 /* CMUXCLISessionsListForkDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE6495000000000000000A /* CMUXCLISessionsListForkDiagnosticsTests.swift */; }; @@ -700,9 +799,11 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A9F200000000000000000016 /* CodexAppServerQueuedInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000016 /* CodexAppServerQueuedInput.swift */; }; A9E02000000000000000000E /* CodexAppServerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E01000000000000000000E /* CodexAppServerSession.swift */; }; A9E040000000000000000002 /* CodexAppServerSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E040000000000000000001 /* CodexAppServerSessionTests.swift */; }; + A91778670000000000000001 /* CodexHookWriterOwnershipRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91778670000000000000002 /* CodexHookWriterOwnershipRegressionTests.swift */; }; C0DECAFE0000000000000001 /* CodexTeamsApprovalBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */; }; C0DECAFE0000000000000003 /* CodexTeamsApprovalBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */; }; C0DE7100C0DE7100C0DE7100 /* CodexTerminalErrorNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE7101C0DE7101C0DE7101 /* CodexTerminalErrorNotificationTests.swift */; }; + C0D3F1F50000000000000104 /* CodexWrapperResumeRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F40000000000000104 /* CodexWrapperResumeRegressionTests.swift */; }; C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4041001000000000000001A /* CommandClickFileOpenRouter.swift */; }; C0DEC0DE000000000000F302 /* CommandPaletteEmojiTitleSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F301 /* CommandPaletteEmojiTitleSearchTests.swift */; }; C0DE32470000000000000005 /* CommandPaletteIdentifierClipboardUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000006 /* CommandPaletteIdentifierClipboardUITests.swift */; }; @@ -1371,6 +1472,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C6711A040000000000000001 /* RestorableAgentHookSessionRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6711B040000000000000001 /* RestorableAgentHookSessionRecord.swift */; }; C6711A060000000000000001 /* RestorableAgentHookSessionStoreFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6711B060000000000000001 /* RestorableAgentHookSessionStoreFile.swift */; }; F5410002A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410003A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift */; }; + A79700200000000000000001 /* RestorableAgentRegistrySnapshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79700200000000000000002 /* RestorableAgentRegistrySnapshots.swift */; }; A5001662 /* RestorableAgentSession+Campfire.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001663 /* RestorableAgentSession+Campfire.swift */; }; A5001660 /* RestorableAgentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* RestorableAgentSession.swift */; }; C6711A020000000000000001 /* RestorableAgentSessionIndexCodexWeakRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6711B020000000000000001 /* RestorableAgentSessionIndexCodexWeakRecordTests.swift */; }; @@ -1429,6 +1531,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources FE003101 /* SessionIndexStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003001 /* SessionIndexStore.swift */; }; FE003102 /* SessionIndexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003002 /* SessionIndexView.swift */; }; 8A3392FE64E0605D942213D1 /* SessionIndexViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42D69572C8D276745E502B94 /* SessionIndexViewTests.swift */; }; + A78671A00000000000000001 /* SessionListEntryAccumulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78671A00000000000000002 /* SessionListEntryAccumulator.swift */; }; + A78671A00000000000000003 /* SessionListEntryAccumulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78671A00000000000000002 /* SessionListEntryAccumulator.swift */; }; A5F10000000000000000000F /* SessionNotificationSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F100000000000000000010 /* SessionNotificationSnapshot.swift */; }; 481D7BF878AB38DA5642D1C9 /* SessionPersistence+Todos.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F7D1FD3622B7D06998B0F /* SessionPersistence+Todos.swift */; }; A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; }; @@ -1441,6 +1545,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 806600000000000000000002 /* SessionRestorableAgentSnapshotPermissionModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806600000000000000000001 /* SessionRestorableAgentSnapshotPermissionModeTests.swift */; }; A5001670 /* SessionRestoredTerminalCommandStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001671 /* SessionRestoredTerminalCommandStore.swift */; }; C54860090000000000000001 /* SessionRestoreIdentityExclusions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54860090000000000000002 /* SessionRestoreIdentityExclusions.swift */; }; + A79701130000000000000001 /* SessionsListClaudeTranscriptLookupCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79701130000000000000002 /* SessionsListClaudeTranscriptLookupCache.swift */; }; + A79701130000000000000003 /* SessionsListClaudeTranscriptLookupCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79701130000000000000002 /* SessionsListClaudeTranscriptLookupCache.swift */; }; A50016B1A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */; }; F5320002A1B2C3D4E5F60718 /* SessionTranscriptTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5320003A1B2C3D4E5F60718 /* SessionTranscriptTypes.swift */; }; 583A675AA1224E8D82A44883 /* SetAutoTitleSocketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92AB6D1CB714189A8F167E5 /* SetAutoTitleSocketTests.swift */; }; @@ -1697,6 +1803,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 804100000000000000000001 /* TabManager+AdjacentWorkspaceReordering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804100000000000000000002 /* TabManager+AdjacentWorkspaceReordering.swift */; }; 7136B0017136B0017136B001 /* TabManager+BackgroundWorkspaceMountPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7136F0017136F0017136F001 /* TabManager+BackgroundWorkspaceMountPolicy.swift */; }; DCDC1000000000000000B015 /* TabManager+BrowserFocus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDC1000000000000000B016 /* TabManager+BrowserFocus.swift */; }; + A797000C0000000000000001 /* TabManager+ClosedWorkspaceAgentHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A797000C0000000000000002 /* TabManager+ClosedWorkspaceAgentHistory.swift */; }; D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */; }; E3309A03 /* TabManager+EqualizeSplits.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A04 /* TabManager+EqualizeSplits.swift */; }; E3B7A400000000000000000B /* TabManager+FocusHistoryHosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A400000000000000000C /* TabManager+FocusHistoryHosting.swift */; }; @@ -1725,6 +1832,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; E4D1768B7041CDE4F9A084A4 /* TerminalClearScreenKeepScrollbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B7555E04A04849992547A2 /* TerminalClearScreenKeepScrollbackTests.swift */; }; C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; + A83100020000000000000001 /* TerminalController+AgentObservations.swift in Sources */ = {isa = PBXBuildFile; fileRef = A83100020000000000000002 /* TerminalController+AgentObservations.swift */; }; D35A00000000000000000014 /* TerminalController+AgentPromptDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B00000000000000000014 /* TerminalController+AgentPromptDelivery.swift */; }; 8054A0030000000000000003 /* TerminalController+BrowserAutomationRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054A0040000000000000004 /* TerminalController+BrowserAutomationRecovery.swift */; }; D35A00000000000000000011 /* TerminalController+BrowserDesignMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */; }; @@ -1951,7 +2059,9 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D7632B13A1B2C3D4E5F60718 /* WKWebView+CmuxPrintOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7632B14A1B2C3D4E5F60718 /* WKWebView+CmuxPrintOperation.swift */; }; C0DE43000000000000000007 /* Workspace+AgentChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE43000000000000000008 /* Workspace+AgentChat.swift */; }; A6AC73020000000000000001 /* Workspace+AgentLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73020000000000000002 /* Workspace+AgentLifecycle.swift */; }; + FEA700050000000000000001 /* Workspace+AgentTerminalStateDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA700050000000000000002 /* Workspace+AgentTerminalStateDetection.swift */; }; CA52B0150000000000000000 /* Workspace+CanvasLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA52C0150000000000000000 /* Workspace+CanvasLayout.swift */; }; + A797000A0000000000000001 /* Workspace+ClosedPanelAgentHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A797000A0000000000000002 /* Workspace+ClosedPanelAgentHistory.swift */; }; C54860020000000000000001 /* Workspace+CmuxNavigationDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54860020000000000000002 /* Workspace+CmuxNavigationDescriptor.swift */; }; A5FB120D /* Workspace+CustomLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FB120E /* Workspace+CustomLayout.swift */; }; C57B00030000000000000001 /* Workspace+CustomSidebarPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = C57B00030000000000000002 /* Workspace+CustomSidebarPane.swift */; }; @@ -2140,6 +2250,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A11CE0020000000000000001 /* AboutLicensesResourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutLicensesResourceTests.swift; sourceTree = ""; }; A9E010000000000000000006 /* agent-session-react */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "agent-session-react"; sourceTree = ""; }; A9E010000000000000000007 /* agent-session-solid */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "agent-session-solid"; sourceTree = ""; }; + A78670140000000000000002 /* AgentActivitySnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentActivitySnapshot.swift; sourceTree = ""; }; C7AF10000000000000000006 /* AgentChatArtifactGalleryBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatArtifactGalleryBuilder.swift"; sourceTree = ""; }; C7AF10000000000000000002 /* AgentChatArtifactIndex.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatArtifactIndex.swift"; sourceTree = ""; }; C7A52E000000000000000001 /* AgentChatEndedTranscriptListabilityCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatEndedTranscriptListabilityCache.swift"; sourceTree = ""; }; @@ -2166,6 +2277,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C7A52F000000000000000001 /* AgentChatTranscriptService+Wire.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptService+Wire.swift"; sourceTree = ""; }; ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptService.swift"; sourceTree = ""; }; ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptTailer.swift"; sourceTree = ""; }; + A78670320000000000000002 /* AgentCmuxRuntimeIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentCmuxRuntimeIdentity.swift; sourceTree = ""; }; 79390006AA11BB22CC33DD06 /* AgentDeliveryTargetResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentDeliveryTargetResolution.swift; sourceTree = ""; }; A9F100000000000000000001 /* AgentExecutableResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentExecutableResolver.swift; sourceTree = ""; }; A9F100000000000000000002 /* AgentExecutableResolverError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentExecutableResolverError.swift; sourceTree = ""; }; @@ -2187,6 +2299,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources F65760150000000000000002 /* AgentHibernationController+UnableToProtectMarker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/AgentHibernationController+UnableToProtectMarker.swift"; sourceTree = ""; }; D36A00010000000000000002 /* AgentHibernationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/AgentHibernationController.swift; sourceTree = ""; }; D36A00030000000000000002 /* AgentHibernationLifecycleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHibernation/AgentHibernationLifecycleState.swift; sourceTree = ""; }; + A79700060000000000000002 /* AgentHibernationLifecycleStateTestAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHibernationLifecycleStateTestAlias.swift; sourceTree = ""; }; + A7867B010000000000000002 /* AgentHibernationOwnedLiveProcessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHibernationOwnedLiveProcessTests.swift; sourceTree = ""; }; F65760160000000000000002 /* AgentHibernationPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/AgentHibernationPlanner.swift"; sourceTree = ""; }; F65760170000000000000002 /* AgentHibernationPlannerInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/AgentHibernationPlannerInput.swift"; sourceTree = ""; }; F65760010000000000000002 /* AgentHibernationPlannerSwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHibernationPlannerSwiftTests.swift; sourceTree = ""; }; @@ -2208,12 +2322,20 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources F65760250000000000000002 /* AgentHibernationTranscriptSnapshotRaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHibernationTranscriptSnapshotRaceTests.swift; sourceTree = ""; }; A76110010000000000000002 /* AgentHookNotificationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHookNotificationPolicy.swift; sourceTree = ""; }; A76110020000000000000002 /* AgentHookNotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHookNotificationPolicyTests.swift; sourceTree = ""; }; + A78670040000000000000002 /* AgentHookSessionLineage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHookSessionLineage.swift; sourceTree = ""; }; + A78670050000000000000002 /* AgentHookSessionLineageResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHookSessionLineageResolver.swift; sourceTree = ""; }; + A79700100000000000000002 /* AgentHookSessionRegistryBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHookSessionRegistryBridge.swift; sourceTree = ""; }; + A78670180000000000000002 /* AgentHookSessionStateWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHookSessionStateWriter.swift; sourceTree = ""; }; + A78670520000000000000002 /* AgentHookSessionStoreCompletion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHookSessionStoreCompletion.swift; sourceTree = ""; }; + A78670240000000000000002 /* AgentHookSessionStoreModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHookSessionStoreModels.swift; sourceTree = ""; }; + B7970F010000000000000002 /* AgentHookSessionStoreScaleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHookSessionStoreScaleTests.swift; sourceTree = ""; }; 0A1107110000000000000011 /* AgentNotificationDelivery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotificationDelivery.swift; sourceTree = ""; }; A5D41235A1B2C3D4E5F60718 /* AgentNotificationGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotificationGate.swift; sourceTree = ""; }; A5D41231A1B2C3D4E5F60718 /* AgentNotificationGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotificationGateTests.swift; sourceTree = ""; }; 79390004AA11BB22CC33DD04 /* AgentNotificationLiveRetargetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotificationLiveRetargetTests.swift; sourceTree = ""; }; 79390010AA11BB22CC33DD10 /* AgentNotificationMoveRaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotificationMoveRaceTests.swift; sourceTree = ""; }; 79460002AA11BB22CC33EE02 /* AgentNotificationMutationBoundaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotificationMutationBoundaryTests.swift; sourceTree = ""; }; + A79700020000000000000002 /* AgentNotificationOwnershipRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotificationOwnershipRegressionTests.swift; sourceTree = ""; }; C3744E040000000000000001 /* AgentPIDProcessIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentPIDProcessIdentity.swift; sourceTree = ""; }; B79500130000000000000002 /* AgentPortPublicationHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentPortPublicationHistory.swift; sourceTree = ""; }; B79500120000000000000002 /* AgentPortRootIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentPortRootIdentity.swift; sourceTree = ""; }; @@ -2222,26 +2344,48 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B79500040000000000000002 /* AgentPortScanRootInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentPortScanRootInput.swift; sourceTree = ""; }; B79500140000000000000002 /* AgentPortSnapshotReplacementState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentPortSnapshotReplacementState.swift; sourceTree = ""; }; B79500110000000000000002 /* AgentPortTrackingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentPortTrackingState.swift; sourceTree = ""; }; + A78670010000000000000002 /* AgentProcessIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentProcessIdentity.swift; sourceTree = ""; }; 0A1107110000000000000013 /* AgentRelaunchCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRelaunchCommandBuilder.swift; sourceTree = ""; }; 0A1107110000000000000015 /* AgentRestoreMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRestoreMode.swift; sourceTree = ""; }; + A78670510000000000000002 /* AgentResumeExecutableAvailabilityRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentResumeExecutableAvailabilityRegressionTests.swift; sourceTree = ""; }; + B78672A00000000000000002 /* AgentRuntimeOwnershipProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRuntimeOwnershipProbe.swift; sourceTree = ""; }; + A79700040000000000000002 /* AgentSessionAuthorityEvidence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionAuthorityEvidence.swift; sourceTree = ""; }; D3610B010000000000000002 /* AgentSessionAutoResumeSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionAutoResumeSettingsTests.swift; sourceTree = ""; }; D3610B020000000000000002 /* AgentSessionAutoResumeSwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionAutoResumeSwiftTests.swift; sourceTree = ""; }; A9E010000000000000000008 /* AgentSessionBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionBridge.swift; sourceTree = ""; }; A9F10000000000000000000B /* AgentSessionBridgeError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionBridgeError.swift; sourceTree = ""; }; A9F10000000000000000000C /* AgentSessionBridgeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionBridgeRequest.swift; sourceTree = ""; }; + A78670220000000000000002 /* AgentSessionCLIRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionCLIRegressionTests.swift; sourceTree = ""; }; A9F100000000000000000003 /* AgentSessionDebugMenuButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionDebugMenuButtons.swift; sourceTree = ""; }; + A78670070000000000000002 /* AgentSessionGraphEdge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionGraphEdge.swift; sourceTree = ""; }; + A79700090000000000000002 /* AgentSessionGraphEdgeResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionGraphEdgeResolver.swift; sourceTree = ""; }; + A78670060000000000000002 /* AgentSessionGraphNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionGraphNode.swift; sourceTree = ""; }; + A78670080000000000000002 /* AgentSessionGraphSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionGraphSnapshot.swift; sourceTree = ""; }; + A78670230000000000000002 /* AgentSessionHibernationLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionHibernationLifecycleTests.swift; sourceTree = ""; }; A9F10000000000000000001B /* AgentSessionInputWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionInputWriter.swift; sourceTree = ""; }; A9F100000000000000000004 /* AgentSessionLaunchPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionLaunchPlan.swift; sourceTree = ""; }; + A78670300000000000000002 /* AgentSessionLifecycleAuthorityRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionLifecycleAuthorityRegressionTests.swift; sourceTree = ""; }; + A78670270000000000000002 /* AgentSessionLifecycleFlowDocumentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionLifecycleFlowDocumentation.swift; sourceTree = ""; }; + A79700030000000000000002 /* AgentSessionLifecycleRaceRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionLifecycleRaceRegressionTests.swift; sourceTree = ""; }; + A79701020000000000000002 /* AgentSessionLineageAuthorityRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionLineageAuthorityRegressionTests.swift; sourceTree = ""; }; A9F10000000000000000000E /* AgentSessionOutputLineBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionOutputLineBuffer.swift; sourceTree = ""; }; A9E010000000000000000002 /* AgentSessionPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionPanel.swift; sourceTree = ""; }; A9E010000000000000000004 /* AgentSessionPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionPanelView.swift; sourceTree = ""; }; A9F10000000000000000000F /* AgentSessionPermissionMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionPermissionMode.swift; sourceTree = ""; }; A9E010000000000000000009 /* AgentSessionProcessStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionProcessStore.swift; sourceTree = ""; }; + A7867A100000000000000002 /* AgentSessionProjectionParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionProjectionParityTests.swift; sourceTree = ""; }; A9E010000000000000000001 /* AgentSessionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionProvider.swift; sourceTree = ""; }; + A78670020000000000000002 /* AgentSessionRelationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionRelationship.swift; sourceTree = ""; }; A9E01000000000000000000F /* AgentSessionRendererKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionRendererKind.swift; sourceTree = ""; }; A9F100000000000000000010 /* AgentSessionRunningSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionRunningSession.swift; sourceTree = ""; }; + A78670030000000000000002 /* AgentSessionRunRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionRunRecord.swift; sourceTree = ""; }; + A79701030000000000000002 /* AgentSessionRuntimeIdentityRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionRuntimeIdentityRegressionTests.swift; sourceTree = ""; }; + A78670310000000000000002 /* AgentSessionRuntimeScopingRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionRuntimeScopingRegressionTests.swift; sourceTree = ""; }; + A79701040000000000000002 /* AgentSessionSocketRuntimeScopingRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionSocketRuntimeScopingRegressionTests.swift; sourceTree = ""; }; A9E030000000000000000001 /* AgentSessionSocketSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionSocketSurfaceTests.swift; sourceTree = ""; }; A9F100000000000000000011 /* AgentSessionStartedSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionStartedSession.swift; sourceTree = ""; }; + A78670130000000000000002 /* AgentSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionState.swift; sourceTree = ""; }; + A78670150000000000000002 /* AgentSessionStateProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionStateProjection.swift; sourceTree = ""; }; A9E01000000000000000000A /* AgentSessionStreamAccumulators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionStreamAccumulators.swift; sourceTree = ""; }; A9F100000000000000000012 /* AgentSessionWebHostGeometryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionWebHostGeometryState.swift; sourceTree = ""; }; A9E01000000000000000000B /* AgentSessionWebHostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionWebHostView.swift; sourceTree = ""; }; @@ -2251,6 +2395,23 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A9E050000000000000000001 /* AgentSessionWebRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionWebRendererTests.swift; sourceTree = ""; }; A9E01000000000000000000D /* AgentSessionWebTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionWebTheme.swift; sourceTree = ""; }; A9F100000000000000000014 /* AgentSessionWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/AgentSessionWebView.swift; sourceTree = ""; }; + A78670160000000000000002 /* AgentSessionWorkloadReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionWorkloadReconciler.swift; sourceTree = ""; }; + A79701050000000000000002 /* AgentSessionWriterGenerationRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionWriterGenerationRegressionTests.swift; sourceTree = ""; }; + A71860010000000000000002 /* AgentStagedOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentStagedOutput.swift; sourceTree = ""; }; + A78670210000000000000002 /* AgentStopStateAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentStopStateAdapter.swift; sourceTree = ""; }; + A78670200000000000000002 /* AgentSubtreeActivitySnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSubtreeActivitySnapshot.swift; sourceTree = ""; }; + FEA700020000000000000002 /* AgentTerminalClassificationWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentTerminalClassificationWorker.swift; sourceTree = ""; }; + A83100010000000000000002 /* AgentTerminalObservationJoiner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentTerminalObservationJoiner.swift; sourceTree = ""; }; + FEA700040000000000000002 /* AgentTerminalStateRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentTerminalStateRuntime.swift; sourceTree = ""; }; + A57A7E010000000000000002 /* AgentTerminalStateRuntimeLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentTerminalStateRuntimeLifecycleTests.swift; sourceTree = ""; }; + FEA700030000000000000002 /* AgentTerminalStateSurfaceObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentTerminalStateSurfaceObserver.swift; sourceTree = ""; }; + C386C7FA4103F812B0A00002 /* AgentTerminalSurfaceTaskSequencer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentTerminalSurfaceTaskSequencer.swift; sourceTree = ""; }; + A78671900000000000000002 /* AgentTreeTextLineSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentTreeTextLineSequence.swift; sourceTree = ""; }; + A79701140000000000000002 /* AgentVisibleMutationOwnershipAgentName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentVisibleMutationOwnershipAgentName.swift; sourceTree = ""; }; + A78670100000000000000002 /* AgentWorkloadKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentWorkloadKind.swift; sourceTree = ""; }; + A78670110000000000000002 /* AgentWorkloadPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentWorkloadPhase.swift; sourceTree = ""; }; + A78670120000000000000002 /* AgentWorkloadRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentWorkloadRecord.swift; sourceTree = ""; }; + A78670190000000000000002 /* AgentWorkloadSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentWorkloadSnapshot.swift; sourceTree = ""; }; REE0CA0000000000000000F1 /* AIAccountCredentialSources.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIAccountCredentialSources.swift; sourceTree = ""; }; REE0CA0000000000000000E1 /* AIAccountsClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIAccountsClient.swift; sourceTree = ""; }; A115C0DE0000000000000001 /* AllShortcutsPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllShortcutsPopover.swift; sourceTree = ""; }; @@ -2327,6 +2488,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7752D68A758642EFA282C208 /* AutoNamingEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoNamingEngineTests.swift; sourceTree = ""; }; 07EC52007B2144C59C047774 /* AutoNamingGrokAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoNamingGrokAdapterTests.swift; sourceTree = ""; }; 46792DAA478444ED87B17B31 /* AutoNamingHookPayloadAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoNamingHookPayloadAdapterTests.swift; sourceTree = ""; }; + A78670500000000000000002 /* AutoNamingTranscriptMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoNamingTranscriptMessage.swift; sourceTree = ""; }; C0DE35860000000000000002 /* BackgroundWorkspacePrimeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkspacePrimeCoordinator.swift; sourceTree = ""; }; A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; 74060000000000000000001A /* BonsplitConfiguration+RemoteTmuxEmbedded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BonsplitConfiguration+RemoteTmuxEmbedded.swift"; sourceTree = ""; }; @@ -2530,13 +2692,16 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 319C469164C74F2F8E41D2C3 /* ChecklistSummaryPopoverModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChecklistSummaryPopoverModifier.swift"; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEMarkedSelectionTests.swift; sourceTree = ""; }; + A78670170000000000000002 /* ClaudeAgentWorkloadAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeAgentWorkloadAdapter.swift; sourceTree = ""; }; A5D41233A1B2C3D4E5F60718 /* ClaudeBackgroundWorkNotifyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeBackgroundWorkNotifyTests.swift; sourceTree = ""; }; + A78670280000000000000002 /* ClaudeChildSessionObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeChildSessionObserver.swift; sourceTree = ""; }; C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeConfigDirectoryPathTests.swift; sourceTree = ""; }; C7A534000000000000000001 /* ClaudeHookFeedTelemetrySwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookFeedTelemetrySwiftTests.swift; sourceTree = ""; }; 7939000CAA11BB22CC33DD0C /* ClaudeHookLifecycleCleanupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookLifecycleCleanupTests.swift; sourceTree = ""; }; 79390002AA11BB22CC33DD02 /* ClaudeHookLiveDeliveryTargetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookLiveDeliveryTargetTests.swift; sourceTree = ""; }; 7939000AAA11BB22CC33DD0A /* ClaudeHookLiveDeliveryTargetTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookLiveDeliveryTargetTestSupport.swift; sourceTree = ""; }; 79390012AA11BB22CC33DD12 /* ClaudeHookPIDAuthenticationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookPIDAuthenticationTests.swift; sourceTree = ""; }; + A79701100000000000000002 /* ClaudeHookSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookSessionStore.swift; sourceTree = ""; }; A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookSurfaceResolutionSwiftTests.swift; sourceTree = ""; }; A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNotificationStatusLifecycleTests.swift; sourceTree = ""; }; C7A533000000000000000001 /* ClaudeSessionCanonicalizationContext.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "ClaudeSessionCanonicalizationContext.swift"; sourceTree = ""; }; @@ -2545,10 +2710,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIAuthAliasTests.swift; sourceTree = ""; }; 805500000000000000000002 /* CLIBrowserEvalOutputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIBrowserEvalOutputTests.swift; sourceTree = ""; }; CA11E4D0FA017DE500000F102 /* CLICallerWorkspaceDefaultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICallerWorkspaceDefaultTests.swift; sourceTree = ""; }; + C0D3F1F20000000000000104 /* CLICodexHookSessionStartRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexHookSessionStartRegressionTests.swift; sourceTree = ""; }; C0D3F1F00000000000000104 /* CLICodexHookTimeoutRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexHookTimeoutRegressionTests.swift; sourceTree = ""; }; C0D3F1F10000000000000104 /* CLICodexHookTimeoutRegressionTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexHookTimeoutRegressionTestSupport.swift; sourceTree = ""; }; C6711B010000000000000001 /* CLICodexWeakEnvironmentRestoreBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexWeakEnvironmentRestoreBindingTests.swift; sourceTree = ""; }; - C0D3F1F20000000000000104 /* CLICodexYoloResumePersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexYoloResumePersistenceTests.swift; sourceTree = ""; }; + C0D3F1F30000000000000104 /* CLICodexYoloResumePersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexYoloResumePersistenceTests.swift; sourceTree = ""; }; 1590EE4F01FEF02D40A48698 /* CLIExplicitSurfaceRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIExplicitSurfaceRoutingTests.swift; sourceTree = ""; }; C46790000000000000000002 /* CLIForwardingLaunchArgumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIForwardingLaunchArgumentTests.swift; sourceTree = ""; }; C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CLIForwardingLaunchRouter.swift; sourceTree = ""; }; @@ -2557,6 +2723,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5D41212A1B2C3D4E5F60718 /* CLIHookNoResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIHookNoResponseTests.swift; sourceTree = ""; }; A5D4120CA1B2C3D4E5F60718 /* CLILegacyHookAliasTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLILegacyHookAliasTests.swift; sourceTree = ""; }; A5D41331A1B2C3D4E5F60718 /* CLIMockSocketServerSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIMockSocketServerSupport.swift; sourceTree = ""; }; + A79701010000000000000002 /* CLINestedAgentLifecycleRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLINestedAgentLifecycleRegressionTests.swift; sourceTree = ""; }; F0F0CF0E0000000000000002 /* CLINotifyClaudeForkOfForkRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLINotifyClaudeForkOfForkRegressionTests.swift; sourceTree = ""; }; C72280000000000000000003 /* CLINotifyClaudeHookWorkspaceRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLINotifyClaudeHookWorkspaceRoutingTests.swift; sourceTree = ""; }; A5D41204A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLINotifyProcessIntegrationRegressionTests.swift; sourceTree = ""; }; @@ -2576,7 +2743,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 773600000000000000000002 /* CLIWindowCommandMockServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIWindowCommandMockServer.swift; sourceTree = ""; }; 773600000000000000000004 /* CLIWindowHandleRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIWindowHandleRoutingTests.swift; sourceTree = ""; }; C10D51700000000000000001 /* ClosedItemHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedItemHistory.swift; sourceTree = ""; }; + A797000B0000000000000002 /* ClosedItemHistoryStore+AgentEnrichment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ClosedItemHistoryStore+AgentEnrichment.swift"; sourceTree = ""; }; 7375A0047375A0047375A004 /* ClosedMainWindowRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedMainWindowRoutingTests.swift; sourceTree = ""; }; + A79700080000000000000002 /* ClosedPanelAgentHistoryRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedPanelAgentHistoryRegressionTests.swift; sourceTree = ""; }; B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = ""; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = ""; }; B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = ""; }; @@ -2606,6 +2775,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookDefinitions.swift"; sourceTree = ""; }; 5257257034CA4729B1211170 /* CMUXCLI+AgentHookPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookPayload.swift"; sourceTree = ""; }; C6711B050000000000000001 /* CMUXCLI+AgentHookRestoreEvidence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookRestoreEvidence.swift"; sourceTree = ""; }; + A79700070000000000000002 /* CMUXCLI+AgentHookRuntimeIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookRuntimeIdentity.swift"; sourceTree = ""; }; + A79700010000000000000002 /* CMUXCLI+AgentNotificationOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentNotificationOwnership.swift"; sourceTree = ""; }; + A78670250000000000000002 /* CMUXCLI+Agents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Agents.swift"; sourceTree = ""; }; + A78670090000000000000002 /* CMUXCLI+AgentsTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentsTree.swift"; sourceTree = ""; }; B9000069A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AmpExtension.swift"; sourceTree = ""; }; 5257257034CA4729B1211166 /* CMUXCLI+AutoNaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AutoNaming.swift"; sourceTree = ""; }; 5257257034CA4729B121116A /* CMUXCLI+AutoNamingDispatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AutoNamingDispatch.swift"; sourceTree = ""; }; @@ -2630,6 +2803,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000065A1B2C3D4E5F60719 /* CMUXCLI+Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Events.swift"; sourceTree = ""; }; B9000047A1B2C3D4E5F60719 /* CMUXCLI+ExecutableResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ExecutableResolution.swift"; sourceTree = ""; }; B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+HermesAgentHooks.swift"; sourceTree = ""; }; + A79700110000000000000002 /* CMUXCLI+HooksUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+HooksUsage.swift"; sourceTree = ""; }; B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+InstallPreview.swift"; sourceTree = ""; }; 7A3D532FF0A00E20DA31667F /* CMUXCLI+KimiHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+KimiHooks.swift"; sourceTree = ""; }; B9000070A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Memory.swift"; sourceTree = ""; }; @@ -2644,10 +2818,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Remotes.swift"; sourceTree = ""; }; DF43B7BD28A755A6280428D6 /* CMUXCLI+RovoDevHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RovoDevHooks.swift"; sourceTree = ""; }; C0DE64950000000000000002 /* CMUXCLI+SessionsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SessionsList.swift"; sourceTree = ""; }; - C0DE64960000000000000006 /* CMUXCLI+SessionsListClaudeWorkflow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SessionsListClaudeWorkflow.swift"; sourceTree = ""; }; C0DE64950000000000000008 /* CMUXCLI+SessionsListForkDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SessionsListForkDiagnostics.swift"; sourceTree = ""; }; C0DE64960000000000000004 /* CMUXCLI+SessionsListForkStartupInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SessionsListForkStartupInput.swift"; sourceTree = ""; }; C0DE64960000000000000002 /* CMUXCLI+SessionsListProcessArguments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SessionsListProcessArguments.swift"; sourceTree = ""; }; + A78670400000000000000002 /* CMUXCLI+SessionsListRendering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SessionsListRendering.swift"; sourceTree = ""; }; C12984000000000000000001 /* CMUXCLI+SIGPIPEProbes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SIGPIPEProbes.swift"; sourceTree = ""; }; C79470010000000000000002 /* CMUXCLI+SocketClientCapability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SocketClientCapability.swift"; sourceTree = ""; }; B9000040A1B2C3D4E5F60719 /* CMUXCLI+SSHCommandSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SSHCommandSupport.swift"; sourceTree = ""; }; @@ -2664,6 +2838,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D1FF52000000000000000002 /* CMUXCLI+TypedDiffViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TypedDiffViewer.swift"; sourceTree = ""; }; 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+WorkspaceTodo.swift"; sourceTree = ""; }; C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXCLIErrorOutputRegressionTests.swift; sourceTree = ""; }; + A78670260000000000000002 /* CMUXCLIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXCLIModels.swift; sourceTree = ""; }; 8A4FE96C3F394FC6A6D4B018 /* CmuxCLIPathInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxCLIPathInstaller.swift; sourceTree = ""; }; C12985000000000000000003 /* CMUXCLISentryTelemetryRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXCLISentryTelemetryRegressionTests.swift; sourceTree = ""; }; C0DE6495000000000000000A /* CMUXCLISessionsListForkDiagnosticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXCLISessionsListForkDiagnosticsTests.swift; sourceTree = ""; }; @@ -2765,8 +2940,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A9F100000000000000000016 /* CodexAppServerQueuedInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CodexAppServerQueuedInput.swift; sourceTree = ""; }; A9E01000000000000000000E /* CodexAppServerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CodexAppServerSession.swift; sourceTree = ""; }; A9E040000000000000000001 /* CodexAppServerSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexAppServerSessionTests.swift; sourceTree = ""; }; + A91778670000000000000002 /* CodexHookWriterOwnershipRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexHookWriterOwnershipRegressionTests.swift; sourceTree = ""; }; C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexTeamsApprovalBridge.swift; sourceTree = ""; }; C0DE7101C0DE7101C0DE7101 /* CodexTerminalErrorNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexTerminalErrorNotificationTests.swift; sourceTree = ""; }; + C0D3F1F40000000000000104 /* CodexWrapperResumeRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexWrapperResumeRegressionTests.swift; sourceTree = ""; }; C4041001000000000000001A /* CommandClickFileOpenRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandClickFileOpenRouter.swift; sourceTree = ""; }; C0DEC0DE000000000000F301 /* CommandPaletteEmojiTitleSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteEmojiTitleSearchTests.swift; sourceTree = ""; }; C0DE32470000000000000006 /* CommandPaletteIdentifierClipboardUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIdentifierClipboardUITests.swift; sourceTree = ""; }; @@ -3427,6 +3604,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C6711B040000000000000001 /* RestorableAgentHookSessionRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookSessionRecord.swift; sourceTree = ""; }; C6711B060000000000000001 /* RestorableAgentHookSessionStoreFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookSessionStoreFile.swift; sourceTree = ""; }; F5410003A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentNonInteractiveTests.swift; sourceTree = ""; }; + A79700200000000000000002 /* RestorableAgentRegistrySnapshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentRegistrySnapshots.swift; sourceTree = ""; }; A5001663 /* RestorableAgentSession+Campfire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestorableAgentSession+Campfire.swift"; sourceTree = ""; }; A5001661 /* RestorableAgentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentSession.swift; sourceTree = ""; }; C6711B020000000000000001 /* RestorableAgentSessionIndexCodexWeakRecordTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentSessionIndexCodexWeakRecordTests.swift; sourceTree = ""; }; @@ -3481,6 +3659,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FE003001 /* SessionIndexStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexStore.swift; sourceTree = ""; }; FE003002 /* SessionIndexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexView.swift; sourceTree = ""; }; 42D69572C8D276745E502B94 /* SessionIndexViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexViewTests.swift; sourceTree = ""; }; + A78671A00000000000000002 /* SessionListEntryAccumulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListEntryAccumulator.swift; sourceTree = ""; }; A5F100000000000000000010 /* SessionNotificationSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionNotificationSnapshot.swift; sourceTree = ""; }; 769F7D1FD3622B7D06998B0F /* SessionPersistence+Todos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionPersistence+Todos.swift"; sourceTree = ""; }; A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; @@ -3493,6 +3672,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 806600000000000000000001 /* SessionRestorableAgentSnapshotPermissionModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRestorableAgentSnapshotPermissionModeTests.swift; sourceTree = ""; }; A5001671 /* SessionRestoredTerminalCommandStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRestoredTerminalCommandStore.swift; sourceTree = ""; }; C54860090000000000000002 /* SessionRestoreIdentityExclusions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRestoreIdentityExclusions.swift; sourceTree = ""; }; + A79701130000000000000002 /* SessionsListClaudeTranscriptLookupCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsListClaudeTranscriptLookupCache.swift; sourceTree = ""; }; A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SessionSnapshotDebugBenchmark.swift; sourceTree = ""; }; F5320003A1B2C3D4E5F60718 /* SessionTranscriptTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTranscriptTypes.swift; sourceTree = ""; }; F92AB6D1CB714189A8F167E5 /* SetAutoTitleSocketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAutoTitleSocketTests.swift; sourceTree = ""; }; @@ -3741,6 +3921,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 804100000000000000000002 /* TabManager+AdjacentWorkspaceReordering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+AdjacentWorkspaceReordering.swift"; sourceTree = ""; }; 7136F0017136F0017136F001 /* TabManager+BackgroundWorkspaceMountPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+BackgroundWorkspaceMountPolicy.swift"; sourceTree = ""; }; DCDC1000000000000000B016 /* TabManager+BrowserFocus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+BrowserFocus.swift"; sourceTree = ""; }; + A797000C0000000000000002 /* TabManager+ClosedWorkspaceAgentHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+ClosedWorkspaceAgentHistory.swift"; sourceTree = ""; }; D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+DetachedWorkspace.swift"; sourceTree = ""; }; E3309A04 /* TabManager+EqualizeSplits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+EqualizeSplits.swift"; sourceTree = ""; }; E3B7A400000000000000000C /* TabManager+FocusHistoryHosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+FocusHistoryHosting.swift"; sourceTree = ""; }; @@ -3769,6 +3950,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; F2B7555E04A04849992547A2 /* TerminalClearScreenKeepScrollbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalClearScreenKeepScrollbackTests.swift; sourceTree = ""; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; + A83100020000000000000002 /* TerminalController+AgentObservations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AgentObservations.swift"; sourceTree = ""; }; D35B00000000000000000014 /* TerminalController+AgentPromptDelivery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AgentPromptDelivery.swift"; sourceTree = ""; }; 8054A0040000000000000004 /* TerminalController+BrowserAutomationRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserAutomationRecovery.swift"; sourceTree = ""; }; D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserDesignMode.swift"; sourceTree = ""; }; @@ -3995,7 +4177,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7632B14A1B2C3D4E5F60718 /* WKWebView+CmuxPrintOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/WKWebView+CmuxPrintOperation.swift"; sourceTree = ""; }; C0DE43000000000000000008 /* Workspace+AgentChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+AgentChat.swift"; sourceTree = ""; }; A6AC73020000000000000002 /* Workspace+AgentLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+AgentLifecycle.swift"; sourceTree = ""; }; + FEA700050000000000000002 /* Workspace+AgentTerminalStateDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+AgentTerminalStateDetection.swift"; sourceTree = ""; }; CA52C0150000000000000000 /* Workspace+CanvasLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CanvasLayout.swift"; sourceTree = ""; }; + A797000A0000000000000002 /* Workspace+ClosedPanelAgentHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+ClosedPanelAgentHistory.swift"; sourceTree = ""; }; C54860020000000000000002 /* Workspace+CmuxNavigationDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CmuxNavigationDescriptor.swift"; sourceTree = ""; }; A5FB120E /* Workspace+CustomLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CustomLayout.swift"; sourceTree = ""; }; C57B00030000000000000002 /* Workspace+CustomSidebarPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CustomSidebarPane.swift"; sourceTree = ""; }; @@ -4423,6 +4607,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001041 /* Sources */ = { isa = PBXGroup; children = ( + A78670AA0000000000000001 /* AgentSessions */, A11CE0030000000000000001 /* AboutLicenseContent.swift */, 3023B1003023B1003023B100 /* ConfigSource.swift */, 3023B1013023B1013023B101 /* ConfigSettingsView.swift */, @@ -4764,11 +4949,13 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE1A030000000000000002 /* TabManager+SavedLayouts.swift */, C548600B0000000000000002 /* TabManager+SessionRestoreIdentity.swift */, DCDC1000000000000000B016 /* TabManager+BrowserFocus.swift */, + A797000C0000000000000002 /* TabManager+ClosedWorkspaceAgentHistory.swift */, C5B6A10000000000000000B2 /* TabManager+SidebarGitHosting.swift */, 736200000000000000000003 /* TabManager+WorkspaceCustomTitle.swift */, F0C05170000000000000001 /* FocusHistory.swift */, C0DEFB100000000000000002 /* FocusSurfaceBroadcaster.swift */, C10D51700000000000000001 /* ClosedItemHistory.swift */, + A797000B0000000000000002 /* ClosedItemHistoryStore+AgentEnrichment.swift */, D1FFC0DE000000000000D001 /* DiffCommentSubmissionPool.swift */, D1FFC0DE000000000000A001 /* DiffCommentStore.swift */, E3309A04 /* TabManager+EqualizeSplits.swift */, @@ -4799,6 +4986,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 791102020000000000000001 /* RemoteDisconnectPreparationService.swift */, C0DE43000000000000000008 /* Workspace+AgentChat.swift */, C3744E040000000000000001 /* AgentPIDProcessIdentity.swift */, + FEA700020000000000000002 /* AgentTerminalClassificationWorker.swift */, + FEA700030000000000000002 /* AgentTerminalStateSurfaceObserver.swift */, + FEA700040000000000000002 /* AgentTerminalStateRuntime.swift */, + A83100020000000000000002 /* TerminalController+AgentObservations.swift */, + C386C7FA4103F812B0A00002 /* AgentTerminalSurfaceTaskSequencer.swift */, 6799A0026799A0026799A002 /* WorkspaceSidebarAgentRuntimeObservationModel.swift */, 4AF464BD9CEDF14931D55126 /* Workspace+Todos.swift */, 97A8F8FE70450C596AE80D94 /* Workspace+TodoNotifications.swift */, @@ -4828,6 +5020,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C3744E020000000000000001 /* Workspace+PanelLifecycle.swift */, C3744E100000000000000002 /* Workspace+SidebarStatusVisibility.swift */, A6AC73020000000000000002 /* Workspace+AgentLifecycle.swift */, + A797000A0000000000000002 /* Workspace+ClosedPanelAgentHistory.swift */, + FEA700050000000000000002 /* Workspace+AgentTerminalStateDetection.swift */, 9864510B84F2428BB83568CF /* RestoredAgentCompletedGeneration.swift */, 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */, 42C2726A34F27E7A43B77368 /* Workspace+CustomSidebarPullRequests.swift */, @@ -5515,6 +5709,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C6711B060000000000000001 /* RestorableAgentHookSessionStoreFile.swift */, A5001663 /* RestorableAgentSession+Campfire.swift */, A5001661 /* RestorableAgentSession.swift */, + A79700200000000000000002 /* RestorableAgentRegistrySnapshots.swift */, F0ACC0DE0000000000000012 /* SharedLiveAgentIndex.swift */, F0ACC0DE0000000000000010 /* SurfaceResumeBindingIndex.swift */, C0DEF0D30000000000000002 /* AgentForkExecutableIdentityResolver.swift */, @@ -5633,6 +5828,20 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = path = Sources; sourceTree = ""; }; + A78670AA0000000000000001 /* AgentSessions */ = { + isa = PBXGroup; + children = ( + A78670100000000000000002 /* AgentWorkloadKind.swift */, + A78670110000000000000002 /* AgentWorkloadPhase.swift */, + A78670120000000000000002 /* AgentWorkloadRecord.swift */, + A78670130000000000000002 /* AgentSessionState.swift */, + A78670180000000000000002 /* AgentHookSessionStateWriter.swift */, + B78672A00000000000000002 /* AgentRuntimeOwnershipProbe.swift */, + A78670270000000000000002 /* AgentSessionLifecycleFlowDocumentation.swift */, + ); + path = AgentSessions; + sourceTree = ""; + }; CA52D0010000000000000000 /* Canvas */ = { isa = PBXGroup; children = ( @@ -5666,6 +5875,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = children = ( 805500000000000000000003 /* BrowserValueTextFormatter.swift */, B9000001A1B2C3D4E5F60719 /* cmux.swift */, + A78670500000000000000002 /* AutoNamingTranscriptMessage.swift */, + A78670520000000000000002 /* AgentHookSessionStoreCompletion.swift */, + A79701100000000000000002 /* ClaudeHookSessionStore.swift */, REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */, C77070000000000000000001 /* SSHPTYAttachExitCode.swift */, 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */, @@ -5692,6 +5904,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = DCF61A4F72B75DC4EA47DF6F /* CMUXCLI+SSHReconnectPrompt.swift */, D7AB0000000000000000000E /* CMUXCLI+MoveTabToNewWorkspace.swift */, B9000047A1B2C3D4E5F60719 /* CMUXCLI+ExecutableResolution.swift */, + A78670260000000000000002 /* CMUXCLIModels.swift */, 7837A0027837A0027837A002 /* CMUXCLI+TmuxCompatResizePane.swift */, B9000045A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatSupport.swift */, B9000049A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift */, @@ -5702,11 +5915,42 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */, 61AD48A9E6C3F1BE2B547FFE /* CMUXCLI+AgentHookCatalog.swift */, 7A3D532FF0A00E20DA31667F /* CMUXCLI+KimiHooks.swift */, + A79700110000000000000002 /* CMUXCLI+HooksUsage.swift */, DF43B7BD28A755A6280428D6 /* CMUXCLI+RovoDevHooks.swift */, 5257257034CA4729B1211170 /* CMUXCLI+AgentHookPayload.swift */, C79630010000000000000001 /* CMUXCLI+ClaudeHookAck.swift */, 4CF59318F1D5B2195AC77C28 /* CMUXCLI+ClaudePushNotificationHook.swift */, + A79700010000000000000002 /* CMUXCLI+AgentNotificationOwnership.swift */, + A79701140000000000000002 /* AgentVisibleMutationOwnershipAgentName.swift */, + A79700070000000000000002 /* CMUXCLI+AgentHookRuntimeIdentity.swift */, C6711B050000000000000001 /* CMUXCLI+AgentHookRestoreEvidence.swift */, + A78670010000000000000002 /* AgentProcessIdentity.swift */, + A71860010000000000000002 /* AgentStagedOutput.swift */, + A79700040000000000000002 /* AgentSessionAuthorityEvidence.swift */, + A78670020000000000000002 /* AgentSessionRelationship.swift */, + A78670030000000000000002 /* AgentSessionRunRecord.swift */, + A78670040000000000000002 /* AgentHookSessionLineage.swift */, + A78670320000000000000002 /* AgentCmuxRuntimeIdentity.swift */, + A78670050000000000000002 /* AgentHookSessionLineageResolver.swift */, + A78670240000000000000002 /* AgentHookSessionStoreModels.swift */, + A79700100000000000000002 /* AgentHookSessionRegistryBridge.swift */, + A78670060000000000000002 /* AgentSessionGraphNode.swift */, + A83100010000000000000002 /* AgentTerminalObservationJoiner.swift */, + A78670070000000000000002 /* AgentSessionGraphEdge.swift */, + A79700090000000000000002 /* AgentSessionGraphEdgeResolver.swift */, + A78670080000000000000002 /* AgentSessionGraphSnapshot.swift */, + A78671900000000000000002 /* AgentTreeTextLineSequence.swift */, + A78670090000000000000002 /* CMUXCLI+AgentsTree.swift */, + A78670250000000000000002 /* CMUXCLI+Agents.swift */, + A78670140000000000000002 /* AgentActivitySnapshot.swift */, + A78670150000000000000002 /* AgentSessionStateProjection.swift */, + A78670160000000000000002 /* AgentSessionWorkloadReconciler.swift */, + A78670170000000000000002 /* ClaudeAgentWorkloadAdapter.swift */, + A78670280000000000000002 /* ClaudeChildSessionObserver.swift */, + A78670190000000000000002 /* AgentWorkloadSnapshot.swift */, + A78670200000000000000002 /* AgentSubtreeActivitySnapshot.swift */, + A78671A00000000000000002 /* SessionListEntryAccumulator.swift */, + A78670210000000000000002 /* AgentStopStateAdapter.swift */, 5257257034CA4729B1211166 /* CMUXCLI+AutoNaming.swift */, 5257257034CA4729B121116A /* CMUXCLI+AutoNamingDispatch.swift */, 5257257034CA4729B1211167 /* CMUXCLI+AutoNamingGenericHooks.swift */, @@ -5736,8 +5980,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000065A1B2C3D4E5F60719 /* CMUXCLI+Events.swift */, B9000055A1B2C3D4E5F60719 /* CMUXCLI+Process.swift */, C0DE64950000000000000002 /* CMUXCLI+SessionsList.swift */, - C0DE64960000000000000006 /* CMUXCLI+SessionsListClaudeWorkflow.swift */, + A78670400000000000000002 /* CMUXCLI+SessionsListRendering.swift */, C0DE64950000000000000008 /* CMUXCLI+SessionsListForkDiagnostics.swift */, + A79701130000000000000002 /* SessionsListClaudeTranscriptLookupCache.swift */, C0DE64960000000000000002 /* CMUXCLI+SessionsListProcessArguments.swift */, C0DE64960000000000000004 /* CMUXCLI+SessionsListForkStartupInput.swift */, C12984000000000000000001 /* CMUXCLI+SIGPIPEProbes.swift */, @@ -5804,6 +6049,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A51E000000000000000001 /* AgentChatSessionRegistryLifecycleTests.swift */, C7A51D100000000000000001 /* AgentChatSessionRegistryObservationReviewRegressionTests.swift */, C7A51D000000000000000001 /* AgentChatSessionRegistryObservationTests.swift */, + B7970F010000000000000002 /* AgentHookSessionStoreScaleTests.swift */, D3610B010000000000000002 /* AgentSessionAutoResumeSettingsTests.swift */, D3610B020000000000000002 /* AgentSessionAutoResumeSwiftTests.swift */, A9E010000000000000000005 /* AgentExecutableResolverTests.swift */, @@ -5811,6 +6057,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A9E050000000000000000001 /* AgentSessionWebRendererTests.swift */, A9E040000000000000000001 /* CodexAppServerSessionTests.swift */, F65760010000000000000002 /* AgentHibernationPlannerSwiftTests.swift */, + A7867B010000000000000002 /* AgentHibernationOwnedLiveProcessTests.swift */, F65760240000000000000002 /* AgentHibernationRestoreMonitorTests.swift */, F65760040000000000000002 /* AgentHibernationTranscriptGuardTests.swift */, F65760080000000000000002 /* AgentHibernationTranscriptGuardScanTests.swift */, @@ -5818,6 +6065,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D72260010000000000000002 /* AgentHibernationPortalVisibilityTests.swift */, F65760020000000000000002 /* AgentHibernationTestHelpers.swift */, D36A00020000000000000002 /* AgentHibernationTests.swift */, + A79700060000000000000002 /* AgentHibernationLifecycleStateTestAlias.swift */, + A78670230000000000000002 /* AgentSessionHibernationLifecycleTests.swift */, D36A00050000000000000002 /* RendererRealizationPlannerTests.swift */, C58410010000000000000002 /* RenderableSystemSymbolTests.swift */, DCDC0000000000000000B002 /* DockControlDefinitionDecodingTests.swift */, @@ -6023,6 +6272,19 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = CA11E4D0FA017DE500000F102 /* CLICallerWorkspaceDefaultTests.swift */, C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */, C0DE64950000000000000006 /* CMUXCLISessionsListTests.swift */, + A78670220000000000000002 /* AgentSessionCLIRegressionTests.swift */, + A79701010000000000000002 /* CLINestedAgentLifecycleRegressionTests.swift */, + A79701020000000000000002 /* AgentSessionLineageAuthorityRegressionTests.swift */, + A79701030000000000000002 /* AgentSessionRuntimeIdentityRegressionTests.swift */, + A79701040000000000000002 /* AgentSessionSocketRuntimeScopingRegressionTests.swift */, + A79701050000000000000002 /* AgentSessionWriterGenerationRegressionTests.swift */, + A78670300000000000000002 /* AgentSessionLifecycleAuthorityRegressionTests.swift */, + A79700030000000000000002 /* AgentSessionLifecycleRaceRegressionTests.swift */, + A7867A100000000000000002 /* AgentSessionProjectionParityTests.swift */, + A79700080000000000000002 /* ClosedPanelAgentHistoryRegressionTests.swift */, + A79700020000000000000002 /* AgentNotificationOwnershipRegressionTests.swift */, + A78670510000000000000002 /* AgentResumeExecutableAvailabilityRegressionTests.swift */, + A78670310000000000000002 /* AgentSessionRuntimeScopingRegressionTests.swift */, C0DE6495000000000000000A /* CMUXCLISessionsListForkDiagnosticsTests.swift */, C0DE64970000000000000002 /* CMUXCLISessionsListOpenCodeTrustTests.swift */, C0DE64970000000000000004 /* CMUXCLISessionsListProcessArgumentTests.swift */, @@ -6205,6 +6467,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */, 79390002AA11BB22CC33DD02 /* ClaudeHookLiveDeliveryTargetTests.swift */, 7939000CAA11BB22CC33DD0C /* ClaudeHookLifecycleCleanupTests.swift */, + A57A7E010000000000000002 /* AgentTerminalStateRuntimeLifecycleTests.swift */, 7939000AAA11BB22CC33DD0A /* ClaudeHookLiveDeliveryTargetTestSupport.swift */, 79390004AA11BB22CC33DD04 /* AgentNotificationLiveRetargetTests.swift */, 79390010AA11BB22CC33DD10 /* AgentNotificationMoveRaceTests.swift */, @@ -6231,8 +6494,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B05553B10000000000000002 /* CLITmuxCompatRemoteSplitTests.swift */, 7837E0067837E0067837E006 /* CLITmuxCompatResizePaneTests.swift */, C0D3F1F00000000000000104 /* CLICodexHookTimeoutRegressionTests.swift */, - C0D3F1F20000000000000104 /* CLICodexYoloResumePersistenceTests.swift */, + A91778670000000000000002 /* CodexHookWriterOwnershipRegressionTests.swift */, + C0D3F1F40000000000000104 /* CodexWrapperResumeRegressionTests.swift */, + C0D3F1F30000000000000104 /* CLICodexYoloResumePersistenceTests.swift */, C0D3F1F10000000000000104 /* CLICodexHookTimeoutRegressionTestSupport.swift */, + C0D3F1F20000000000000104 /* CLICodexHookSessionStartRegressionTests.swift */, C6711B010000000000000001 /* CLICodexWeakEnvironmentRestoreBindingTests.swift */, A5D41208A1B2C3D4E5F60718 /* CLIGenericHookPersistenceTests.swift */, A5D41212A1B2C3D4E5F60718 /* CLIHookNoResponseTests.swift */, @@ -6812,6 +7078,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F65760050000000000000001 /* AgentHibernationTranscriptGuard.swift in Sources */, F65760200000000000000001 /* AgentHibernationTranscriptHookStoreFileMirror.swift in Sources */, F65760210000000000000001 /* AgentHibernationTranscriptHookStoreRecord.swift in Sources */, + A78670180000000000000003 /* AgentHookSessionStateWriter.swift in Sources */, 0A1107110000000000000010 /* AgentNotificationDelivery.swift in Sources */, A5D41234A1B2C3D4E5F60718 /* AgentNotificationGate.swift in Sources */, C3744E030000000000000001 /* AgentPIDProcessIdentity.swift in Sources */, @@ -6824,12 +7091,14 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B79500110000000000000001 /* AgentPortTrackingState.swift in Sources */, 0A1107110000000000000012 /* AgentRelaunchCommandBuilder.swift in Sources */, 0A1107110000000000000014 /* AgentRestoreMode.swift in Sources */, + B78672A00000000000000001 /* AgentRuntimeOwnershipProbe.swift in Sources */, A9E020000000000000000008 /* AgentSessionBridge.swift in Sources */, A9F20000000000000000000B /* AgentSessionBridgeError.swift in Sources */, A9F20000000000000000000C /* AgentSessionBridgeRequest.swift in Sources */, A9F200000000000000000003 /* AgentSessionDebugMenuButtons.swift in Sources */, A9F20000000000000000001B /* AgentSessionInputWriter.swift in Sources */, A9F200000000000000000004 /* AgentSessionLaunchPlan.swift in Sources */, + A78670270000000000000001 /* AgentSessionLifecycleFlowDocumentation.swift in Sources */, A9F20000000000000000000E /* AgentSessionOutputLineBuffer.swift in Sources */, A9E020000000000000000002 /* AgentSessionPanel.swift in Sources */, A9E020000000000000000004 /* AgentSessionPanelView.swift in Sources */, @@ -6839,6 +7108,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A9E02000000000000000000F /* AgentSessionRendererKind.swift in Sources */, A9F200000000000000000010 /* AgentSessionRunningSession.swift in Sources */, A9F200000000000000000011 /* AgentSessionStartedSession.swift in Sources */, + A78670130000000000000003 /* AgentSessionState.swift in Sources */, A9E02000000000000000000A /* AgentSessionStreamAccumulators.swift in Sources */, A9F200000000000000000012 /* AgentSessionWebHostGeometryState.swift in Sources */, A9E02000000000000000000B /* AgentSessionWebHostView.swift in Sources */, @@ -6847,6 +7117,13 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A9E02000000000000000000C /* AgentSessionWebRendererSession.swift in Sources */, A9E02000000000000000000D /* AgentSessionWebTheme.swift in Sources */, A9F200000000000000000014 /* AgentSessionWebView.swift in Sources */, + FEA700020000000000000001 /* AgentTerminalClassificationWorker.swift in Sources */, + FEA700040000000000000001 /* AgentTerminalStateRuntime.swift in Sources */, + FEA700030000000000000001 /* AgentTerminalStateSurfaceObserver.swift in Sources */, + C386C7FA4103F812B0A00001 /* AgentTerminalSurfaceTaskSequencer.swift in Sources */, + A78670100000000000000003 /* AgentWorkloadKind.swift in Sources */, + A78670110000000000000003 /* AgentWorkloadPhase.swift in Sources */, + A78670120000000000000003 /* AgentWorkloadRecord.swift in Sources */, REE0CA0000000000000000F2 /* AIAccountCredentialSources.swift in Sources */, REE0CA0000000000000000E2 /* AIAccountsClient.swift in Sources */, A115C0DE0000000000000002 /* AllShortcutsPopover.swift in Sources */, @@ -7049,6 +7326,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A9F200000000000000000015 /* ClaudeStreamJSONAccumulator.swift in Sources */, C46790000000000000000003 /* CLIForwardingLaunchRouter.swift in Sources */, C10D51700000000000000002 /* ClosedItemHistory.swift in Sources */, + A797000B0000000000000001 /* ClosedItemHistoryStore+AgentEnrichment.swift in Sources */, C10D00010000000000000001 /* CloudVMActionLauncher.swift in Sources */, A5001654 /* CmuxActionTrust.swift in Sources */, C0DE43000000000000000001 /* CmuxAgentChatConfig.swift in Sources */, @@ -7563,6 +7841,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D36A00070000000000000001 /* RendererRealizationReclaimTrigger.swift in Sources */, C6711A040000000000000001 /* RestorableAgentHookSessionRecord.swift in Sources */, C6711A060000000000000001 /* RestorableAgentHookSessionStoreFile.swift in Sources */, + A79700200000000000000001 /* RestorableAgentRegistrySnapshots.swift in Sources */, A5001662 /* RestorableAgentSession+Campfire.swift in Sources */, A5001660 /* RestorableAgentSession.swift in Sources */, C13519000000000000000005 /* RestorableAgentTypes.swift in Sources */, @@ -7774,6 +8053,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 804100000000000000000001 /* TabManager+AdjacentWorkspaceReordering.swift in Sources */, 7136B0017136B0017136B001 /* TabManager+BackgroundWorkspaceMountPolicy.swift in Sources */, DCDC1000000000000000B015 /* TabManager+BrowserFocus.swift in Sources */, + A797000C0000000000000001 /* TabManager+ClosedWorkspaceAgentHistory.swift in Sources */, D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */, E3309A03 /* TabManager+EqualizeSplits.swift in Sources */, E3B7A400000000000000000B /* TabManager+FocusHistoryHosting.swift in Sources */, @@ -7791,6 +8071,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A506000000000000000002 /* TaskManagerView.swift in Sources */, C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */, 7490C00F7490C00F7490C00F /* TaskVMInfoMemoryPressureFootprintSampler.swift in Sources */, + A83100020000000000000001 /* TerminalController+AgentObservations.swift in Sources */, D35A00000000000000000014 /* TerminalController+AgentPromptDelivery.swift in Sources */, 8054A0030000000000000003 /* TerminalController+BrowserAutomationRecovery.swift in Sources */, D35A00000000000000000011 /* TerminalController+BrowserDesignMode.swift in Sources */, @@ -7972,7 +8253,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7632B13A1B2C3D4E5F60718 /* WKWebView+CmuxPrintOperation.swift in Sources */, C0DE43000000000000000007 /* Workspace+AgentChat.swift in Sources */, A6AC73020000000000000001 /* Workspace+AgentLifecycle.swift in Sources */, + FEA700050000000000000001 /* Workspace+AgentTerminalStateDetection.swift in Sources */, CA52B0150000000000000000 /* Workspace+CanvasLayout.swift in Sources */, + A797000A0000000000000001 /* Workspace+ClosedPanelAgentHistory.swift in Sources */, C54860020000000000000001 /* Workspace+CmuxNavigationDescriptor.swift in Sources */, A5FB120D /* Workspace+CustomLayout.swift in Sources */, C57B00030000000000000001 /* Workspace+CustomSidebarPane.swift in Sources */, @@ -8054,9 +8337,41 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A78670140000000000000001 /* AgentActivitySnapshot.swift in Sources */, + A78670320000000000000001 /* AgentCmuxRuntimeIdentity.swift in Sources */, D36A00030000000000000003 /* AgentHibernationLifecycleState.swift in Sources */, A76110010000000000000001 /* AgentHookNotificationPolicy.swift in Sources */, + A78670040000000000000001 /* AgentHookSessionLineage.swift in Sources */, + A78670050000000000000001 /* AgentHookSessionLineageResolver.swift in Sources */, + A79700100000000000000001 /* AgentHookSessionRegistryBridge.swift in Sources */, + A78670520000000000000001 /* AgentHookSessionStoreCompletion.swift in Sources */, + A78670240000000000000001 /* AgentHookSessionStoreModels.swift in Sources */, + A78670010000000000000001 /* AgentProcessIdentity.swift in Sources */, + A79700040000000000000001 /* AgentSessionAuthorityEvidence.swift in Sources */, + A78670070000000000000001 /* AgentSessionGraphEdge.swift in Sources */, + A79700090000000000000001 /* AgentSessionGraphEdgeResolver.swift in Sources */, + A78670060000000000000001 /* AgentSessionGraphNode.swift in Sources */, + A78670080000000000000001 /* AgentSessionGraphSnapshot.swift in Sources */, + A78670020000000000000001 /* AgentSessionRelationship.swift in Sources */, + A78670030000000000000001 /* AgentSessionRunRecord.swift in Sources */, + A78670130000000000000001 /* AgentSessionState.swift in Sources */, + A78670150000000000000001 /* AgentSessionStateProjection.swift in Sources */, + A78670160000000000000001 /* AgentSessionWorkloadReconciler.swift in Sources */, + A71860010000000000000001 /* AgentStagedOutput.swift in Sources */, + A78670210000000000000001 /* AgentStopStateAdapter.swift in Sources */, + A78670200000000000000001 /* AgentSubtreeActivitySnapshot.swift in Sources */, + A83100010000000000000001 /* AgentTerminalObservationJoiner.swift in Sources */, + A78671900000000000000001 /* AgentTreeTextLineSequence.swift in Sources */, + A79701140000000000000001 /* AgentVisibleMutationOwnershipAgentName.swift in Sources */, + A78670100000000000000001 /* AgentWorkloadKind.swift in Sources */, + A78670110000000000000001 /* AgentWorkloadPhase.swift in Sources */, + A78670120000000000000001 /* AgentWorkloadRecord.swift in Sources */, + A78670190000000000000001 /* AgentWorkloadSnapshot.swift in Sources */, + A78670500000000000000001 /* AutoNamingTranscriptMessage.swift in Sources */, 805500000000000000000004 /* BrowserValueTextFormatter.swift in Sources */, + A78670170000000000000001 /* ClaudeAgentWorkloadAdapter.swift in Sources */, + A78670280000000000000001 /* ClaudeChildSessionObserver.swift in Sources */, + A79701100000000000000001 /* ClaudeHookSessionStore.swift in Sources */, B900004AA1B2C3D4E5F60719 /* CLISocketPathResolver.swift in Sources */, C12985000000000000000002 /* CLISocketSentryTelemetry.swift in Sources */, B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */, @@ -8066,6 +8381,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000063A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift in Sources */, 6D9C51AB30A64B1ABC819146 /* CMUXCLI+AgentHookPayload.swift in Sources */, C6711A050000000000000001 /* CMUXCLI+AgentHookRestoreEvidence.swift in Sources */, + A79700070000000000000001 /* CMUXCLI+AgentHookRuntimeIdentity.swift in Sources */, + A79700010000000000000001 /* CMUXCLI+AgentNotificationOwnership.swift in Sources */, + A78670250000000000000001 /* CMUXCLI+Agents.swift in Sources */, + A78670090000000000000001 /* CMUXCLI+AgentsTree.swift in Sources */, B9000068A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift in Sources */, 6D9C51AB30A64B1ABC819142 /* CMUXCLI+AutoNaming.swift in Sources */, 6D9C51AB30A64B1ABC81914A /* CMUXCLI+AutoNamingDispatch.swift in Sources */, @@ -8090,6 +8409,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000064A1B2C3D4E5F60719 /* CMUXCLI+Events.swift in Sources */, B9000046A1B2C3D4E5F60719 /* CMUXCLI+ExecutableResolution.swift in Sources */, B9000061A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift in Sources */, + A79700110000000000000001 /* CMUXCLI+HooksUsage.swift in Sources */, B9000052A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift in Sources */, 489F4CF9B768C42D87B5EB2F /* CMUXCLI+KimiHooks.swift in Sources */, B9000071A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift in Sources */, @@ -8104,10 +8424,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = REE0CA0000000000000000C2 /* CMUXCLI+Remotes.swift in Sources */, 888222D96880953F33F554F1 /* CMUXCLI+RovoDevHooks.swift in Sources */, C0DE64950000000000000001 /* CMUXCLI+SessionsList.swift in Sources */, - C0DE64960000000000000005 /* CMUXCLI+SessionsListClaudeWorkflow.swift in Sources */, C0DE64950000000000000007 /* CMUXCLI+SessionsListForkDiagnostics.swift in Sources */, C0DE64960000000000000003 /* CMUXCLI+SessionsListForkStartupInput.swift in Sources */, C0DE64960000000000000001 /* CMUXCLI+SessionsListProcessArguments.swift in Sources */, + A78670400000000000000001 /* CMUXCLI+SessionsListRendering.swift in Sources */, C12984000000000000000002 /* CMUXCLI+SIGPIPEProbes.swift in Sources */, C79470010000000000000001 /* CMUXCLI+SocketClientCapability.swift in Sources */, B9000041A1B2C3D4E5F60719 /* CMUXCLI+SSHCommandSupport.swift in Sources */, @@ -8123,6 +8443,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000033A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift in Sources */, D1FF52000000000000000001 /* CMUXCLI+TypedDiffViewer.swift in Sources */, 06CC2F6C1340C7424D1C7E0A /* CMUXCLI+WorkspaceTodo.swift in Sources */, + A78670260000000000000001 /* CMUXCLIModels.swift in Sources */, C0DECAFE0000000000000001 /* CodexTeamsApprovalBridge.swift in Sources */, FEEDC1A50000000000000001 /* FeedEventClassifier.swift in Sources */, A5FB1308 /* JSONCObjectEditor+Remove.swift in Sources */, @@ -8132,6 +8453,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000028A1B2C3D4E5F60719 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */, B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, 5E2701020000000000000003 /* SentryEventScrubber.swift in Sources */, + A78671A00000000000000001 /* SessionListEntryAccumulator.swift in Sources */, + A79701130000000000000001 /* SessionsListClaudeTranscriptLookupCache.swift in Sources */, C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */, C77070000000000000000002 /* SSHPTYAttachExitCode.swift in Sources */, B816E948EC5E42AF967648AC /* SSHPTYAttachReconnectInputFilter.swift in Sources */, @@ -8209,6 +8532,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = buildActionMask = 2147483647; files = ( A11CE0010000000000000001 /* AboutLicensesResourceTests.swift in Sources */, + A78670600000000000000001 /* AgentActivitySnapshot.swift in Sources */, CDFE000000000000000000C1 /* AgentChatProseStreamerTests.swift in Sources */, C7A51F000000000000000002 /* AgentChatSessionRegistryClaudeObservationTests.swift in Sources */, C7A51C000000000000000002 /* AgentChatSessionRegistryHookStoreTests.swift in Sources */, @@ -8216,7 +8540,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A51E000000000000000002 /* AgentChatSessionRegistryLifecycleTests.swift in Sources */, C7A51D100000000000000002 /* AgentChatSessionRegistryObservationReviewRegressionTests.swift in Sources */, C7A51D000000000000000002 /* AgentChatSessionRegistryObservationTests.swift in Sources */, + A78670610000000000000001 /* AgentCmuxRuntimeIdentity.swift in Sources */, A9E020000000000000000005 /* AgentExecutableResolverTests.swift in Sources */, + A79700060000000000000001 /* AgentHibernationLifecycleStateTestAlias.swift in Sources */, + A7867B010000000000000001 /* AgentHibernationOwnedLiveProcessTests.swift in Sources */, F65760010000000000000001 /* AgentHibernationPlannerSwiftTests.swift in Sources */, D72260010000000000000001 /* AgentHibernationPortalVisibilityTests.swift in Sources */, F65760240000000000000001 /* AgentHibernationRestoreMonitorTests.swift in Sources */, @@ -8227,14 +8554,54 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F65760250000000000000001 /* AgentHibernationTranscriptSnapshotRaceTests.swift in Sources */, A76110010000000000000003 /* AgentHookNotificationPolicy.swift in Sources */, A76110020000000000000001 /* AgentHookNotificationPolicyTests.swift in Sources */, + A78670620000000000000001 /* AgentHookSessionLineage.swift in Sources */, + A78670630000000000000001 /* AgentHookSessionLineageResolver.swift in Sources */, + A79700100000000000000003 /* AgentHookSessionRegistryBridge.swift in Sources */, + A79701110000000000000003 /* AgentHookSessionStoreCompletion.swift in Sources */, + A78670640000000000000001 /* AgentHookSessionStoreModels.swift in Sources */, + B7970F010000000000000001 /* AgentHookSessionStoreScaleTests.swift in Sources */, A5D41230A1B2C3D4E5F60718 /* AgentNotificationGateTests.swift in Sources */, 79390003AA11BB22CC33DD03 /* AgentNotificationLiveRetargetTests.swift in Sources */, 7939000FAA11BB22CC33DD0F /* AgentNotificationMoveRaceTests.swift in Sources */, 79460001AA11BB22CC33EE01 /* AgentNotificationMutationBoundaryTests.swift in Sources */, + A79700020000000000000001 /* AgentNotificationOwnershipRegressionTests.swift in Sources */, + A78670650000000000000001 /* AgentProcessIdentity.swift in Sources */, + A78670510000000000000001 /* AgentResumeExecutableAvailabilityRegressionTests.swift in Sources */, + A79700050000000000000001 /* AgentSessionAuthorityEvidence.swift in Sources */, D3610B010000000000000001 /* AgentSessionAutoResumeSettingsTests.swift in Sources */, D3610B020000000000000001 /* AgentSessionAutoResumeSwiftTests.swift in Sources */, + A78670220000000000000001 /* AgentSessionCLIRegressionTests.swift in Sources */, + A78671200000000000000003 /* AgentSessionGraphEdge.swift in Sources */, + A78671300000000000000003 /* AgentSessionGraphEdgeResolver.swift in Sources */, + A78671400000000000000003 /* AgentSessionGraphNode.swift in Sources */, + A78671500000000000000003 /* AgentSessionGraphSnapshot.swift in Sources */, + A78670230000000000000001 /* AgentSessionHibernationLifecycleTests.swift in Sources */, + A78670300000000000000001 /* AgentSessionLifecycleAuthorityRegressionTests.swift in Sources */, + A79700030000000000000001 /* AgentSessionLifecycleRaceRegressionTests.swift in Sources */, + A79701020000000000000001 /* AgentSessionLineageAuthorityRegressionTests.swift in Sources */, + A7867A100000000000000001 /* AgentSessionProjectionParityTests.swift in Sources */, + A78670660000000000000001 /* AgentSessionRelationship.swift in Sources */, + A78670670000000000000001 /* AgentSessionRunRecord.swift in Sources */, + A79701030000000000000001 /* AgentSessionRuntimeIdentityRegressionTests.swift in Sources */, + A78670310000000000000001 /* AgentSessionRuntimeScopingRegressionTests.swift in Sources */, + A79701040000000000000001 /* AgentSessionSocketRuntimeScopingRegressionTests.swift in Sources */, A9E030000000000000000002 /* AgentSessionSocketSurfaceTests.swift in Sources */, + A78670700000000000000001 /* AgentSessionState.swift in Sources */, + A78670680000000000000001 /* AgentSessionStateProjection.swift in Sources */, A9E050000000000000000002 /* AgentSessionWebRendererTests.swift in Sources */, + A78670690000000000000001 /* AgentSessionWorkloadReconciler.swift in Sources */, + A79701050000000000000001 /* AgentSessionWriterGenerationRegressionTests.swift in Sources */, + A71860010000000000000003 /* AgentStagedOutput.swift in Sources */, + A786706A0000000000000001 /* AgentStopStateAdapter.swift in Sources */, + A78671800000000000000003 /* AgentSubtreeActivitySnapshot.swift in Sources */, + A78671600000000000000003 /* AgentTerminalObservationJoiner.swift in Sources */, + A57A7E010000000000000001 /* AgentTerminalStateRuntimeLifecycleTests.swift in Sources */, + A78671900000000000000003 /* AgentTreeTextLineSequence.swift in Sources */, + A79701140000000000000003 /* AgentVisibleMutationOwnershipAgentName.swift in Sources */, + A78670710000000000000001 /* AgentWorkloadKind.swift in Sources */, + A78670720000000000000001 /* AgentWorkloadPhase.swift in Sources */, + A78670730000000000000001 /* AgentWorkloadRecord.swift in Sources */, + A78671700000000000000003 /* AgentWorkloadSnapshot.swift in Sources */, 725746692D9647948561044D /* AppDelegateBareSpaceShortcutRoutingTests.swift in Sources */, A1B2C3D4E5F600000000CF01 /* AppDelegateDisplayConfigRestoreTests.swift in Sources */, E3309A09 /* AppDelegateEqualizeSplitsShortcutTests.swift in Sources */, @@ -8258,6 +8625,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7C592156DCB3419BB95383E5 /* AutoNamingEngineTests.swift in Sources */, A4ECF22C62724853A72D6BDE /* AutoNamingGrokAdapterTests.swift in Sources */, 74AB3F34FE3B4974A3A5D264 /* AutoNamingHookPayloadAdapterTests.swift in Sources */, + A78670500000000000000003 /* AutoNamingTranscriptMessage.swift in Sources */, D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */, 8054B0030000000000000003 /* BrowserAutomationRecoveryLifecycleTests.swift in Sources */, BCBC0A0E0000000000000D01 /* BrowserChromeMetricsTests.swift in Sources */, @@ -8313,16 +8681,18 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 79390001AA11BB22CC33DD01 /* ClaudeHookLiveDeliveryTargetTests.swift in Sources */, 79390009AA11BB22CC33DD09 /* ClaudeHookLiveDeliveryTargetTestSupport.swift in Sources */, 79390011AA11BB22CC33DD11 /* ClaudeHookPIDAuthenticationTests.swift in Sources */, + A79701100000000000000003 /* ClaudeHookSessionStore.swift in Sources */, A5D41220A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift in Sources */, A5D41222A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift in Sources */, CE7000000000000000000001 /* ClaudeWrapperResumeEnvironmentTests.swift in Sources */, A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */, 805500000000000000000001 /* CLIBrowserEvalOutputTests.swift in Sources */, CA11E4D0FA017DE500000B101 /* CLICallerWorkspaceDefaultTests.swift in Sources */, + C0D3F1F20000000000000103 /* CLICodexHookSessionStartRegressionTests.swift in Sources */, C0D3F1F00000000000000103 /* CLICodexHookTimeoutRegressionTests.swift in Sources */, C0D3F1F10000000000000103 /* CLICodexHookTimeoutRegressionTestSupport.swift in Sources */, C6711A010000000000000001 /* CLICodexWeakEnvironmentRestoreBindingTests.swift in Sources */, - C0D3F1F20000000000000103 /* CLICodexYoloResumePersistenceTests.swift in Sources */, + C0D3F1F30000000000000103 /* CLICodexYoloResumePersistenceTests.swift in Sources */, E295EA3753206FE3FFCA6C0A /* CLIExplicitSurfaceRoutingTests.swift in Sources */, C46790000000000000000001 /* CLIForwardingLaunchArgumentTests.swift in Sources */, A5D41207A1B2C3D4E5F60718 /* CLIGenericHookPersistenceTests.swift in Sources */, @@ -8330,6 +8700,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5D41211A1B2C3D4E5F60718 /* CLIHookNoResponseTests.swift in Sources */, A5D4120BA1B2C3D4E5F60718 /* CLILegacyHookAliasTests.swift in Sources */, A5D41330A1B2C3D4E5F60718 /* CLIMockSocketServerSupport.swift in Sources */, + A79701010000000000000001 /* CLINestedAgentLifecycleRegressionTests.swift in Sources */, F0F0CF0E0000000000000001 /* CLINotifyClaudeForkOfForkRegressionTests.swift in Sources */, C72280000000000000000004 /* CLINotifyClaudeHookWorkspaceRoutingTests.swift in Sources */, A5D41203A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift in Sources */, @@ -8347,6 +8718,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 773600000000000000000001 /* CLIWindowCommandMockServer.swift in Sources */, 773600000000000000000003 /* CLIWindowHandleRoutingTests.swift in Sources */, 7375A0037375A0037375A003 /* ClosedMainWindowRoutingTests.swift in Sources */, + A79700080000000000000001 /* ClosedPanelAgentHistoryRegressionTests.swift in Sources */, C75740010000000000000001 /* CloudVMMenuItemMetricsTests.swift in Sources */, C0DE43000000000000000009 /* CmuxAgentChatConfigTests.swift in Sources */, 8295A0058295A0058295A005 /* CmuxAlertContentTests.swift in Sources */, @@ -8387,8 +8759,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A6AC72080000000000000001 /* CMUXWorkspaceLoadingCLIRegressionTests.swift in Sources */, AAE06C3AEC68484DA33D42A1 /* CoalesceLatestPublisherDemandTests.swift in Sources */, A9E040000000000000000002 /* CodexAppServerSessionTests.swift in Sources */, + A91778670000000000000001 /* CodexHookWriterOwnershipRegressionTests.swift in Sources */, C0DECAFE0000000000000003 /* CodexTeamsApprovalBridge.swift in Sources */, C0DE7100C0DE7100C0DE7100 /* CodexTerminalErrorNotificationTests.swift in Sources */, + C0D3F1F50000000000000104 /* CodexWrapperResumeRegressionTests.swift in Sources */, C0DEC0DE000000000000F302 /* CommandPaletteEmojiTitleSearchTests.swift in Sources */, C0DEC0DE000000000000F202 /* CommandPaletteNucleoFFILibrarySupport.swift in Sources */, C0DEC0DE000000000000F102 /* CommandPaletteNucleoFFITests.swift in Sources */, @@ -8598,9 +8972,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E2701030000000000000001 /* SentryEventScrubberTests.swift in Sources */, C71510010000000000000001 /* SessionContentWidthSettingsFileStoreTests.swift in Sources */, 8A3392FE64E0605D942213D1 /* SessionIndexViewTests.swift in Sources */, + A78671A00000000000000003 /* SessionListEntryAccumulator.swift in Sources */, F6572002A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, 806600000000000000000002 /* SessionRestorableAgentSnapshotPermissionModeTests.swift in Sources */, + A79701130000000000000003 /* SessionsListClaudeTranscriptLookupCache.swift in Sources */, 583A675AA1224E8D82A44883 /* SetAutoTitleSocketTests.swift in Sources */, A50019B2 /* SettingsSearchIndexTests.swift in Sources */, D80100010000000000000001 /* SettingsWindowChromeTests.swift in Sources */, diff --git a/cmuxTests/AgentChatSessionRegistryHookStoreTests.swift b/cmuxTests/AgentChatSessionRegistryHookStoreTests.swift index e356bf229471..d16a8efb73a5 100644 --- a/cmuxTests/AgentChatSessionRegistryHookStoreTests.swift +++ b/cmuxTests/AgentChatSessionRegistryHookStoreTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing import Darwin import CMUXAgentLaunch +import CmuxFoundation #if canImport(cmux_DEV) @testable import cmux_DEV @@ -10,6 +11,158 @@ import CMUXAgentLaunch #endif struct AgentChatSessionRegistryHookStoreTests { + @Test func hookStoreFindsCanonicalSessionBeyondLegacyProjection() throws { + let home = try temporaryHomeDirectory() + defer { try? FileManager.default.removeItem(at: home) } + let stateDirectory = home.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + + let targetSessionID = "chat-session-older-than-projection" + let targetWorkspaceID = UUID().uuidString + let targetSurfaceID = UUID().uuidString + let targetTranscriptPath = "/tmp/\(targetSessionID).jsonl" + let registry = CmuxAgentSessionRegistry( + url: stateDirectory.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + var records = try (0..<300).map { index in + try canonicalHookRecord( + provider: "claude", + sessionID: String(format: "recent-%03d", index), + workspaceID: UUID().uuidString, + surfaceID: UUID().uuidString, + transcriptPath: "/tmp/recent-\(index).jsonl", + updatedAt: TimeInterval(1_000 + index) + ) + } + records.append(try canonicalHookRecord( + provider: "claude", + sessionID: targetSessionID, + workspaceID: targetWorkspaceID, + surfaceID: targetSurfaceID, + transcriptPath: targetTranscriptPath, + updatedAt: 1 + )) + try registry.apply(provider: "claude", records: records) + try writeCanonicalLegacyProjection( + records: Array(records.prefix(256)), + to: stateDirectory.appendingPathComponent("claude-hook-sessions.json") + ) + + let store = AgentChatHookSessionStore(homeDirectory: home) + let entry = try #require(store.entry(agentSource: "claude", sessionID: targetSessionID)) + #expect(entry.workspaceID == targetWorkspaceID) + #expect(entry.surfaceID == targetSurfaceID) + #expect(entry.transcriptPath == targetTranscriptPath) + #expect(store.entries(agentSource: "claude").count == 301) + } + + @Test func hookStoreRetainsBoundedFlatLegacyFallback() throws { + let home = try temporaryHomeDirectory() + defer { try? FileManager.default.removeItem(at: home) } + let stateDirectory = home.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + + let sessionID = "flat-chat-session" + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let data = try JSONSerialization.data(withJSONObject: [ + sessionID: [ + "workspaceId": workspaceID, + "surfaceId": surfaceID, + "cwd": "/tmp/flat-chat", + "updatedAt": 1.0, + ], + ], options: [.sortedKeys]) + try data.write( + to: stateDirectory.appendingPathComponent("claude-hook-sessions.json"), + options: .atomic + ) + + let entry = try #require( + AgentChatHookSessionStore(homeDirectory: home) + .entry(agentSource: "claude", sessionID: sessionID) + ) + #expect(entry.workspaceID == workspaceID) + #expect(entry.surfaceID == surfaceID) + #expect(entry.workingDirectory == "/tmp/flat-chat") + } + + @MainActor + @Test func hookStoreSeedIsBoundedButExactHistoryRemainsAvailable() async throws { + let home = try temporaryHomeDirectory() + defer { try? FileManager.default.removeItem(at: home) } + let stateDirectory = home.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + + let activeOldSessionID = "active-old-session" + let exactOldSessionID = "inactive-old-session" + let activeWorkspaceID = UUID().uuidString + let activeSurfaceID = UUID().uuidString + let registry = CmuxAgentSessionRegistry( + url: stateDirectory.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + var records = try (0..<700).map { index in + try canonicalHookRecord( + provider: "claude", + sessionID: String(format: "recent-%03d", index), + workspaceID: UUID().uuidString, + surfaceID: UUID().uuidString, + transcriptPath: "/tmp/recent-\(index).jsonl", + updatedAt: TimeInterval(1_000 + index) + ) + } + records.append(try canonicalHookRecord( + provider: "claude", + sessionID: activeOldSessionID, + workspaceID: activeWorkspaceID, + surfaceID: activeSurfaceID, + transcriptPath: "/tmp/active-old.jsonl", + updatedAt: 1 + )) + records.append(try canonicalHookRecord( + provider: "claude", + sessionID: exactOldSessionID, + workspaceID: UUID().uuidString, + surfaceID: UUID().uuidString, + transcriptPath: "/tmp/inactive-old.jsonl", + updatedAt: 2 + )) + let slotJSON = try JSONSerialization.data(withJSONObject: [ + "sessionId": activeOldSessionID, + "updatedAt": 1.0, + ], options: [.sortedKeys]) + try registry.apply( + provider: "claude", + records: records, + activeSlots: [ + .init( + provider: "claude", + scope: .surface, + scopeID: activeSurfaceID, + sessionID: activeOldSessionID, + updatedAt: 1, + json: slotJSON + ), + ] + ) + + let store = AgentChatHookSessionStore(homeDirectory: home) + let seeded = store.entries(agentSource: "claude") + #expect(seeded.count == AgentChatHookSessionStore.maximumSeedRecords) + #expect(seeded.first?.sessionID == activeOldSessionID) + #expect(seeded.contains { $0.sessionID == activeOldSessionID }) + #expect(!seeded.contains { $0.sessionID == exactOldSessionID }) + #expect(store.entry(agentSource: "claude", sessionID: exactOldSessionID) != nil) + + let chatRegistry = AgentChatSessionRegistry(hookStore: store) + var appliedCount = 0 + chatRegistry.onRecordChanged = { _, _ in appliedCount += 1 } + await chatRegistry.seedFromHookStores(agentSources: ["claude"]) + #expect(appliedCount == AgentChatHookSessionStore.maximumSeedRecords) + #expect(chatRegistry.record(sessionID: activeOldSessionID) != nil) + #expect(chatRegistry.record(sessionID: exactOldSessionID) == nil) + } + @Test func mobileChatObserverDetectsCmuxLaunchedOpaqueClaudeWrapper() throws { let workspaceID = UUID() let surfaceID = UUID() @@ -282,4 +435,44 @@ struct AgentChatSessionRegistryHookStoreTests { let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) try data.write(to: directory.appendingPathComponent("claude-hook-sessions.json")) } + + private func canonicalHookRecord( + provider: String, + sessionID: String, + workspaceID: String, + surfaceID: String, + transcriptPath: String, + updatedAt: TimeInterval + ) throws -> CmuxAgentSessionRegistry.Record { + let json = try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "workspaceId": workspaceID, + "surfaceId": surfaceID, + "cwd": "/tmp/project", + "transcriptPath": transcriptPath, + "pid": 999_999, + "updatedAt": updatedAt, + ], options: [.sortedKeys]) + return CmuxAgentSessionRegistry.Record( + provider: provider, + sessionID: sessionID, + updatedAt: updatedAt, + json: json + ) + } + + private func writeCanonicalLegacyProjection( + records: [CmuxAgentSessionRegistry.Record], + to url: URL + ) throws { + var sessions: [String: Any] = [:] + for record in records { + sessions[record.sessionID] = try JSONSerialization.jsonObject(with: record.json) + } + let data = try JSONSerialization.data(withJSONObject: [ + "version": 1, + "sessions": sessions, + ], options: [.sortedKeys]) + try data.write(to: url, options: .atomic) + } } diff --git a/cmuxTests/AgentHibernationLifecycleStateTestAlias.swift b/cmuxTests/AgentHibernationLifecycleStateTestAlias.swift new file mode 100644 index 000000000000..4855fb58707e --- /dev/null +++ b/cmuxTests/AgentHibernationLifecycleStateTestAlias.swift @@ -0,0 +1,7 @@ +#if canImport(cmux_DEV) +@testable import cmux_DEV +typealias AgentHibernationLifecycleState = cmux_DEV.AgentHibernationLifecycleState +#elseif canImport(cmux) +@testable import cmux +typealias AgentHibernationLifecycleState = cmux.AgentHibernationLifecycleState +#endif diff --git a/cmuxTests/AgentHibernationOwnedLiveProcessTests.swift b/cmuxTests/AgentHibernationOwnedLiveProcessTests.swift new file mode 100644 index 000000000000..a751e09cce4b --- /dev/null +++ b/cmuxTests/AgentHibernationOwnedLiveProcessTests.swift @@ -0,0 +1,1068 @@ +import Darwin +import CmuxTerminal +import Foundation +import os +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite +struct AgentHibernationOwnedLiveProcessTests { + private let shellPID = 4_242 + private let ttyDevice: Int64 = 9_001 + + @Test + func processFreeShellProducesMinimalLease() throws { + let key = panelKey() + let identity = processIdentity(pid: shellPID) + let index = topologyIndex( + key: key, + identity: identity, + ttyEnumeration: .complete([shellPID]), + childEnumeration: .complete([]) + ) + + let evidence = index.evidence(for: key) + let lease = try #require(evidence.lease) + #expect(evidence.allowsHibernation) + #expect(evidence.processIDs.isEmpty) + #expect(lease.workspaceId == key.workspaceId) + #expect(lease.panelId == key.panelId) + #expect(lease.shellPID == shellPID) + #expect(lease.shellIdentity == identity) + #expect(lease.ttyDevice == ttyDevice) + #expect(lease.arguments == ["/bin/zsh", "-l"]) + } + + @Test + func targetTTYOrChildUncertaintyFailsClosed() { + let key = panelKey() + let identity = processIdentity(pid: shellPID) + let cases: [(CmuxTopTargetedPIDEnumeration, CmuxTopTargetedPIDEnumeration)] = [ + (.incomplete, .complete([])), + (.complete([shellPID, shellPID + 1]), .complete([])), + (.complete([shellPID]), .incomplete), + (.complete([shellPID]), .complete([shellPID + 1])), + ] + + for (tty, children) in cases { + let evidence = topologyIndex( + key: key, + identity: identity, + ttyEnumeration: tty, + childEnumeration: children + ).evidence(for: key) + #expect(!evidence.allowsHibernation) + #expect(evidence.lease == nil) + } + } + + @Test + func unrelatedUnreadableProcessDoesNotPoisonTargetedProof() { + let key = panelKey() + let otherKey = panelKey() + let identity = processIdentity(pid: shellPID) + let snapshot = CmuxTopProcessSnapshot( + processes: [ + shellProcess(key: key, identity: identity), + shellProcess( + key: otherKey, + pid: shellPID + 100, + ttyDevice: ttyDevice + 100, + identity: nil + ), + ], + sampledAt: Date(), + includesProcessDetails: true + ) + let index = AgentHibernationProcessTopologyIndex( + processSnapshot: snapshot, + targetPanelKeys: [key], + processArguments: { pid in pid == self.shellPID ? self.shellArguments(key: key) : nil }, + processIdentity: { pid in pid == self.shellPID ? identity : nil }, + processExecutablePath: { pid in pid == self.shellPID ? "/bin/zsh" : nil }, + processSessionID: { pid in pid == self.shellPID ? pid_t(self.shellPID) : nil }, + ttyProcessIDs: { _ in .complete([self.shellPID]) }, + childProcessIDs: { _ in .complete([]) } + ) + + #expect(index.evidence(for: key).allowsHibernation) + } + + @Test + func topologyQueriesEachTargetTTYOnlyOnce() { + let firstKey = panelKey() + let secondKey = panelKey() + let firstPID = shellPID + let secondPID = shellPID + 1 + let firstIdentity = processIdentity(pid: firstPID) + let secondIdentity = processIdentity(pid: secondPID) + let snapshot = CmuxTopProcessSnapshot( + processes: [ + shellProcess(key: firstKey, pid: firstPID, identity: firstIdentity), + shellProcess(key: secondKey, pid: secondPID, identity: secondIdentity), + ], + sampledAt: Date(), + includesProcessDetails: true + ) + var ttyQueries = 0 + _ = AgentHibernationProcessTopologyIndex( + processSnapshot: snapshot, + targetPanelKeys: [firstKey, secondKey], + processArguments: { pid in + pid == firstPID ? self.shellArguments(key: firstKey) : self.shellArguments(key: secondKey) + }, + processIdentity: { pid in pid == firstPID ? firstIdentity : secondIdentity }, + processExecutablePath: { _ in "/bin/zsh" }, + processSessionID: { pid_t($0) }, + ttyProcessIDs: { _ in + ttyQueries += 1 + return .complete([firstPID, secondPID]) + }, + childProcessIDs: { _ in .complete([]) } + ) + + #expect(ttyQueries == 1) + } + + @Test + func finalLeaseValidationRejectsEveryTopologyDrift() throws { + let key = panelKey() + let identity = processIdentity(pid: shellPID) + let lease = try #require(topologyIndex( + key: key, + identity: identity, + ttyEnumeration: .complete([shellPID]), + childEnumeration: .complete([]) + ).evidence(for: key).lease) + let topology = AgentHibernationProcessFreeLease.ProcessTopology( + parentPID: 1, + name: "zsh", + ttyDevice: ttyDevice, + processGroupID: shellPID, + terminalProcessGroupID: shellPID + ) + func validates( + arguments: [String] = ["/bin/zsh", "-l"], + path: String = "/bin/zsh", + ttyPIDs: Set = [shellPID], + children: Set = [], + currentIdentity: AgentPIDProcessIdentity? = identity, + currentTopology: AgentHibernationProcessFreeLease.ProcessTopology? = topology + ) -> Bool { + lease.isStillProcessFree( + processArguments: { _ in + CmuxTopProcessArguments( + arguments: arguments, + environment: [ + "CMUX_WORKSPACE_ID": key.workspaceId.uuidString, + "CMUX_SURFACE_ID": key.panelId.uuidString, + ] + ) + }, + processIdentity: { _ in currentIdentity }, + processExecutablePath: { _ in path }, + processSessionID: { _ in pid_t(self.shellPID) }, + ttyProcessIDs: { _ in .complete(ttyPIDs) }, + childProcessIDs: { _ in .complete(children) }, + processTopology: { _ in currentTopology } + ) + } + + #expect(validates()) + #expect(!validates(arguments: ["/usr/bin/read", "secret"])) + #expect(!validates(path: "/bin/bash")) + #expect(!validates(ttyPIDs: [shellPID, shellPID + 1])) + #expect(!validates(children: [shellPID + 1])) + #expect(!validates(currentIdentity: processIdentity(pid: shellPID, seconds: 101))) + #expect(!validates(currentTopology: nil)) + } + + @Test + func frozenLeaseClosesForkWindowUntilExplicitResume() throws { + let key = panelKey() + let identity = processIdentity(pid: shellPID) + let lease = try #require(topologyIndex( + key: key, + identity: identity, + ttyEnumeration: .complete([shellPID]), + childEnumeration: .complete([]) + ).evidence(for: key).lease) + let state = AgentHibernationFrozenShellTestState(identity: identity, status: UInt32(SRUN)) + + let frozen = try #require(lease.freezeForFinalTeardown( + processIdentity: { _ in state.identity }, + processStatus: { _ in state.status }, + processGenerationFence: { _ in state.generationFence() }, + sendSignal: { _, signal in state.send(signal) }, + waitForStoppedChild: { _ in state.status == UInt32(SSTOP) }, + finalProcessFreeValidation: { + state.recordFinalValidation() + return state.status == UInt32(SSTOP) && !state.hasChild + } + )) + + #expect(state.finalValidationObservedStop) + state.attemptFork() + #expect(!state.hasChild) + #expect(frozen.isStillFrozenAndProcessFree(finalProcessFreeValidation: { + state.status == UInt32(SSTOP) && !state.hasChild + })) + frozen.resume() + #expect(state.signals == [SIGSTOP, SIGCONT]) + state.attemptFork() + #expect(state.hasChild) + } + + @Test + func frozenLeaseRejectsPreStoppedShellWithoutResumingIt() throws { + let key = panelKey() + let identity = processIdentity(pid: shellPID) + let lease = try #require(topologyIndex( + key: key, + identity: identity, + ttyEnumeration: .complete([shellPID]), + childEnumeration: .complete([]) + ).evidence(for: key).lease) + let state = AgentHibernationFrozenShellTestState(identity: identity, status: UInt32(SSTOP)) + + #expect(lease.freezeForFinalTeardown( + processIdentity: { _ in state.identity }, + processStatus: { _ in state.status }, + processGenerationFence: { _ in state.generationFence() }, + sendSignal: { _, signal in state.send(signal) }, + waitForStoppedChild: { _ in state.status == UInt32(SSTOP) }, + finalProcessFreeValidation: { true } + ) == nil) + #expect(state.signals.isEmpty) + } + + @Test + func failedFrozenValidationResumesExactShellGeneration() throws { + let key = panelKey() + let identity = processIdentity(pid: shellPID) + let lease = try #require(topologyIndex( + key: key, + identity: identity, + ttyEnumeration: .complete([shellPID]), + childEnumeration: .complete([]) + ).evidence(for: key).lease) + let state = AgentHibernationFrozenShellTestState(identity: identity, status: UInt32(SRUN)) + + #expect(lease.freezeForFinalTeardown( + processIdentity: { _ in state.identity }, + processStatus: { _ in state.status }, + processGenerationFence: { _ in state.generationFence() }, + sendSignal: { _, signal in state.send(signal) }, + waitForStoppedChild: { _ in state.status == UInt32(SSTOP) }, + finalProcessFreeValidation: { false } + ) == nil) + #expect(state.signals == [SIGSTOP, SIGCONT]) + } + + @Test + func frozenLeaseUsesGenerationFenceWhenIdentityReadIsUnavailable() throws { + let key = panelKey() + let identity = processIdentity(pid: shellPID) + let lease = try #require(topologyIndex( + key: key, + identity: identity, + ttyEnumeration: .complete([shellPID]), + childEnumeration: .complete([]) + ).evidence(for: key).lease) + let state = AgentHibernationFrozenShellTestState(identity: identity, status: UInt32(SRUN)) + let frozen = try #require(lease.freezeForFinalTeardown( + processIdentity: { _ in state.readIdentity() }, + processStatus: { _ in state.status }, + processGenerationFence: { _ in state.generationFence() }, + sendSignal: { _, signal in state.send(signal) }, + waitForStoppedChild: { _ in state.status == UInt32(SSTOP) }, + finalProcessFreeValidation: { true } + )) + state.failNextIdentityReads(1) + + frozen.resume() + #expect(state.status == UInt32(SRUN)) + #expect(state.signals == [SIGSTOP, SIGCONT]) + + frozen.resume() + #expect(state.signals == [SIGSTOP, SIGCONT]) + } + + @Test + func frozenLeaseResumesThroughPersistentIdentityProbeFailure() throws { + let key = panelKey() + let identity = processIdentity(pid: shellPID) + let lease = try #require(topologyIndex( + key: key, + identity: identity, + ttyEnumeration: .complete([shellPID]), + childEnumeration: .complete([]) + ).evidence(for: key).lease) + let state = AgentHibernationFrozenShellTestState(identity: identity, status: UInt32(SRUN)) + var frozen: AgentHibernationFrozenShellLease? = try #require(lease.freezeForFinalTeardown( + processIdentity: { _ in state.readIdentity() }, + processStatus: { _ in state.status }, + processGenerationFence: { _ in state.generationFence() }, + sendSignal: { _, signal in state.send(signal) }, + waitForStoppedChild: { _ in state.status == UInt32(SSTOP) }, + finalProcessFreeValidation: { true } + )) + state.failNextIdentityReads(.max) + + frozen?.resume() + frozen = nil + + #expect(state.status == UInt32(SRUN)) + #expect(state.signals == [SIGSTOP, SIGCONT]) + } + + @Test + func frozenLeaseNeverSignalsAReplacementPIDGeneration() throws { + let key = panelKey() + let identity = processIdentity(pid: shellPID) + let lease = try #require(topologyIndex( + key: key, + identity: identity, + ttyEnumeration: .complete([shellPID]), + childEnumeration: .complete([]) + ).evidence(for: key).lease) + let state = AgentHibernationFrozenShellTestState(identity: identity, status: UInt32(SRUN)) + let frozen = try #require(lease.freezeForFinalTeardown( + processIdentity: { _ in state.identity }, + processStatus: { _ in state.status }, + processGenerationFence: { _ in state.generationFence() }, + sendSignal: { _, signal in state.send(signal) }, + waitForStoppedChild: { _ in state.status == UInt32(SSTOP) }, + finalProcessFreeValidation: { true } + )) + state.replaceIdentity(processIdentity(pid: shellPID, seconds: 101)) + + frozen.resume() + frozen.resume() + #expect(state.signals == [SIGSTOP]) + } + + @Test + func realChildStopBoundarySurvivesSchedulerLoad() throws { + var attributes: posix_spawnattr_t? + let attributeStatus = posix_spawnattr_init(&attributes) + guard attributeStatus == 0 else { + Issue.record( + "posix_spawnattr_init failed: \(String(cString: strerror(attributeStatus)))" + ) + return + } + defer { posix_spawnattr_destroy(&attributes) } + var defaultSignals = sigset_t() + var signalMask = sigset_t() + guard sigemptyset(&defaultSignals) == 0, + sigaddset(&defaultSignals, SIGTERM) == 0, + sigemptyset(&signalMask) == 0, + posix_spawnattr_setsigdefault(&attributes, &defaultSignals) == 0, + posix_spawnattr_setsigmask(&attributes, &signalMask) == 0, + posix_spawnattr_setflags( + &attributes, + Int16(POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK) + ) == 0 else { + Issue.record("failed to configure child signal defaults") + return + } + var childPID: pid_t = 0 + let spawnStatus = withPOSIXCStringArray(["/bin/sleep", "30"]) { arguments in + "/bin/sleep".withCString { executablePath in + posix_spawn( + &childPID, + executablePath, + nil, + &attributes, + arguments, + environ + ) + } + } + guard spawnStatus == 0 else { + Issue.record("posix_spawn failed: \(String(cString: strerror(spawnStatus)))") + return + } + + var didReapChild = false + defer { + if !didReapChild { + _ = kill(childPID, SIGKILL) + _ = waitpid(childPID, nil, 0) + } + } + let identity = try #require(AgentPIDProcessIdentity(pid: childPID)) + let lease = AgentHibernationProcessFreeLease( + workspaceId: UUID(), + panelId: UUID(), + shellPID: Int(childPID), + shellIdentity: identity, + shellParentPID: Int(getpid()), + shellName: "test-child", + executablePath: "/test-child", + arguments: [], + ttyDevice: 1, + sessionID: Int(childPID), + processGroupID: Int(childPID), + terminalProcessGroupID: Int(childPID) + ) + let signalTrace = OSAllocatedUnfairLock(initialState: [String]()) + let initialStatus = liveProcessStatus(childPID) + + let keepLoading = OSAllocatedUnfairLock(initialState: true) + let loadGroup = DispatchGroup() + for _ in 0..<8 { + loadGroup.enter() + DispatchQueue.global(qos: .userInitiated).async { + var value = 0 + while keepLoading.withLock({ $0 }) { + value &+= 1 + } + _ = value + loadGroup.leave() + } + } + let frozen = lease.freezeForFinalTeardown( + sendSignal: { pid, signal in + let statusBefore = liveProcessStatus(pid) + errno = 0 + let result = Darwin.kill(pid, signal) + let signalError = errno + let statusAfter = liveProcessStatus(pid) + signalTrace.withLock { + $0.append( + "signal=\(signal) result=\(result) errno=\(signalError) " + + "before=\(String(describing: statusBefore)) " + + "after=\(String(describing: statusAfter))" + ) + } + return result + }, + finalProcessFreeValidation: { true } + ) + let stoppedStatus = liveProcessStatus(childPID) + keepLoading.withLock { $0 = false } + loadGroup.wait() + let exactFrozen = try #require(frozen) + exactFrozen.resume() + let resumedStatus = liveProcessStatus(childPID) + + let didExit = DispatchSemaphore(value: 0) + let exitSourceTrace = OSAllocatedUnfairLock(initialState: ["created"]) + let exitSource = DispatchSource.makeProcessSource( + identifier: childPID, + eventMask: .exit, + queue: .global(qos: .utility) + ) + exitSource.setEventHandler { + exitSourceTrace.withLock { $0.append("exit-event") } + didExit.signal() + } + exitSource.setCancelHandler { + exitSourceTrace.withLock { $0.append("cancel-handler") } + } + exitSourceTrace.withLock { $0.append("activating") } + exitSource.activate() + exitSourceTrace.withLock { $0.append("activated") } + errno = 0 + let terminateResult = kill(childPID, SIGTERM) + let terminateError = errno + let terminatedStatus = liveProcessStatus(childPID) + let exitResult = didExit.wait(timeout: .now() + 2) + let statusAfterExitWait = liveProcessStatus(childPID) + exitSourceTrace.withLock { $0.append("wait=\(exitResult)") } + exitSource.cancel() + exitSourceTrace.withLock { $0.append("cancelled") } + if exitResult != .success { _ = kill(childPID, SIGKILL) } + var childStatus: Int32 = 0 + let waitResult = waitpid(childPID, &childStatus, 0) + didReapChild = true + + let evidence = [ + "initial=\(String(describing: initialStatus))", + "stopped=\(String(describing: stoppedStatus))", + "resumed=\(String(describing: resumedStatus))", + "terminateResult=\(terminateResult)", + "terminateErrno=\(terminateError)", + "terminated=\(String(describing: terminatedStatus))", + "afterExitWait=\(String(describing: statusAfterExitWait))", + "waitResult=\(waitResult)", + "waitStatus=\(childStatus)", + "signals=\(signalTrace.withLock { $0 })", + "exitSource=\(exitSourceTrace.withLock { $0 })", + ].joined(separator: " ") + #expect(exitResult == .success, Comment(rawValue: evidence)) + } + + @MainActor + @Test + func frozenShellSpansFinalProcessProofDurableCommitAndNativeFree() async throws { + let key = panelKey() + let identity = processIdentity(pid: shellPID) + let lease = try #require(topologyIndex( + key: key, + identity: identity, + ttyEnumeration: .complete([shellPID]), + childEnumeration: .complete([]) + ).evidence(for: key).lease) + let state = AgentHibernationFrozenShellTestState(identity: identity, status: UInt32(SRUN)) + let workspace = Workspace(workingDirectory: "/tmp") + let panelID = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.terminalPanel(for: panelID)) + let runtimeSurface = UnsafeMutableRawPointer(bitPattern: 0x7867)! + panel.surface.installRuntimeSurfaceForTesting(runtimeSurface) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + state.recordNativeFree() + } + defer { + if panel.surface.surface == runtimeSurface { + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in } + panel.surface.teardownSurface() + } + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil + } + + let didHibernate = await panel.enterAgentHibernation( + agent: restorableAgent(), + lastActivityAt: Date(timeIntervalSince1970: 10), + finalValidation: { true }, + finalTeardownPreparation: { + guard let frozen = lease.freezeForFinalTeardown( + processIdentity: { _ in state.identity }, + processStatus: { _ in state.status }, + processGenerationFence: { _ in state.generationFence() }, + sendSignal: { _, signal in state.send(signal) }, + waitForStoppedChild: { _ in state.status == UInt32(SSTOP) }, + finalProcessFreeValidation: { + state.recordFinalValidation() + return state.status == UInt32(SSTOP) && !state.hasChild + } + ) else { + return nil + } + return { frozen.resume() } + }, + finalCommit: { + state.recordLifecycleCommit(accepted: true) + return true + } + ) + + #expect(didHibernate) + #expect(panel.isAgentHibernated) + #expect(state.events == [ + "SIGSTOP", + "processFree", + "lifecycleCommit", + "nativeFree", + "SIGCONT", + ]) + } + + @MainActor + @Test + func rejectedDurableCommitResumesShellAndRestoresExactLiveRuntime() async throws { + let key = panelKey() + let identity = processIdentity(pid: shellPID) + let lease = try #require(topologyIndex( + key: key, + identity: identity, + ttyEnumeration: .complete([shellPID]), + childEnumeration: .complete([]) + ).evidence(for: key).lease) + let state = AgentHibernationFrozenShellTestState(identity: identity, status: UInt32(SRUN)) + let workspace = Workspace(workingDirectory: "/tmp") + let panelID = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.terminalPanel(for: panelID)) + let runtimeSurface = UnsafeMutableRawPointer(bitPattern: 0x7868)! + panel.surface.installRuntimeSurfaceForTesting(runtimeSurface) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + state.recordNativeFree() + } + defer { + if panel.surface.surface == runtimeSurface { + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in } + panel.surface.teardownSurface() + } + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil + } + + let didHibernate = await panel.enterAgentHibernation( + agent: restorableAgent(), + lastActivityAt: Date(timeIntervalSince1970: 10), + finalValidation: { true }, + finalTeardownPreparation: { + guard let frozen = lease.freezeForFinalTeardown( + processIdentity: { _ in state.identity }, + processStatus: { _ in state.status }, + processGenerationFence: { _ in state.generationFence() }, + sendSignal: { _, signal in state.send(signal) }, + waitForStoppedChild: { _ in state.status == UInt32(SSTOP) }, + finalProcessFreeValidation: { + state.recordFinalValidation() + return state.status == UInt32(SSTOP) && !state.hasChild + } + ) else { + return nil + } + return { frozen.resume() } + }, + finalCommit: { + state.recordLifecycleCommit(accepted: false) + return false + } + ) + + #expect(!didHibernate) + #expect(!panel.isAgentHibernated) + #expect(panel.surface.surface == runtimeSurface) + #expect(state.events == [ + "SIGSTOP", + "processFree", + "lifecycleCommitRejected", + "SIGCONT", + ]) + } + + @Test + func standardLoaderCannotAccidentallyAuthorizeHibernation() throws { + let fixture = try hookFixture(sessionId: "standard-load") + defer { try? FileManager.default.removeItem(at: fixture.home) } + let identity = processIdentity(pid: shellPID) + let snapshot = processSnapshot(key: fixture.key, identity: identity) + + let standard = loadIndex(fixture: fixture, snapshot: snapshot, identity: identity, mode: .standard) + let hibernation = loadIndex( + fixture: fixture, + snapshot: snapshot, + identity: identity, + mode: .hibernation(processSnapshot: snapshot) + ) + + #expect(!standard.processEvidence( + workspaceId: fixture.key.workspaceId, + panelId: fixture.key.panelId + ).allowsHibernation) + #expect(hibernation.processEvidence( + workspaceId: fixture.key.workspaceId, + panelId: fixture.key.panelId + ).allowsHibernation) + } + + @Test + func staleLegacyPIDDoesNotPermanentlyPoisonProcessFreePanel() throws { + let fixture = try hookFixture(sessionId: "legacy-stale-pid", recordedPID: 99_999) + defer { try? FileManager.default.removeItem(at: fixture.home) } + let identity = processIdentity(pid: shellPID) + let snapshot = processSnapshot(key: fixture.key, identity: identity) + let index = loadIndex( + fixture: fixture, + snapshot: snapshot, + identity: identity, + mode: .hibernation(processSnapshot: snapshot) + ) + + #expect(index.processEvidence( + workspaceId: fixture.key.workspaceId, + panelId: fixture.key.panelId + ).allowsHibernation) + } + + @Test + func restoredWorkspaceRekeyUsesExactLiveShellScope() throws { + let fixture = try hookFixture(sessionId: "restored-workspace-rekey") + defer { try? FileManager.default.removeItem(at: fixture.home) } + let liveKey = RestorableAgentSessionIndex.PanelKey( + workspaceId: UUID(), + panelId: fixture.key.panelId + ) + let identity = processIdentity(pid: shellPID) + let snapshot = processSnapshot(key: liveKey, identity: identity) + let index = RestorableAgentSessionIndex.load( + homeDirectory: fixture.home.path, + fileManager: .default, + registry: CmuxVaultAgentRegistry(registrations: []), + detectedSnapshots: [:], + mode: .hibernation(processSnapshot: snapshot), + processArgumentsProvider: { pid in + pid == self.shellPID ? self.shellArguments(key: liveKey) : nil + }, + processIdentityProvider: { pid in pid == self.shellPID ? identity : nil }, + processExecutablePathProvider: { _ in "/bin/zsh" }, + processSessionIDProvider: { _ in pid_t(self.shellPID) }, + ttyProcessIDsProvider: { _ in .complete([self.shellPID]) }, + childProcessIDsProvider: { _ in .complete([]) } + ) + + #expect(index.entry( + workspaceId: liveKey.workspaceId, + panelId: liveKey.panelId + )?.snapshot.sessionId == "restored-workspace-rekey") + #expect(index.processEvidence( + workspaceId: liveKey.workspaceId, + panelId: liveKey.panelId + ).allowsHibernation) + } + + @Test + func liveSameSurfaceInAnotherWorkspaceRevokesProcessFreeLease() throws { + let fixture = try hookFixture(sessionId: "cross-runtime-live-owner") + defer { try? FileManager.default.removeItem(at: fixture.home) } + let currentKey = RestorableAgentSessionIndex.PanelKey( + workspaceId: UUID(), + panelId: fixture.key.panelId + ) + let identity = processIdentity(pid: shellPID) + let otherAgentPID = shellPID + 500 + let otherAgentIdentity = processIdentity(pid: otherAgentPID) + let snapshot = processSnapshot(key: currentKey, identity: identity) + let detected: [RestorableAgentSessionIndex.PanelKey: RestorableAgentSessionIndex.ProcessDetectedSnapshotEntry] = [ + fixture.key: ( + snapshot: SessionRestorableAgentSnapshot( + kind: .opencode, + sessionId: "cross-runtime-live-owner", + workingDirectory: "/tmp/cmux-process-free", + launchCommand: nil + ), + updatedAt: 200, + processIDs: [otherAgentPID], + agentProcessIDs: [otherAgentPID], + sessionIDSource: .explicit + ), + ] + let index = RestorableAgentSessionIndex.load( + homeDirectory: fixture.home.path, + fileManager: .default, + registry: CmuxVaultAgentRegistry(registrations: []), + detectedSnapshots: detected, + mode: .hibernation(processSnapshot: snapshot), + processArgumentsProvider: { pid in + pid == self.shellPID ? self.shellArguments(key: currentKey) : nil + }, + processIdentityProvider: { pid in + pid == self.shellPID ? identity : (pid == otherAgentPID ? otherAgentIdentity : nil) + }, + processExecutablePathProvider: { _ in "/bin/zsh" }, + processSessionIDProvider: { _ in pid_t(self.shellPID) }, + ttyProcessIDsProvider: { _ in .complete([self.shellPID]) }, + childProcessIDsProvider: { _ in .complete([]) } + ) + + #expect(!index.processEvidence( + workspaceId: currentKey.workspaceId, + panelId: currentKey.panelId + ).allowsHibernation) + #expect(index.processEvidence( + workspaceId: fixture.key.workspaceId, + panelId: fixture.key.panelId + ).processIDs == [otherAgentPID]) + } + + @Test + func promptAndBothCloseGatesAreExact() { + #expect(AgentHibernationController.passesPromptAndCloseGates( + workspaceShellActivity: .promptIdle, + panelShellActivity: .promptIdle, + rawNeedsConfirmClose: false, + workspaceNeedsConfirmClose: false + )) + #expect(!AgentHibernationController.passesPromptAndCloseGates( + workspaceShellActivity: .unknown, + panelShellActivity: .promptIdle, + rawNeedsConfirmClose: false, + workspaceNeedsConfirmClose: false + )) + #expect(!AgentHibernationController.passesPromptAndCloseGates( + workspaceShellActivity: .promptIdle, + panelShellActivity: .commandRunning, + rawNeedsConfirmClose: false, + workspaceNeedsConfirmClose: false + )) + #expect(!AgentHibernationController.passesPromptAndCloseGates( + workspaceShellActivity: .promptIdle, + panelShellActivity: .promptIdle, + rawNeedsConfirmClose: true, + workspaceNeedsConfirmClose: false + )) + #expect(!AgentHibernationController.passesPromptAndCloseGates( + workspaceShellActivity: .promptIdle, + panelShellActivity: .promptIdle, + rawNeedsConfirmClose: false, + workspaceNeedsConfirmClose: true + )) + } + + private struct HookFixture { + let home: URL + let key: RestorableAgentSessionIndex.PanelKey + } + + private func panelKey() -> RestorableAgentSessionIndex.PanelKey { + .init(workspaceId: UUID(), panelId: UUID()) + } + + private func processIdentity(pid: Int, seconds: Int64 = 100) -> AgentPIDProcessIdentity { + AgentPIDProcessIdentity(pid: pid_t(pid), startSeconds: seconds, startMicroseconds: 0) + } + + private func shellProcess( + key: RestorableAgentSessionIndex.PanelKey, + pid: Int? = nil, + ttyDevice: Int64? = nil, + identity: AgentPIDProcessIdentity? + ) -> CmuxTopProcessInfo { + let pid = pid ?? shellPID + return CmuxTopProcessInfo( + pid: pid, + parentPID: 1, + name: "zsh", + path: "/bin/zsh", + ttyDevice: ttyDevice ?? self.ttyDevice, + cmuxWorkspaceID: key.workspaceId, + cmuxSurfaceID: key.panelId, + cmuxAttributionReason: "environment", + processGroupID: pid, + terminalProcessGroupID: pid, + cpuPercent: 0, + residentBytes: 0, + virtualBytes: 0, + threadCount: 1, + generationIdentity: identity + ) + } + + private func shellArguments(key: RestorableAgentSessionIndex.PanelKey) -> CmuxTopProcessArguments { + CmuxTopProcessArguments( + arguments: ["/bin/zsh", "-l"], + environment: [ + "CMUX_WORKSPACE_ID": key.workspaceId.uuidString, + "CMUX_SURFACE_ID": key.panelId.uuidString, + ] + ) + } + + private func processSnapshot( + key: RestorableAgentSessionIndex.PanelKey, + identity: AgentPIDProcessIdentity + ) -> CmuxTopProcessSnapshot { + CmuxTopProcessSnapshot( + processes: [shellProcess(key: key, identity: identity)], + sampledAt: Date(), + includesProcessDetails: true + ) + } + + private func restorableAgent() -> SessionRestorableAgentSnapshot { + SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: "frozen-shell-integration", + workingDirectory: "/tmp", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex"], + workingDirectory: "/tmp", + environment: nil, + capturedAt: 10, + source: "test" + ) + ) + } + + private func topologyIndex( + key: RestorableAgentSessionIndex.PanelKey, + identity: AgentPIDProcessIdentity, + ttyEnumeration: CmuxTopTargetedPIDEnumeration, + childEnumeration: CmuxTopTargetedPIDEnumeration + ) -> AgentHibernationProcessTopologyIndex { + AgentHibernationProcessTopologyIndex( + processSnapshot: processSnapshot(key: key, identity: identity), + targetPanelKeys: [key], + processArguments: { _ in self.shellArguments(key: key) }, + processIdentity: { _ in identity }, + processExecutablePath: { _ in "/bin/zsh" }, + processSessionID: { _ in pid_t(self.shellPID) }, + ttyProcessIDs: { _ in ttyEnumeration }, + childProcessIDs: { _ in childEnumeration } + ) + } + + private func hookFixture(sessionId: String, recordedPID: Int? = nil) throws -> HookFixture { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-process-free-lease-\(UUID().uuidString)", isDirectory: true) + let storeURL = RestorableAgentKind.opencode.hookStoreFileURL(homeDirectory: home.path) + try FileManager.default.createDirectory( + at: storeURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let key = panelKey() + var record: [String: Any] = [ + "sessionId": sessionId, + "workspaceId": key.workspaceId.uuidString, + "surfaceId": key.panelId.uuidString, + "cwd": "/tmp/cmux-process-free", + "agentLifecycle": "idle", + "updatedAt": 100, + "launchCommand": [ + "launcher": "opencode", + "executablePath": "/usr/local/bin/opencode", + "arguments": ["/usr/local/bin/opencode"], + "workingDirectory": "/tmp/cmux-process-free", + ], + ] + if let recordedPID { record["pid"] = recordedPID } + let data = try JSONSerialization.data( + withJSONObject: ["version": 1, "sessions": [sessionId: record]], + options: [.prettyPrinted] + ) + try data.write(to: storeURL, options: .atomic) + return HookFixture(home: home, key: key) + } + + private func loadIndex( + fixture: HookFixture, + snapshot: CmuxTopProcessSnapshot, + identity: AgentPIDProcessIdentity, + mode: RestorableAgentSessionIndex.LoadMode + ) -> RestorableAgentSessionIndex { + RestorableAgentSessionIndex.load( + homeDirectory: fixture.home.path, + fileManager: .default, + registry: CmuxVaultAgentRegistry(registrations: []), + detectedSnapshots: [:], + mode: mode, + processArgumentsProvider: { pid in + pid == self.shellPID ? self.shellArguments(key: fixture.key) : nil + }, + processIdentityProvider: { pid in pid == self.shellPID ? identity : nil }, + processExecutablePathProvider: { pid in pid == self.shellPID ? "/bin/zsh" : nil }, + processSessionIDProvider: { pid in pid == self.shellPID ? pid_t(self.shellPID) : nil }, + ttyProcessIDsProvider: { _ in .complete([self.shellPID]) }, + childProcessIDsProvider: { _ in .complete([]) } + ) + } +} + +private func withPOSIXCStringArray( + _ strings: [String], + _ body: (UnsafeMutablePointer?>) -> Result +) -> Result { + var cStrings: [UnsafeMutablePointer?] = strings.map { strdup($0) } + cStrings.append(nil) + defer { cStrings.forEach { free($0) } } + return cStrings.withUnsafeMutableBufferPointer { body($0.baseAddress!) } +} + +private func liveProcessStatus(_ processID: pid_t) -> UInt32? { + var info = proc_bsdinfo() + let expectedSize = MemoryLayout.stride + let size = proc_pidinfo( + processID, + PROC_PIDTBSDINFO, + 0, + &info, + Int32(expectedSize) + ) + return size == expectedSize ? info.pbi_status : nil +} + +private final class AgentHibernationFrozenShellTestState: @unchecked Sendable { + private struct Storage { + var identity: AgentPIDProcessIdentity? + var status: UInt32 + var signals: [Int32] = [] + var hasChild = false + var finalValidationObservedStop = false + var events: [String] = [] + var identityReadFailuresRemaining = 0 + var generationFenceState = AgentHibernationProcessGenerationFence.State + .originalGenerationAlive + } + + private let storage: OSAllocatedUnfairLock + + init(identity: AgentPIDProcessIdentity, status: UInt32) { + storage = OSAllocatedUnfairLock(initialState: Storage(identity: identity, status: status)) + } + + var identity: AgentPIDProcessIdentity? { storage.withLock { $0.identity } } + var status: UInt32 { storage.withLock { $0.status } } + var signals: [Int32] { storage.withLock { $0.signals } } + var hasChild: Bool { storage.withLock { $0.hasChild } } + var events: [String] { storage.withLock { $0.events } } + var finalValidationObservedStop: Bool { + storage.withLock { $0.finalValidationObservedStop } + } + + func readIdentity() -> AgentPIDProcessIdentity? { + storage.withLock { state in + if state.identityReadFailuresRemaining > 0 { + state.identityReadFailuresRemaining -= 1 + return nil + } + return state.identity + } + } + + func failNextIdentityReads(_ count: Int) { + storage.withLock { $0.identityReadFailuresRemaining = max(0, count) } + } + + func generationFence() -> AgentHibernationProcessGenerationFence { + AgentHibernationProcessGenerationFence { [self] in + storage.withLock { $0.generationFenceState } + } + } + + func replaceIdentity(_ identity: AgentPIDProcessIdentity?) { + storage.withLock { $0.identity = identity } + } + + func send(_ signal: Int32) -> Int32 { + storage.withLock { state -> Int32 in + state.signals.append(signal) + if signal == SIGSTOP { + state.status = UInt32(SSTOP) + state.events.append("SIGSTOP") + } else if signal == SIGCONT { + state.status = UInt32(SRUN) + state.events.append("SIGCONT") + } + return 0 + } + } + + func attemptFork() { + storage.withLock { state in + if state.status != UInt32(SSTOP) { + state.hasChild = true + } + } + } + + func recordFinalValidation() { + storage.withLock { state in + state.finalValidationObservedStop = state.status == UInt32(SSTOP) + state.events.append("processFree") + } + } + + func recordLifecycleCommit(accepted: Bool) { + storage.withLock { + $0.events.append(accepted ? "lifecycleCommit" : "lifecycleCommitRejected") + } + } + + func recordNativeFree() { + storage.withLock { state in + #expect(state.status == UInt32(SSTOP)) + state.events.append("nativeFree") + } + } +} diff --git a/cmuxTests/AgentHibernationPlannerSwiftTests.swift b/cmuxTests/AgentHibernationPlannerSwiftTests.swift index cf44c48602f7..132b8c7f042d 100644 --- a/cmuxTests/AgentHibernationPlannerSwiftTests.swift +++ b/cmuxTests/AgentHibernationPlannerSwiftTests.swift @@ -133,8 +133,7 @@ struct AgentHibernationPlannerSwiftTests { hasUnconfirmedTerminalInput: false, lastActivityAt: 0, isProtected: false, - hasLiveProcess: false, - processIDs: [] + processEvidence: .unverified(processIDs: []) ) #expect(record.isStillOwnedByOriginalWorkspace) @@ -165,7 +164,7 @@ struct AgentHibernationPlannerSwiftTests { key: runningAgent, hasRestorableAgent: true, isLive: true, - hasLiveProcess: true, + processEvidence: .unverified(processIDs: [42]), isProtected: false, lifecycle: .idle, hasUnconfirmedTerminalInput: false, @@ -175,6 +174,7 @@ struct AgentHibernationPlannerSwiftTests { key: exitedAgent, hasRestorableAgent: true, isLive: true, + processEvidence: processFreeEvidence(for: exitedAgent), isProtected: false, lifecycle: .idle, hasUnconfirmedTerminalInput: false, @@ -188,6 +188,48 @@ struct AgentHibernationPlannerSwiftTests { #expect(selected == Set([exitedAgent])) } + @Test + func onlyConfirmedProcessFreeSessionCanHibernate() { + let workspaceId = UUID() + let now: TimeInterval = 1_000 + let processFreeAgent = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let protectedAgent = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let settings = AgentHibernationSettings.Values( + enabled: true, + idleSeconds: 60, + maxLiveTerminals: 1, + confirmationSeconds: 5 + ) + + let selected = AgentHibernationPlanner.selectedPanelKeys( + inputs: [ + .init( + key: processFreeAgent, + hasRestorableAgent: true, + isLive: true, + processEvidence: processFreeEvidence(for: processFreeAgent), + isProtected: false, + lifecycle: .idle, + hasUnconfirmedTerminalInput: false, + lastActivityAt: now - 300 + ), + .init( + key: protectedAgent, + hasRestorableAgent: true, + isLive: true, + isProtected: true, + lifecycle: .idle, + hasUnconfirmedTerminalInput: false, + lastActivityAt: now - 200 + ), + ], + settings: settings, + now: now + ) + + #expect(selected == Set([processFreeAgent])) + } + @Test func unableToProtectPaneCreatesPressureButIsNotSelected() { let workspaceId = UUID() @@ -207,6 +249,7 @@ struct AgentHibernationPlannerSwiftTests { key: unableToProtectAgent, hasRestorableAgent: true, isLive: true, + processEvidence: processFreeEvidence(for: unableToProtectAgent), isProtected: false, lifecycle: .idle, isTemporarilyUnableToProtect: true, @@ -217,6 +260,7 @@ struct AgentHibernationPlannerSwiftTests { key: safeAgent, hasRestorableAgent: true, isLive: true, + processEvidence: processFreeEvidence(for: safeAgent), isProtected: false, lifecycle: .idle, hasUnconfirmedTerminalInput: false, @@ -370,8 +414,7 @@ struct AgentHibernationPlannerSwiftTests { hasUnconfirmedTerminalInput: false, lastActivityAt: 100, isProtected: false, - hasLiveProcess: false, - processIDs: [] + processEvidence: .unverified(processIDs: []) ) #expect(controller.postSnapshotLifecycle(for: record, index: index) == .running) @@ -393,4 +436,27 @@ struct AgentHibernationPlannerSwiftTests { controller.postSnapshotValidationIndexSequence = 0 controller.postSnapshotValidationIndexTask = nil } + + private func processFreeEvidence( + for key: AgentHibernationPanelKey + ) -> AgentHibernationProcessEvidence { + .confirmedProcessFree(AgentHibernationProcessFreeLease( + workspaceId: key.workspaceId, + panelId: key.panelId, + shellPID: 42, + shellIdentity: AgentPIDProcessIdentity( + pid: 42, + startSeconds: 100, + startMicroseconds: 200 + ), + shellParentPID: 1, + shellName: "zsh", + executablePath: "/bin/zsh", + arguments: ["/bin/zsh", "-l"], + ttyDevice: 9_001, + sessionID: 42, + processGroupID: 42, + terminalProcessGroupID: 42 + )) + } } diff --git a/cmuxTests/AgentHibernationRestoreMonitorTests.swift b/cmuxTests/AgentHibernationRestoreMonitorTests.swift index 51c9298be560..c91e604ea34d 100644 --- a/cmuxTests/AgentHibernationRestoreMonitorTests.swift +++ b/cmuxTests/AgentHibernationRestoreMonitorTests.swift @@ -1,4 +1,6 @@ +import Darwin import Foundation +import os import Testing #if canImport(cmux_DEV) @@ -152,6 +154,94 @@ struct AgentHibernationRestoreMonitorTests { #expect(restoredContent.hasPrefix(snapshotContent)) } + @MainActor + @Test + func immediatelyCompletedArmedMonitorsNeverLeaveStaleRegistryEntries() async throws { + let controller = AgentHibernationController.shared + defer { resetSharedHibernationState(controller) } + + let directory = try temporaryDirectory(prefix: "immediate-monitor-completion") + defer { try? FileManager.default.removeItem(at: directory) } + for index in 0..<64 { + let live = directory.appendingPathComponent("live-\(index).jsonl") + let snapshot = directory.appendingPathComponent("snapshot-\(index).jsonl") + try #"{"type":"user","message":{"content":"live"}}"#.appending("\n").write( + to: live, + atomically: true, + encoding: .utf8 + ) + try #"{"type":"user","message":{"content":"snapshot"}}"#.appending("\n").write( + to: snapshot, + atomically: true, + encoding: .utf8 + ) + #expect(controller.armPostTeardownRestoreMonitor( + snapshot: .init( + transcriptPath: live.path, + snapshotPath: snapshot.path + ), + processIDs: [], + initialRetryDelaysNanoseconds: [], + backstopDelaysSeconds: [] + )) + } + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while !controller.postTeardownRestoreTasksByTranscriptPath.isEmpty, + clock.now < deadline { + await Task.yield() + } + #expect(controller.postTeardownRestoreTasksByTranscriptPath.isEmpty) + } + + @MainActor + @Test + func rejectedArmedMonitorCannotRunRestoreBeforeCancellationPolicyIsInstalled() async throws { + let controller = AgentHibernationController.shared + defer { resetSharedHibernationState(controller) } + + let directory = try temporaryDirectory(prefix: "rejected-monitor-gate") + defer { try? FileManager.default.removeItem(at: directory) } + let live = directory.appendingPathComponent("live.jsonl") + let occupyingSnapshot = directory.appendingPathComponent("occupying.jsonl") + let rejectedSnapshot = directory.appendingPathComponent("rejected.jsonl") + let metadata = #"{"type":"last-prompt","prompt":"continue"}"# + "\n" + let rejectedContent = #"{"type":"user","message":{"content":"must not restore"}}"# + "\n" + try metadata.write(to: live, atomically: true, encoding: .utf8) + try rejectedContent.write(to: rejectedSnapshot, atomically: true, encoding: .utf8) + try rejectedContent.write(to: occupyingSnapshot, atomically: true, encoding: .utf8) + + let occupyingRequestID = UUID() + let occupyingState = AgentHibernationController.PostTeardownRestoreCancellationState() + let occupyingTask = pendingTask() + #expect(controller.storePostTeardownRestoreTask( + occupyingTask, + transcriptPath: live.path, + requestID: occupyingRequestID, + cancellationState: occupyingState + )) + + #expect(controller.armPostTeardownRestoreMonitor( + snapshot: .init( + transcriptPath: live.path, + snapshotPath: rejectedSnapshot.path + ), + processIDs: [], + initialRetryDelaysNanoseconds: [0], + backstopDelaysSeconds: [] + ) == false) + for _ in 0..<32 { await Task.yield() } + #expect(try String(contentsOf: live, encoding: .utf8) == metadata) + #expect(controller.postTeardownRestoreTaskIsCurrent( + transcriptPath: live.path, + requestID: occupyingRequestID + )) + await controller.cancelPostTeardownRestoreTaskForReplacement( + transcriptPath: live.path + ) + } + @MainActor @Test func bulkCancelDrainCompletesFinalRestoreBeforeNextTeardown() async throws { @@ -206,9 +296,9 @@ struct AgentHibernationRestoreMonitorTests { let snapshot = directory.appendingPathComponent("snapshot.jsonl") let earlierTurn = #"{"type":"user","message":{"content":"kept"}}"# + "\n" let snapshotContent = earlierTurn + #"{"type":"assistant","message":{"content":"dropped tail"}}"# + "\n" - // A partial rewrite kept an earlier turn but dropped the tail: the live - // file is populated, so no restore fires, yet it does not contain the - // snapshot. The forfeit disposal must retain the copy, never delete it. + // A partial rewrite kept an earlier turn but dropped the tail. The + // protected snapshot is an append-only superset, so restore the missing + // tail while retaining the displaced inode as recovery authority. try earlierTurn.write(to: live, atomically: true, encoding: .utf8) try snapshotContent.write(to: snapshot, atomically: true, encoding: .utf8) @@ -220,10 +310,104 @@ struct AgentHibernationRestoreMonitorTests { snapshotDisposal: .retainForRecovery(sessionId: "forfeit-retain") ) - #expect(try String(contentsOf: live, encoding: .utf8) == earlierTurn) + #expect(try String(contentsOf: live, encoding: .utf8) == snapshotContent) + #expect(FileManager.default.fileExists(atPath: snapshot.path) == false) + let recoveryEntries = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + let pointers = recoveryEntries.filter { + $0.lastPathComponent.contains("-pointer-") + } + let displaced = recoveryEntries.filter { + $0.lastPathComponent.hasPrefix(".live.jsonl.cmux-recovery-") + } + #expect(pointers.count == 1) + #expect(displaced.count == 1) + #expect( + try String( + contentsOf: #require(displaced.first), + encoding: .utf8 + ) == earlierTurn + ) + } + + @Test + func normalMonitorPreservesSnapshotWhenLiveIsPopulatedButDivergent() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-normal-divergence-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + let snapshotContent = #"{"type":"user","message":{"content":"protected"}}"# + "\n" + let divergentContent = #"{"type":"user","message":{"content":"different history"}}"# + "\n" + try divergentContent.write(to: live, atomically: true, encoding: .utf8) + try snapshotContent.write(to: snapshot, atomically: true, encoding: .utf8) + + await AgentHibernationTranscriptGuard.runPostTeardownRestoreChecks( + snapshot: .init(transcriptPath: live.path, snapshotPath: snapshot.path), + processIDs: [], + initialRetryDelaysNanoseconds: [0], + backstopDelaysSeconds: [] + ) + + #expect(try String(contentsOf: live, encoding: .utf8) == divergentContent) + #expect(try String(contentsOf: snapshot, encoding: .utf8) == snapshotContent) + } + + @Test + func normalMonitorDeletesSnapshotWhenStableLivePrefixContainsIt() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-normal-prefix-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + let snapshotContent = #"{"type":"user","message":{"content":"protected"}}"# + "\n" + let appendedContent = #"{"type":"assistant","message":{"content":"continued"}}"# + "\n" + try (snapshotContent + appendedContent).write(to: live, atomically: true, encoding: .utf8) + try snapshotContent.write(to: snapshot, atomically: true, encoding: .utf8) + + await AgentHibernationTranscriptGuard.runPostTeardownRestoreChecks( + snapshot: .init(transcriptPath: live.path, snapshotPath: snapshot.path), + processIDs: [], + initialRetryDelaysNanoseconds: [0], + backstopDelaysSeconds: [] + ) + + #expect(try String(contentsOf: live, encoding: .utf8) == snapshotContent + appendedContent) + #expect(FileManager.default.fileExists(atPath: snapshot.path) == false) + } + + @Test + func forfeitMonitorDeletesSnapshotWhenStableLivePrefixContainsIt() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-forfeit-prefix-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + let snapshotContent = #"{"type":"user","message":{"content":"protected"}}"# + "\n" + let appendedContent = #"{"type":"assistant","message":{"content":"continued"}}"# + "\n" + try (snapshotContent + appendedContent).write(to: live, atomically: true, encoding: .utf8) + try snapshotContent.write(to: snapshot, atomically: true, encoding: .utf8) + + await AgentHibernationTranscriptGuard.runPostTeardownRestoreChecks( + snapshot: .init(transcriptPath: live.path, snapshotPath: snapshot.path), + processIDs: [], + initialRetryDelaysNanoseconds: [0], + backstopDelaysSeconds: [], + snapshotDisposal: .retainForRecovery(sessionId: "forfeit-prefix") + ) + #expect(FileManager.default.fileExists(atPath: snapshot.path) == false) - let retained = directory.appendingPathComponent("forfeit-retain-retained.jsonl") - #expect(try String(contentsOf: retained, encoding: .utf8) == snapshotContent) + #expect(FileManager.default.fileExists( + atPath: directory.appendingPathComponent("forfeit-prefix-retained.jsonl").path + ) == false) } @MainActor @@ -272,6 +456,888 @@ struct AgentHibernationRestoreMonitorTests { #expect(FileManager.default.fileExists(atPath: snapshot.path)) } + @Test + func processWaitRejectsReusedPIDGeneration() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-pid-generation-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let live = directory.appendingPathComponent("live.jsonl") + let snapshotURL = directory.appendingPathComponent("snapshot.jsonl") + let content = #"{"type":"user","message":{"content":"protected"}}"# + "\n" + try content.write(to: live, atomically: true, encoding: .utf8) + try content.write(to: snapshotURL, atomically: true, encoding: .utf8) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sleep") + process.arguments = ["30"] + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + let liveIdentity = try #require( + AgentPIDProcessIdentity(pid: process.processIdentifier) + ) + let staleIdentity = AgentPIDProcessIdentity( + pid: liveIdentity.pid, + startSeconds: liveIdentity.startSeconds + 1, + startMicroseconds: liveIdentity.startMicroseconds + ) + + let clock = ContinuousClock() + let startedAt = clock.now + await AgentHibernationTranscriptGuard.runPostTeardownRestoreChecks( + snapshot: .init( + transcriptPath: live.path, + snapshotPath: snapshotURL.path, + guardedProcessIdentities: [staleIdentity] + ), + processIDs: [ + Int(liveIdentity.pid), + -1, + Int(Int32.max) + 1, + ], + initialRetryDelaysNanoseconds: [0], + backstopDelaysSeconds: [] + ) + + #expect(startedAt.duration(to: clock.now) < .seconds(1)) + } + + @Test + func prefinishedDispatchWaitActivatesCanceledSuspendedSources() async { + let cancellation = AgentHibernationRestoreTestFlag() + let source = DispatchSource.makeTimerSource() + source.setCancelHandler { + Task { await cancellation.set() } + } + let waiter = AgentHibernationRestoreDispatchWait() + + // Models exit/cancellation winning before the source array is armed. + await waiter.finish() + await waiter.wait(for: [source]) + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while clock.now < deadline, !(await cancellation.value) { + try? await clock.sleep(for: .milliseconds(10)) + } + let didCancel = await cancellation.value + #expect(didCancel) + } + + @Test + func inPlaceTranscriptTruncationWakesRestoreWithoutTimerDelay() async throws { + let directory = try temporaryDirectory(prefix: "truncate-wakeup") + defer { try? FileManager.default.removeItem(at: directory) } + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + let protected = #"{"type":"user","message":{"content":"protected"}}"# + "\n" + try protected.write(to: live, atomically: true, encoding: .utf8) + try protected.write(to: snapshot, atomically: true, encoding: .utf8) + let (armedEvents, armedContinuation) = AsyncStream.makeStream() + + let monitor = Task { + await AgentHibernationTranscriptGuard.runPostTeardownRestoreChecks( + snapshot: .init(transcriptPath: live.path, snapshotPath: snapshot.path), + processIDs: [], + initialRetryDelaysNanoseconds: [5_000_000_000], + backstopDelaysSeconds: [], + onMutationWaitArmed: { + armedContinuation.yield() + armedContinuation.finish() + } + ) + } + var armedIterator = armedEvents.makeAsyncIterator() + _ = await armedIterator.next() + let descriptor = open(live.path, O_WRONLY | O_TRUNC | O_CLOEXEC) + #expect(descriptor >= 0) + if descriptor >= 0 { + let replacement = Data(#"{"type":"last-prompt","prompt":"continue"}"#.utf8) + _ = replacement.withUnsafeBytes { bytes in + Darwin.write(descriptor, bytes.baseAddress, bytes.count) + } + Darwin.close(descriptor) + } + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while clock.now < deadline, + !((try? String(contentsOf: live, encoding: .utf8)) ?? "").hasPrefix(protected) { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(try String(contentsOf: live, encoding: .utf8).hasPrefix(protected)) + monitor.cancel() + await monitor.value + } + + @Test + func transcriptWriteStormHasGlobalComparisonBudget() async throws { + let directory = try temporaryDirectory(prefix: "write-storm") + defer { try? FileManager.default.removeItem(at: directory) } + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + let protected = #"{"type":"user","message":{"content":"protected"}}"# + "\n" + try protected.write(to: live, atomically: true, encoding: .utf8) + try protected.write(to: snapshot, atomically: true, encoding: .utf8) + let clock = ContinuousClock() + let startedAt = clock.now + + let monitor = Task { + await AgentHibernationTranscriptGuard.runPostTeardownRestoreChecks( + snapshot: .init(transcriptPath: live.path, snapshotPath: snapshot.path), + processIDs: [], + initialRetryDelaysNanoseconds: [200_000_000, 200_000_000, 200_000_000], + backstopDelaysSeconds: [], + maximumMutationChecksPerMonitor: 2 + ) + } + for index in 0..<40 { + try (protected + "{\"storm\":\(index)}\n").write( + to: live, + atomically: true, + encoding: .utf8 + ) + try await Task.sleep(for: .milliseconds(10)) + } + await monitor.value + #expect(startedAt.duration(to: clock.now) < .seconds(2)) + } + + @Test + func sharedRestoreSchedulerBoundsConcurrentMonitorResources() async { + let scheduler = AgentHibernationRestoreMonitorScheduler( + maximumConcurrentMonitors: 3 + ) + let concurrency = AgentHibernationRestoreConcurrencyProbe() + + await withTaskGroup(of: Void.self) { group in + for _ in 0..<48 { + group.addTask { + guard await scheduler.acquire() else { return } + await concurrency.enter() + try? await Task.sleep(for: .milliseconds(10)) + await concurrency.leave() + scheduler.release() + } + } + } + + #expect(await concurrency.maximum == 3) + #expect(await concurrency.active == 0) + } + + @Test + func cancelledRestoreSchedulerWaitersDoNotConsumePermits() async throws { + let scheduler = AgentHibernationRestoreMonitorScheduler( + maximumConcurrentMonitors: 1 + ) + #expect(await scheduler.acquire()) + let waiters = (0..<128).map { _ in + Task { await scheduler.acquire() } + } + await Task.yield() + for waiter in waiters { waiter.cancel() } + for waiter in waiters { + #expect(await waiter.value == false) + } + scheduler.release() + + let completed = AgentHibernationRestoreTestFlag() + let successor = Task { + guard await scheduler.acquire() else { return } + await completed.set() + scheduler.release() + } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while clock.now < deadline, !(await completed.value) { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(await completed.value) + successor.cancel() + await successor.value + } + + @Test + func processSourceCountAndOverflowAreBounded() async throws { + let directory = try temporaryDirectory(prefix: "process-cap") + defer { try? FileManager.default.removeItem(at: directory) } + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + let protected = #"{"type":"user","message":{"content":"protected"}}"# + "\n" + try protected.write(to: live, atomically: true, encoding: .utf8) + try protected.write(to: snapshot, atomically: true, encoding: .utf8) + let identity = try #require(AgentPIDProcessIdentity(pid: getpid())) + let processIDs = Set((1...300).map { Int(Int32.max) + $0 }).union([Int(getpid())]) + + let monitor = Task { + await AgentHibernationTranscriptGuard.runPostTeardownRestoreChecks( + snapshot: .init( + transcriptPath: live.path, + snapshotPath: snapshot.path, + guardedProcessIdentities: [identity], + hasUncapturedGuardedProcesses: true + ), + processIDs: processIDs, + initialRetryDelaysNanoseconds: [0], + backstopDelaysSeconds: [], + processExitBackstopSeconds: 1 + ) + } + await Task.yield() + monitor.cancel() + await monitor.value + } + + @Test + func currentProcessOwnedSnapshotIsNotClaimedByStartupRecovery() throws { + let home = try temporaryDirectory(prefix: "same-owner") + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let sessionID = "same-owner" + let workingDirectory = "/tmp/same-owner" + let transcript = try writeClaudeTranscript( + home: home, + workingDirectory: workingDirectory, + sessionID: sessionID + ) + let captured = try #require(snapshotValue( + AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: .init( + kind: .claude, + sessionId: sessionID, + workingDirectory: workingDirectory + ), + homeDirectory: home.path, + snapshotDirectory: snapshots + ) + )) + try #"{"type":"last-prompt","prompt":"continue"}"#.write( + to: transcript, + atomically: true, + encoding: .utf8 + ) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: snapshots + ) == 0) + #expect(FileManager.default.fileExists(atPath: captured.snapshotPath)) + } + + @Test + func exhaustedMonitorRetiresOwnerAndEnqueuesImmediateRecovery() async throws { + let home = try temporaryDirectory(prefix: "retired-owner") + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let sessionID = "retired-owner" + let workingDirectory = "/tmp/retired-owner" + let transcript = try writeClaudeTranscript( + home: home, + workingDirectory: workingDirectory, + sessionID: sessionID + ) + let captured = try #require(snapshotValue( + AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: .init( + kind: .claude, + sessionId: sessionID, + workingDirectory: workingDirectory + ), + homeDirectory: home.path, + snapshotDirectory: snapshots + ) + )) + let divergentLive = [ + #"{"type":"user","message":{"content":"independent"}}"#, + #"{"type":"assistant","message":{"content":"branch"}}"#, + ].joined(separator: "\n") + "\n" + try divergentLive.write(to: transcript, atomically: true, encoding: .utf8) + let enqueued = AgentHibernationRestoreTestFlag() + + await AgentHibernationTranscriptGuard.runPostTeardownRestoreChecks( + snapshot: captured, + processIDs: [], + initialRetryDelaysNanoseconds: [0], + backstopDelaysSeconds: [], + recoveryAuthorityRetired: { await enqueued.set() } + ) + + #expect(await enqueued.value) + #expect(FileManager.default.fileExists(atPath: captured.snapshotPath)) + #expect(try String(contentsOf: transcript, encoding: .utf8) == divergentLive) + + let metadataStub = #"{"type":"last-prompt","prompt":"continue"}"# + "\n" + try metadataStub.write(to: transcript, atomically: true, encoding: .utf8) + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: snapshots + ) == 1) + let protectedTranscript = + #"{"type":"user","message":{"content":"protected"}}"# + "\n" + #expect( + try String(contentsOf: transcript, encoding: .utf8) + == protectedTranscript + metadataStub + ) + } + + @Test + func oversizedTranscriptFailsHibernationBeforeCopy() throws { + let home = try temporaryDirectory(prefix: "oversized") + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let sessionID = "oversized" + let workingDirectory = "/tmp/oversized" + let transcript = try writeClaudeTranscript( + home: home, + workingDirectory: workingDirectory, + sessionID: sessionID + ) + let handle = try FileHandle(forUpdating: transcript) + try handle.truncate( + atOffset: AgentHibernationTranscriptGuard.maximumProtectedTranscriptBytes + 1 + ) + try handle.close() + + let outcome = AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: .init( + kind: .claude, + sessionId: sessionID, + workingDirectory: workingDirectory + ), + homeDirectory: home.path, + snapshotDirectory: snapshots + ) + guard case .unableToProtect = outcome else { + Issue.record("Expected oversized transcript to fail closed") + return + } + #expect((try? FileManager.default.contentsOfDirectory(atPath: snapshots.path))?.isEmpty != false) + } + + @Test + func guardedProcessOverflowFailsHibernationBeforeTeardown() throws { + let home = try temporaryDirectory(prefix: "process-overflow") + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let sessionID = "process-overflow" + let workingDirectory = "/tmp/process-overflow" + _ = try writeClaudeTranscript( + home: home, + workingDirectory: workingDirectory, + sessionID: sessionID + ) + + let outcome = AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: .init( + kind: .claude, + sessionId: sessionID, + workingDirectory: workingDirectory + ), + guardedProcessIDs: [Int(getpid())], + homeDirectory: home.path, + snapshotDirectory: snapshots, + maximumGuardedProcessIdentities: 0 + ) + guard case .unableToProtect = outcome else { + Issue.record("Expected uncaptured live process to fail hibernation closed") + return + } + } + + @Test + func snapshotDirectorySymlinkFailsClosedWithoutTouchingTarget() throws { + let home = try temporaryDirectory(prefix: "snapshot-symlink") + defer { try? FileManager.default.removeItem(at: home) } + let target = home.appendingPathComponent("target", isDirectory: true) + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + let sentinel = target.appendingPathComponent("sentinel") + try "keep".write(to: sentinel, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink(at: snapshots, withDestinationURL: target) + let sessionID = "snapshot-symlink" + let workingDirectory = "/tmp/snapshot-symlink" + _ = try writeClaudeTranscript( + home: home, + workingDirectory: workingDirectory, + sessionID: sessionID + ) + + let outcome = AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: .init( + kind: .claude, + sessionId: sessionID, + workingDirectory: workingDirectory + ), + homeDirectory: home.path, + snapshotDirectory: snapshots + ) + guard case .unableToProtect = outcome else { + Issue.record("Expected symlinked snapshot directory to fail closed") + return + } + #expect(try String(contentsOf: sentinel, encoding: .utf8) == "keep") + } + + @Test + func recoveryLockHardlinkIsRejectedWithoutTruncatingTarget() throws { + let directory = try temporaryDirectory(prefix: "lock-hardlink") + defer { try? FileManager.default.removeItem(at: directory) } + let target = directory.appendingPathComponent("target") + let lock = directory.appendingPathComponent(".agent-transcript-recovery.lock") + try "do-not-truncate".write(to: target, atomically: true, encoding: .utf8) + try FileManager.default.linkItem(at: target, to: lock) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: directory + ) == 0) + #expect(try String(contentsOf: target, encoding: .utf8) == "do-not-truncate") + } + + @Test + func heldRecoveryLockCancellationReleasesAfterLockOwnerDrains() async throws { + let directory = try temporaryDirectory(prefix: "held-recovery-lock") + defer { try? FileManager.default.removeItem(at: directory) } + let lock = directory.appendingPathComponent(".agent-transcript-recovery.lock") + let descriptor = open( + lock.path, + O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, + mode_t(S_IRUSR | S_IWUSR) + ) + #expect(descriptor >= 0) + guard descriptor >= 0 else { return } + defer { Darwin.close(descriptor) } + #expect(flock(descriptor, LOCK_EX | LOCK_NB) == 0) + + let recovery = Task { + await AgentHibernationTranscriptGuard.recoverPendingSnapshotsAwaitingLock( + snapshotDirectory: directory, + cancellationCheck: { Task.isCancelled } + ) + } + await Task.yield() + recovery.cancel() + #expect(flock(descriptor, LOCK_UN) == 0) + #expect(await recovery.value == 0) + } + + @MainActor + @Test + func stopStartDefersSuccessorUntilCancelledLockOwnerDrains() async throws { + struct State { + var invocationCount = 0 + var firstAcquiredLock = false + var secondAcquiredLock = false + } + let directory = try temporaryDirectory(prefix: "recovery-stop-start") + defer { try? FileManager.default.removeItem(at: directory) } + let lockURL = directory.appendingPathComponent("recovery.lock") + let state = OSAllocatedUnfairLock(initialState: State()) + let firstStarted = DispatchSemaphore(value: 0) + let cancellationObserved = DispatchSemaphore(value: 0) + let releaseFirst = DispatchSemaphore(value: 0) + let secondFinished = DispatchSemaphore(value: 0) + let waitForSignal: @Sendable (DispatchSemaphore) async -> DispatchTimeoutResult = { + semaphore in + await Task.detached { + semaphore.wait(timeout: .now() + 2) + }.value + } + + let coordinator = AgentHibernationStartupRecoveryCoordinator { + cancellationCheck in + let invocation = state.withLock { state -> Int in + state.invocationCount += 1 + return state.invocationCount + } + let descriptor = open( + lockURL.path, + O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, + mode_t(S_IRUSR | S_IWUSR) + ) + guard descriptor >= 0 else { return 0 } + defer { close(descriptor) } + let acquired = flock(descriptor, LOCK_EX | LOCK_NB) == 0 + if invocation == 1 { + state.withLock { $0.firstAcquiredLock = acquired } + firstStarted.signal() + while !cancellationCheck() { _ = sched_yield() } + cancellationObserved.signal() + _ = releaseFirst.wait(timeout: .now() + 2) + if acquired { _ = flock(descriptor, LOCK_UN) } + return 0 + } + state.withLock { $0.secondAcquiredLock = acquired } + if acquired { _ = flock(descriptor, LOCK_UN) } + secondFinished.signal() + return acquired ? 1 : 0 + } + + coordinator.start() + #expect(await waitForSignal(firstStarted) == .success) + coordinator.stop() + #expect(await waitForSignal(cancellationObserved) == .success) + coordinator.start() + #expect(coordinator.hasDeferredRecoveryForCurrentStart) + #expect(state.withLock { $0.invocationCount } == 1) + releaseFirst.signal() + #expect(await waitForSignal(secondFinished) == .success) + #expect(state.withLock { $0.firstAcquiredLock }) + #expect(state.withLock { $0.secondAcquiredLock }) + #expect(state.withLock { $0.invocationCount } == 2) + coordinator.stop() + } + + @Test + func crashAfterStagedSnapshotFsyncRestoresOnStartup() throws { + let home = try temporaryDirectory(prefix: "staged-crash") + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let sessionID = "staged-crash" + let workingDirectory = "/tmp/staged-crash" + let transcript = try writeClaudeTranscript( + home: home, + workingDirectory: workingDirectory, + sessionID: sessionID + ) + let protected = try String(contentsOf: transcript, encoding: .utf8) + + let captured = try #require(snapshotValue( + AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: .init( + kind: .claude, + sessionId: sessionID, + workingDirectory: workingDirectory + ), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + let publishedURL = URL(fileURLWithPath: captured.snapshotPath) + let stagedURL = snapshots.appendingPathComponent( + ".\(sessionID)-capture-\(UUID().uuidString).tmp" + ) + #expect(AgentHibernationTranscriptGuard.atomicallyRename( + publishedURL, + to: stagedURL + )) + // Occupy the canonical v2 publication name. The staged inode must be + // publishable under an alternate name without rewriting its xattr. + try FileManager.default.createDirectory( + at: publishedURL, + withIntermediateDirectories: false + ) + + try #"{"type":"last-prompt","prompt":"continue"}"#.appending("\n").write( + to: transcript, + atomically: true, + encoding: .utf8 + ) + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: snapshots + ) == 1) + #expect(try String(contentsOf: transcript, encoding: .utf8).hasPrefix(protected)) + #expect( + try FileManager.default.contentsOfDirectory(atPath: snapshots.path) + .contains(where: { $0.contains("-capture-") }) == false + ) + #expect(FileManager.default.fileExists(atPath: publishedURL.path)) + } + + @Test + func startupRecoveryQuarantinesInvalidHiddenCaptureAuthority() async throws { + let directory = try temporaryDirectory(prefix: "invalid-hidden-capture") + defer { try? FileManager.default.removeItem(at: directory) } + let hidden = directory.appendingPathComponent( + ".invalid-capture-\(UUID().uuidString).tmp" + ) + try Data("not recovery metadata".utf8).write(to: hidden) + + #expect(await AgentHibernationTranscriptGuard.recoverPendingSnapshotsAwaitingLock( + snapshotDirectory: directory + ) == 0) + #expect(!FileManager.default.fileExists(atPath: hidden.path)) + let quarantine = directory.appendingPathComponent(".recovery-quarantine") + let quarantined = try FileManager.default.contentsOfDirectory( + at: quarantine, + includingPropertiesForKeys: nil + ) + #expect(quarantined.count == 1) + } + + @Test + func latePopulatedTranscriptWinsAtomicRestoreCAS() throws { + let directory = try temporaryDirectory(prefix: "late-restore-writer") + defer { try? FileManager.default.removeItem(at: directory) } + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + let protected = #"{"type":"user","message":{"content":"protected"}}"# + "\n" + let metadata = #"{"type":"last-prompt","prompt":"continue"}"# + "\n" + let lateBranch = #"{"type":"user","message":{"content":"late branch"}}"# + "\n" + try protected.write(to: snapshot, atomically: true, encoding: .utf8) + try metadata.write(to: live, atomically: true, encoding: .utf8) + + try lateBranch.write(to: live, atomically: true, encoding: .utf8) + let restored = AgentHibernationTranscriptGuard.restoreIfClobbered( + .init(transcriptPath: live.path, snapshotPath: snapshot.path) + ) + + #expect(restored == false) + #expect(try String(contentsOf: live, encoding: .utf8) == lateBranch) + #expect(try String(contentsOf: snapshot, encoding: .utf8) == protected) + } + + @Test + func writerAppendingAfterRestoreKeepsDisplacedBranchAsRecoveryCandidate() throws { + let directory = try temporaryDirectory(prefix: "post-swap-restore-writer") + defer { try? FileManager.default.removeItem(at: directory) } + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + let protected = #"{"type":"user","message":{"content":"protected"}}"# + "\n" + let metadata = #"{"type":"last-prompt","prompt":"continue"}"# + "\n" + let lateBranch = #"{"type":"user","message":{"content":"post-swap branch"}}"# + "\n" + try protected.write(to: snapshot, atomically: true, encoding: .utf8) + try metadata.write(to: live, atomically: true, encoding: .utf8) + let liveDescriptor = open(live.path, O_WRONLY | O_APPEND | O_CLOEXEC | O_NOFOLLOW) + #expect(liveDescriptor >= 0) + guard liveDescriptor >= 0 else { return } + defer { Darwin.close(liveDescriptor) } + let restored = AgentHibernationTranscriptGuard.restoreIfClobbered( + .init(transcriptPath: live.path, snapshotPath: snapshot.path) + ) + #expect(restored) + let lateData = Data(lateBranch.utf8) + _ = lateData.withUnsafeBytes { bytes in + Darwin.write(liveDescriptor, bytes.baseAddress, bytes.count) + } + let restoredContent = try String(contentsOf: live, encoding: .utf8) + #expect(restoredContent.hasPrefix(protected)) + let recoveryEntries = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + let pointers = recoveryEntries.filter { + $0.lastPathComponent.contains("-pointer-") + } + let displacedCandidates = recoveryEntries.filter { + $0.lastPathComponent.hasPrefix(".live.jsonl.cmux-recovery-") + } + #expect(pointers.count == 1) + #expect(displacedCandidates.count == 1) + let displacedContent = try String( + contentsOf: #require(displacedCandidates.first), + encoding: .utf8 + ) + #expect(displacedContent.hasPrefix(metadata)) + #expect(displacedContent.contains("post-swap branch")) + } + + @Test + func restoreOutputSymlinkSwapCannotMutateReplacementTarget() throws { + let directory = try temporaryDirectory(prefix: "restore-output-symlink") + defer { try? FileManager.default.removeItem(at: directory) } + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + let sentinel = directory.appendingPathComponent("sentinel.jsonl") + let protected = #"{"type":"user","message":{"content":"protected"}}"# + "\n" + let metadata = #"{"type":"last-prompt","prompt":"continue"}"# + "\n" + try protected.write(to: snapshot, atomically: true, encoding: .utf8) + try metadata.write(to: live, atomically: true, encoding: .utf8) + try "do-not-mutate".write(to: sentinel, atomically: true, encoding: .utf8) + + let restoreOutput = directory.appendingPathComponent("restore-output.jsonl") + try FileManager.default.createSymbolicLink( + at: restoreOutput, + withDestinationURL: sentinel + ) + #expect(throws: (any Error).self) { + try AgentHibernationTranscriptGuard.appendLiveStubIfPresent( + from: live, + toRestoreFile: restoreOutput, + fileManager: .default + ) + } + #expect(try String(contentsOf: sentinel, encoding: .utf8) == "do-not-mutate") + #expect(try String(contentsOf: live, encoding: .utf8) == metadata) + } + + @Test + func startupRecoveryContinuesPastFirstDirectoryBatch() async throws { + let home = try temporaryDirectory(prefix: "startup-continuation") + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + try FileManager.default.createDirectory(at: snapshots, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + for index in 0..<1_100 { + try Data().write(to: snapshots.appendingPathComponent("filler-\(index)")) + } + let sessionID = "startup-continuation" + let workingDirectory = "/tmp/startup-continuation" + let transcript = try writeClaudeTranscript( + home: home, + workingDirectory: workingDirectory, + sessionID: sessionID + ) + let protected = try String(contentsOf: transcript, encoding: .utf8) + let captured = try #require(snapshotValue( + AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: .init( + kind: .claude, + sessionId: sessionID, + workingDirectory: workingDirectory + ), + homeDirectory: home.path, + snapshotDirectory: snapshots, + maximumRecoveryStorageFileCount: 20_000, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + #expect(FileManager.default.fileExists(atPath: captured.snapshotPath)) + try #"{"type":"mode","mode":"default"}"#.appending("\n").write( + to: transcript, + atomically: true, + encoding: .utf8 + ) + + let restoredCount = await AgentHibernationTranscriptGuard.recoverPendingSnapshotsAwaitingLock( + snapshotDirectory: snapshots + ) + #expect(restoredCount == 1) + #expect(try String(contentsOf: transcript, encoding: .utf8).hasPrefix(protected)) + } + + @Test + func startupRecoveryChoosesNewestTranscriptAcrossDirectoryBatches() async throws { + let home = try temporaryDirectory(prefix: "startup-global-newest") + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let sessionID = "startup-global-newest" + let workingDirectory = "/tmp/startup-global-newest" + let transcript = try writeClaudeTranscript( + home: home, + workingDirectory: workingDirectory, + sessionID: sessionID + ) + let olderContent = try String(contentsOf: transcript, encoding: .utf8) + _ = try #require(snapshotValue( + AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: .init( + kind: .claude, + sessionId: sessionID, + workingDirectory: workingDirectory + ), + homeDirectory: home.path, + snapshotDirectory: snapshots, + maximumRecoveryStorageFileCount: 20_000, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + for index in 0..<1_100 { + try Data().write(to: snapshots.appendingPathComponent("filler-\(index)")) + } + let newerContent = olderContent + + #"{"type":"assistant","message":{"content":"newest across batch"}}"# + + "\n" + try newerContent.write(to: transcript, atomically: true, encoding: .utf8) + _ = try #require(snapshotValue( + AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: .init( + kind: .claude, + sessionId: sessionID, + workingDirectory: workingDirectory + ), + homeDirectory: home.path, + snapshotDirectory: snapshots, + maximumRecoveryStorageFileCount: 20_000, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + try #"{"type":"mode","mode":"default"}"#.appending("\n").write( + to: transcript, + atomically: true, + encoding: .utf8 + ) + + #expect(await AgentHibernationTranscriptGuard.recoverPendingSnapshotsAwaitingLock( + snapshotDirectory: snapshots + ) == 1) + #expect(try String(contentsOf: transcript, encoding: .utf8).hasPrefix(newerContent)) + } + + @Test + func startupRecoveryMakesDurableProgressPastLaunchEntryCap() async throws { + let home = try temporaryDirectory(prefix: "startup-entry-cap") + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + try FileManager.default.createDirectory(at: snapshots, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + for index in 0..<16_500 { + try Data().write(to: snapshots.appendingPathComponent("filler-\(index)")) + } + let sessionID = "startup-entry-cap" + let workingDirectory = "/tmp/startup-entry-cap" + let transcript = try writeClaudeTranscript( + home: home, + workingDirectory: workingDirectory, + sessionID: sessionID + ) + let protected = try String(contentsOf: transcript, encoding: .utf8) + _ = try #require(snapshotValue( + AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: .init( + kind: .claude, + sessionId: sessionID, + workingDirectory: workingDirectory + ), + homeDirectory: home.path, + snapshotDirectory: snapshots, + maximumRecoveryStorageFileCount: 20_000, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + try #"{"type":"mode","mode":"default"}"#.appending("\n").write( + to: transcript, + atomically: true, + encoding: .utf8 + ) + + #expect(await AgentHibernationTranscriptGuard + .recoverPendingSnapshotsAwaitingLock(snapshotDirectory: snapshots) == 1) + #expect(try String(contentsOf: transcript, encoding: .utf8).hasPrefix(protected)) + } + + @Test + func quarantinePruningExaminesAtMostOneBoundedBatch() throws { + let home = try temporaryDirectory(prefix: "bounded-quarantine") + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + let quarantine = snapshots.appendingPathComponent(".recovery-quarantine", isDirectory: true) + try FileManager.default.createDirectory(at: quarantine, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + for index in 0..<1_200 { + try Data([0]).write(to: quarantine.appendingPathComponent("invalid-\(index).jsonl")) + } + let sessionID = "bounded-quarantine" + let workingDirectory = "/tmp/bounded-quarantine" + _ = try writeClaudeTranscript( + home: home, + workingDirectory: workingDirectory, + sessionID: sessionID + ) + + _ = AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: .init( + kind: .claude, + sessionId: sessionID, + workingDirectory: workingDirectory + ), + homeDirectory: home.path, + snapshotDirectory: snapshots + ) + + let remaining = try FileManager.default.contentsOfDirectory(atPath: quarantine.path).count + #expect(remaining == 176) + } + @MainActor private func restoreTask( live: URL, @@ -312,8 +1378,70 @@ struct AgentHibernationRestoreMonitorTests { } } + private func temporaryDirectory(prefix: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-hibernation-\(prefix)-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func writeClaudeTranscript( + home: URL, + workingDirectory: String, + sessionID: String + ) throws -> URL { + let transcript = home + .appendingPathComponent(".claude/projects", isDirectory: true) + .appendingPathComponent( + RestorableAgentSessionIndex.encodeClaudeProjectDir(workingDirectory), + isDirectory: true + ) + .appendingPathComponent("\(sessionID).jsonl") + try FileManager.default.createDirectory( + at: transcript.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try #"{"type":"user","message":{"content":"protected"}}"#.appending("\n").write( + to: transcript, + atomically: true, + encoding: .utf8 + ) + return transcript + } + + private func snapshotValue( + _ outcome: AgentHibernationTranscriptGuard.TeardownSnapshotOutcome + ) -> AgentHibernationTranscriptGuard.TeardownTranscriptSnapshot? { + guard case .snapshot(let snapshot) = outcome else { return nil } + return snapshot + } + @MainActor private func resetSharedHibernationState(_ controller: AgentHibernationController) { controller.cancelPostTeardownRestoreTasks() } } + +private actor AgentHibernationRestoreTestFlag { + private(set) var value = false + + func set() { + value = true + } +} + +private actor AgentHibernationRestoreConcurrencyProbe { + private(set) var active = 0 + private(set) var maximum = 0 + + func enter() { + active += 1 + maximum = max(maximum, active) + } + + func leave() { + active -= 1 + } +} diff --git a/cmuxTests/AgentHibernationTests.swift b/cmuxTests/AgentHibernationTests.swift index 2c1a03986ae3..a97fe26e4602 100644 --- a/cmuxTests/AgentHibernationTests.swift +++ b/cmuxTests/AgentHibernationTests.swift @@ -1,3 +1,4 @@ +import CMUXAgentLaunch import Foundation import Testing import Bonsplit @@ -797,6 +798,16 @@ struct AgentHibernationTests { expectEqual(snapshot.agentDisplayName, "Local Agent") expectEqual(snapshot.resumeCommand, "cd -- '/tmp/custom-agent' 2>/dev/null || [ ! -d '/tmp/custom-agent' ] && '/usr/local/bin/local-agent' 'resume' 'custom-session'") + + let attemptID = UUID() + let startupInput = snapshot.resumeStartupInput( + allowLauncherScript: false, + hibernationResumeAttemptId: attemptID + ) + let expectedAssignment = "'\(AgentHibernationResumeEvidence.environmentKey)=\(attemptID.uuidString)'" + expectTrue(startupInput?.hasPrefix("/usr/bin/env \(expectedAssignment) /bin/zsh -c ") == true) + expectTrue(startupInput?.contains("local-agent") == true) + expectTrue(startupInput?.hasSuffix("\n") == true) } @MainActor @@ -888,6 +899,64 @@ struct AgentHibernationTests { expectEqual(workspace.restoredAgentResumeStatesByPanelId[panelId], .awaitingAutoResumeCommand) } + @MainActor + @Test + func testUnavailableResumeAuthorityDoesNotLeakOversizedLauncherScripts() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let sessionId = "authority-leak-\(UUID().uuidString.prefix(12))" + let launcherPrefix = "codex-\(sessionId)" + let launcherDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-resume", isDirectory: true) + func matchingLaunchers() -> [URL] { + (try? FileManager.default.contentsOfDirectory( + at: launcherDirectory, + includingPropertiesForKeys: nil + ))?.filter { $0.lastPathComponent.hasPrefix(launcherPrefix) } ?? [] + } + defer { + for url in matchingLaunchers() { try? FileManager.default.removeItem(at: url) } + } + + let oversizedDirectory = "/tmp/" + String(repeating: "long-resume-directory/", count: 80) + let snapshot = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionId, + workingDirectory: oversizedDirectory, + launchCommand: launch("codex", "/usr/local/bin/codex", cwd: oversizedDirectory) + ) + let resumeCommand = try #require(snapshot.resumeCommand) + #expect(resumeCommand.utf8.count > SessionRestorableAgentSnapshot.maxInlineStartupInputBytes) + workspace.surfaceResumeBindingsByPanelId[panelId] = SurfaceResumeBindingSnapshot( + kind: snapshot.kind.rawValue, + command: resumeCommand, + cwd: oversizedDirectory, + checkpointId: sessionId, + source: "agent-hook", + autoResume: false, + updatedAt: 100 + ) + expectTrue(workspace.enterAgentHibernation( + panelId: panelId, + agent: snapshot, + lastActivityAt: Date(timeIntervalSince1970: 0) + )) + + for _ in 0..<3 { + expectFalse(workspace.resumeVisibleAgentHibernationPanels( + panelIds: [panelId], + retryPendingAdoptions: false, + authorityClaimHandler: { requests in + Dictionary(uniqueKeysWithValues: requests.map { + ($0.surfaceId, .unavailable) + }) + } + )) + expectTrue(workspace.terminalPanel(for: panelId)?.isAgentHibernated == true) + #expect(matchingLaunchers().isEmpty) + } + } + @MainActor @Test func testHiddenMountedWorkspaceDoesNotAutoResumeHibernatedTerminal() throws { @@ -1090,7 +1159,7 @@ struct AgentHibernationTests { @MainActor @Test - func testResumePreparationWithoutStartupInputStillLeavesHibernation() throws { + func testResumePreparationWithoutStartupInputKeepsHibernationRetryable() throws { let workspace = Workspace() let panelId = try #require(workspace.focusedPanelId) let panel = try #require(workspace.panels[panelId] as? TerminalPanel) @@ -1109,8 +1178,8 @@ struct AgentHibernationTests { let preparation = panel.prepareAgentHibernationResume() - expectEqual(preparation, .resumed(queuedStartupInput: false)) - expectFalse(panel.isAgentHibernated) + expectEqual(preparation, .unavailable) + expectTrue(panel.isAgentHibernated) expectFalse(panel.surface.debugInitialInputMetadata().hasInitialInput) } diff --git a/cmuxTests/AgentHibernationTranscriptGuardScanTests.swift b/cmuxTests/AgentHibernationTranscriptGuardScanTests.swift index 583293f67546..bea592c023b8 100644 --- a/cmuxTests/AgentHibernationTranscriptGuardScanTests.swift +++ b/cmuxTests/AgentHibernationTranscriptGuardScanTests.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import Testing @@ -9,6 +10,124 @@ import Testing @Suite struct AgentHibernationTranscriptGuardScanTests { + @Test + func liveVersionCheckRevalidatesBytesAfterSameSizeRewriteWithRestoredModificationDate() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + let original = Data("abcdef".utf8) + let replacement = Data("UVWXYZ".utf8) + try original.write(to: live) + try original.write(to: snapshot) + let validated = try #require(AgentHibernationTranscriptGuard.snapshotStillMatchesLive( + .init(transcriptPath: live.path, snapshotPath: snapshot.path) + )) + let originalVersion = try #require(validated.liveFileVersion) + + let handle = try FileHandle(forWritingTo: live) + try handle.seek(toOffset: 0) + try handle.write(contentsOf: replacement) + try handle.truncate(atOffset: UInt64(replacement.count)) + try handle.close() + try FileManager.default.setAttributes( + [.modificationDate: originalVersion.modificationDate], + ofItemAtPath: live.path + ) + let rewrittenAttributes = try FileManager.default.attributesOfItem(atPath: live.path) + #expect((rewrittenAttributes[.systemFileNumber] as? NSNumber)?.uint64Value == originalVersion.fileNumber) + #expect((rewrittenAttributes[.size] as? NSNumber)?.uint64Value == originalVersion.size) + #expect(rewrittenAttributes[.modificationDate] as? Date == originalVersion.modificationDate) + + #expect(AgentHibernationTranscriptGuard.liveFileVersionStillMatches(validated) == false) + } + + @Test + func transcriptReadsRejectSymlinksNamedPipesAndDirectories() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let conversation = Data((#"{"type":"user","message":{"content":"protected"}}"# + "\n").utf8) + let target = directory.appendingPathComponent("target.jsonl") + let symlink = directory.appendingPathComponent("symlink.jsonl") + try conversation.write(to: target) + try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: target) + #expect(AgentHibernationTranscriptGuard.transcriptHasConversationTurns(atPath: symlink.path) == false) + + let fifo = directory.appendingPathComponent("transcript.fifo") + try #require(mkfifo(fifo.path, S_IRUSR | S_IWUSR) == 0) + let fifoDescriptor = open(fifo.path, O_RDWR | O_NONBLOCK | O_CLOEXEC) + try #require(fifoDescriptor >= 0) + defer { Darwin.close(fifoDescriptor) } + let bytesWritten = conversation.withUnsafeBytes { buffer in + Darwin.write(fifoDescriptor, buffer.baseAddress, buffer.count) + } + try #require(bytesWritten == conversation.count) + #expect(AgentHibernationTranscriptGuard.transcriptHasConversationTurns(atPath: fifo.path) == false) + + #expect(AgentHibernationTranscriptGuard.transcriptHasConversationTurns(atPath: directory.path) == false) + } + + @Test + func stableComparisonRejectsSymlinksAndNamedPipesBeforeReading() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + // Keep the relative symlink target and file contents the same byte + // length so metadata-only validation cannot reject it by size alone. + let target = directory.appendingPathComponent("target") + let symlink = directory.appendingPathComponent("live") + let snapshot = directory.appendingPathComponent("snapshot") + try Data("abcdef".utf8).write(to: target) + try Data("abcdef".utf8).write(to: snapshot) + try FileManager.default.createSymbolicLink( + atPath: symlink.path, + withDestinationPath: target.lastPathComponent + ) + #expect(AgentHibernationTranscriptGuard.snapshotStillMatchesLive( + .init(transcriptPath: symlink.path, snapshotPath: snapshot.path) + ) == nil) + + let fifo = directory.appendingPathComponent("empty.fifo") + let emptySnapshot = directory.appendingPathComponent("empty.snapshot") + try #require(mkfifo(fifo.path, S_IRUSR | S_IWUSR) == 0) + try Data().write(to: emptySnapshot) + let writer = Process() + writer.executableURL = URL(fileURLWithPath: "/bin/sh") + writer.arguments = ["-c", ": > \"$1\"", "fifo-writer", fifo.path] + try writer.run() + defer { + if writer.isRunning { + writer.terminate() + writer.waitUntilExit() + } + } + #expect(AgentHibernationTranscriptGuard.snapshotStillMatchesLive( + .init(transcriptPath: fifo.path, snapshotPath: emptySnapshot.path) + ) == nil) + } + + @Test + func conversationScanStopsAtTotalByteBudget() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("over-budget.jsonl") + _ = FileManager.default.createFile(atPath: transcript.path, contents: nil) + let handle = try FileHandle(forWritingTo: transcript) + defer { try? handle.close() } + var malformedLine = Data(repeating: 120, count: 1024 * 1024) + malformedLine.append(10) + for _ in 0..<65 { + try handle.write(contentsOf: malformedLine) + } + try handle.write(contentsOf: Data((#"{"type":"user","message":{"content":"too late"}}"# + "\n").utf8)) + try handle.synchronize() + + #expect(AgentHibernationTranscriptGuard.transcriptHasConversationTurns(atPath: transcript.path) == false) + } + @Test func oversizedLineDiscardResumesScanningAfterNewline() throws { let directory = try temporaryDirectory() @@ -404,6 +523,79 @@ struct AgentHibernationTranscriptGuardScanTests { ) == workflowTranscript.path) } + @Test + func resolveTranscriptPathDoesNotScanWorkflowsWhenDirectClaudeTranscriptResolves() throws { + let home = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let cwd = "/tmp/direct-before-workflow" + let sessionId = "direct-before-workflow-session" + let directTranscript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + try writeFile(#"{"type":"user","message":{"content":"before"}}"# + "\n", to: directTranscript) + let projectRoot = directTranscript.deletingLastPathComponent().path + let fileManager = DirectoryReadTrackingFileManager(trackedPath: projectRoot) + + #expect(AgentHibernationTranscriptGuard.resolveTranscriptPath( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + fileManager: fileManager + ) == directTranscript.path) + #expect(fileManager.trackedDirectoryReadCount == 0) + } + + @Test + func resolveTranscriptPathDoesNotFollowWorkflowDirectorySymlinks() throws { + let home = try temporaryDirectory() + let externalRoot = try temporaryDirectory() + defer { + try? FileManager.default.removeItem(at: home) + try? FileManager.default.removeItem(at: externalRoot) + } + + let cwd = "/tmp/symlinked-workflow" + let sessionId = "symlinked-workflow-session" + let projectRoot = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + .deletingLastPathComponent() + try FileManager.default.createDirectory(at: projectRoot, withIntermediateDirectories: true) + let externalTranscript = externalRoot + .appendingPathComponent("messages", isDirectory: true) + .appendingPathComponent("\(sessionId).jsonl", isDirectory: false) + try writeFile(#"{"type":"user","message":{"content":"outside"}}"# + "\n", to: externalTranscript) + try FileManager.default.createSymbolicLink( + at: projectRoot.appendingPathComponent("linked-workflow", isDirectory: true), + withDestinationURL: externalRoot + ) + + #expect(AgentHibernationTranscriptGuard.resolveTranscriptPath( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path + ) == nil) + } + + @Test + func resolveTranscriptPathRejectsWorkflowCandidateThatExhaustsGlobalByteBudget() throws { + let home = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let cwd = "/tmp/oversized-workflow" + let sessionId = "oversized-workflow-session" + let workflowTranscript = workflowTranscriptURL( + home: home, + cwd: cwd, + containerSessionId: "workflow-container", + sessionId: sessionId + ) + try writeFile(#"{"type":"user","message":{"content":"before"}}"# + "\n", to: workflowTranscript) + let handle = try FileHandle(forWritingTo: workflowTranscript) + try handle.truncate(atOffset: UInt64(64 * 1_024 * 1_024 + 1)) + try handle.close() + + #expect(AgentHibernationTranscriptGuard.resolveTranscriptPath( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path + ) == nil) + } + private var metadataStub: String { [ #"{"type":"last-prompt","prompt":"continue"}"#, @@ -494,4 +686,21 @@ struct AgentHibernationTranscriptGuardScanTests { try super.copyItem(atPath: srcPath, toPath: dstPath) } } + + private final class DirectoryReadTrackingFileManager: FileManager { + private let trackedPath: String + private(set) var trackedDirectoryReadCount = 0 + + init(trackedPath: String) { + self.trackedPath = (trackedPath as NSString).standardizingPath + super.init() + } + + override func contentsOfDirectory(atPath path: String) throws -> [String] { + if (path as NSString).standardizingPath == trackedPath { + trackedDirectoryReadCount += 1 + } + return try super.contentsOfDirectory(atPath: path) + } + } } diff --git a/cmuxTests/AgentHibernationTranscriptGuardTests.swift b/cmuxTests/AgentHibernationTranscriptGuardTests.swift index 162660b486e4..e18101225f93 100644 --- a/cmuxTests/AgentHibernationTranscriptGuardTests.swift +++ b/cmuxTests/AgentHibernationTranscriptGuardTests.swift @@ -1,5 +1,7 @@ +import Darwin import Foundation import Testing +import CmuxFoundation #if canImport(cmux_DEV) @testable import cmux_DEV @@ -9,6 +11,177 @@ import Testing @Suite struct AgentHibernationTranscriptGuardTests { + @Test + func budgetRejectedNewestGenerationBlocksOlderGeneration() { + struct Candidate: Equatable { + let id: String + let transcript: String + let generation: Int + let contentBytes: UInt64 + } + + let filler = Candidate(id: "filler", transcript: "/a", generation: 1, contentBytes: 90) + let older = Candidate(id: "older", transcript: "/z", generation: 1, contentBytes: 5) + let newer = Candidate(id: "newer", transcript: "/z", generation: 2, contentBytes: 20) + let selection = AgentHibernationTranscriptGuard.selectRecoveryCandidatesUnderBudget( + [older, filler, newer], + transcriptKey: \.transcript, + metadataByteCount: { _ in 1 }, + contentByteCount: \.contentBytes, + isNewer: { $0.generation > $1.generation }, + maximumCount: 10, + maximumMetadataBytes: 10, + maximumContentBytes: 100 + ) + + #expect(selection.candidates == [filler]) + #expect(selection.blockedTranscriptKeys == ["/z"]) + } + + @Test + func controlCharacterSessionIDCannotAliasSnapshotPrefix() throws { + let home = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + try FileManager.default.createDirectory(at: snapshots, withIntermediateDirectories: true) + let sentinel = snapshots.appendingPathComponent("forged") + try "keep".write(to: sentinel, atomically: true, encoding: .utf8) + + let outcome = AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: "forged\0suffix", workingDirectory: "/tmp"), + homeDirectory: home.path, + snapshotDirectory: snapshots + ) + + guard case .unableToProtect = outcome else { + Issue.record("Expected control-character session ID to fail closed") + return + } + #expect(try String(contentsOf: sentinel, encoding: .utf8) == "keep") + #expect(try FileManager.default.contentsOfDirectory(atPath: snapshots.path) == ["forged"]) + } + + @Test + func stableFileIdentityIncludesDevice() { + var first = stat() + first.st_mode = mode_t(S_IFREG | S_IRUSR | S_IWUSR) + first.st_dev = dev_t(11) + first.st_ino = ino_t(42) + first.st_size = 128 + first.st_mtimespec = timespec(tv_sec: 100, tv_nsec: 200) + var second = first + + #expect(AgentHibernationTranscriptGuard.sameStableFile(first, second)) + second.st_dev = dev_t(12) + #expect(!AgentHibernationTranscriptGuard.sameStableFile(first, second)) + } + + @Test + func descriptorBoundMetadataWriteRejectsPathReplacement() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let authority = directory.appendingPathComponent("authority.jsonl") + let replacement = directory.appendingPathComponent("replacement.jsonl") + let displaced = directory.appendingPathComponent("displaced.jsonl") + try populatedTranscript.write(to: authority, atomically: true, encoding: .utf8) + try metadataStub.write(to: replacement, atomically: true, encoding: .utf8) + let descriptor = open( + authority.path, + O_RDWR | O_CLOEXEC | O_NOFOLLOW + ) + #expect(descriptor >= 0) + guard descriptor >= 0 else { return } + defer { Darwin.close(descriptor) } + #expect(AgentHibernationTranscriptGuard.atomicallyRename( + authority, + to: displaced + )) + #expect(AgentHibernationTranscriptGuard.atomicallyRename( + replacement, + to: authority + )) + + #expect(!AgentHibernationTranscriptGuard.writeRecoveryMetadataData( + Data("{}".utf8), + toDescriptor: descriptor, + expectedPath: authority.path + )) + errno = 0 + #expect(getxattr( + authority.path, + "com.cmux.agent-transcript-recovery", + nil, + 0, + 0, + XATTR_NOFOLLOW + ) == -1) + #expect(errno == ENOATTR) + } + + @Test + func atomicSwapCapabilityProbeIsStableUnderConcurrency() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let results = await withTaskGroup(of: Bool.self) { group in + for _ in 0..<16 { + group.addTask { + AgentHibernationTranscriptGuard.volumeSupportsAtomicSwap( + in: directory + ) + } + } + var values: [Bool] = [] + for await value in group { values.append(value) } + return values + } + + #expect(results.count == 16) + #expect(results.allSatisfy { $0 }) + #expect( + try FileManager.default.contentsOfDirectory(atPath: directory.path) + .isEmpty + ) + } + + @Test + func recordedTranscriptFindsCanonicalSessionBeyondLegacyProjection() throws { + let home = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + let stateDirectory = home.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + + let targetSessionID = "claude-session-older-than-projection" + let transcript = home.appendingPathComponent("transcripts/\(targetSessionID).jsonl") + try writeFile(populatedTranscript, to: transcript) + let registry = CmuxAgentSessionRegistry( + url: stateDirectory.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + var records = try (0..<300).map { index in + try canonicalTranscriptRecord( + sessionID: String(format: "recent-%03d", index), + transcriptPath: "/tmp/recent-\(index).jsonl", + updatedAt: TimeInterval(1_000 + index) + ) + } + records.append(try canonicalTranscriptRecord( + sessionID: targetSessionID, + transcriptPath: transcript.path, + updatedAt: 1 + )) + try registry.apply(provider: "claude", records: records) + try writeTranscriptLegacyProjection( + records: Array(records.prefix(256)), + to: stateDirectory.appendingPathComponent("claude-hook-sessions.json") + ) + + #expect( + AgentHibernationTranscriptGuard.resolveTranscriptPath( + agent: agent(sessionId: targetSessionID, workingDirectory: nil), + homeDirectory: home.path + ) == transcript.path + ) + } + @Test func transcriptHasConversationTurnsClassifiesTranscriptLines() throws { let directory = try temporaryDirectory() @@ -157,6 +330,1230 @@ struct AgentHibernationTranscriptGuardTests { ) } + @Test + func teardownSnapshotPersistsRestartRecoveryMetadata() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + let cwd = "/tmp/restart-recovery" + let sessionId = "session-restart-recovery" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + try writeFile(populatedTranscript, to: transcript) + let snapshot = try #require( + snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + ) + ) + let metadataName = "com.cmux.agent-transcript-recovery" + let metadataSize = getxattr(snapshot.snapshotPath, metadataName, nil, 0, 0, 0) + #expect(metadataSize > 0) + var metadataData = Data(count: max(0, metadataSize)) + let bytesRead = metadataData.withUnsafeMutableBytes { buffer in + getxattr(snapshot.snapshotPath, metadataName, buffer.baseAddress, buffer.count, 0, 0) + } + #expect(bytesRead == metadataSize) + let metadata = try #require( + JSONSerialization.jsonObject(with: metadataData) as? [String: Any] + ) + #expect(metadata["version"] as? Int == 2) + #expect(metadata["sessionId"] as? String == sessionId) + #expect(metadata["transcriptPath"] as? String == transcript.path) + #expect(metadata["snapshotPath"] == nil) + #expect(UUID(uuidString: try #require(metadata["candidateId"] as? String)) != nil) + #expect(metadata["candidateState"] as? String == "recoverable") + #expect(metadata["ownerProcessId"] == nil) + + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSinceNow: -60)], + ofItemAtPath: snapshot.snapshotPath + ) + let newestContent = populatedTranscript + + #"{"type":"assistant","message":{"role":"assistant","content":"newest"}}"# + + "\n" + try newestContent.write(to: transcript, atomically: true, encoding: .utf8) + let newestSnapshot = try #require( + snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + ) + ) + try metadataStub.write(to: transcript, atomically: true, encoding: .utf8) + #expect( + AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: snapshots + ) == 1 + ) + #expect(AgentHibernationTranscriptGuard.transcriptHasConversationTurns(atPath: transcript.path)) + #expect( + try String(contentsOf: transcript, encoding: .utf8) == + expectedRestoredTranscript(snapshotContent: newestContent) + ) + #expect(FileManager.default.fileExists(atPath: snapshot.snapshotPath) == false) + #expect(FileManager.default.fileExists(atPath: newestSnapshot.snapshotPath) == false) + } + + @Test + func restartRecoveryPreservesDivergentPopulatedLiveTranscriptAndSnapshot() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + let cwd = "/tmp/restart-divergent-live" + let sessionId = "session-restart-divergent-live" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + try writeFile(populatedTranscript, to: transcript) + let snapshot = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + let divergentLive = [ + #"{"type":"user","message":{"role":"user","content":"new branch"}}"#, + #"{"type":"assistant","message":{"role":"assistant","content":"different history"}}"#, + ].joined(separator: "\n") + "\n" + try divergentLive.write(to: transcript, atomically: true, encoding: .utf8) + + #expect( + AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: snapshots + ) == 0 + ) + #expect(try String(contentsOf: transcript, encoding: .utf8) == divergentLive) + #expect(try String(contentsOfFile: snapshot.snapshotPath, encoding: .utf8) == populatedTranscript) + } + + @Test + func exhaustedProcessProbeBudgetBlocksOlderTranscriptGeneration() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let cwd = "/tmp/process-probe-budget" + let sessionId = "process-probe-budget" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + try writeFile(populatedTranscript, to: transcript) + let older = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + let newerContent = populatedTranscript + + #"{"type":"assistant","message":{"content":"new generation"}}"# + + "\n" + try writeFile(newerContent, to: transcript) + let newer = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots + ) + )) + try metadataStub.write(to: transcript, atomically: true, encoding: .utf8) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: snapshots, + maximumProcessIdentityProbes: 0 + ) == 0) + #expect(try String(contentsOf: transcript, encoding: .utf8) == metadataStub) + #expect(FileManager.default.fileExists(atPath: older.snapshotPath)) + #expect(FileManager.default.fileExists(atPath: newer.snapshotPath)) + } + + @Test + func retainedRenamePreservesMetadataAndNewestCaptureWinsRecovery() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + let cwd = "/tmp/restart-retained-ordering" + let sessionId = "session-retained-ordering" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + try writeFile(populatedTranscript, to: transcript) + let olderSnapshot = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + var olderMetadata = try recoveryMetadataJSON(atPath: olderSnapshot.snapshotPath) + let oldCaptureDate = Date(timeIntervalSinceNow: -120) + olderMetadata["capturedAt"] = oldCaptureDate.timeIntervalSinceReferenceDate + try setRecoveryMetadata( + try JSONSerialization.data(withJSONObject: olderMetadata), + atPath: olderSnapshot.snapshotPath + ) + + let newestContent = populatedTranscript + + #"{"type":"assistant","message":{"role":"assistant","content":"newest"}}"# + + "\n" + try newestContent.write(to: transcript, atomically: true, encoding: .utf8) + let newestSnapshot = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + + AgentHibernationTranscriptGuard.retainSnapshotForRecovery( + olderSnapshot, + sessionId: sessionId + ) + let retained = snapshots.appendingPathComponent("\(sessionId)-retained.jsonl") + #expect(FileManager.default.fileExists(atPath: olderSnapshot.snapshotPath) == false) + #expect(FileManager.default.fileExists(atPath: retained.path)) + let retainedMetadata = try recoveryMetadataJSON(atPath: retained.path) + #expect(retainedMetadata["version"] as? Int == 2) + #expect(retainedMetadata["snapshotPath"] == nil) + #expect( + abs((retainedMetadata["capturedAt"] as? Double ?? 0) - + oldCaptureDate.timeIntervalSinceReferenceDate) < 0.001 + ) + + try metadataStub.write(to: transcript, atomically: true, encoding: .utf8) + #expect( + AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: snapshots + ) == 1 + ) + #expect( + try String(contentsOf: transcript, encoding: .utf8) == + expectedRestoredTranscript(snapshotContent: newestContent) + ) + #expect(FileManager.default.fileExists(atPath: newestSnapshot.snapshotPath) == false) + #expect(FileManager.default.fileExists(atPath: retained.path) == false) + } + + @Test + func retainedSwapCrashStateKeepsBothV2CandidatesDiscoverable() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let cwd = "/tmp/retained-v2-swap-crash" + let sessionId = "retained-v2-swap-crash" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + try writeFile(populatedTranscript, to: transcript) + let older = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + AgentHibernationTranscriptGuard.retainSnapshotForRecovery( + older, + sessionId: sessionId + ) + let retained = snapshots.appendingPathComponent("\(sessionId)-retained.jsonl") + let newerContent = populatedTranscript + + #"{"type":"assistant","message":{"content":"newer tail"}}"# + + "\n" + try newerContent.write(to: transcript, atomically: true, encoding: .utf8) + let newer = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + #expect(renamex_np( + newer.snapshotPath, + retained.path, + UInt32(RENAME_SWAP) + ) == 0) + try metadataStub.write(to: transcript, atomically: true, encoding: .utf8) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: snapshots + ) == 1) + #expect(try String(contentsOf: transcript, encoding: .utf8).hasPrefix(newerContent)) + } + + @Test + func restartRecoveryIgnoresMissingCorruptAndOversizedMetadata() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let missing = directory.appendingPathComponent("missing-xattr-1.jsonl") + let corrupt = directory.appendingPathComponent("corrupt-xattr-1.jsonl") + let oversized = directory.appendingPathComponent("oversized-xattr-1.jsonl") + try populatedTranscript.write(to: missing, atomically: true, encoding: .utf8) + try populatedTranscript.write(to: corrupt, atomically: true, encoding: .utf8) + try populatedTranscript.write(to: oversized, atomically: true, encoding: .utf8) + try setRecoveryMetadata(Data("{".utf8), atPath: corrupt.path) + try setRecoveryMetadata(Data(repeating: 0x78, count: 64 * 1024 + 1), atPath: oversized.path) + + #expect( + AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: directory + ) == 0 + ) + #expect(FileManager.default.fileExists(atPath: missing.path) == false) + #expect(FileManager.default.fileExists(atPath: corrupt.path) == false) + #expect(FileManager.default.fileExists(atPath: oversized.path) == false) + let quarantine = directory.appendingPathComponent( + ".recovery-quarantine", + isDirectory: true + ) + let quarantined = try FileManager.default.contentsOfDirectory( + at: quarantine, + includingPropertiesForKeys: nil + ) + #expect(quarantined.count == 3) + for candidate in quarantined { + #expect(try String(contentsOf: candidate, encoding: .utf8) == populatedTranscript) + } + } + + @Test + func versionOneRecoveryMetadataCannotAuthorizeAPathAfterRename() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let recoveryDirectory = directory.appendingPathComponent( + "recovery", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: recoveryDirectory, + withIntermediateDirectories: true + ) + let sessionId = "v1-path-bound" + let live = directory.appendingPathComponent("live.jsonl") + let candidate = recoveryDirectory.appendingPathComponent( + "\(sessionId)-candidate.jsonl" + ) + let authorizedPath = recoveryDirectory.appendingPathComponent( + "\(sessionId)-authorized.jsonl" + ) + try metadataStub.write(to: live, atomically: true, encoding: .utf8) + try populatedTranscript.write(to: candidate, atomically: true, encoding: .utf8) + try setRecoveryMetadata( + recoveryMetadataData( + sessionId: sessionId, + transcriptPath: live.path, + snapshotPath: authorizedPath.path, + capturedAt: Date() + ), + atPath: candidate.path + ) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: recoveryDirectory + ) == 0) + #expect(try String(contentsOf: live, encoding: .utf8) == metadataStub) + #expect(FileManager.default.fileExists(atPath: candidate.path) == false) + let quarantine = recoveryDirectory.appendingPathComponent( + ".recovery-quarantine", + isDirectory: true + ) + let quarantined = try FileManager.default.contentsOfDirectory( + at: quarantine, + includingPropertiesForKeys: nil + ) + #expect(quarantined.count == 1) + #expect( + try String( + contentsOf: #require(quarantined.first), + encoding: .utf8 + ) == populatedTranscript + ) + } + + @Test + func crossVolumeDisplacementKeepsOldInodeAndDurableRecoveryPointer() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let recoveryDirectory = directory.appendingPathComponent( + "recovery", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: recoveryDirectory, + withIntermediateDirectories: true + ) + let live = directory.appendingPathComponent("live.jsonl") + let protected = directory.appendingPathComponent("protected.jsonl") + try metadataStub.write(to: live, atomically: true, encoding: .utf8) + try populatedTranscript.write(to: protected, atomically: true, encoding: .utf8) + let descriptor = open( + live.path, + O_WRONLY | O_APPEND | O_CLOEXEC | O_NOFOLLOW + ) + #expect(descriptor >= 0) + guard descriptor >= 0 else { return } + defer { Darwin.close(descriptor) } + var liveStatus = stat() + #expect(lstat(live.path, &liveStatus) == 0) + let guardedIdentity = try #require(AgentPIDProcessIdentity(pid: getpid())) + let candidateId = UUID().uuidString + + let authority = try #require( + AgentHibernationTranscriptGuard + .preserveDisplacedLiveTranscriptAcrossVolumes( + transcriptURL: live, + recoveryDirectory: recoveryDirectory, + protectedSnapshot: .init( + transcriptPath: live.path, + snapshotPath: protected.path, + guardedProcessIdentities: [guardedIdentity] + ), + expectedLiveStatus: liveStatus, + sessionId: "cross-volume-pointer", + candidateId: candidateId, + capturedAt: Date(), + fileManager: .default + ) + ) + #expect(authority.authorityURL.path != authority.contentURL.path) + #expect(FileManager.default.fileExists(atPath: live.path) == false) + #expect(FileManager.default.fileExists(atPath: authority.authorityURL.path)) + #expect(FileManager.default.fileExists(atPath: authority.contentURL.path)) + + let lateBranch = #"{"type":"user","message":{"content":"late external branch"}}"# + + "\n" + let lateData = Data(lateBranch.utf8) + let bytesWritten = lateData.withUnsafeBytes { bytes in + Darwin.write(descriptor, bytes.baseAddress, bytes.count) + } + #expect(bytesWritten == lateData.count) + #expect(fsync(descriptor) == 0) + let displacedContent = try String( + contentsOf: authority.contentURL, + encoding: .utf8 + ) + #expect(displacedContent.hasPrefix(metadataStub)) + #expect(displacedContent.contains("late external branch")) + + let pointerMetadata = try recoveryMetadataJSON( + atPath: authority.authorityURL.path + ) + #expect(pointerMetadata["version"] as? Int == 2) + #expect(pointerMetadata["candidateId"] as? String == candidateId) + #expect(pointerMetadata["snapshotPath"] == nil) + #expect( + pointerMetadata["externalCandidatePath"] as? String + == authority.contentURL.path + ) + #expect(pointerMetadata["ownerProcessId"] as? Int == Int(getpid())) + let guardedProcesses = try #require( + pointerMetadata["guardedProcesses"] as? [[String: Any]] + ) + #expect(guardedProcesses.first?["processId"] as? Int == Int(getpid())) + + // Simulate the next launch after both the cmux owner and guarded + // writer exited. Both sides of the pointer retain the same candidate + // identity, so startup can claim the pointer and consume the exact + // adjacent inode without copying it across volumes first. + var ownerlessMetadata = pointerMetadata + ownerlessMetadata.removeValue(forKey: "ownerProcessId") + ownerlessMetadata.removeValue(forKey: "ownerProcessStartSeconds") + ownerlessMetadata.removeValue(forKey: "ownerProcessStartMicroseconds") + ownerlessMetadata.removeValue(forKey: "ownerRuntimeId") + ownerlessMetadata.removeValue(forKey: "ownerBundleIdentifier") + ownerlessMetadata["guardedProcesses"] = [] + let ownerlessData = try JSONSerialization.data( + withJSONObject: ownerlessMetadata + ) + try setRecoveryMetadata( + ownerlessData, + atPath: authority.authorityURL.path + ) + try setRecoveryMetadata( + ownerlessData, + atPath: authority.contentURL.path + ) + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: recoveryDirectory + ) == 1) + #expect( + try String(contentsOf: live, encoding: .utf8) + .contains("late external branch") + ) + #expect( + FileManager.default.fileExists(atPath: authority.authorityURL.path) + == false + ) + #expect( + FileManager.default.fileExists(atPath: authority.contentURL.path) + == false + ) + } + + @Test + func crossVolumePointerRejectsWeakenedExternalMetadata() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let recoveryDirectory = directory.appendingPathComponent( + "recovery", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: recoveryDirectory, + withIntermediateDirectories: true + ) + let live = directory.appendingPathComponent("live.jsonl") + let protected = directory.appendingPathComponent("protected.jsonl") + try metadataStub.write(to: live, atomically: true, encoding: .utf8) + try populatedTranscript.write(to: protected, atomically: true, encoding: .utf8) + var liveStatus = stat() + #expect(lstat(live.path, &liveStatus) == 0) + let candidateId = UUID().uuidString + let authority = try #require( + AgentHibernationTranscriptGuard + .preserveDisplacedLiveTranscriptAcrossVolumes( + transcriptURL: live, + recoveryDirectory: recoveryDirectory, + protectedSnapshot: .init( + transcriptPath: live.path, + snapshotPath: protected.path + ), + expectedLiveStatus: liveStatus, + sessionId: "cross-volume-mismatch", + candidateId: candidateId, + capturedAt: Date(), + fileManager: .default + ) + ) + + var pointerMetadata = try recoveryMetadataJSON( + atPath: authority.authorityURL.path + ) + pointerMetadata.removeValue(forKey: "ownerProcessId") + pointerMetadata.removeValue(forKey: "ownerProcessStartSeconds") + pointerMetadata.removeValue(forKey: "ownerProcessStartMicroseconds") + pointerMetadata.removeValue(forKey: "ownerRuntimeId") + pointerMetadata.removeValue(forKey: "ownerBundleIdentifier") + pointerMetadata["guardedProcesses"] = [] + pointerMetadata["hasUncapturedGuardedProcesses"] = false + var weakenedExternalMetadata = pointerMetadata + weakenedExternalMetadata["hasUncapturedGuardedProcesses"] = true + try setRecoveryMetadata( + try JSONSerialization.data(withJSONObject: pointerMetadata), + atPath: authority.authorityURL.path + ) + try setRecoveryMetadata( + try JSONSerialization.data(withJSONObject: weakenedExternalMetadata), + atPath: authority.contentURL.path + ) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: recoveryDirectory + ) == 0) + #expect(!FileManager.default.fileExists(atPath: authority.authorityURL.path)) + #expect(FileManager.default.fileExists(atPath: authority.contentURL.path)) + let quarantined = try FileManager.default.contentsOfDirectory( + at: recoveryDirectory.appendingPathComponent(".recovery-quarantine"), + includingPropertiesForKeys: nil + ) + #expect(quarantined.count == 1) + } + + @Test + func startupCleansPendingExternalStagingBeforeInodePublication() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let recoveryDirectory = directory.appendingPathComponent("recovery") + try FileManager.default.createDirectory( + at: recoveryDirectory, + withIntermediateDirectories: true + ) + let sessionId = "pending-external-stage" + let candidateId = UUID().uuidString + let live = directory.appendingPathComponent("live.jsonl") + let external = directory.appendingPathComponent( + ".live.jsonl.cmux-recovery-\(candidateId).jsonl" + ) + let pointer = recoveryDirectory.appendingPathComponent( + "\(sessionId)-staging-\(candidateId).jsonl" + ) + try Data().write(to: external) + try metadataStub.write(to: pointer, atomically: true, encoding: .utf8) + try setRecoveryMetadata( + try externalStagingMetadataData( + sessionId: sessionId, + transcriptPath: live.path, + candidateId: candidateId, + state: "external-staging-pending", + externalPath: external.path + ), + atPath: pointer.path + ) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: recoveryDirectory + ) == 0) + #expect(!FileManager.default.fileExists(atPath: pointer.path)) + #expect(!FileManager.default.fileExists(atPath: external.path)) + } + + @Test + func startupCleansPopulatedExternalStagingBeforeSwap() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let recoveryDirectory = directory.appendingPathComponent("recovery") + try FileManager.default.createDirectory( + at: recoveryDirectory, + withIntermediateDirectories: true + ) + let sessionId = "populated-external-stage" + let candidateId = UUID().uuidString + let live = directory.appendingPathComponent("live.jsonl") + let external = directory.appendingPathComponent( + ".live.jsonl.cmux-recovery-\(candidateId).jsonl" + ) + let pointer = recoveryDirectory.appendingPathComponent( + "\(sessionId)-staging-\(candidateId).jsonl" + ) + try metadataStub.write(to: pointer, atomically: true, encoding: .utf8) + try populatedTranscript.write(to: external, atomically: true, encoding: .utf8) + var externalStatus = stat() + #expect(lstat(external.path, &externalStatus) == 0) + let metadata = try externalStagingMetadataData( + sessionId: sessionId, + transcriptPath: live.path, + candidateId: candidateId, + state: "external-staging", + externalPath: external.path, + externalDevice: UInt64(externalStatus.st_dev), + externalFileNumber: UInt64(externalStatus.st_ino) + ) + try setRecoveryMetadata(metadata, atPath: pointer.path) + try setRecoveryMetadata(metadata, atPath: external.path) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: recoveryDirectory + ) == 0) + #expect(!FileManager.default.fileExists(atPath: pointer.path)) + #expect(!FileManager.default.fileExists(atPath: external.path)) + } + + @Test + func restartRecoveryValidatesMetadataBeforeApplyingCandidateLimit() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + let cwd = "/tmp/restart-candidate-admission" + let sessionId = "session-valid-beyond-invalid-limit" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + try writeFile(populatedTranscript, to: transcript) + let validSnapshot = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSinceNow: -600)], + ofItemAtPath: validSnapshot.snapshotPath + ) + for index in 0..<300 { + let invalid = snapshots.appendingPathComponent("invalid-newer-\(index).jsonl") + try populatedTranscript.write(to: invalid, atomically: true, encoding: .utf8) + } + try metadataStub.write(to: transcript, atomically: true, encoding: .utf8) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots(snapshotDirectory: snapshots) == 1) + #expect( + try String(contentsOf: transcript, encoding: .utf8) == + expectedRestoredTranscript(snapshotContent: populatedTranscript) + ) + #expect(FileManager.default.fileExists(atPath: validSnapshot.snapshotPath) == false) + } + + @Test + func restartRecoveryRotatesFairlyPastDivergentTranscriptGroups() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let recoveryDirectory = directory.appendingPathComponent( + "recovery", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: recoveryDirectory, + withIntermediateDirectories: true + ) + + let divergentLive = [ + #"{"type":"user","message":{"role":"user","content":"live branch"}}"#, + #"{"type":"assistant","message":{"role":"assistant","content":"live answer"}}"#, + ].joined(separator: "\n") + "\n" + for index in 0..<260 { + let sessionId = String(format: "fair-%03d", index) + let live = directory.appendingPathComponent("live-\(sessionId).jsonl") + let snapshot = recoveryDirectory.appendingPathComponent( + "\(sessionId)-snapshot.jsonl" + ) + try divergentLive.write(to: live, atomically: true, encoding: .utf8) + try populatedTranscript.write(to: snapshot, atomically: true, encoding: .utf8) + try setRecoveryMetadata( + recoveryMetadataData( + sessionId: sessionId, + transcriptPath: live.path, + snapshotPath: snapshot.path, + capturedAt: Date(timeIntervalSinceNow: TimeInterval(-index)) + ), + atPath: snapshot.path + ) + } + + let targetSessionId = "fair-zzz" + let targetLive = directory.appendingPathComponent("live-\(targetSessionId).jsonl") + let targetSnapshot = recoveryDirectory.appendingPathComponent( + "\(targetSessionId)-snapshot.jsonl" + ) + try metadataStub.write(to: targetLive, atomically: true, encoding: .utf8) + try populatedTranscript.write(to: targetSnapshot, atomically: true, encoding: .utf8) + try setRecoveryMetadata( + recoveryMetadataData( + sessionId: targetSessionId, + transcriptPath: targetLive.path, + snapshotPath: targetSnapshot.path, + capturedAt: Date(timeIntervalSinceNow: -3_600) + ), + atPath: targetSnapshot.path + ) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSinceNow: -3_600)], + ofItemAtPath: targetSnapshot.path + ) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: recoveryDirectory + ) == 0) + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: recoveryDirectory + ) == 1) + #expect( + try String(contentsOf: targetLive, encoding: .utf8) == + expectedRestoredTranscript(snapshotContent: populatedTranscript) + ) + #expect(FileManager.default.fileExists(atPath: targetSnapshot.path) == false) + } + + @Test + func retainedSlotPreservesDivergentProtectedBranches() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + let cwd = "/tmp/retained-divergent-branches" + let sessionId = "retained-divergent-branches" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + let firstBranch = populatedTranscript + try writeFile(firstBranch, to: transcript) + let first = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + AgentHibernationTranscriptGuard.retainSnapshotForRecovery(first, sessionId: sessionId) + let retained = snapshots.appendingPathComponent("\(sessionId)-retained.jsonl") + + let secondBranch = [ + #"{"type":"user","message":{"role":"user","content":"different branch"}}"#, + #"{"type":"assistant","message":{"role":"assistant","content":"different answer"}}"#, + ].joined(separator: "\n") + "\n" + try secondBranch.write(to: transcript, atomically: true, encoding: .utf8) + let second = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots + ) + )) + AgentHibernationTranscriptGuard.retainSnapshotForRecovery(second, sessionId: sessionId) + + #expect(try String(contentsOf: retained, encoding: .utf8) == firstBranch) + #expect(try String(contentsOfFile: second.snapshotPath, encoding: .utf8) == secondBranch) + } + + @Test + func recoveryQuotaRejectsAnotherDivergentBranchWithoutChangingLive() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let cwd = "/tmp/recovery-storage-quota" + let sessionId = "recovery-storage-quota" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + let branches = (1...3).map { branch in + [ + #"{"type":"user","message":{"role":"user","content":"branch \#(branch)"}}"#, + #"{"type":"assistant","message":{"role":"assistant","content":"answer \#(branch)"}}"#, + ].joined(separator: "\n") + "\n" + } + + try writeFile(branches[0], to: transcript) + let first = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + maximumRecoveryStorageFileCount: 2, + maximumRecoveryStorageBytes: 1_024 * 1_024, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + AgentHibernationTranscriptGuard.retainSnapshotForRecovery( + first, + sessionId: sessionId + ) + + try writeFile(branches[1], to: transcript) + let second = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + maximumRecoveryStorageFileCount: 2, + maximumRecoveryStorageBytes: 1_024 * 1_024, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + AgentHibernationTranscriptGuard.retainSnapshotForRecovery( + second, + sessionId: sessionId + ) + let authoritiesBefore = try FileManager.default.contentsOfDirectory( + atPath: snapshots.path + ).filter { !$0.hasPrefix(".") && $0.hasSuffix(".jsonl") } + #expect(authoritiesBefore.count == 2) + + try writeFile(branches[2], to: transcript) + let rejected = AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + maximumRecoveryStorageFileCount: 2, + maximumRecoveryStorageBytes: 1_024 * 1_024, + recoveryMetadataOwnerProcessIdentity: nil + ) + + #expect(outcomeIsUnableToProtect(rejected)) + #expect(try String(contentsOf: transcript, encoding: .utf8) == branches[2]) + let authoritiesAfter = try FileManager.default.contentsOfDirectory( + atPath: snapshots.path + ).filter { !$0.hasPrefix(".") && $0.hasSuffix(".jsonl") } + #expect(authoritiesAfter.sorted() == authoritiesBefore.sorted()) + } + + @Test + func retainedSlotQuarantinesFutureMetadataBeforeConsideringItsBytes() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + let cwd = "/tmp/retained-invalid-version" + let sessionId = "retained-invalid-version" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + try writeFile(populatedTranscript, to: transcript) + let fresh = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + let retained = snapshots.appendingPathComponent("\(sessionId)-retained.jsonl") + let invalidSuperset = populatedTranscript + + #"{"type":"assistant","message":{"content":"invalid future metadata"}}"# + + "\n" + try invalidSuperset.write(to: retained, atomically: true, encoding: .utf8) + let invalidMetadata = try JSONSerialization.data(withJSONObject: [ + "version": 99, + "sessionId": sessionId, + "transcriptPath": transcript.path, + "candidateId": UUID().uuidString, + "candidateState": "recoverable", + ], options: [.sortedKeys]) + try setRecoveryMetadata(invalidMetadata, atPath: retained.path) + + AgentHibernationTranscriptGuard.retainSnapshotForRecovery( + fresh, + sessionId: sessionId + ) + + #expect(try String(contentsOf: retained, encoding: .utf8) == populatedTranscript) + #expect(!FileManager.default.fileExists(atPath: fresh.snapshotPath)) + let quarantine = snapshots.appendingPathComponent(".recovery-quarantine") + let quarantined = try FileManager.default.contentsOfDirectory( + at: quarantine, + includingPropertiesForKeys: nil + ) + #expect(quarantined.count == 1) + #expect(try String(contentsOf: #require(quarantined.first), encoding: .utf8) == invalidSuperset) + } + + @Test + func retainedSlotMissingPathCommitNeverOverwritesLateDestination() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("source.jsonl") + let destination = directory.appendingPathComponent("destination.jsonl") + let lateDestinationContent = "independent recovery authority\n" + try populatedTranscript.write(to: source, atomically: true, encoding: .utf8) + try lateDestinationContent.write(to: destination, atomically: true, encoding: .utf8) + + #expect(AgentHibernationTranscriptGuard.atomicallyRename( + source, + to: destination + ) == false) + #expect(try String(contentsOf: destination, encoding: .utf8) == lateDestinationContent) + #expect(try String(contentsOf: source, encoding: .utf8) == populatedTranscript) + } + + @Test + func repeatedPrefixRelatedAbortsStayInOneRetainedSlot() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let cwd = "/tmp/repeated-abort-retention" + let sessionId = "repeated-abort-retention" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + var content = populatedTranscript + + for index in 0..<20 { + content += #"{"type":"assistant","message":{"role":"assistant","content":"abort "# + + String(index) + + #""}}"# + + "\n" + try writeFile(content, to: transcript) + let snapshot = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots + ) + )) + AgentHibernationTranscriptGuard.retainSnapshotForRecovery( + snapshot, + sessionId: sessionId + ) + } + + let visibleSnapshots = try FileManager.default.contentsOfDirectory(atPath: snapshots.path) + .filter { !$0.hasPrefix(".") && $0.hasSuffix(".jsonl") } + #expect(visibleSnapshots == ["\(sessionId)-retained.jsonl"]) + #expect( + try String( + contentsOf: snapshots.appendingPathComponent(visibleSnapshots[0]), + encoding: .utf8 + ) == content + ) + } + + @Test + func restartRecoveryDefersWhileCapturedOwnerProcessIsAlive() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + let cwd = "/tmp/restart-live-owner" + let sessionId = "session-restart-live-owner" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + try writeFile(populatedTranscript, to: transcript) + let snapshot = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots + ) + )) + + let owner = Process() + owner.executableURL = URL(fileURLWithPath: "/bin/sleep") + owner.arguments = ["30"] + try owner.run() + defer { + if owner.isRunning { + owner.terminate() + owner.waitUntilExit() + } + } + let ownerIdentity = try #require(AgentPIDProcessIdentity(pid: owner.processIdentifier)) + var metadata = try recoveryMetadataJSON(atPath: snapshot.snapshotPath) + metadata.removeValue(forKey: "ownerRuntimeId") + metadata.removeValue(forKey: "ownerBundleIdentifier") + metadata["ownerProcessId"] = Int(ownerIdentity.pid) + metadata["ownerProcessStartSeconds"] = ownerIdentity.startSeconds + metadata["ownerProcessStartMicroseconds"] = ownerIdentity.startMicroseconds + try setRecoveryMetadata(try JSONSerialization.data(withJSONObject: metadata), atPath: snapshot.snapshotPath) + try metadataStub.write(to: transcript, atomically: true, encoding: .utf8) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots(snapshotDirectory: snapshots) == 0) + #expect(FileManager.default.fileExists(atPath: snapshot.snapshotPath)) + + owner.terminate() + owner.waitUntilExit() + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots(snapshotDirectory: snapshots) == 1) + #expect( + try String(contentsOf: transcript, encoding: .utf8) == + expectedRestoredTranscript(snapshotContent: populatedTranscript) + ) + } + + @Test + func restartRecoveryDefersWhileCapturedAgentProcessIsAlive() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + let cwd = "/tmp/restart-live-agent" + let sessionId = "session-restart-live-agent" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + try writeFile(populatedTranscript, to: transcript) + let snapshot = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + + let agentProcess = Process() + agentProcess.executableURL = URL(fileURLWithPath: "/bin/sleep") + agentProcess.arguments = ["30"] + try agentProcess.run() + defer { + if agentProcess.isRunning { + agentProcess.terminate() + agentProcess.waitUntilExit() + } + } + let processIdentity = try #require(AgentPIDProcessIdentity(pid: agentProcess.processIdentifier)) + var metadata = try recoveryMetadataJSON(atPath: snapshot.snapshotPath) + metadata["guardedProcesses"] = [[ + "processId": Int(processIdentity.pid), + "processStartSeconds": processIdentity.startSeconds, + "processStartMicroseconds": processIdentity.startMicroseconds, + ]] + try setRecoveryMetadata(try JSONSerialization.data(withJSONObject: metadata), atPath: snapshot.snapshotPath) + try metadataStub.write(to: transcript, atomically: true, encoding: .utf8) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots(snapshotDirectory: snapshots) == 0) + #expect(FileManager.default.fileExists(atPath: snapshot.snapshotPath)) + + agentProcess.terminate() + agentProcess.waitUntilExit() + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots(snapshotDirectory: snapshots) == 1) + #expect( + try String(contentsOf: transcript, encoding: .utf8) == + expectedRestoredTranscript(snapshotContent: populatedTranscript) + ) + } + + @Test + func restartRecoveryDoesNotTrustSpoofableFileVersionMetadata() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + let cwd = "/tmp/restart-version-spoof" + let sessionId = "session-restart-version-spoof" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + try writeFile(populatedTranscript, to: transcript) + let snapshot = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + let originalVersion = try #require(snapshot.liveFileVersion) + let divergentSameSize = populatedTranscript.replacingOccurrences(of: "hello", with: "jello") + #expect(divergentSameSize.utf8.count == populatedTranscript.utf8.count) + let handle = try FileHandle(forWritingTo: transcript) + try handle.seek(toOffset: 0) + try handle.write(contentsOf: Data(divergentSameSize.utf8)) + try handle.truncate(atOffset: UInt64(divergentSameSize.utf8.count)) + try handle.close() + try FileManager.default.setAttributes( + [.modificationDate: originalVersion.modificationDate], + ofItemAtPath: transcript.path + ) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots(snapshotDirectory: snapshots) == 0) + #expect(try String(contentsOf: transcript, encoding: .utf8) == divergentSameSize) + #expect(try String(contentsOfFile: snapshot.snapshotPath, encoding: .utf8) == populatedTranscript) + } + + @Test + func restartRecoveryUsesAnInterprocessDirectoryLock() throws { + let home = try temporaryDirectory() + let snapshots = home.appendingPathComponent("snapshots", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + let cwd = "/tmp/restart-directory-lock" + let sessionId = "session-restart-directory-lock" + let transcript = transcriptURL(home: home, cwd: cwd, sessionId: sessionId) + try writeFile(populatedTranscript, to: transcript) + let snapshot = try #require(snapshotOutcomeValue( + from: AgentHibernationTranscriptGuard.snapshotBeforeTeardown( + agent: agent(sessionId: sessionId, workingDirectory: cwd), + homeDirectory: home.path, + snapshotDirectory: snapshots, + recoveryMetadataOwnerProcessIdentity: nil + ) + )) + try metadataStub.write(to: transcript, atomically: true, encoding: .utf8) + + let lockPath = snapshots.appendingPathComponent(".agent-transcript-recovery.lock").path + let lockDescriptor = open(lockPath, O_CREAT | O_RDWR | O_CLOEXEC, S_IRUSR | S_IWUSR) + #expect(lockDescriptor >= 0) + defer { if lockDescriptor >= 0 { close(lockDescriptor) } } + #expect(flock(lockDescriptor, LOCK_EX | LOCK_NB) == 0) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots(snapshotDirectory: snapshots) == 0) + #expect(FileManager.default.fileExists(atPath: snapshot.snapshotPath)) + + #expect(flock(lockDescriptor, LOCK_UN) == 0) + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots(snapshotDirectory: snapshots) == 1) + #expect( + try String(contentsOf: transcript, encoding: .utf8) == + expectedRestoredTranscript(snapshotContent: populatedTranscript) + ) + } + + @Test + func restartRecoveryDefersAmbiguousDivergentSnapshotGenerations() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let recoveryDirectory = directory.appendingPathComponent( + "recovery", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: recoveryDirectory, + withIntermediateDirectories: true + ) + + let sessionId = "restart-divergent-older" + let live = directory.appendingPathComponent("live.jsonl") + let older = recoveryDirectory.appendingPathComponent("\(sessionId)-older.jsonl") + let newest = recoveryDirectory.appendingPathComponent("\(sessionId)-newest.jsonl") + let olderBranch = [ + #"{"type":"user","message":{"role":"user","content":"older branch"}}"#, + #"{"type":"assistant","message":{"role":"assistant","content":"older answer"}}"#, + ].joined(separator: "\n") + "\n" + try metadataStub.write(to: live, atomically: true, encoding: .utf8) + try olderBranch.write(to: older, atomically: true, encoding: .utf8) + try populatedTranscript.write(to: newest, atomically: true, encoding: .utf8) + try setRecoveryMetadata( + recoveryMetadataData( + sessionId: sessionId, + transcriptPath: live.path, + snapshotPath: older.path, + capturedAt: Date(timeIntervalSinceNow: -60) + ), + atPath: older.path + ) + try setRecoveryMetadata( + recoveryMetadataData( + sessionId: sessionId, + transcriptPath: live.path, + snapshotPath: newest.path, + capturedAt: Date() + ), + atPath: newest.path + ) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: recoveryDirectory + ) == 0) + #expect(try String(contentsOf: live, encoding: .utf8) == metadataStub) + #expect(try String(contentsOf: newest, encoding: .utf8) == populatedTranscript) + #expect(try String(contentsOf: older, encoding: .utf8) == olderBranch) + } + + @Test + func restartRecoveryUsesAppendAncestryAcrossClockRollback() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let recoveryDirectory = directory.appendingPathComponent( + "recovery", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: recoveryDirectory, + withIntermediateDirectories: true + ) + + let sessionId = "restart-clock-rollback" + let live = directory.appendingPathComponent("live.jsonl") + let prefix = recoveryDirectory.appendingPathComponent("\(sessionId)-prefix.jsonl") + let appendSuperset = recoveryDirectory.appendingPathComponent( + "\(sessionId)-superset.jsonl" + ) + let laterTurn = #"{"type":"assistant","message":{"role":"assistant","content":"after rollback"}}"# + "\n" + let supersetContent = populatedTranscript + laterTurn + try metadataStub.write(to: live, atomically: true, encoding: .utf8) + try populatedTranscript.write(to: prefix, atomically: true, encoding: .utf8) + try supersetContent.write(to: appendSuperset, atomically: true, encoding: .utf8) + + let apparentNewerDate = Date() + let rolledBackDate = apparentNewerDate.addingTimeInterval(-3_600) + try setRecoveryMetadata( + recoveryMetadataData( + sessionId: sessionId, + transcriptPath: live.path, + snapshotPath: prefix.path, + capturedAt: apparentNewerDate + ), + atPath: prefix.path + ) + try setRecoveryMetadata( + recoveryMetadataData( + sessionId: sessionId, + transcriptPath: live.path, + snapshotPath: appendSuperset.path, + capturedAt: rolledBackDate + ), + atPath: appendSuperset.path + ) + try FileManager.default.setAttributes( + [.modificationDate: rolledBackDate], + ofItemAtPath: appendSuperset.path + ) + + #expect(AgentHibernationTranscriptGuard.recoverPendingSnapshots( + snapshotDirectory: recoveryDirectory + ) == 1) + #expect( + try String(contentsOf: live, encoding: .utf8) + == expectedRestoredTranscript(snapshotContent: supersetContent) + ) + #expect(!FileManager.default.fileExists(atPath: prefix.path)) + #expect(!FileManager.default.fileExists(atPath: appendSuperset.path)) + } + @Test func resolveTranscriptPathHonorsConfigOverrideAndRejectsUnsupportedAgents() throws { let home = try temporaryDirectory() @@ -365,6 +1762,39 @@ struct AgentHibernationTranscriptGuardTests { #expect(restored) #expect(try String(contentsOf: live, encoding: .utf8) == populatedTranscript.trimmedTrailingNewlines + "\n" + metadataStub) + let entries = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + let pointer = try #require(entries.first { + $0.lastPathComponent.contains("-pointer-") + }) + let displaced = try #require(entries.first { + $0.lastPathComponent.hasPrefix(".live.jsonl.cmux-recovery-") + }) + #expect(try String(contentsOf: displaced, encoding: .utf8) == metadataStub) + let pointerMetadata = try recoveryMetadataJSON(atPath: pointer.path) + let displacedMetadata = try recoveryMetadataJSON(atPath: displaced.path) + #expect( + NSDictionary(dictionary: pointerMetadata) + .isEqual(to: displacedMetadata) + ) + } + + @Test + func restoreIfClobberedRestoresZeroByteInPlaceTruncation() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + try Data().write(to: live) + try populatedTranscript.write(to: snapshot, atomically: true, encoding: .utf8) + + #expect(AgentHibernationTranscriptGuard.restoreIfClobbered( + .init(transcriptPath: live.path, snapshotPath: snapshot.path) + )) + #expect(try String(contentsOf: live, encoding: .utf8) == populatedTranscript) } @Test @@ -386,6 +1816,47 @@ struct AgentHibernationTranscriptGuardTests { #expect(try String(contentsOf: live, encoding: .utf8) == liveContent) } + @Test + func restoreIfClobberedCompletesProtectedAppendPrefix() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + let protectedPrefix = [ + #"{"type":"summary","summary":"Session"}"#, + #"{"type":"user","message":{"role":"user","content":"hello"}}"#, + ].joined(separator: "\n") + "\n" + let protected = protectedPrefix + + #"{"type":"assistant","message":{"role":"assistant","content":"hi"}}"# + + "\n" + try protectedPrefix.write(to: live, atomically: true, encoding: .utf8) + try protected.write(to: snapshot, atomically: true, encoding: .utf8) + + #expect(AgentHibernationTranscriptGuard.restoreIfClobbered( + .init(transcriptPath: live.path, snapshotPath: snapshot.path) + )) + #expect(try String(contentsOf: live, encoding: .utf8) == protected) + } + + @Test + func restoreIfClobberedPreservesSameSizeDivergentLiveTranscript() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let live = directory.appendingPathComponent("live.jsonl") + let snapshot = directory.appendingPathComponent("snapshot.jsonl") + let divergent = populatedTranscript.replacingOccurrences(of: "hello", with: "jello") + #expect(divergent.utf8.count == populatedTranscript.utf8.count) + try divergent.write(to: live, atomically: true, encoding: .utf8) + try populatedTranscript.write(to: snapshot, atomically: true, encoding: .utf8) + + #expect(!AgentHibernationTranscriptGuard.restoreIfClobbered( + .init(transcriptPath: live.path, snapshotPath: snapshot.path) + )) + #expect(try String(contentsOf: live, encoding: .utf8) == divergent) + } + @Test func restoreIfClobberedRestoresMissingLiveTranscriptExactly() throws { let directory = try temporaryDirectory() @@ -438,6 +1909,10 @@ struct AgentHibernationTranscriptGuardTests { ].joined(separator: "\n") + "\n" } + private func expectedRestoredTranscript(snapshotContent: String) -> String { + snapshotContent.trimmedTrailingNewlines + "\n" + metadataStub + } + private func temporaryDirectory() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-transcript-guard-\(UUID().uuidString)", isDirectory: true) @@ -472,6 +1947,112 @@ struct AgentHibernationTranscriptGuardTests { private func writeFile(_ content: String, to url: URL) throws { try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true); try content.write(to: url, atomically: true, encoding: .utf8) } + private func recoveryMetadataJSON(atPath path: String) throws -> [String: Any] { + let metadataName = "com.cmux.agent-transcript-recovery" + let byteCount = getxattr(path, metadataName, nil, 0, 0, 0) + guard byteCount > 0 else { + throw CocoaError(.fileReadUnknown) + } + var data = Data(count: byteCount) + let bytesRead = data.withUnsafeMutableBytes { buffer in + getxattr(path, metadataName, buffer.baseAddress, buffer.count, 0, 0) + } + guard bytesRead == byteCount, + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CocoaError(.fileReadCorruptFile) + } + return object + } + + private func setRecoveryMetadata(_ data: Data, atPath path: String) throws { + let result = data.withUnsafeBytes { buffer in + setxattr( + path, + "com.cmux.agent-transcript-recovery", + buffer.baseAddress, + buffer.count, + 0, + 0 + ) + } + guard result == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + } + + private func recoveryMetadataData( + sessionId: String, + transcriptPath: String, + snapshotPath: String, + capturedAt: Date + ) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "version": 1, + "sessionId": sessionId, + "transcriptPath": transcriptPath, + "snapshotPath": snapshotPath, + "capturedAt": capturedAt.timeIntervalSinceReferenceDate, + ], options: [.sortedKeys]) + } + + private func externalStagingMetadataData( + sessionId: String, + transcriptPath: String, + candidateId: String, + state: String, + externalPath: String, + externalDevice: UInt64? = nil, + externalFileNumber: UInt64? = nil + ) throws -> Data { + var object: [String: Any] = [ + "version": 2, + "sessionId": sessionId, + "transcriptPath": transcriptPath, + "candidateId": candidateId, + "candidateState": state, + "externalCandidatePath": externalPath, + "capturedAt": Date().timeIntervalSinceReferenceDate, + "guardedProcesses": [], + "hasUncapturedGuardedProcesses": false, + ] + if let externalDevice { object["externalFileDevice"] = externalDevice } + if let externalFileNumber { object["externalFileNumber"] = externalFileNumber } + return try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + } + + private func canonicalTranscriptRecord( + sessionID: String, + transcriptPath: String, + updatedAt: TimeInterval + ) throws -> CmuxAgentSessionRegistry.Record { + let json = try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "transcriptPath": transcriptPath, + "updatedAt": updatedAt, + ], options: [.sortedKeys]) + return CmuxAgentSessionRegistry.Record( + provider: "claude", + sessionID: sessionID, + updatedAt: updatedAt, + json: json + ) + } + + private func writeTranscriptLegacyProjection( + records: [CmuxAgentSessionRegistry.Record], + to url: URL + ) throws { + var sessions: [String: Any] = [:] + for record in records { + sessions[record.sessionID] = try JSONSerialization.jsonObject(with: record.json) + } + let data = try JSONSerialization.data(withJSONObject: [ + "version": 1, + "sessions": sessions, + ], options: [.sortedKeys]) + try data.write(to: url, options: .atomic) + } + private func agent( kind: RestorableAgentKind = .claude, sessionId: String, diff --git a/cmuxTests/AgentHookSessionStoreScaleTests.swift b/cmuxTests/AgentHookSessionStoreScaleTests.swift new file mode 100644 index 000000000000..c3b99e681382 --- /dev/null +++ b/cmuxTests/AgentHookSessionStoreScaleTests.swift @@ -0,0 +1,474 @@ +import CmuxFoundation +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite("Agent hook session inspection limits") +struct AgentHookSessionStoreScaleTests { + @Test("inspection validates record, provider, selection, and legacy byte caps before decode") + func inspectionStorageCapsAreTyped() throws { + let mebibyte: Int64 = 1_024 * 1_024 + let cases: [( + source: AgentHookSessionRegistryBridge.InspectionSourcePreflight, + expectedScope: AgentHookSessionStoreLoadFailure.Scope, + expectedObserved: Int64, + expectedMaximum: Int64 + )] = [ + ( + source( + provider: "record", + recordBytes: 4 * mebibyte + 1, + largestRecordBytes: 4 * mebibyte + 1, + legacyBytes: 0 + ), + .registryRecord, + 4 * mebibyte + 1, + 4 * mebibyte + ), + ( + source( + provider: "provider", + recordBytes: 64 * mebibyte + 1, + largestRecordBytes: 1, + legacyBytes: 0 + ), + .registryProvider, + 64 * mebibyte + 1, + 64 * mebibyte + ), + ( + source( + provider: "legacy", + recordBytes: 0, + largestRecordBytes: 0, + legacyBytes: 64 * mebibyte + 1 + ), + .legacyFile, + 64 * mebibyte + 1, + 64 * mebibyte + ), + ( + source( + provider: "combined", + recordBytes: 40 * mebibyte, + largestRecordBytes: 1, + legacyBytes: 25 * mebibyte + ), + .providerMaterialization, + 65 * mebibyte, + 64 * mebibyte + ), + ] + + for item in cases { + let failure = try #require(storageFailure(for: [item.source])) + #expect(failure.code == .storageLimitExceeded) + #expect(failure.scope == item.expectedScope) + #expect(failure.observedBytes == item.expectedObserved) + #expect(failure.maximumBytes == item.expectedMaximum) + #expect(failure.provider == item.source.provider) + } + + let aggregateSources = [ + source( + provider: "first", + recordBytes: 64 * mebibyte, + largestRecordBytes: 1, + legacyBytes: 0 + ), + source( + provider: "second", + recordBytes: 64 * mebibyte, + largestRecordBytes: 1, + legacyBytes: 0 + ), + source( + provider: "third", + recordBytes: 1, + largestRecordBytes: 1, + legacyBytes: 0 + ), + ] + let aggregateFailure = try #require(storageFailure(for: aggregateSources)) + #expect(aggregateFailure.scope == .selectionMaterialization) + #expect(aggregateFailure.observedBytes == 128 * mebibyte + 1) + #expect(aggregateFailure.maximumBytes == 128 * mebibyte) + #expect(aggregateFailure.provider == "third") + + let legacyAggregate = ["first", "second", "third"].map { + source( + provider: $0, + recordBytes: 0, + largestRecordBytes: 0, + legacyBytes: 45 * mebibyte + ) + } + let legacyAggregateFailure = try #require(storageFailure(for: legacyAggregate)) + #expect(legacyAggregateFailure.scope == .selectionMaterialization) + #expect(legacyAggregateFailure.observedBytes == 135 * mebibyte) + #expect(legacyAggregateFailure.maximumBytes == 128 * mebibyte) + #expect(legacyAggregateFailure.provider == "third") + } + + @Test("aggregate sidecar cap stops before reading the overflowing source") + func aggregateSidecarCapPrecedesAdmissionReads() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-preflight-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let registry = CmuxAgentSessionRegistry(url: registryURL) + let sidecar = try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + "session": [ + "sessionId": "session", + "runs": [["runId": "run"]], + ], + ], + ], options: [.sortedKeys]) + let sources = try ["a", "b", "c"].map { provider in + let source = CmuxAgentSessionRegistry.LegacySource( + provider: provider, + url: root.appendingPathComponent("\(provider).json") + ) + try sidecar.write(to: source.url, options: .atomic) + return source + } + var admittedProviders: [String] = [] + var failure: AgentHookSessionStoreLoadFailure? + do { + _ = try AgentHookSessionRegistryBridge.preflightInspectionSources( + sources, + registry: registry, + registryPath: registryURL.path, + fileManager: .default, + maximumLegacyGraphNodes: 20_000, + limits: .init( + recordBytes: Int64(1_024 * 1_024), + providerBytes: Int64(1_024 * 1_024), + selectionBytes: Int64(sidecar.count * 2), + legacyFileBytes: Int64(1_024 * 1_024) + ), + admissionLoader: { source, stamp, remainingGraphNodes in + admittedProviders.append(source.provider) + return try registry.hookLegacySourceAdmission( + source: source, + expectedStamp: stamp, + maximumGraphNodes: remainingGraphNodes + ) + } + ) + } catch let error as AgentHookSessionStoreLoadFailure { + failure = error + } + + let captured = try #require(failure) + #expect(captured.scope == .selectionMaterialization) + #expect(captured.provider == "c") + #expect(captured.observedBytes == Int64(sidecar.count * 3)) + #expect(captured.maximumBytes == Int64(sidecar.count * 2)) + #expect(admittedProviders == ["a", "b"]) + } + + @Test("sidecar admission retries one descriptor revision without mixing bytes") + func sidecarAdmissionRetriesOneRevision() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-revision-retry-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let registry = CmuxAgentSessionRegistry(url: registryURL) + let source = CmuxAgentSessionRegistry.LegacySource( + provider: "codex", + url: root.appendingPathComponent("codex.json") + ) + let first = try inspectionSidecar(sessionID: "a") + let second = try inspectionSidecar(sessionID: "b") + #expect(first.count == second.count) + try writeRevision(first, to: source.url, modifiedAt: 100) + var attempts = 0 + + let preflight = try AgentHookSessionRegistryBridge.preflightInspectionSources( + [source], + registry: registry, + registryPath: registryURL.path, + fileManager: .default, + maximumLegacyGraphNodes: 20_000, + admissionLoader: { source, stamp, remainingGraphNodes in + attempts += 1 + if attempts == 1 { + try writeRevision(second, to: source.url, modifiedAt: 101) + } + return try registry.hookLegacySourceAdmission( + source: source, + expectedStamp: stamp, + maximumGraphNodes: remainingGraphNodes + ) + } + ) + #expect(attempts == 2) + #expect(preflight.warnings.isEmpty) + #expect(preflight.admissions.count == 1) + + let snapshot = try #require(registry.snapshotsImportingAdmittedLegacy( + sources: [source], + admissions: preflight.admissions + )[source.provider]) + #expect(snapshot.records.map(\.sessionID) == ["b"]) + } + + @Test("sidecar deletion after preflight falls back to a valid canonical snapshot") + func sidecarDeletionAfterPreflightUsesCanonicalFallback() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-deletion-race-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let registry = CmuxAgentSessionRegistry(url: registryURL) + let source = CmuxAgentSessionRegistry.LegacySource( + provider: "codex", + url: root.appendingPathComponent("codex.json") + ) + let canonicalRoot = try #require( + try JSONSerialization.jsonObject( + with: inspectionSidecar(sessionID: "canonical") + ) as? [String: Any] + ) + let canonicalSessions = try #require(canonicalRoot["sessions"] as? [String: Any]) + let canonical = try #require(canonicalSessions["canonical"] as? [String: Any]) + try registry.apply(provider: source.provider, records: [ + CmuxAgentSessionRegistry.Record( + provider: source.provider, + sessionID: "canonical", + updatedAt: 1, + json: try JSONSerialization.data( + withJSONObject: canonical, + options: [.sortedKeys] + ) + ), + ]) + try writeRevision( + inspectionSidecar(sessionID: "legacy"), + to: source.url, + modifiedAt: 300 + ) + var attempts = 0 + + let preflight = try AgentHookSessionRegistryBridge.preflightInspectionSources( + [source], + registry: registry, + registryPath: registryURL.path, + fileManager: .default, + maximumLegacyGraphNodes: 20_000, + admissionLoader: { source, stamp, remainingGraphNodes in + attempts += 1 + try FileManager.default.removeItem(at: source.url) + return try registry.hookLegacySourceAdmission( + source: source, + expectedStamp: stamp, + maximumGraphNodes: remainingGraphNodes + ) + } + ) + + #expect(attempts == 1) + #expect(preflight.admissions.isEmpty) + #expect(preflight.warnings == [AgentHookSessionStoreLoadWarning( + provider: source.provider, + path: source.url.path, + code: .legacySourceImportFailed, + fallback: .registry + )]) + let snapshot = try #require(registry.snapshotsImportingAdmittedLegacy( + sources: [source], + admissions: preflight.admissions + )[source.provider]) + #expect(snapshot.records.map(\.sessionID) == ["canonical"]) + } + + @Test("continuously changing sidecars warn on valid canonical fallback and fail legacy-only") + func unstableSidecarFallbackIsExplicitAndLossless() throws { + func exercise(hasCanonical: Bool) throws -> ( + result: AgentHookSessionRegistryBridge.InspectionPreflightResult?, + failure: AgentHookSessionStoreLoadFailure?, + registry: CmuxAgentSessionRegistry, + source: CmuxAgentSessionRegistry.LegacySource, + root: URL + ) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-agent-unstable-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let registry = CmuxAgentSessionRegistry(url: registryURL) + let source = CmuxAgentSessionRegistry.LegacySource( + provider: "codex", + url: root.appendingPathComponent("codex.json") + ) + if hasCanonical { + let rootObject = try #require( + try JSONSerialization.jsonObject( + with: inspectionSidecar(sessionID: "canonical") + ) as? [String: Any] + ) + let sessions = try #require(rootObject["sessions"] as? [String: Any]) + let object = try #require(sessions["canonical"] as? [String: Any]) + try registry.apply(provider: source.provider, records: [ + CmuxAgentSessionRegistry.Record( + provider: source.provider, + sessionID: "canonical", + updatedAt: 1, + json: try JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys] + ) + ), + ]) + } + try writeRevision( + inspectionSidecar(sessionID: "a"), + to: source.url, + modifiedAt: 200 + ) + var revision = 200.0 + var result: AgentHookSessionRegistryBridge.InspectionPreflightResult? + var failure: AgentHookSessionStoreLoadFailure? + do { + result = try AgentHookSessionRegistryBridge.preflightInspectionSources( + [source], + registry: registry, + registryPath: registryURL.path, + fileManager: .default, + maximumLegacyGraphNodes: 20_000, + admissionLoader: { source, stamp, remainingGraphNodes in + revision += 1 + let sessionID = Int(revision).isMultiple(of: 2) ? "b" : "c" + try writeRevision( + inspectionSidecar(sessionID: sessionID), + to: source.url, + modifiedAt: revision + ) + return try registry.hookLegacySourceAdmission( + source: source, + expectedStamp: stamp, + maximumGraphNodes: remainingGraphNodes + ) + } + ) + } catch let error as AgentHookSessionStoreLoadFailure { + failure = error + } + return (result, failure, registry, source, root) + } + + let canonical = try exercise(hasCanonical: true) + defer { try? FileManager.default.removeItem(at: canonical.root) } + let canonicalResult = try #require(canonical.result) + #expect(canonical.failure?.code == nil) + #expect(canonicalResult.admissions.isEmpty) + #expect(canonicalResult.warnings == [AgentHookSessionStoreLoadWarning( + provider: "codex", + path: canonical.source.url.path, + code: .legacySourceImportFailed, + fallback: .registry + )]) + let canonicalSnapshot = try #require( + canonical.registry.snapshotsImportingAdmittedLegacy( + sources: [canonical.source], + admissions: canonicalResult.admissions + )[canonical.source.provider] + ) + #expect(canonicalSnapshot.records.map(\.sessionID) == ["canonical"]) + + let legacyOnly = try exercise(hasCanonical: false) + defer { try? FileManager.default.removeItem(at: legacyOnly.root) } + #expect(legacyOnly.result?.warnings.count == nil) + #expect(legacyOnly.failure?.code == .legacySourceImportFailed) + #expect(try legacyOnly.registry.snapshot(provider: "codex").records.isEmpty) + } + + private func source( + provider: String, + recordBytes: Int64, + largestRecordBytes: Int64, + legacyBytes: Int64 + ) -> AgentHookSessionRegistryBridge.InspectionSourcePreflight { + AgentHookSessionRegistryBridge.InspectionSourcePreflight( + provider: provider, + registryPath: "/registry.sqlite3", + legacyPath: "/\(provider).json", + metrics: CmuxAgentSessionRegistry.HookStorageMetrics( + recordCount: largestRecordBytes == 0 ? 0 : 1, + recordBytes: recordBytes, + activeSlotBytes: 0, + largestRecordSessionID: largestRecordBytes == 0 ? nil : "session", + largestRecordBytes: largestRecordBytes + ), + legacyBytes: legacyBytes + ) + } + + private func inspectionSidecar(sessionID: String) throws -> Data { + let runID = "run-\(sessionID)" + return try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + sessionID: [ + "sessionId": sessionID, + "workspaceId": "workspace-\(sessionID)", + "surfaceId": "surface-\(sessionID)", + "runId": runID, + "activeRunId": runID, + "restoreAuthority": false, + "sessionState": "ended", + "foregroundState": "completed", + "startedAt": 1.0, + "updatedAt": 1.0, + "completedAt": 1.0, + "runs": [[ + "runId": runID, + "restoreAuthority": false, + "startedAt": 1.0, + "updatedAt": 1.0, + "endedAt": 1.0, + ]], + ], + ], + ], options: [.sortedKeys]) + } + + private func writeRevision( + _ data: Data, + to url: URL, + modifiedAt: TimeInterval + ) throws { + try data.write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: modifiedAt)], + ofItemAtPath: url.path + ) + } + + private func storageFailure( + for sources: [AgentHookSessionRegistryBridge.InspectionSourcePreflight] + ) -> AgentHookSessionStoreLoadFailure? { + do { + try AgentHookSessionRegistryBridge.validateInspectionStorage(sources) + return nil + } catch let failure as AgentHookSessionStoreLoadFailure { + return failure + } catch { + return nil + } + } +} diff --git a/cmuxTests/AgentNotificationOwnershipRegressionTests.swift b/cmuxTests/AgentNotificationOwnershipRegressionTests.swift new file mode 100644 index 000000000000..243f1923c18b --- /dev/null +++ b/cmuxTests/AgentNotificationOwnershipRegressionTests.swift @@ -0,0 +1,138 @@ +import Dispatch +import Foundation +import Testing + +@Suite(.serialized) +struct AgentNotificationOwnershipRegressionTests { + @Test func debugEnvironmentCannotPromoteAManagedChild() throws { + let result = try runManagedCodexHook( + name: "codex-child-debug-override", + subcommand: "session-start", + input: #"{"session_id":"child-debug-override","cwd":"/tmp/x","hook_event_name":"SessionStart"}"#, + suppressNotifications: nil, + testRootVisibleMutations: true + ) + + expectNoVisibleOwnership(result.commands) + } + + @Test func managedCodexStopCanNotifyWithoutTakingVisibleOwnership() throws { + let result = try runManagedCodexHook( + name: "codex-child-stop-opt-in", + subcommand: "stop", + input: #"{"session_id":"child-stop","cwd":"/tmp/x","hook_event_name":"Stop","last_assistant_message":"child done"}"#, + suppressNotifications: false + ) + + #expect(result.commands.contains { $0.hasPrefix("notify_target_async \(result.workspaceId) \(result.surfaceId) Codex|") }) + expectNoVisibleOwnership(result.commands) + } + + @Test func managedCodexNotificationCanDeliverWithoutUpdatingRootState() throws { + let result = try runManagedCodexHook( + name: "codex-child-alert-opt-in", + subcommand: "notification", + input: #"{"session_id":"child-alert","cwd":"/tmp/x","hook_event_name":"Notification","message":"child needs input","notification_type":"permission_prompt"}"#, + suppressNotifications: false + ) + + #expect(result.commands.contains { $0.hasPrefix("notify_target_async \(result.workspaceId) \(result.surfaceId) Codex|") }) + expectNoVisibleOwnership(result.commands) + } + + @Test func managedCodexNotificationIsSuppressedByDefault() throws { + let result = try runManagedCodexHook( + name: "codex-child-alert-default", + subcommand: "notification", + input: #"{"session_id":"child-alert-default","cwd":"/tmp/x","hook_event_name":"Notification","message":"child needs input","notification_type":"permission_prompt"}"#, + suppressNotifications: nil + ) + + #expect(!result.commands.contains { $0.hasPrefix("notify_target") }) + expectNoVisibleOwnership(result.commands) + } + + private func runManagedCodexHook( + name: String, + subcommand: String, + input: String, + suppressNotifications: Bool?, + testRootVisibleMutations: Bool = false + ) throws -> (commands: [String], workspaceId: String, surfaceId: String) { + let harness = ClaudeHookSurfaceResolutionSwiftTests() + let context = try harness.makeClaudeHookContext(name: name) + defer { context.cleanup() } + let codexProcess = try startCodexFixture(in: context.root) + defer { + if codexProcess.isRunning { + codexProcess.terminate() + } + codexProcess.waitUntilExit() + } + let ttyName = "ttys-\(name)" + let handled = harness.startClaudeSurfaceResolutionServer( + context: context, + surfaces: [(context.surfaceId, "surface:1", true)], + ttyName: ttyName, + ttySurfaceId: context.surfaceId + ) + var environment = harness.claudeHookEnvironment( + context: context, + surfaceId: context.surfaceId, + ttyName: ttyName, + storeURL: context.root.appendingPathComponent("claude-hook-sessions.json") + ) + environment["CMUX_AGENT_HOOK_STATE_DIR"] = context.root.path + environment["CMUX_CODEX_PID"] = String(codexProcess.processIdentifier) + environment["CMUX_AGENT_MANAGED_SUBAGENT"] = "1" + environment["CMUX_CODEX_TEAMS_THREAD_ID"] = "child-thread" + environment["CMUX_CODEX_TEAMS_PARENT_THREAD_ID"] = "root-thread" + environment["CMUX_CODEX_TEAMS_DEPTH"] = "1" + if let suppressNotifications { + environment["CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS"] = suppressNotifications ? "1" : "0" + } + if testRootVisibleMutations { + environment["CMUX_TEST_AGENT_ROOT_VISIBLE_MUTATIONS"] = "1" + } + let process = harness.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "codex", subcommand], + environment: environment, + standardInput: input, + timeout: 5 + ) + + #expect(handled.wait(timeout: .now() + 5) == .success) + #expect(!process.timedOut, Comment(rawValue: process.stderr)) + #expect(process.status == 0, Comment(rawValue: process.stderr)) + return (context.state.snapshot(), context.workspaceId, context.surfaceId) + } + + private func startCodexFixture(in root: URL) throws -> Process { + let executable = root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createSymbolicLink( + at: executable, + withDestinationURL: URL(fileURLWithPath: "/bin/sleep", isDirectory: false) + ) + let process = Process() + process.executableURL = executable + process.arguments = ["30"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + return process + } + + private func expectNoVisibleOwnership(_ commands: [String]) { + #expect(!commands.contains { command in + command.hasPrefix("set_status codex ") + || command.hasPrefix("set_agent_lifecycle codex ") + || (jsonObject(command)?["method"] as? String) == "surface.resume.set" + }) + } + + private func jsonObject(_ line: String) -> [String: Any]? { + guard let data = line.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } +} diff --git a/cmuxTests/AgentResumeExecutableAvailabilityRegressionTests.swift b/cmuxTests/AgentResumeExecutableAvailabilityRegressionTests.swift new file mode 100644 index 000000000000..580cd67b1c28 --- /dev/null +++ b/cmuxTests/AgentResumeExecutableAvailabilityRegressionTests.swift @@ -0,0 +1,1613 @@ +import AppKit +import CmuxFoundation +import CmuxTerminal +import Darwin +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension CMUXCLIErrorOutputRegressionTests { + @Test func directShebangAvailabilityMatchesExecveAndTracksSymlinkRetargeting() throws { + let root = try makeShortExecutableTestRoot("direct") + let bin = root.appendingPathComponent("bin", isDirectory: true) + let interpreter = bin.appendingPathComponent("runtime", isDirectory: false) + let agent = bin.appendingPathComponent("agent", isDirectory: false) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try writeResumeTestExecutable( + at: agent, + shebang: "#!\(interpreter.path)" + ) + let descriptor = AgentCommandExecutionDescriptor( + executable: agent.path, + searchPath: bin.path, + workingDirectory: root.path + ) + + #expect(AgentCommandExecutableResolver().resolve(descriptor) == nil) + #expect(directExecOutcome(agent, path: bin.path) == .launchError(Int(ENOENT))) + + try FileManager.default.createSymbolicLink(atPath: interpreter.path, withDestinationPath: "/bin/sh") + let resolution = try #require(AgentCommandExecutableResolver().resolve(descriptor)) + #expect(directExecOutcome(agent, path: bin.path) == .exit(0)) + #expect(resolution.watchDirectories.contains(bin.path)) + + try FileManager.default.removeItem(at: interpreter) + try FileManager.default.createSymbolicLink(atPath: interpreter.path, withDestinationPath: "/bin/zsh") + let retargeted = try #require(AgentCommandExecutableResolver().resolve(descriptor)) + #expect(directExecOutcome(agent, path: bin.path) == .exit(0)) + #expect(retargeted.cachePart != resolution.cachePart) + #expect(!AgentCommandExecutableResolver.revalidate(resolution)) + + try FileManager.default.removeItem(at: interpreter) + #expect(AgentCommandExecutableResolver().resolve(descriptor) == nil) + #expect(!AgentCommandExecutableResolver.revalidate(retargeted)) + } + + @Test func envNodeAndBunShebangsTrackRuntimeAndStopAtBrokenFirstPATHCandidate() throws { + let root = try makeShortExecutableTestRoot("env") + let bin = root.appendingPathComponent("bin", isDirectory: true) + let laterBin = root.appendingPathComponent("later", isDirectory: true) + let node = bin.appendingPathComponent("node", isDirectory: false) + let bun = bin.appendingPathComponent("bun", isDirectory: false) + let nodeAgent = bin.appendingPathComponent("node-agent", isDirectory: false) + let bunAgent = bin.appendingPathComponent("bun-agent", isDirectory: false) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: laterBin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try writeResumeTestExecutable(at: nodeAgent, shebang: "#!/usr/bin/env node --plain-tail") + try writeResumeTestExecutable(at: bunAgent, shebang: "#!/usr/bin/env -S bun --split-tail") + + func descriptor(_ url: URL, path: String? = nil) -> AgentCommandExecutionDescriptor { + AgentCommandExecutionDescriptor( + executable: url.path, + searchPath: path ?? bin.path, + workingDirectory: root.path + ) + } + + #expect(AgentCommandExecutableResolver().resolve(descriptor(nodeAgent)) == nil) + #expect(AgentCommandExecutableResolver().resolve(descriptor(bunAgent)) == nil) + #expect(directExecOutcome(nodeAgent, path: bin.path) == .exit(127)) + #expect(directExecOutcome(bunAgent, path: bin.path) == .exit(127)) + + // /usr/bin/true proves Darwin passes both plain and -S tails as + // separate argv entries. A Linux-style unsplit command name would fail. + try FileManager.default.createSymbolicLink(atPath: node.path, withDestinationPath: "/usr/bin/true") + try FileManager.default.createSymbolicLink(atPath: bun.path, withDestinationPath: "/usr/bin/true") + let nodeResolution = try #require(AgentCommandExecutableResolver().resolve(descriptor(nodeAgent))) + let bunResolution = try #require(AgentCommandExecutableResolver().resolve(descriptor(bunAgent))) + #expect(directExecOutcome(nodeAgent, path: bin.path) == .exit(0)) + #expect(directExecOutcome(bunAgent, path: bin.path) == .exit(0)) + + try FileManager.default.removeItem(at: node) + #expect(!AgentCommandExecutableResolver.revalidate(nodeResolution)) + #expect(AgentCommandExecutableResolver.revalidate(bunResolution)) + + let commandName = "earlier-broken" + let broken = bin.appendingPathComponent(commandName) + try writeResumeTestExecutable(at: broken, shebang: "#!/usr/bin/env missing-runtime") + try writeResumeTestExecutable(at: laterBin.appendingPathComponent(commandName)) + let searchPath = "\(bin.path):\(laterBin.path)" + let lookup = AgentCommandExecutableResolver().lookup(AgentCommandExecutionDescriptor( + executable: commandName, + searchPath: searchPath, + workingDirectory: root.path + )) + #expect(lookup.candidateLookupPath == broken.path) + #expect(lookup.resolution == nil) + #expect(directExecOutcome(broken, path: searchPath) == .exit(127)) + + #expect(nodeResolution.cachePart != bunResolution.cachePart) + } + + @Test func shebangParsingMatchesExecveBoundariesAndNestedInterpreterRule() throws { + let root = try makeShortExecutableTestRoot("kernel") + defer { try? FileManager.default.removeItem(at: root) } + let descriptor: (URL) -> AgentCommandExecutionDescriptor = { url in + AgentCommandExecutionDescriptor( + executable: url.path, + searchPath: "/usr/bin:/bin", + workingDirectory: root.path + ) + } + + let crlf = root.appendingPathComponent("crlf") + try writeResumeTestExecutable(at: crlf, shebang: "#!/bin/sh\r") + #expect(directExecOutcome(crlf) == .exit(0)) + #expect(AgentCommandExecutableResolver().resolve(descriptor(crlf)) != nil) + + let hashComment = root.appendingPathComponent("hash-comment") + try writeResumeTestExecutable(at: hashComment, shebang: "#!/bin/sh#not-a-path") + #expect(directExecOutcome(hashComment) == .exit(0)) + #expect(AgentCommandExecutableResolver().resolve(descriptor(hashComment)) != nil) + + let line512 = root.appendingPathComponent("line-512") + let line513 = root.appendingPathComponent("line-513") + try writeResumeTestExecutable( + at: line512, + shebang: "#!/bin/sh" + String(repeating: " ", count: 502) + ) + try writeResumeTestExecutable( + at: line513, + shebang: "#!/bin/sh" + String(repeating: " ", count: 503) + ) + #expect(directExecOutcome(line512) == .exit(0)) + #expect(AgentCommandExecutableResolver().resolve(descriptor(line512)) != nil) + #expect(directExecOutcome(line513) == .launchError(Int(ENOEXEC))) + #expect(AgentCommandExecutableResolver().resolve(descriptor(line513)) == nil) + + let relative = root.appendingPathComponent("relative") + try writeResumeTestExecutable(at: relative, shebang: "#!bin/sh") + #expect(directExecOutcome(relative) == .launchError(Int(ENOENT))) + #expect(AgentCommandExecutableResolver().resolve(descriptor(relative)) == nil) + + let scriptInterpreter = root.appendingPathComponent("script-interpreter") + let nested = root.appendingPathComponent("nested") + try writeResumeTestExecutable(at: scriptInterpreter) + try writeResumeTestExecutable(at: nested, shebang: "#!\(scriptInterpreter.path)") + // XNU's IMGPF_INTERPRET permits one script activation per exec. + #expect(directExecOutcome(nested) == .launchError(Int(ENOEXEC))) + #expect(AgentCommandExecutableResolver().resolve(descriptor(nested)) == nil) + + let plainInterpreter = root.appendingPathComponent("plain-interpreter") + let plainNested = root.appendingPathComponent("plain-nested") + try "exit 0\n".write(to: plainInterpreter, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: plainInterpreter.path + ) + try writeResumeTestExecutable(at: plainNested, shebang: "#!\(plainInterpreter.path)") + #expect(directExecOutcome(plainNested) == .launchError(Int(ENOEXEC))) + #expect(AgentCommandExecutableResolver().resolve(descriptor(plainNested)) == nil) + + let unreadable = root.appendingPathComponent("unreadable") + try writeResumeTestExecutable(at: unreadable) + try FileManager.default.setAttributes( + [.posixPermissions: 0o111], + ofItemAtPath: unreadable.path + ) + #expect(directExecOutcome(unreadable) == .exit(0)) + #expect(AgentCommandExecutableResolver().resolve(descriptor(unreadable)) == nil) + } + + @Test func envTargetScriptsCyclesAndDefensiveDepthBoundaryFailClosed() throws { + let root = try makeShortExecutableTestRoot("cycle") + let bin = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let descriptor: (URL) -> AgentCommandExecutionDescriptor = { url in + AgentCommandExecutionDescriptor( + executable: url.path, + searchPath: bin.path, + workingDirectory: root.path + ) + } + + let target = bin.appendingPathComponent("script-target") + let targetAgent = bin.appendingPathComponent("target-agent") + try writeResumeTestExecutable(at: target) + try writeResumeTestExecutable(at: targetAgent, shebang: "#!/usr/bin/env script-target") + #expect(directExecOutcome(targetAgent, path: bin.path) == .exit(0)) + let targetResolution = try #require( + AgentCommandExecutableResolver().resolve(descriptor(targetAgent)) + ) + try FileManager.default.removeItem(at: target) + #expect(!AgentCommandExecutableResolver.revalidate(targetResolution)) + + let a = bin.appendingPathComponent("a") + let b = bin.appendingPathComponent("b") + try writeResumeTestExecutable(at: a, shebang: "#!/usr/bin/env b") + try writeResumeTestExecutable(at: b, shebang: "#!/usr/bin/env a") + #expect(directExecOutcome(a, path: bin.path, timeout: 0.2) == .timedOut) + #expect(AgentCommandExecutableResolver().resolve(descriptor(a)) == nil) + + let wrappers = (0...17).map { bin.appendingPathComponent("w\($0)") } + for index in 0..<17 { + try writeResumeTestExecutable( + at: wrappers[index], + shebang: "#!/usr/bin/env w\(index + 1)" + ) + } + try writeResumeTestExecutable(at: wrappers[17]) + // Fresh env execs have no Darwin recursion limit. The resolver accepts + // 16 hops and conservatively rejects the 17th to bound filesystem work. + #expect(directExecOutcome(wrappers[1], path: bin.path) == .exit(0)) + #expect(AgentCommandExecutableResolver().resolve(descriptor(wrappers[1])) != nil) + #expect(directExecOutcome(wrappers[0], path: bin.path) == .exit(0)) + #expect(AgentCommandExecutableResolver().resolve(descriptor(wrappers[0])) == nil) + } + + @MainActor + @Test func missingPATHResumeExecutableStaysHibernatedUntilPATHRecovers() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-resume-path-retry-\(UUID().uuidString)", isDirectory: true) + let capturedBin = root.appendingPathComponent("captured-bin", isDirectory: true) + let nonExecutableBin = root.appendingPathComponent("non-executable-bin", isDirectory: true) + let directoryBin = root.appendingPathComponent("directory-bin", isDirectory: true) + let brokenLinkBin = root.appendingPathComponent("broken-link-bin", isDirectory: true) + let availableBin = root.appendingPathComponent("available-bin", isDirectory: true) + for directory in [capturedBin, nonExecutableBin, directoryBin, brokenLinkBin, availableBin] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let executableName = "cmux-path-resume-\(UUID().uuidString)" + try writeResumeTestExecutable(at: capturedBin.appendingPathComponent(executableName)) + let nonExecutable = nonExecutableBin.appendingPathComponent(executableName) + try "not executable\n".write(to: nonExecutable, atomically: true, encoding: .utf8) + try FileManager.default.createDirectory( + at: directoryBin.appendingPathComponent(executableName, isDirectory: true), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink( + at: brokenLinkBin.appendingPathComponent(executableName), + withDestinationURL: root.appendingPathComponent("absent-target") + ) + let unavailablePATH = [nonExecutableBin, directoryBin, brokenLinkBin] + .map(\.path) + .joined(separator: ":") + let agent = resumeExecutableTestAgent( + kind: .amp, + sessionID: "path-retry-session", + executable: executableName, + workingDirectory: root.path, + launchEnvironment: ["PATH": capturedBin.path] + ) + + try withResumeExecutableEnvironment( + root: root, + registryURL: registryURL, + path: unavailablePATH + ) { + let fixture = try makeRestoredResumeExecutableFixture( + root: root, + registryURL: registryURL, + agent: agent + ) + var claimOperations = 0 + let resumedWhileMissing = fixture.workspace.resumeVisibleAgentHibernationPanels( + panelIds: [fixture.panelID], + retryPendingAdoptions: false, + authorityClaimHandler: { requests in + claimOperations += 1 + return AgentHookSessionStateWriter.acquireHibernatedResumeAuthorities(requests) + } + ) + + #expect(!resumedWhileMissing) + #expect(claimOperations == 0) + #expect(fixture.panel.isAgentHibernated) + #expect(!fixture.panel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(try durableSessionState( + fixture.registry, + provider: agent.kind.rawValue, + sessionID: agent.sessionId + ) == "hibernated") + guard !resumedWhileMissing else { return } + + try writeResumeTestExecutable( + at: availableBin.appendingPathComponent(executableName, isDirectory: false) + ) + setenv("PATH", availableBin.path, 1) + let resumedAfterPATHRepair = fixture.workspace.resumeVisibleAgentHibernationPanels( + panelIds: [fixture.panelID], + retryPendingAdoptions: false, + authorityClaimHandler: { requests in + claimOperations += 1 + return AgentHookSessionStateWriter.acquireHibernatedResumeAuthorities(requests) + } + ) + + #expect(resumedAfterPATHRepair) + #expect(claimOperations == 1) + #expect(!fixture.panel.isAgentHibernated) + #expect(try durableSessionState( + fixture.registry, + provider: agent.kind.rawValue, + sessionID: agent.sessionId + ) == "restoring") + } + } + + @MainActor + @Test func missingAbsoluteResumeExecutableDoesNotFallBackToMatchingPATHName() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-resume-absolute-missing-\(UUID().uuidString)", isDirectory: true) + let bin = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let executableName = "cmux-absolute-resume-\(UUID().uuidString)" + try writeResumeTestExecutable(at: bin.appendingPathComponent(executableName)) + let missingAbsoluteExecutable = root + .appendingPathComponent("removed", isDirectory: true) + .appendingPathComponent(executableName, isDirectory: false) + try FileManager.default.createDirectory( + at: missingAbsoluteExecutable.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink( + at: missingAbsoluteExecutable, + withDestinationURL: root.appendingPathComponent("absent-absolute-target") + ) + let agent = resumeExecutableTestAgent( + kind: .amp, + sessionID: "absolute-missing-session", + executable: missingAbsoluteExecutable.path, + workingDirectory: root.path + ) + + try withResumeExecutableEnvironment(root: root, registryURL: registryURL, path: bin.path) { + let fixture = try makeRestoredResumeExecutableFixture( + root: root, + registryURL: registryURL, + agent: agent + ) + var claimOperations = 0 + let didResume = fixture.workspace.resumeVisibleAgentHibernationPanels( + panelIds: [fixture.panelID], + retryPendingAdoptions: false, + authorityClaimHandler: { requests in + claimOperations += 1 + return AgentHookSessionStateWriter.acquireHibernatedResumeAuthorities(requests) + } + ) + + #expect(!didResume) + #expect(claimOperations == 0) + #expect(fixture.panel.isAgentHibernated) + #expect(try durableSessionState( + fixture.registry, + provider: agent.kind.rawValue, + sessionID: agent.sessionId + ) == "hibernated") + } + } + + @MainActor + @Test func missingEnvRuntimeStaysHibernatedUntilRuntimeIsInstalled() throws { + let root = try makeShortExecutableTestRoot("env-retry") + let bin = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let executableName = "env-agent" + let runtimeName = "env-runtime" + try writeResumeTestExecutable( + at: bin.appendingPathComponent(executableName), + shebang: "#!/usr/bin/env \(runtimeName)" + ) + let agent = resumeExecutableTestAgent( + kind: .amp, + sessionID: "env-runtime-retry", + executable: executableName, + workingDirectory: root.path, + launchEnvironment: ["PATH": bin.path] + ) + + try withResumeExecutableEnvironment(root: root, registryURL: registryURL, path: bin.path) { + let fixture = try makeRestoredResumeExecutableFixture( + root: root, + registryURL: registryURL, + agent: agent + ) + var claimOperations = 0 + #expect(!fixture.workspace.resumeVisibleAgentHibernationPanels( + panelIds: [fixture.panelID], + retryPendingAdoptions: false, + authorityClaimHandler: { requests in + claimOperations += 1 + return AgentHookSessionStateWriter.acquireHibernatedResumeAuthorities(requests) + } + )) + #expect(claimOperations == 0) + #expect(fixture.panel.isAgentHibernated) + + try FileManager.default.createSymbolicLink( + atPath: bin.appendingPathComponent(runtimeName).path, + withDestinationPath: "/bin/sh" + ) + #expect(fixture.workspace.resumeVisibleAgentHibernationPanels( + panelIds: [fixture.panelID], + retryPendingAdoptions: false, + authorityClaimHandler: { requests in + claimOperations += 1 + return AgentHookSessionStateWriter.acquireHibernatedResumeAuthorities(requests) + } + )) + #expect(claimOperations == 1) + #expect(!fixture.panel.isAgentHibernated) + } + } + + @MainActor + @Test func capturedPiPATHIsTheEffectiveResumePATHAndRemainsRetryable() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-resume-captured-path-\(UUID().uuidString)", isDirectory: true) + let capturedBin = root.appendingPathComponent("captured-bin", isDirectory: true) + let currentBin = root.appendingPathComponent("current-bin", isDirectory: true) + try FileManager.default.createDirectory(at: capturedBin, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: currentBin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let executableName = "cmux-pi-resume-\(UUID().uuidString)" + let capturedExecutable = capturedBin.appendingPathComponent(executableName) + try "not executable\n".write(to: capturedExecutable, atomically: true, encoding: .utf8) + try writeResumeTestExecutable(at: currentBin.appendingPathComponent(executableName)) + let agent = resumeExecutableTestAgent( + kind: .pi, + sessionID: "captured-path-session", + executable: executableName, + workingDirectory: root.path, + launchEnvironment: ["PATH": capturedBin.path] + ) + #expect(agent.resumeCommand?.contains("PATH=\(capturedBin.path)") == true) + + try withResumeExecutableEnvironment(root: root, registryURL: registryURL, path: currentBin.path) { + let fixture = try makeRestoredResumeExecutableFixture( + root: root, + registryURL: registryURL, + agent: agent + ) + var claimOperations = 0 + let resumedThroughWrongPATH = fixture.workspace.resumeVisibleAgentHibernationPanels( + panelIds: [fixture.panelID], + retryPendingAdoptions: false, + authorityClaimHandler: { requests in + claimOperations += 1 + return AgentHookSessionStateWriter.acquireHibernatedResumeAuthorities(requests) + } + ) + + #expect(!resumedThroughWrongPATH) + #expect(claimOperations == 0) + #expect(fixture.panel.isAgentHibernated) + #expect(try durableSessionState( + fixture.registry, + provider: agent.kind.rawValue, + sessionID: agent.sessionId + ) == "hibernated") + guard !resumedThroughWrongPATH else { return } + + try writeResumeTestExecutable(at: capturedExecutable) + #expect(fixture.workspace.resumeAgentHibernation(panelId: fixture.panelID, focus: false)) + #expect(!fixture.panel.isAgentHibernated) + #expect(try durableSessionState( + fixture.registry, + provider: agent.kind.rawValue, + sessionID: agent.sessionId + ) == "restoring") + } + } + + @MainActor + @Test func missingCustomProviderResumeExecutableKeepsDurableAuthority() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-resume-custom-missing-\(UUID().uuidString)", isDirectory: true) + let bin = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let executableName = "cmux-custom-resume-\(UUID().uuidString)" + let registration = CmuxVaultAgentRegistration( + id: "resume-preflight-vault", + name: "Resume Preflight Vault", + detect: CmuxVaultAgentDetectRule(processName: executableName), + sessionIdSource: .argvOption("--session"), + resumeCommand: "{{executable}} --resume {{sessionId}}" + ) + let agent = resumeExecutableTestAgent( + kind: .custom(registration.id), + sessionID: "custom-missing-session", + executable: executableName, + workingDirectory: root.path, + registration: registration + ) + + try withResumeExecutableEnvironment(root: root, registryURL: registryURL, path: bin.path) { + let fixture = try makeRestoredResumeExecutableFixture( + root: root, + registryURL: registryURL, + agent: agent + ) + var claimOperations = 0 + let didResume = fixture.workspace.resumeVisibleAgentHibernationPanels( + panelIds: [fixture.panelID], + retryPendingAdoptions: false, + authorityClaimHandler: { requests in + claimOperations += 1 + return AgentHookSessionStateWriter.acquireHibernatedResumeAuthorities(requests) + } + ) + + #expect(!didResume) + #expect(claimOperations == 0) + #expect(fixture.panel.isAgentHibernated) + #expect(try durableSessionState( + fixture.registry, + provider: registration.id, + sessionID: agent.sessionId + ) == "hibernated") + } + } + + @MainActor + @Test func capturedCustomOmpPATHIsTheEffectiveResumePATH() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-resume-custom-omp-path-\(UUID().uuidString)", isDirectory: true) + let capturedBin = root.appendingPathComponent("captured-bin", isDirectory: true) + let currentBin = root.appendingPathComponent("current-bin", isDirectory: true) + try FileManager.default.createDirectory(at: capturedBin, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: currentBin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let executableName = "cmux-omp-resume-\(UUID().uuidString)" + let capturedExecutable = capturedBin.appendingPathComponent(executableName) + try "not executable\n".write(to: capturedExecutable, atomically: true, encoding: .utf8) + try writeResumeTestExecutable(at: currentBin.appendingPathComponent(executableName)) + var registration = CmuxVaultAgentRegistration.builtInOmp + registration.detect = CmuxVaultAgentDetectRule(processName: executableName) + let agent = resumeExecutableTestAgent( + kind: .custom("omp"), + sessionID: "custom-omp-captured-path-session", + executable: executableName, + workingDirectory: root.path, + registration: registration, + launchEnvironment: ["PATH": capturedBin.path] + ) + #expect(agent.resumeCommand?.contains("PATH=\(capturedBin.path)") == true) + + try withResumeExecutableEnvironment(root: root, registryURL: registryURL, path: currentBin.path) { + let fixture = try makeRestoredResumeExecutableFixture( + root: root, + registryURL: registryURL, + agent: agent + ) + var claimOperations = 0 + let resumedThroughWrongPATH = fixture.workspace.resumeVisibleAgentHibernationPanels( + panelIds: [fixture.panelID], + retryPendingAdoptions: false, + authorityClaimHandler: { requests in + claimOperations += 1 + return AgentHookSessionStateWriter.acquireHibernatedResumeAuthorities(requests) + } + ) + + #expect(!resumedThroughWrongPATH) + #expect(claimOperations == 0) + #expect(fixture.panel.isAgentHibernated) + #expect(try durableSessionState( + fixture.registry, + provider: agent.kind.rawValue, + sessionID: agent.sessionId + ) == "hibernated") + guard !resumedThroughWrongPATH else { return } + + try writeResumeTestExecutable(at: capturedExecutable) + #expect(fixture.workspace.resumeAgentHibernation(panelId: fixture.panelID, focus: false)) + #expect(!fixture.panel.isAgentHibernated) + #expect(try durableSessionState( + fixture.registry, + provider: agent.kind.rawValue, + sessionID: agent.sessionId + ) == "restoring") + } + } + + @MainActor + @Test func shellResolvedResumeExecutablePassesAvailabilityPreflight() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-resume-shell-path-\(UUID().uuidString)", isDirectory: true) + let bin = root.appendingPathComponent("shell resolved bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let executableName = "cmux-shell-resume-\(UUID().uuidString)" + try writeResumeTestExecutable(at: bin.appendingPathComponent(executableName)) + let agent = resumeExecutableTestAgent( + kind: .amp, + sessionID: "shell-path-session", + executable: executableName, + workingDirectory: root.path + ) + + try withResumeExecutableEnvironment(root: root, registryURL: registryURL, path: bin.path) { + let fixture = try makeRestoredResumeExecutableFixture( + root: root, + registryURL: registryURL, + agent: agent + ) + var claimOperations = 0 + let didResume = fixture.workspace.resumeVisibleAgentHibernationPanels( + panelIds: [fixture.panelID], + retryPendingAdoptions: false, + authorityClaimHandler: { requests in + claimOperations += 1 + return AgentHookSessionStateWriter.acquireHibernatedResumeAuthorities(requests) + } + ) + + #expect(didResume) + #expect(claimOperations == 1) + #expect(!fixture.panel.isAgentHibernated) + #expect(try durableSessionState( + fixture.registry, + provider: agent.kind.rawValue, + sessionID: agent.sessionId + ) == "restoring") + } + } + + @MainActor + @Test func executableRemovedAfterAuthorityClaimRollsBackToHibernated() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-resume-post-claim-removal-\(UUID().uuidString)", isDirectory: true) + let bin = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let executableName = "cmux-post-claim-resume-\(UUID().uuidString)" + let executable = bin.appendingPathComponent(executableName, isDirectory: false) + try writeResumeTestExecutable(at: executable) + let agent = resumeExecutableTestAgent( + kind: .amp, + sessionID: "post-claim-removal-session", + executable: executableName, + workingDirectory: root.path + ) + + try withResumeExecutableEnvironment(root: root, registryURL: registryURL, path: bin.path) { + let fixture = try makeRestoredResumeExecutableFixture( + root: root, + registryURL: registryURL, + agent: agent + ) + var claimOperations = 0 + let didResume = fixture.workspace.resumeVisibleAgentHibernationPanels( + panelIds: [fixture.panelID], + retryPendingAdoptions: false, + authorityClaimHandler: { requests in + claimOperations += 1 + try? FileManager.default.removeItem(at: executable) + return AgentHookSessionStateWriter.acquireHibernatedResumeAuthorities(requests) + } + ) + + #expect(!didResume) + #expect(claimOperations == 1) + #expect(fixture.panel.isAgentHibernated) + #expect(!fixture.panel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(try durableSessionState( + fixture.registry, + provider: agent.kind.rawValue, + sessionID: agent.sessionId + ) == "hibernated") + guard !didResume else { return } + + try writeResumeTestExecutable(at: executable) + #expect(fixture.workspace.resumeAgentHibernation(panelId: fixture.panelID, focus: false)) + #expect(!fixture.panel.isAgentHibernated) + } + } + + @MainActor + @Test func missingExecutableRejectsLiveHibernationBeforeValidationOrAuthorityCommit() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-live-hibernation-missing-executable-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let missingExecutable = root.appendingPathComponent("missing-agent", isDirectory: false) + let agent = resumeExecutableTestAgent( + kind: .amp, + sessionID: "live-hibernation-missing-executable", + executable: missingExecutable.path, + workingDirectory: root.path + ) + let workspace = Workspace(workingDirectory: root.path) + let panelID = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.terminalPanel(for: panelID)) + let runtimeSurface = UnsafeMutableRawPointer(bitPattern: 0x78670001)! + panel.surface.installRuntimeSurfaceForTesting(runtimeSurface) + let validationCalled = AtomicBooleanGate(false) + let teardownPreparationCalled = AtomicBooleanGate(false) + let authorityCommitCalled = AtomicBooleanGate(false) + let nativeFreeCalled = AtomicBooleanGate(false) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + nativeFreeCalled.storeRelease(true) + } + defer { + if panel.surface.surface == runtimeSurface { + panel.surface.teardownSurface() + } + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil + } + + let didHibernate = await panel.enterAgentHibernation( + agent: agent, + lastActivityAt: Date(timeIntervalSince1970: 10), + finalValidation: { + validationCalled.storeRelease(true) + return true + }, + finalTeardownPreparation: { + teardownPreparationCalled.storeRelease(true) + return {} + }, + finalCommit: { + authorityCommitCalled.storeRelease(true) + return true + } + ) + + #expect(!didHibernate) + #expect(!panel.isAgentHibernated) + #expect(panel.surface.surface == runtimeSurface) + #expect(!validationCalled.loadAcquire()) + #expect(!teardownPreparationCalled.loadAcquire()) + #expect(!authorityCommitCalled.loadAcquire()) + #expect(!nativeFreeCalled.loadAcquire()) + } + + @MainActor + @Test func shebangInterpreterRemovedDuringValidationRejectsLiveHibernationBeforeFinalTeardown() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-live-hibernation-executable-race-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let executable = root.appendingPathComponent("agent", isDirectory: false) + let interpreter = root.appendingPathComponent("runtime", isDirectory: false) + try FileManager.default.createSymbolicLink(atPath: interpreter.path, withDestinationPath: "/bin/sh") + try writeResumeTestExecutable(at: executable, shebang: "#!\(interpreter.path)") + let agent = resumeExecutableTestAgent( + kind: .amp, + sessionID: "live-hibernation-executable-race", + executable: executable.path, + workingDirectory: root.path + ) + let workspace = Workspace(workingDirectory: root.path) + let panelID = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.terminalPanel(for: panelID)) + let runtimeSurface = UnsafeMutableRawPointer(bitPattern: 0x78670002)! + panel.surface.installRuntimeSurfaceForTesting(runtimeSurface) + let validationCalled = AtomicBooleanGate(false) + let teardownPreparationCalled = AtomicBooleanGate(false) + let authorityCommitCalled = AtomicBooleanGate(false) + let nativeFreeCalled = AtomicBooleanGate(false) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + nativeFreeCalled.storeRelease(true) + } + defer { + if panel.surface.surface == runtimeSurface { + panel.surface.teardownSurface() + } + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil + } + + let didHibernate = await panel.enterAgentHibernation( + agent: agent, + lastActivityAt: Date(timeIntervalSince1970: 10), + finalValidation: { + validationCalled.storeRelease(true) + try? FileManager.default.removeItem(at: interpreter) + return true + }, + finalTeardownPreparation: { + teardownPreparationCalled.storeRelease(true) + return {} + }, + finalCommit: { + authorityCommitCalled.storeRelease(true) + return true + } + ) + + #expect(!didHibernate) + #expect(!panel.isAgentHibernated) + #expect(panel.surface.surface == runtimeSurface) + #expect(validationCalled.loadAcquire()) + #expect(!teardownPreparationCalled.loadAcquire()) + #expect(!authorityCommitCalled.loadAcquire()) + #expect(!nativeFreeCalled.loadAcquire()) + } + + @MainActor + @Test func missingExecutableOrdinaryAutoRestoreFallsBackToManualResume() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-auto-restore-missing-executable-\(UUID().uuidString)", isDirectory: true) + let emptyBin = root.appendingPathComponent("empty-bin", isDirectory: true) + try FileManager.default.createDirectory(at: emptyBin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let defaultsSuite = "cmux-auto-restore-missing-executable-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsSuite)) + defaults.set(true, forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) + defer { defaults.removePersistentDomain(forName: defaultsSuite) } + + let executableName = "cmux-auto-restore-\(UUID().uuidString)" + let agent = resumeExecutableTestAgent( + kind: .grok, + sessionID: "ordinary-auto-restore-session", + executable: executableName, + workingDirectory: root.path + ) + let source = Workspace(agentSessionAutoResumeDefaults: defaults) + let sourcePanelID = try #require(source.focusedPanelId) + var snapshot = source.sessionSnapshot(includeScrollback: false) + let panelIndex = try #require(snapshot.panels.firstIndex { $0.id == sourcePanelID }) + var terminal = try #require(snapshot.panels[panelIndex].terminal) + terminal.agent = agent + terminal.resumeBinding = nil + terminal.hibernation = nil + terminal.wasAgentRunning = true + terminal.scrollback = "saved output that must remain visible" + snapshot.panels[panelIndex].terminal = terminal + var breadcrumbs: [[StartupBreadcrumbEvent]] = [] + + try withResumeExecutableEnvironment(root: root, registryURL: registryURL, path: emptyBin.path) { + let restored = Workspace( + agentSessionAutoResumeDefaults: defaults, + startupBreadcrumbBatchWriter: { breadcrumbs.append($0) } + ) + let mapping = restored.restoreSessionSnapshot(snapshot) + let panelID = try #require(mapping[sourcePanelID]) + let panel = try #require(restored.terminalPanel(for: panelID)) + + #expect(panel.surface.debugInitialCommand() == nil) + #expect(!panel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(restored.restoredAgentSnapshotForTesting(panelId: panelID)?.sessionId == agent.sessionId) + #expect(restored.restoredAgentResumeStatesByPanelId[panelID] == .manualResumeAvailable) + let restoredPanel = try #require( + restored.sessionSnapshot(includeScrollback: false).panels.first { $0.id == panelID } + ) + #expect(restoredPanel.terminal?.agent?.sessionId == agent.sessionId) + let event = try #require(breadcrumbs.flatMap { $0 }.first { + $0.fields["panel"] == String(sourcePanelID.uuidString.lowercased().prefix(8)) + }) + #expect(event.fields["resume"] == "suppressed") + #expect(event.fields["resumeReason"] == "resume_executable_unavailable") + } + } + + @Test func cwdIgnoredCommandsFailClosedWhenExecutableLookupDependsOnUnknownCWD() throws { + let currentDirectory = URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true + ).standardizedFileURL + let upwardComponents = Array( + repeating: "..", + count: max(0, currentDirectory.pathComponents.count - 1) + ) + let relativeSystemShell = (upwardComponents + ["bin", "sh"]).joined(separator: "/") + var registration = CmuxVaultAgentRegistration.builtInOmp + registration.cwd = .ignore + let relativeExecutableAgent = resumeExecutableTestAgent( + kind: .custom("omp"), + sessionID: "cwd-ignore-relative-executable", + executable: relativeSystemShell, + workingDirectory: "/tmp/ignored-launch-cwd", + registration: registration, + launchEnvironment: ["PATH": "/usr/bin:/bin"] + ) + let relativeDescriptor = try #require(relativeExecutableAgent.resumeExecutionDescriptor) + #expect(relativeDescriptor.workingDirectory == nil) + #expect(AgentCommandExecutableResolver().resolve(relativeDescriptor) == nil) + + let emptyPATHAgent = resumeExecutableTestAgent( + kind: .custom("omp"), + sessionID: "cwd-ignore-empty-path-entry", + executable: "sh", + workingDirectory: "/tmp/ignored-launch-cwd", + registration: registration, + launchEnvironment: ["PATH": ":/usr/bin"] + ) + let emptyPATHDescriptor = try #require(emptyPATHAgent.resumeExecutionDescriptor) + #expect(emptyPATHDescriptor.workingDirectory == nil) + #expect(emptyPATHDescriptor.searchPath == ":/usr/bin") + #expect(AgentCommandExecutableResolver().resolve(emptyPATHDescriptor) == nil) + } + + @Test func bindingPreflightMirrorsPortableWrapperFallbackWithoutGeneralAbsoluteFallback() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-binding-wrapper-preflight-\(UUID().uuidString)", isDirectory: true) + let bin = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let bareCodex = bin.appendingPathComponent("codex", isDirectory: false) + let bareAmp = bin.appendingPathComponent("amp", isDirectory: false) + let bareClaude = bin.appendingPathComponent("claude", isDirectory: false) + try writeResumeTestExecutable(at: bareCodex) + try writeResumeTestExecutable(at: bareAmp) + try writeResumeTestExecutable(at: bareClaude) + + let staleCodex = root + .appendingPathComponent("removed", isDirectory: true) + .appendingPathComponent("codex", isDirectory: false) + let codexBinding = SurfaceResumeBindingSnapshot( + kind: "codex", + command: "'\(staleCodex.path)' 'resume' 'binding-codex-session'", + cwd: root.path, + checkpointId: "binding-codex-session", + source: "agent-hook", + environment: ["PATH": bin.path], + autoResume: true + ) + let codexDescriptor = try #require(codexBinding.agentHookExecutionDescriptor) + #expect(codexDescriptor.executable == staleCodex.path) + #expect(codexDescriptor.fallbackExecutables == ["codex"]) + #expect(AgentCommandExecutableResolver().resolve(codexDescriptor)?.lookupPath == bareCodex.path) + + let staleAmp = root + .appendingPathComponent("removed", isDirectory: true) + .appendingPathComponent("amp", isDirectory: false) + let ampBinding = SurfaceResumeBindingSnapshot( + kind: "amp", + command: "'\(staleAmp.path)' 'threads' 'continue' 'binding-amp-session'", + cwd: root.path, + checkpointId: "binding-amp-session", + source: "agent-hook", + environment: ["PATH": bin.path], + autoResume: true + ) + let ampDescriptor = try #require(ampBinding.agentHookExecutionDescriptor) + #expect(ampDescriptor.executable == staleAmp.path) + #expect(ampDescriptor.fallbackExecutables.isEmpty) + #expect(AgentCommandExecutableResolver().resolve(ampDescriptor) == nil) + + let managedClaude = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/bin/claude", isDirectory: false) + let claudeBinding = SurfaceResumeBindingSnapshot( + kind: "claude", + command: "'\(managedClaude.path)' '--resume' 'binding-claude-session'", + cwd: root.path, + checkpointId: "binding-claude-session", + source: "agent-hook", + environment: ["PATH": bin.path], + autoResume: true + ) + let claudeDescriptor = try #require(claudeBinding.agentHookExecutionDescriptor) + if claudeDescriptor.executable == "claude" { + #expect(claudeDescriptor.fallbackExecutables.isEmpty) + } else { + #expect(claudeDescriptor.fallbackExecutables == ["claude"]) + } + #expect(AgentCommandExecutableResolver().resolve(claudeDescriptor) != nil) + } + + @MainActor + @Test func unavailableWinningAgentHookBindingSuppressesSnapshotAndBindingOnlyAutoResume() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-binding-executable-unavailable-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let availableSnapshotExecutable = root.appendingPathComponent("available-amp", isDirectory: false) + try writeResumeTestExecutable(at: availableSnapshotExecutable) + let missingBindingExecutable = root.appendingPathComponent("missing-amp", isDirectory: false) + let defaultsSuite = "cmux-binding-executable-unavailable-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsSuite)) + defaults.set(true, forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) + defer { defaults.removePersistentDomain(forName: defaultsSuite) } + + for includesSnapshot in [true, false] { + let sessionID = includesSnapshot + ? "stale-snapshot-binding-session" + : "binding-only-missing-session" + let source = Workspace(agentSessionAutoResumeDefaults: defaults) + let sourcePanelID = try #require(source.focusedPanelId) + var snapshot = source.sessionSnapshot(includeScrollback: false) + let panelIndex = try #require(snapshot.panels.firstIndex { $0.id == sourcePanelID }) + var terminal = try #require(snapshot.panels[panelIndex].terminal) + if includesSnapshot { + terminal.agent = resumeExecutableTestAgent( + kind: .amp, + sessionID: sessionID, + executable: availableSnapshotExecutable.path, + workingDirectory: root.path + ) + } else { + terminal.agent = nil + } + terminal.resumeBinding = SurfaceResumeBindingSnapshot( + name: "Amp", + kind: "amp", + command: "'\(missingBindingExecutable.path)' 'threads' 'continue' '\(sessionID)'", + cwd: root.path, + checkpointId: sessionID, + source: "agent-hook", + autoResume: true, + updatedAt: 20 + ) + terminal.hibernation = nil + terminal.wasAgentRunning = true + terminal.scrollback = "saved binding output" + snapshot.panels[panelIndex].terminal = terminal + var breadcrumbs: [[StartupBreadcrumbEvent]] = [] + + let restored = Workspace( + agentSessionAutoResumeDefaults: defaults, + startupBreadcrumbBatchWriter: { breadcrumbs.append($0) } + ) + let mapping = restored.restoreSessionSnapshot(snapshot) + let panelID = try #require(mapping[sourcePanelID]) + let panel = try #require(restored.terminalPanel(for: panelID)) + #expect(panel.surface.debugInitialCommand() == nil, Comment(rawValue: sessionID)) + #expect(!panel.surface.debugInitialInputMetadata().hasInitialInput, Comment(rawValue: sessionID)) + let persistedTerminal = try #require( + restored.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == panelID }?.terminal + ) + #expect( + persistedTerminal.resumeBinding?.command.contains(missingBindingExecutable.path) == true, + Comment(rawValue: sessionID) + ) + #expect( + (restored.restoredAgentSnapshotForTesting(panelId: panelID) != nil) == includesSnapshot, + Comment(rawValue: sessionID) + ) + let event = try #require(breadcrumbs.flatMap { $0 }.first { + $0.fields["panel"] == String(sourcePanelID.uuidString.lowercased().prefix(8)) + }) + #expect(event.fields["resume"] == "suppressed", Comment(rawValue: sessionID)) + #expect( + event.fields["resumeReason"] == "resume_executable_unavailable", + Comment(rawValue: sessionID) + ) + } + } + + @MainActor + @Test func registryOwnedCustomProviderBindingsSurviveRoundTripAndClaimExactAuthority() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-registry-provider-binding-\(UUID().uuidString)", isDirectory: true) + let bin = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let ollama = CmuxVaultAgentRegistration( + id: "ollama", + name: "Ollama", + detect: CmuxVaultAgentDetectRule(processName: "ollama"), + sessionIdSource: .argvOption("--session"), + resumeCommand: "{{executable}} --resume {{sessionId}}", + forkCommand: "{{executable}} --fork {{sessionId}}" + ) + var registrations = [ + CmuxVaultAgentRegistration.builtInPi, + CmuxVaultAgentRegistration.builtInGrok, + CmuxVaultAgentRegistration.builtInAntigravity, + ollama, + ] + + try withResumeExecutableEnvironment(root: root, registryURL: registryURL, path: bin.path) { + for index in registrations.indices { + let provider = registrations[index].id + let executable = bin.appendingPathComponent("\(provider)-resume", isDirectory: false) + try writeResumeTestExecutable(at: executable) + registrations[index].detect = CmuxVaultAgentDetectRule(processName: executable.lastPathComponent) + let workingDirectory = root.appendingPathComponent("\(provider)-working", isDirectory: true) + try FileManager.default.createDirectory(at: workingDirectory, withIntermediateDirectories: true) + var agent = resumeExecutableTestAgent( + kind: .custom(provider), + sessionID: "registry-owned-\(provider)-session", + executable: executable.path, + workingDirectory: workingDirectory.path, + registration: registrations[index] + ) + if provider == "grok" { agent.launchCommand?.executablePath = nil } + let fixture = try makeHibernatedRestoreFixture(root: root, agent: agent) + var snapshot = fixture.snapshot + let sourcePanelIndex = try #require( + snapshot.panels.firstIndex { $0.id == fixture.sourcePanelID } + ) + var sourceTerminal = try #require(snapshot.panels[sourcePanelIndex].terminal) + var sourceBinding = try #require(sourceTerminal.resumeBinding) + sourceBinding.kind = " \(provider) \n" + sourceBinding.cwd = nil + sourceTerminal.resumeBinding = sourceBinding + snapshot.panels[sourcePanelIndex].terminal = sourceTerminal + let decoded = try JSONDecoder().decode( + SessionWorkspaceSnapshot.self, + from: JSONEncoder().encode(snapshot) + ) + let decodedAgent = try #require(decoded.panels[sourcePanelIndex].terminal?.agent) + #expect(decodedAgent.kind == .custom(provider), Comment(rawValue: provider)) + _ = try installHibernatedAuthority( + root: root, + registryURL: registryURL, + agent: decodedAgent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID + ) + + let restored = Workspace() + let mapping = restored.restoreSessionSnapshot(decoded) + let panelID = try #require(mapping[fixture.sourcePanelID]) + let panel = try #require(restored.terminalPanel(for: panelID)) + #expect(panel.isAgentHibernated, Comment(rawValue: provider)) + let persisted = try #require( + restored.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == panelID }?.terminal + ) + #expect(persisted.agent?.kind == .custom(provider), Comment(rawValue: provider)) + #expect(persisted.resumeBinding?.kind?.trimmingCharacters(in: .whitespacesAndNewlines) == provider) + #expect(persisted.resumeBinding?.cwd == workingDirectory.path, Comment(rawValue: provider)) + + var claimedKinds: [RestorableAgentKind] = [] + let didResume = restored.resumeVisibleAgentHibernationPanels( + panelIds: [panelID], + retryPendingAdoptions: false, + authorityClaimHandler: { requests in + claimedKinds.append(contentsOf: requests.map(\.agent.kind)) + return Dictionary(uniqueKeysWithValues: requests.map { + ($0.surfaceId, .unavailable) + }) + } + ) + #expect(!didResume) + #expect(claimedKinds == [.custom(provider)], Comment(rawValue: provider)) + #expect(panel.isAgentHibernated) + } + + var mismatchedAgent = resumeExecutableTestAgent( + kind: .custom("pi"), + sessionID: "registry-provider-mismatch", + executable: bin.appendingPathComponent("pi-resume").path, + workingDirectory: root.path, + registration: CmuxVaultAgentRegistration.builtInPi + ) + mismatchedAgent.registration = CmuxVaultAgentRegistration.builtInPi + let mismatchFixture = try makeHibernatedRestoreFixture(root: root, agent: mismatchedAgent) + var mismatchSnapshot = mismatchFixture.snapshot + let mismatchIndex = try #require( + mismatchSnapshot.panels.firstIndex { $0.id == mismatchFixture.sourcePanelID } + ) + var mismatchTerminal = try #require(mismatchSnapshot.panels[mismatchIndex].terminal) + mismatchTerminal.resumeBinding?.kind = "grok" + mismatchSnapshot.panels[mismatchIndex].terminal = mismatchTerminal + let decodedMismatch = try JSONDecoder().decode( + SessionWorkspaceSnapshot.self, + from: JSONEncoder().encode(mismatchSnapshot) + ) + let rejected = Workspace() + let rejectedMapping = rejected.restoreSessionSnapshot(decodedMismatch) + let rejectedPanelID = try #require(rejectedMapping[mismatchFixture.sourcePanelID]) + let rejectedPanel = try #require(rejected.terminalPanel(for: rejectedPanelID)) + #expect(!rejectedPanel.isAgentHibernated) + #expect(rejected.restoredAgentSnapshotForTesting(panelId: rejectedPanelID) == nil) + let rejectedSnapshot = try #require( + rejected.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == rejectedPanelID }?.terminal + ) + #expect(rejectedSnapshot.agent == nil) + } + } + + @Test func configuredAntigravityResumeTemplateOwnsRegistrySnapshotSemantics() throws { + var registration = CmuxVaultAgentRegistration.builtInAntigravity + registration.name = "Project Antigravity" + registration.resumeCommand = "{{executable}} --project-resume {{sessionId}}" + let snapshot = SessionRestorableAgentSnapshot( + kind: .custom("antigravity"), + sessionId: "configured-antigravity-session", + workingDirectory: "/tmp/configured-antigravity", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "antigravity", + executablePath: "/opt/bin/agy", + arguments: ["/opt/bin/agy", "--model", "configured-model"], + workingDirectory: "/tmp/configured-antigravity", + environment: nil, + capturedAt: 10, + source: "agent-hook" + ), + registration: registration + ) + + #expect( + snapshot.resumeCommand == TerminalStartupWorkingDirectoryPrefix.prefix( + "'/opt/bin/agy' '--project-resume' 'configured-antigravity-session'", + workingDirectory: "/tmp/configured-antigravity" + ) + ) + } + + @MainActor + private func makeHibernatedRestoreFixture( + root: URL, + sessionID: String + ) throws -> ( + source: Workspace, + snapshot: SessionWorkspaceSnapshot, + sourcePanelID: UUID, + agent: SessionRestorableAgentSnapshot + ) { + let agent = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: root.path, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex"], + workingDirectory: root.path, + environment: nil, + capturedAt: 10, + source: "agent-hook" + ) + ) + return try makeHibernatedRestoreFixture(root: root, agent: agent) + } + + @MainActor + private func makeHibernatedRestoreFixture( + root: URL, + agent: SessionRestorableAgentSnapshot + ) throws -> ( + source: Workspace, + snapshot: SessionWorkspaceSnapshot, + sourcePanelID: UUID, + agent: SessionRestorableAgentSnapshot + ) { + let sessionID = agent.sessionId + let source = Workspace() + let sourcePanelID = try #require(source.focusedPanelId) + let sourcePaneID = try #require(source.paneId(forPanelId: sourcePanelID)) + _ = try #require(source.newTerminalSurface(inPane: sourcePaneID, focus: true)) + source.focusPanel(sourcePanelID) + var snapshot = source.sessionSnapshot(includeScrollback: false) + let panelIndex = try #require(snapshot.panels.firstIndex { $0.id == sourcePanelID }) + var terminal = try #require(snapshot.panels[panelIndex].terminal) + terminal.agent = agent + terminal.resumeBinding = SurfaceResumeBindingSnapshot( + kind: agent.kind.rawValue, + command: try #require(agent.resumeCommand), + cwd: root.path, + checkpointId: sessionID, + source: "agent-hook", + autoResume: false, + updatedAt: 20 + ) + terminal.hibernation = SessionAgentHibernationSnapshot( + hibernatedAt: 20, + lastActivityAt: 10 + ) + terminal.wasAgentRunning = true + snapshot.panels[panelIndex].terminal = terminal + return ( + source: source, + snapshot: snapshot, + sourcePanelID: sourcePanelID, + agent: agent + ) + } + + private func installHibernatedAuthority( + root: URL, + registryURL: URL, + agent: SessionRestorableAgentSnapshot, + workspaceId: UUID, + surfaceId: UUID, + runtime: [String: Any]? = nil + ) throws -> CmuxAgentSessionRegistry { + let runtime = runtime ?? provablyDeadRuntime( + id: "retired-\(agent.kind.rawValue)-runtime" + ) + let activeSlot: [String: Any] = [ + "sessionId": agent.sessionId, + "updatedAt": 20.0, + ] + var record: [String: Any] = [ + "sessionId": agent.sessionId, + "workspaceId": workspaceId.uuidString, + "surfaceId": surfaceId.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + ] + record["activeRunId"] = "restored-run" + record["cmuxRuntime"] = runtime + record["runs"] = [[ + "runId": "restored-run", + "restoreAuthority": true, + "cmuxRuntime": runtime, + "startedAt": 10.0, + "updatedAt": 20.0, + ]] + let stateURL = agent.kind.hookStoreFileURL( + homeDirectory: root.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": root.path] + ) + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [agent.sessionId: record], + "activeSessionsByWorkspace": [workspaceId.uuidString: activeSlot], + "activeSessionsBySurface": [surfaceId.uuidString: activeSlot], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let registry = CmuxAgentSessionRegistry(url: registryURL) + _ = try registry.snapshotImportingLegacy( + provider: agent.kind.rawValue, + legacyURL: stateURL, + fileManager: .default + ) + return registry + } + + private func resumeExecutableTestAgent( + kind: RestorableAgentKind, + sessionID: String, + executable: String, + workingDirectory: String, + registration: CmuxVaultAgentRegistration? = nil, + launchEnvironment: [String: String]? = nil + ) -> SessionRestorableAgentSnapshot { + SessionRestorableAgentSnapshot( + kind: kind, + sessionId: sessionID, + workingDirectory: workingDirectory, + launchCommand: AgentLaunchCommandSnapshot( + launcher: kind.customAgentID == nil ? kind.rawValue : nil, + executablePath: executable, + arguments: [executable], + workingDirectory: workingDirectory, + environment: launchEnvironment, + capturedAt: 10, + source: "agent-hook" + ), + registration: registration + ) + } + + @MainActor + private func makeRestoredResumeExecutableFixture( + root: URL, + registryURL: URL, + agent: SessionRestorableAgentSnapshot + ) throws -> ( + workspace: Workspace, + panelID: UUID, + panel: TerminalPanel, + registry: CmuxAgentSessionRegistry + ) { + let source = try makeHibernatedRestoreFixture(root: root, agent: agent) + let registry = try installHibernatedAuthority( + root: root, + registryURL: registryURL, + agent: agent, + workspaceId: source.source.id, + surfaceId: source.sourcePanelID + ) + let workspace = Workspace() + let mapping = workspace.restoreSessionSnapshot(source.snapshot) + let panelID = try #require(mapping[source.sourcePanelID]) + let panel = try #require(workspace.terminalPanel(for: panelID)) + #expect(panel.isAgentHibernated) + return (workspace, panelID, panel, registry) + } + + @MainActor + private func withResumeExecutableEnvironment( + root: URL, + registryURL: URL, + path: String, + body: () throws -> T + ) throws -> T { + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "resume-executable-preflight-runtime", + "PATH": path, + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + return try body() + } + + private enum DirectExecOutcome: Equatable { + case exit(Int32) + case launchError(Int) + case timedOut + } + + private func makeShortExecutableTestRoot(_ label: String) throws -> URL { + let root = URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent("cx-\(label)-\(UUID().uuidString.prefix(8))", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func directExecOutcome( + _ executable: URL, + path: String = "/usr/bin:/bin", + timeout: TimeInterval = 1 + ) -> DirectExecOutcome { + let process = Process() + process.executableURL = executable + process.environment = ["PATH": path] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + return .launchError((error as NSError).code) + } + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning, Date() < deadline { + Thread.sleep(forTimeInterval: 0.005) + } + guard process.isRunning else { return .exit(process.terminationStatus) } + process.terminate() + process.waitUntilExit() + return .timedOut + } + + private func writeResumeTestExecutable( + at url: URL, + shebang: String = "#!/bin/sh" + ) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "\(shebang)\nexit 0\n".write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: url.path + ) + } + + private func durableSessionState( + _ registry: CmuxAgentSessionRegistry, + provider: String, + sessionID: String + ) throws -> String? { + let snapshot = try registry.snapshot(provider: provider) + let record = try #require(snapshot.records.first { $0.sessionID == sessionID }) + let object = try #require( + JSONSerialization.jsonObject(with: record.json) as? [String: Any] + ) + return object["sessionState"] as? String + } + + private func provablyDeadRuntime(id: String) -> [String: Any] { + [ + "id": id, + "processId": Int(Int32.max), + "processStartSeconds": 1, + "processStartMicroseconds": 1, + ] + } + +} + +extension WorkspaceForkConversationContextMenuTests { + @Test + func localForkAvailabilityTracksMissingAndRepairedExecutableForEveryCommandShape() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-fork-executable-availability-\(UUID().uuidString)", isDirectory: true) + let bin = root.appendingPathComponent("bin", isDirectory: true) + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let customRegistration = CmuxVaultAgentRegistration( + id: "fork-executable-vault", + name: "Fork Executable Vault", + detect: CmuxVaultAgentDetectRule(processName: "vault-fork"), + sessionIdSource: .argvOption("--session"), + resumeCommand: "{{executable}} --resume {{sessionId}}", + forkCommand: "{{executable}} --fork {{sessionId}}" + ) + let grokExecutable = bin.appendingPathComponent("grok-fork", isDirectory: false) + let vaultExecutable = bin.appendingPathComponent("vault-fork", isDirectory: false) + let cases: [(name: String, snapshot: SessionRestorableAgentSnapshot, executable: URL)] = [ + ( + "native-grok", + SessionRestorableAgentSnapshot( + kind: .grok, + sessionId: "missing-grok-fork", + workingDirectory: root.path, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "grok", + executablePath: grokExecutable.path, + arguments: [grokExecutable.path], + workingDirectory: root.path, + environment: nil, + capturedAt: 123, + source: "process" + ) + ), + grokExecutable + ), + ( + "custom-vault", + SessionRestorableAgentSnapshot( + kind: .custom(customRegistration.id), + sessionId: "missing-custom-fork", + workingDirectory: root.path, + launchCommand: AgentLaunchCommandSnapshot( + launcher: customRegistration.id, + executablePath: vaultExecutable.path, + arguments: [vaultExecutable.path], + workingDirectory: root.path, + environment: nil, + capturedAt: 123, + source: "process" + ), + registration: customRegistration + ), + vaultExecutable + ), + ] + + for testCase in cases { + #expect( + !(await AgentForkSupport.supportsFork(snapshot: testCase.snapshot)), + Comment(rawValue: testCase.name) + ) + #expect( + AgentForkSupport.forkValidationExecutableIdentity(snapshot: testCase.snapshot) == nil, + Comment(rawValue: testCase.name) + ) + #expect( + await AgentForkSupport.supportsFork( + snapshot: testCase.snapshot, + isRemoteContext: true + ), + "Explicit remote contexts must not be rejected by local filesystem availability: \(testCase.name)" + ) + + let runtimeName = "\(testCase.name)-runtime" + let runtime = bin.appendingPathComponent(runtimeName) + try writeAgentAvailabilityTestExecutable( + at: testCase.executable, + shebang: "#!/usr/bin/env \(runtimeName)" + ) + + #expect( + !(await AgentForkSupport.supportsFork(snapshot: testCase.snapshot)), + "The wrapper alone must not hide its missing runtime: \(testCase.name)" + ) + try fileManager.createSymbolicLink(atPath: runtime.path, withDestinationPath: "/bin/sh") + + #expect( + await AgentForkSupport.supportsFork(snapshot: testCase.snapshot), + Comment(rawValue: testCase.name) + ) + #expect( + AgentForkSupport.forkValidationExecutableIdentity(snapshot: testCase.snapshot) != nil, + "Installing the executable must change the validation identity immediately: \(testCase.name)" + ) + try fileManager.removeItem(at: runtime) + #expect( + !(await AgentForkSupport.supportsFork(snapshot: testCase.snapshot)), + "Removing only the shebang runtime must disable fork: \(testCase.name)" + ) + } + } + +} + +private func writeAgentAvailabilityTestExecutable( + at url: URL, + shebang: String = "#!/bin/sh" +) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "\(shebang)\nexit 0\n".write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: url.path + ) +} diff --git a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift index b5059dc49c8f..7d6954a36ac3 100644 --- a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift @@ -12,6 +12,157 @@ import Testing @Suite(.serialized) struct AgentSessionAutoResumeSwiftTests { + @MainActor + @Test func sessionRestoreStartupBreadcrumbsExplainBindingResumeAndPanelOutcomes() throws { + let defaultsSuite = "cmux-tests.restore-breadcrumbs.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsSuite)) + defaults.set(true, forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) + defer { defaults.removePersistentDomain(forName: defaultsSuite) } + + let source = Workspace(agentSessionAutoResumeDefaults: defaults) + let issuedPanelId = try #require(source.focusedPanelId) + let missingPanelId = try #require(source.newTerminalSurfaceInFocusedPane(focus: false)).id + let rejectedPanelId = try #require(source.newTerminalSurfaceInFocusedPane(focus: false)).id + let failedPanelId = UUID() + let sessionId = "codex-private-session-\(UUID().uuidString)" + let privateDirectory = "/tmp/cmux-private-project-\(UUID().uuidString)" + let privateEnvironmentValue = "environment-private-\(UUID().uuidString)" + + source.updatePanelShellActivityState(panelId: issuedPanelId, state: .commandRunning) + source.setRestoredAgentSnapshotForTesting( + SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionId, + workingDirectory: privateDirectory, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex", "resume", sessionId], + workingDirectory: privateDirectory, + environment: ["SAFE_TEST_VALUE": privateEnvironmentValue], + capturedAt: 1_777_777_777, + source: "process" + ) + ), + panelId: issuedPanelId + ) + + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: issuedPanelId): + SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "{ cd -- '\(privateDirectory)' 2>/dev/null || [ ! -d '\(privateDirectory)' ]; } && 'codex' 'resume' '\(sessionId)'", + cwd: privateDirectory, + checkpointId: sessionId, + source: "agent-hook", + environment: ["SAFE_TEST_VALUE": privateEnvironmentValue], + autoResume: true, + updatedAt: 1_777_777_777 + ), + SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: rejectedPanelId): + SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "'codex' 'resume' 'rejected-private-session'", + checkpointId: "rejected-private-session", + source: "agent-hook", + autoResume: false, + approvalPolicy: .manual, + updatedAt: 1_777_777_778 + ), + ]) + + var snapshot = source.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + var failedPanel = try #require(snapshot.panels.first) + failedPanel.id = failedPanelId + failedPanel.type = .markdown + failedPanel.terminal = nil + failedPanel.markdown = nil + snapshot.panels.append(failedPanel) + let bulkPanelIds = (0..<32).map { _ in UUID() } + let bulkPanelTemplate = try #require(snapshot.panels.first { $0.id == missingPanelId }) + for bulkPanelId in bulkPanelIds { + var bulkPanel = bulkPanelTemplate + bulkPanel.id = bulkPanelId + snapshot.panels.append(bulkPanel) + } + guard case .pane(var paneSnapshot) = snapshot.layout else { + Issue.record("Expected one pane for the restore breadcrumb fixture") + return + } + paneSnapshot.panelIds.append(failedPanelId) + paneSnapshot.panelIds.append(contentsOf: bulkPanelIds) + snapshot.layout = .pane(paneSnapshot) + + var recordedBatches: [[StartupBreadcrumbEvent]] = [] + let restored = Workspace( + agentSessionAutoResumeDefaults: defaults, + startupBreadcrumbBatchWriter: { recordedBatches.append($0) } + ) + let mapping = restored.restoreSessionSnapshot(snapshot) + #expect(mapping[issuedPanelId] != nil) + #expect(mapping[missingPanelId] != nil) + #expect(mapping[rejectedPanelId] != nil) + #expect(mapping[failedPanelId] == nil) + for bulkPanelId in bulkPanelIds { + #expect(mapping[bulkPanelId] != nil) + } + + #expect(recordedBatches.count == 1) + let events = try #require(recordedBatches.first) + #expect(events.count == snapshot.panels.count) + #expect(events.allSatisfy { $0.event == "session.restore.panel" }) + let expectedPanelTokens = snapshot.panels + .map { String($0.id.uuidString.lowercased().prefix(8)) } + .sorted() + #expect(events.compactMap { $0.fields["panel"] }.sorted() == expectedPanelTokens) + + let issued = try #require(event(for: issuedPanelId, in: events)) + #expect(issued["type"] == "terminal") + #expect(issued["binding"] == "found") + #expect(issued["bindingReason"] == "approved") + #expect(issued["resume"] == "issued") + #expect(issued["resumeReason"] == "binding") + #expect(issued["resumeMode"] == "command") + #expect(issued["provider"] == "codex") + #expect(issued["outcome"] == "created") + + let missing = try #require(event(for: missingPanelId, in: events)) + #expect(missing["binding"] == "missing") + #expect(missing["bindingReason"] == "absent") + #expect(missing["resume"] == "suppressed") + #expect(missing["resumeReason"] == "no_candidate") + #expect(missing["outcome"] == "created") + + let rejected = try #require(event(for: rejectedPanelId, in: events)) + #expect(rejected["binding"] == "rejected") + #expect(rejected["bindingReason"] == "policy_denied") + #expect(rejected["resume"] == "suppressed") + #expect(rejected["resumeReason"] == "binding_rejected") + #expect(rejected["outcome"] == "created") + + let failed = try #require(event(for: failedPanelId, in: events)) + #expect(failed["type"] == "markdown") + #expect(failed["outcome"] == "failed") + #expect(failed["failureReason"] == "panel_creation_failed") + + let recordedJSON = try JSONSerialization.data( + withJSONObject: events.map { event -> [String: Any] in + ["event": event.event, "fields": event.fields] + }, + options: [.sortedKeys] + ) + let recordedText = String(decoding: recordedJSON, as: UTF8.self) + #expect(!recordedText.contains(sessionId)) + #expect(!recordedText.contains(privateDirectory)) + #expect(!recordedText.contains(privateEnvironmentValue)) + #expect(!recordedText.contains(issuedPanelId.uuidString)) + } + @MainActor @Test func sessionRestoreDropsPersistedAgentStatusRuntimeState() throws { let source = Workspace() @@ -1402,6 +1553,14 @@ struct AgentSessionAutoResumeSwiftTests { return try body() } + private func event( + for panelId: UUID, + in events: [StartupBreadcrumbEvent] + ) -> [String: String]? { + let panelToken = String(panelId.uuidString.lowercased().prefix(8)) + return events.first { $0.fields["panel"] == panelToken }?.fields + } + @MainActor private func assertAgentAutoResumeUsesStartupCommand( _ panel: TerminalPanel, diff --git a/cmuxTests/AgentSessionCLIRegressionTests.swift b/cmuxTests/AgentSessionCLIRegressionTests.swift new file mode 100644 index 000000000000..cf4c6dc9d58b --- /dev/null +++ b/cmuxTests/AgentSessionCLIRegressionTests.swift @@ -0,0 +1,5082 @@ +import CmuxFoundation +import Darwin +import Foundation +import SQLite3 +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension CMUXCLIErrorOutputRegressionTests { + @Test func legacyDefaultListAndTreeUseRunRestoreAuthority() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-run-authority-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + func session( + id: String, + recordAuthority: Bool, + runAuthority: Bool, + updatedAt: TimeInterval + ) -> [String: Any] { + [ + "sessionId": id, + "workspaceId": "workspace-\(id)", + "surfaceId": "surface-\(id)", + "isRestorable": true, + "runId": "run-\(id)", + "activeRunId": "run-\(id)", + "restoreAuthority": recordAuthority, + "sessionState": "active", + "startedAt": updatedAt - 1, + "updatedAt": updatedAt, + "runs": [[ + "runId": "run-\(id)", + "restoreAuthority": runAuthority, + "startedAt": updatedAt - 1, + "updatedAt": updatedAt, + ]], + ] + } + let store: [String: Any] = [ + "version": 2, + "sessions": [ + "record-only-owner": session( + id: "record-only-owner", + recordAuthority: true, + runAuthority: false, + updatedAt: 100 + ), + "run-owner": session( + id: "run-owner", + recordAuthority: false, + runAuthority: true, + updatedAt: 200 + ), + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.sortedKeys]) + .write(to: root.appendingPathComponent("opencode-hook-sessions.json"), options: .atomic) + var environment = isolatedAgentTreeEnvironment(home: root) + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + + for arguments in [ + ["agents", "list", "--agent", "opencode", "--json"], + ["agents", "tree", "--agent", "opencode", "--json"], + ] { + let result = runProcess( + executablePath: cliPath, + arguments: arguments, + environment: environment, + timeout: 5 + ) + #expect(!result.timedOut) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let payload = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let rows = (payload["sessions"] as? [[String: Any]]) + ?? (payload["nodes"] as? [[String: Any]]) + ?? [] + #expect(rows.map { $0["session_id"] as? String } == ["run-owner"]) + } + } + + @Test func agentsListTextRendersLifecycleAndIdentityState() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-list-text-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": ["session-a": [ + "sessionId": "session-a", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "runId": "run-a", + "activeRunId": "run-a", + "sessionState": "active", + "foregroundState": "working", + "attentionState": "none", + "restoreAuthority": true, + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("opencode-hook-sessions.json"), + options: .atomic + ) + + let result = runProcess( + executablePath: try bundledCLIPath(), + arguments: [ + "agents", "list", "--agent", "opencode", "--all", "--state-dir", root.path, + ], + environment: isolatedAgentTreeEnvironment(home: root), + timeout: 5 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + #expect(result.stdout.contains("opencode session-a")) + #expect(result.stdout.contains("state=working")) + #expect(result.stdout.contains("activity=busy")) + #expect(result.stdout.contains("identity=hook_session")) + #expect(result.stdout.contains("state_source=lifecycle")) + #expect(result.stdout.contains("restore_owner=yes")) + } + + @Test func terminalObservationJoinsExactProcessGenerationAndUpdatesState() throws { + let observation = makeTerminalObservation(state: .working, lifecycleAuthoritative: false) + let node = makeTerminalNodeCandidate( + sessionID: "codex-session", + observation: observation, + effectiveState: .idle + ) + + let merged = AgentTerminalObservationJoiner().merge( + nodes: [node], observations: [observation], activeSessionBySurface: [:] + ) + + let result = try #require(merged.first) + #expect(merged.count == 1) + #expect(result.sessionId == "codex-session") + #expect(result.effectiveState == .working) + #expect(result.terminalStateApplied) + #expect(result.activity.counts.foreground == 1) + } + + @Test func lifecycleAuthoritativeObservationDoesNotOverrideKnownHookState() throws { + let observation = makeTerminalObservation(state: .blocked, lifecycleAuthoritative: true) + let node = makeTerminalNodeCandidate( + sessionID: "claude-session", + observation: observation, + effectiveState: .idle + ) + + let result = try #require(AgentTerminalObservationJoiner().merge( + nodes: [node], observations: [observation], activeSessionBySurface: [:] + ).first) + + #expect(result.effectiveState == .idle) + #expect(!result.terminalStateApplied) + #expect(result.terminalObservation == observation) + } + + @Test func activeSurfaceSlotDisambiguatesSessionsSharingOneProcess() throws { + let observation = makeTerminalObservation(state: .blocked, lifecycleAuthoritative: false) + let first = makeTerminalNodeCandidate( + sessionID: "old-session", observation: observation, effectiveState: .idle + ) + let active = makeTerminalNodeCandidate( + sessionID: "active-session", observation: observation, effectiveState: .idle + ) + let surfaceKey = AgentTerminalObservationJoiner.surfaceKey( + provider: observation.sessionProviderID, + runtimeID: observation.runtimeID, + surfaceID: observation.surfaceID.uuidString + ) + + let merged = AgentTerminalObservationJoiner().merge( + nodes: [first, active], + observations: [observation], + activeSessionBySurface: [surfaceKey: "active-session"] + ) + + #expect(merged.first(where: { $0.sessionId == "old-session" })?.effectiveState == .idle) + #expect(merged.first(where: { $0.sessionId == "active-session" })?.effectiveState == .needsInput) + } + + @Test func exactSessionProcessCohortRejectsPIDReuseAndPreservesLegacyFallback() throws { + func record(processStartedAt: TimeInterval?) throws -> ClaudeHookSessionRecord { + var run: [String: Any] = [ + "runId": "run-a", + "pid": 123, + "cmuxRuntime": ["id": "runtime-a"], + "restoreAuthority": true, + "startedAt": 100.0, + "updatedAt": 200.0, + ] + if let processStartedAt { run["processStartedAt"] = processStartedAt } + let data = try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + "session-a": [ + "sessionId": "session-a", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "cmuxRuntime": ["id": "runtime-a"], + "runs": [run], + "startedAt": 100.0, + "updatedAt": 200.0, + ], + ], + ], options: [.sortedKeys]) + return try #require( + JSONDecoder().decode(ClaudeHookSessionStoreFile.self, from: data) + .sessions["session-a"] + ) + } + + let target = try record(processStartedAt: 1_000) + let sameGeneration = try record(processStartedAt: 1_000.0004) + let reusedPID = try record(processStartedAt: 2_000) + let legacy = try record(processStartedAt: nil) + var exactMatcher = AgentSessionProcessCohortMatcher() + exactMatcher.insert(provider: "codex", record: target, run: try #require(target.runs?.first)) + + #expect(exactMatcher.matches( + provider: "codex", record: sameGeneration, run: try #require(sameGeneration.runs?.first) + )) + #expect(!exactMatcher.matches( + provider: "codex", record: reusedPID, run: try #require(reusedPID.runs?.first) + )) + #expect(exactMatcher.matches( + provider: "codex", record: legacy, run: try #require(legacy.runs?.first) + )) + + var legacyMatcher = AgentSessionProcessCohortMatcher() + legacyMatcher.insert(provider: "codex", record: legacy, run: try #require(legacy.runs?.first)) + #expect(legacyMatcher.matches( + provider: "codex", record: reusedPID, run: try #require(reusedPID.runs?.first) + )) + } + + @Test func unmatchedObservationBecomesTerminalProcessNodeWithoutInventedSessionID() throws { + let observation = makeTerminalObservation(state: .idle, lifecycleAuthoritative: false) + + let result = try #require(AgentTerminalObservationJoiner().merge( + nodes: [], observations: [observation], activeSessionBySurface: [:] + ).first) + + #expect(result.sessionId == nil) + #expect(result.identitySource == "terminal_process") + #expect(result.pid == Int(observation.pid)) + #expect(result.cwd == observation.cwd) + #expect(result.effectiveState == .idle) + #expect(!result.restoreAuthority) + } + + @Test func duplicateTerminalObservationsUseNewestPublishedStateOnce() { + let workspaceID = UUID() + let surfaceID = UUID() + let stale = makeTerminalObservation( + state: .idle, + lifecycleAuthoritative: false, + workspaceID: workspaceID, + surfaceID: surfaceID, + revision: 99, + publishedAt: 100 + ) + let newest = makeTerminalObservation( + state: .blocked, + lifecycleAuthoritative: false, + workspaceID: workspaceID, + surfaceID: surfaceID, + revision: 1, + publishedAt: 200 + ) + + for observations in [[stale, newest], [newest, stale]] { + let merged = AgentTerminalObservationJoiner().merge( + nodes: [], observations: observations, activeSessionBySurface: [:] + ) + + #expect(merged.count == 1) + #expect(merged.first?.effectiveState == .needsInput) + #expect(merged.first?.terminalObservation?.publishedAt == 200) + } + } + + @Test func terminalObservationCanonicalizationUsesRevisionAsPublishedAtTieBreak() { + let workspaceID = UUID() + let surfaceID = UUID() + let lowerRevision = makeTerminalObservation( + state: .idle, + lifecycleAuthoritative: false, + workspaceID: workspaceID, + surfaceID: surfaceID, + revision: 4, + publishedAt: 200 + ) + let higherRevision = makeTerminalObservation( + state: .working, + lifecycleAuthoritative: false, + workspaceID: workspaceID, + surfaceID: surfaceID, + revision: 5, + publishedAt: 200 + ) + + for observations in [[lowerRevision, higherRevision], [higherRevision, lowerRevision]] { + let canonical = AgentTerminalObservationJoiner().merge( + nodes: [], observations: observations, activeSessionBySurface: [:] + ) + + #expect(canonical.count == 1) + #expect(canonical.first?.terminalObservation == higherRevision) + } + } + + @Test func terminalObservationCanonicalizationPreservesDistinctProcessGenerations() { + let workspaceID = UUID() + let surfaceID = UUID() + let first = makeTerminalObservation( + state: .idle, + lifecycleAuthoritative: false, + workspaceID: workspaceID, + surfaceID: surfaceID, + surfaceGeneration: 9, + publishedAt: 100 + ) + let second = makeTerminalObservation( + state: .working, + lifecycleAuthoritative: false, + workspaceID: workspaceID, + surfaceID: surfaceID, + surfaceGeneration: 10, + publishedAt: 200 + ) + + #expect(AgentTerminalObservationJoiner().merge( + nodes: [], observations: [first, second], activeSessionBySurface: [:] + ).count == 2) + } + + @Test func terminalObservationCanonicalizationPreservesDistinctKernelProcessLifetimes() { + let workspaceID = UUID() + let surfaceID = UUID() + let first = makeTerminalObservation( + state: .idle, + lifecycleAuthoritative: false, + workspaceID: workspaceID, + surfaceID: surfaceID, + publishedAt: 100, + processStartSeconds: 100 + ) + let reusedPID = makeTerminalObservation( + state: .working, + lifecycleAuthoritative: false, + workspaceID: workspaceID, + surfaceID: surfaceID, + publishedAt: 200, + processStartSeconds: 101 + ) + + #expect(AgentTerminalObservationJoiner().merge( + nodes: [], observations: [first, reusedPID], activeSessionBySurface: [:] + ).count == 2) + } + + @Test func terminalObservationCanonicalizationBreaksExactClockTiesDeterministically() { + let surfaceID = UUID() + let lowerWorkspace = makeTerminalObservation( + state: .working, + lifecycleAuthoritative: false, + workspaceID: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + surfaceID: surfaceID, + revision: 5, + publishedAt: 200 + ) + let higherWorkspace = makeTerminalObservation( + state: .idle, + lifecycleAuthoritative: false, + workspaceID: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + surfaceID: surfaceID, + revision: 5, + publishedAt: 200 + ) + + for observations in [[lowerWorkspace, higherWorkspace], [higherWorkspace, lowerWorkspace]] { + let canonical = AgentTerminalObservationJoiner().merge( + nodes: [], observations: observations, activeSessionBySurface: [:] + ) + + #expect(canonical.count == 1) + #expect(canonical.first?.terminalObservation == higherWorkspace) + } + } + + @Test func terminalObservationCanonicalizationTreatsProviderAsProcessMetadata() { + let surfaceID = UUID() + let staleProvider = makeTerminalObservation( + state: .working, + lifecycleAuthoritative: false, + surfaceID: surfaceID, + revision: 4, + publishedAt: 100 + ) + let currentProvider = makeTerminalObservation( + state: .idle, + lifecycleAuthoritative: false, + workspaceID: staleProvider.workspaceID, + surfaceID: surfaceID, + revision: 5, + publishedAt: 200, + sessionProviderID: "claude" + ) + + for observations in [[staleProvider, currentProvider], [currentProvider, staleProvider]] { + let canonical = AgentTerminalObservationJoiner().merge( + nodes: [], observations: observations, activeSessionBySurface: [:] + ) + + #expect(canonical.count == 1) + #expect(canonical.first?.provider == "claude") + #expect(canonical.first?.terminalObservation == currentProvider) + } + } + + @Test func olderCompatibilityWriterCannotHideCurrentCodexRunFromAgentsTree() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-version-clobber-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let sessionID = "current-codex" + let richRecord: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "runId": "current-run", + "activeRunId": "current-run", + "restoreAuthority": true, + "foregroundState": "working", + "startedAt": 100.0, + "updatedAt": 200.0, + "runs": [[ + "runId": "current-run", + "restoreAuthority": true, + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + ] + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try registry.apply(provider: "codex", records: [ + CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: sessionID, + updatedAt: 200, + json: try JSONSerialization.data(withJSONObject: richRecord, options: [.sortedKeys]) + ), + ]) + + let oldWriterStore: [String: Any] = [ + "version": 2, + "sessions": [ + sessionID: [ + "sessionId": sessionID, + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "startedAt": 100.0, + "updatedAt": 300.0, + ], + ], + ] + try JSONSerialization.data(withJSONObject: oldWriterStore, options: [.sortedKeys]) + .write(to: stateURL, options: .atomic) + + var environment = isolatedAgentTreeEnvironment(home: root) + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--all", "--json"], + environment: environment, + timeout: 5 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let output = try #require(JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any]) + let nodes = try #require(output["nodes"] as? [[String: Any]]) + #expect(nodes.contains { + $0["session_id"] as? String == sessionID && $0["run_id"] as? String == "current-run" + }) + } + + @Test func agentsListAndTreeWarnWhenAuthoritativeSnapshotCannotDecode() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-partial-registry-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let legacySessionID = "legacy-complete" + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [legacySessionID: [ + "sessionId": legacySessionID, + "workspaceId": "workspace-legacy", + "surfaceId": "surface-legacy", + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + let registryPath = root.appendingPathComponent(CmuxAgentSessionRegistry.filename).path + _ = try registry.snapshotImportingLegacy( + provider: "codex", legacyURL: stateURL, fileManager: .default + ) + let registryOnlySessionID = "registry-only" + try registry.apply(provider: "codex", records: [ + CmuxAgentSessionRegistry.Record( + provider: "codex", sessionID: registryOnlySessionID, updatedAt: 400, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": registryOnlySessionID, + "workspaceId": "workspace-registry", + "surfaceId": "surface-registry", + "startedAt": 300.0, + "updatedAt": 400.0, + ], options: [.sortedKeys]) + ), + CmuxAgentSessionRegistry.Record( + provider: "codex", sessionID: "malformed", updatedAt: 500, + json: Data("{}".utf8) + ), + ]) + + let environment = isolatedAgentTreeEnvironment(home: root) + let commands: [(arguments: [String], rowsKey: String)] = [ + ([ + "agents", "list", "--agent", "codex", "--all", "--limit", "100", "--json", + "--state-dir", root.path, "--codex-home", root.path, + ], "sessions"), + ([ + "agents", "tree", "--agent", "codex", "--all", "--json", + "--state-dir", root.path, + ], "nodes"), + ] + for command in commands { + let result = runProcess( + executablePath: cliPath, + arguments: command.arguments, + environment: environment, + timeout: 5 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let output = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let warnings = try #require(output["store_warnings"] as? [[String: Any]]) + #expect(warnings.count == 1) + #expect(warnings.first?["provider"] as? String == "codex") + #expect(warnings.first?["path"] as? String == registryPath) + #expect(warnings.first?["code"] as? String == "authoritative_snapshot_decode_failed") + #expect(warnings.first?["fallback"] as? String == "legacy") + let rows = try #require(output[command.rowsKey] as? [[String: Any]]) + #expect(rows.contains { $0["session_id"] as? String == legacySessionID }) + #expect( + !rows.contains { $0["session_id"] as? String == registryOnlySessionID }, + "One malformed registry record must reject the entire CLI projection instead of returning partial state." + ) + } + + for (index, arguments) in [ + [ + "agents", "list", "--agent", "codex", "--all", "--limit", "100", + "--state-dir", root.path, "--codex-home", root.path, + ], + [ + "agents", "tree", "--agent", "codex", "--all", + "--state-dir", root.path, + ], + ].enumerated() { + let stderrURL = root.appendingPathComponent("warning-\(index).txt") + let command = ([cliPath] + arguments) + .map(shellQuoteAgentTreeArgument) + .joined(separator: " ") + let result = runProcess( + executablePath: "/bin/sh", + arguments: [ + "-c", + "\(command) 2>\(shellQuoteAgentTreeArgument(stderrURL.path))", + ], + environment: environment, + timeout: 5 + ) + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + #expect(!result.stdout.contains("authoritative_snapshot_decode_failed")) + #expect(result.stdout.contains(legacySessionID)) + #expect(!result.stdout.contains(registryOnlySessionID)) + let stderr = try String(contentsOf: stderrURL, encoding: .utf8) + #expect(stderr.contains("authoritative_snapshot_decode_failed")) + #expect(stderr.contains("codex")) + #expect(stderr.contains(registryPath)) + } + + try registry.apply(provider: "opencode", records: [ + CmuxAgentSessionRegistry.Record( + provider: "opencode", sessionID: "malformed-without-fallback", updatedAt: 600, + json: Data("{}".utf8) + ), + ]) + for arguments in [ + [ + "agents", "list", "--agent", "opencode", "--all", "--limit", "100", "--json", + "--state-dir", root.path, + ], + [ + "agents", "tree", "--agent", "opencode", "--all", "--json", + "--state-dir", root.path, + ], + ] { + let result = runProcess( + executablePath: cliPath, + arguments: arguments, + environment: environment, + timeout: 5 + ) + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status != 0, Comment(rawValue: result.stdout)) + #expect(result.stdout.contains("authoritative_snapshot_decode_failed")) + #expect(result.stdout.contains("opencode")) + #expect(result.stdout.contains(registryPath)) + #expect(!result.stdout.contains("NSCocoaErrorDomain")) + } + } + + @Test func boundedAgentListLegacyFallbackKeepsExactCountsAndGlobalOrder() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-bounded-fallback-counts-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let codexLegacyURL = root.appendingPathComponent("codex-hook-sessions.json") + let legacyRows: [(id: String, updatedAt: TimeInterval)] = [ + ("codex-older", 100), + ("codex-middle", 400), + ("codex-newest", 600), + ] + let legacySessions = Dictionary(uniqueKeysWithValues: legacyRows.map { row in + (row.id, [ + "sessionId": row.id, + "workspaceId": "workspace-\(row.id)", + "surfaceId": "surface-\(row.id)", + "startedAt": row.updatedAt, + "updatedAt": row.updatedAt, + ] as [String: Any]) + }) + try JSONSerialization.data( + withJSONObject: ["version": 2, "sessions": legacySessions], + options: [.sortedKeys] + ).write(to: codexLegacyURL, options: .atomic) + + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let registry = CmuxAgentSessionRegistry(url: registryURL) + _ = try registry.snapshotImportingLegacy( + provider: "codex", + legacyURL: codexLegacyURL, + fileManager: .default + ) + try registry.apply(provider: "codex", records: [ + .init( + provider: "codex", + sessionID: "codex-malformed", + updatedAt: 700, + json: Data("{}".utf8) + ), + ]) + try registry.apply(provider: "opencode", records: [ + try agentSessionRegistryRecord( + provider: "opencode", + sessionID: "opencode-newest", + updatedAt: 550 + ), + try agentSessionRegistryRecord( + provider: "opencode", + sessionID: "opencode-older", + updatedAt: 300 + ), + ]) + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "agents", "list", "--all", "--limit", "2", "--json", + "--state-dir", root.path, "--codex-home", root.path, + ], + environment: isolatedAgentTreeEnvironment(home: root), + timeout: 10 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let output = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + #expect(output["total_matches"] as? Int == 5) + let rows = try #require(output["sessions"] as? [[String: Any]]) + #expect(rows.compactMap { $0["session_id"] as? String } == [ + "codex-newest", "opencode-newest", + ]) + let stores = try #require(output["stores"] as? [[String: Any]]) + let codexStore = try #require(stores.first { $0["agent"] as? String == "codex" }) + #expect(codexStore["exists"] as? Bool == true) + #expect(codexStore["session_count"] as? Int == 3) + let opencodeStore = try #require(stores.first { $0["agent"] as? String == "opencode" }) + #expect(opencodeStore["exists"] as? Bool == true) + #expect(opencodeStore["session_count"] as? Int == 2) + let warnings = try #require(output["store_warnings"] as? [[String: Any]]) + #expect(warnings.count == 1) + #expect(warnings.first?["provider"] as? String == "codex") + #expect(warnings.first?["code"] as? String == "authoritative_snapshot_decode_failed") + #expect(warnings.first?["fallback"] as? String == "legacy") + } + + @Test func boundedAgentListPreservesGenericCanonicalReadFailures() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-bounded-generic-read-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let registry = CmuxAgentSessionRegistry(url: registryURL) + try registry.apply(provider: "codex", records: [ + try agentSessionRegistryRecord( + provider: "codex", + sessionID: "canonical-session", + updatedAt: 100 + ), + ]) + // Storage preflight does not read this column, while the canonical list + // query does. This injects a canonical SQLite error after preflight. + try executeAgentSessionSQLite( + at: registryURL, + sql: "ALTER TABLE agent_sessions RENAME COLUMN writer_generation TO missing_writer_generation" + ) + + var caught: (any Error)? + do { + _ = try AgentHookSessionRegistryBridge.boundedRecentSnapshotsForList( + specifications: [(provider: "codex", suffix: "codex")], + stateDirectory: root.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": root.path], + fileManager: .default, + maximumRecordsPerProvider: 1 + ) + } catch { + caught = error + } + + #expect(caught != nil) + #expect( + !(caught is AgentHookSessionStoreLoadFailure), + "A canonical query failure must not be relabeled as a legacy import failure." + ) + } + + @Test func agentsListAndTreeFailClosedForMalformedLegacyWithoutRegistryFallback() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-malformed-legacy-empty-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try Data("{ malformed".utf8).write(to: stateURL, options: .atomic) + let environment = isolatedAgentTreeEnvironment(home: root) + + for arguments in [ + [ + "agents", "list", "--agent", "codex", "--all", "--json", + "--state-dir", root.path, "--codex-home", root.path, + ], + [ + "agents", "tree", "--agent", "codex", "--all", "--json", + "--state-dir", root.path, + ], + ] { + let result = runProcess( + executablePath: cliPath, + arguments: arguments, + environment: environment, + timeout: 5 + ) + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status != 0, Comment(rawValue: result.stdout)) + #expect(result.stdout.contains("legacy_source_import_failed")) + #expect(result.stdout.contains("codex")) + #expect(result.stdout.contains(stateURL.path)) + #expect(!result.stdout.contains("NSCocoaErrorDomain")) + #expect(!result.stdout.contains("JSON text did not start")) + } + } + + @Test func agentsListAndTreeWarnWhenMalformedLegacyUsesRegistryFallback() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-malformed-legacy-fallback-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let registrySessionID = "registry-complete" + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try registry.apply(provider: "codex", records: [ + CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: registrySessionID, + updatedAt: 200, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": registrySessionID, + "workspaceId": "workspace-registry", + "surfaceId": "surface-registry", + "startedAt": 100.0, + "updatedAt": 200.0, + ], options: [.sortedKeys]) + ), + ]) + try Data("{ malformed".utf8).write(to: stateURL, options: .atomic) + let environment = isolatedAgentTreeEnvironment(home: root) + + for command in [ + (arguments: [ + "agents", "list", "--agent", "codex", "--all", "--json", + "--state-dir", root.path, "--codex-home", root.path, + ], rowsKey: "sessions"), + (arguments: [ + "agents", "tree", "--agent", "codex", "--all", "--json", + "--state-dir", root.path, + ], rowsKey: "nodes"), + ] { + let result = runProcess( + executablePath: cliPath, + arguments: command.arguments, + environment: environment, + timeout: 5 + ) + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let output = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let warnings = try #require(output["store_warnings"] as? [[String: Any]]) + #expect(warnings.count == 1) + #expect(warnings.first?["provider"] as? String == "codex") + #expect(warnings.first?["path"] as? String == stateURL.path) + #expect(warnings.first?["code"] as? String == "legacy_source_import_failed") + #expect(warnings.first?["fallback"] as? String == "registry") + let rows = try #require(output[command.rowsKey] as? [[String: Any]]) + #expect(rows.count == 1) + #expect(rows.first?["session_id"] as? String == registrySessionID) + } + } + + @Test func agentsListAndTreeFailClosedForLegacySessionIdentityMismatchWithoutRegistryFallback() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-legacy-identity-empty-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + "outer-session": [ + "sessionId": "inner-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "startedAt": 100.0, + "updatedAt": 200.0, + ], + ], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let environment = isolatedAgentTreeEnvironment(home: root) + + for arguments in [ + [ + "agents", "list", "--agent", "codex", "--all", "--json", + "--state-dir", root.path, "--codex-home", root.path, + ], + [ + "agents", "tree", "--agent", "codex", "--all", "--json", + "--state-dir", root.path, + ], + ] { + let result = runProcess( + executablePath: cliPath, + arguments: arguments, + environment: environment, + timeout: 15 + ) + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status != 0, Comment(rawValue: result.stdout)) + #expect(result.stdout.contains("legacy_source_import_failed")) + #expect(result.stdout.contains("codex")) + #expect(result.stdout.contains(stateURL.path)) + #expect(!result.stdout.contains("outer-session")) + #expect(!result.stdout.contains("inner-session")) + } + } + + @Test func agentsListAndTreeWarnWhenLegacySessionIdentityMismatchUsesRegistryFallback() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-legacy-identity-fallback-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let registrySessionID = "registry-complete" + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try registry.apply(provider: "codex", records: [ + CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: registrySessionID, + updatedAt: 200, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": registrySessionID, + "workspaceId": "workspace-registry", + "surfaceId": "surface-registry", + "startedAt": 100.0, + "updatedAt": 200.0, + ], options: [.sortedKeys]) + ), + ]) + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + "outer-session": [ + "sessionId": "inner-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "startedAt": 300.0, + "updatedAt": 400.0, + ], + ], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let environment = isolatedAgentTreeEnvironment(home: root) + + for command in [ + (arguments: [ + "agents", "list", "--agent", "codex", "--all", "--json", + "--state-dir", root.path, "--codex-home", root.path, + ], rowsKey: "sessions"), + (arguments: [ + "agents", "tree", "--agent", "codex", "--all", "--json", + "--state-dir", root.path, + ], rowsKey: "nodes"), + ] { + let result = runProcess( + executablePath: cliPath, + arguments: command.arguments, + environment: environment, + timeout: 15 + ) + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let output = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let warnings = try #require(output["store_warnings"] as? [[String: Any]]) + #expect(warnings.count == 1) + #expect(warnings.first?["provider"] as? String == "codex") + #expect(warnings.first?["path"] as? String == stateURL.path) + #expect(warnings.first?["code"] as? String == "legacy_source_import_failed") + #expect(warnings.first?["fallback"] as? String == "registry") + let rows = try #require(output[command.rowsKey] as? [[String: Any]]) + #expect(rows.count == 1) + #expect(rows.first?["session_id"] as? String == registrySessionID) + #expect(!result.stdout.contains("outer-session")) + #expect(!result.stdout.contains("inner-session")) + } + } + + @Test func authoritativeDecodeDoesNotUseLegacySessionIdentityMismatchAsFallback() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-legacy-identity-decode-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + "outer-session": [ + "sessionId": "inner-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "startedAt": 100.0, + "updatedAt": 200.0, + ], + ], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let bridge = AgentHookSessionRegistryBridge( + provider: "codex", + statePath: stateURL.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": root.path], + fileManager: .default + ) + var loadFailure: AgentHookSessionStoreLoadFailure? + do { + _ = try bridge.loadForInspection(snapshot: CmuxAgentSessionRegistry.Snapshot( + records: [CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: "malformed-registry-record", + updatedAt: 300, + json: Data("{}".utf8) + )], + activeSlots: [] + )) + } catch let failure as AgentHookSessionStoreLoadFailure { + loadFailure = failure + } + let failure = try #require(loadFailure) + #expect(failure.provider == "codex") + #expect(failure.path == root.appendingPathComponent(CmuxAgentSessionRegistry.filename).path) + #expect(failure.code == .authoritativeSnapshotDecodeFailed) + } + + @Test func authoritativeDecodeDoesNotUseLegacySlotIdentityMismatchAsFallback() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-legacy-slot-identity-decode-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + "session-a": [ + "sessionId": "session-a", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "startedAt": 100.0, + "updatedAt": 200.0, + ], + ], + "activeSessionsByWorkspace": [ + "workspace-b": [ + "sessionId": "session-a", + "updatedAt": 200.0, + ], + ], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let bridge = AgentHookSessionRegistryBridge( + provider: "codex", + statePath: stateURL.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": root.path], + fileManager: .default + ) + var loadFailure: AgentHookSessionStoreLoadFailure? + do { + _ = try bridge.loadForInspection(snapshot: CmuxAgentSessionRegistry.Snapshot( + records: [CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: "malformed-registry-record", + updatedAt: 300, + json: Data("{}".utf8) + )], + activeSlots: [] + )) + } catch let failure as AgentHookSessionStoreLoadFailure { + loadFailure = failure + } + let failure = try #require(loadFailure) + #expect(failure.provider == "codex") + #expect(failure.path == root.appendingPathComponent(CmuxAgentSessionRegistry.filename).path) + #expect(failure.code == .authoritativeSnapshotDecodeFailed) + } + + @Test func boundedAuthoritativeDecodeValidatesOmittedNestedRecordPayloads() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-bounded-record-decode-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let provider = "codex" + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + let malformedID = "omitted-malformed" + let newestID = "newest-valid" + try registry.apply(provider: provider, records: [ + .init( + provider: provider, + sessionID: malformedID, + updatedAt: 1, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": malformedID, + "workspaceId": "old-workspace", + "surfaceId": "old-surface", + "startedAt": 1.0, + "updatedAt": 1.0, + "cmuxRuntime": ["id": 42], + ], options: [.sortedKeys]) + ), + .init( + provider: provider, + sessionID: newestID, + updatedAt: 2, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": newestID, + "workspaceId": "new-workspace", + "surfaceId": "new-surface", + "startedAt": 2.0, + "updatedAt": 2.0, + ], options: [.sortedKeys]) + ), + ]) + let bounded = try registry.hookBoundedRecentSnapshot( + provider: provider, + maximumRecords: 1 + ) + #expect(bounded.snapshot.records.map(\.sessionID) == [newestID]) + let bridge = AgentHookSessionRegistryBridge( + provider: provider, + statePath: root.appendingPathComponent("codex-hook-sessions.json").path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": root.path], + fileManager: .default + ) + let snapshots = try AgentHookSessionRegistryBridge.boundedRecentSnapshotsForList( + specifications: [(provider: provider, suffix: provider)], + stateDirectory: root.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": root.path], + fileManager: .default, + maximumRecordsPerProvider: 1 + ) + #expect(snapshots.boundedValidationFailures == Set([provider])) + + var loadFailure: AgentHookSessionStoreLoadFailure? + do { + _ = try bridge.loadBoundedForInspection( + snapshot: try #require(snapshots.snapshots[provider]), + authoritativeValidationFailed: true + ) + } catch let failure as AgentHookSessionStoreLoadFailure { + loadFailure = failure + } + + #expect(try #require(loadFailure).code == .authoritativeSnapshotDecodeFailed) + } + + @Test func boundedAuthoritativeDecodeValidatesOmittedActiveSlotPayloads() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-bounded-slot-decode-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let provider = "opencode" + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + let olderID = "omitted-slot-owner" + let newestID = "newest-valid" + let records = try [ + (olderID, "old-workspace", "old-surface", 1.0), + (newestID, "new-workspace", "new-surface", 2.0), + ].map { item in + let (sessionID, workspaceID, surfaceID, timestamp) = item + return CmuxAgentSessionRegistry.Record( + provider: provider, + sessionID: sessionID, + updatedAt: timestamp, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "workspaceId": workspaceID, + "surfaceId": surfaceID, + "startedAt": timestamp, + "updatedAt": timestamp, + ], options: [.sortedKeys]) + ) + } + let malformedSlot = CmuxAgentSessionRegistry.ActiveSlot( + provider: provider, + scope: .surface, + scopeID: "old-surface", + sessionID: olderID, + updatedAt: 1, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": olderID, + "updatedAt": 1.0, + "allowsNewSessionReplacement": "yes", + ], options: [.sortedKeys]) + ) + try registry.apply( + provider: provider, + records: records, + activeSlots: [malformedSlot] + ) + let bounded = try registry.hookBoundedRecentSnapshot( + provider: provider, + maximumRecords: 1 + ) + #expect(bounded.snapshot.records.map(\.sessionID) == [newestID]) + #expect(bounded.snapshot.activeSlots.isEmpty) + let bridge = AgentHookSessionRegistryBridge( + provider: provider, + statePath: root.appendingPathComponent("opencode-hook-sessions.json").path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": root.path], + fileManager: .default + ) + let snapshots = try AgentHookSessionRegistryBridge.boundedRecentSnapshotsForList( + specifications: [(provider: provider, suffix: provider)], + stateDirectory: root.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": root.path], + fileManager: .default, + maximumRecordsPerProvider: 1 + ) + #expect(snapshots.boundedValidationFailures == Set([provider])) + + var loadFailure: AgentHookSessionStoreLoadFailure? + do { + _ = try bridge.loadBoundedForInspection( + snapshot: try #require(snapshots.snapshots[provider]), + authoritativeValidationFailed: true + ) + } catch let failure as AgentHookSessionStoreLoadFailure { + loadFailure = failure + } + + #expect(try #require(loadFailure).code == .authoritativeSnapshotDecodeFailed) + } + + @Test func kimiHookProviderHasLifecycleRestoreAndHelpParity() throws { + #expect(AgentHibernationLifecycleStatusKeys.isAllowed("kimi")) + + let kind = try #require(RestorableAgentKind(rawValue: "kimi")) + #expect(kind.customAgentID == nil) + #expect(RestorableAgentKind.allCases.contains { $0.rawValue == "kimi" }) + + let result = runProcess( + executablePath: try bundledCLIPath(), + arguments: ["hooks", "--help"], + environment: ["CMUX_CLI_SENTRY_DISABLED": "1"], + timeout: 5 + ) + #expect(!result.timedOut) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + #expect(result.stdout.contains("kimi")) + #expect(result.stdout.contains("~/.kimi/config.toml")) + #expect(!result.stdout.contains("~/.kimi-code/config.toml")) + } + + @Test func agentInspectionAcceptsProviderExecutableAliasesAndOllama() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-filter-aliases-\(UUID().uuidString)", isDirectory: true) + let stateDirectory = root.appendingPathComponent("state", isDirectory: true) + try FileManager.default.createDirectory( + at: stateDirectory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + + var environment = isolatedAgentTreeEnvironment(home: root) + environment["CMUX_AGENT_HOOK_STATE_DIR"] = stateDirectory.path + let cliPath = try bundledCLIPath() + let aliases = [ + "hermes", + "kiro-cli", + "qodercli", + "kimi-cli", + "kimi-code", + "ollama", + ] + + for command in ["list", "tree"] { + for alias in aliases { + let result = runProcess( + executablePath: cliPath, + arguments: [ + "agents", command, + "--agent", alias, + "--state-dir", stateDirectory.path, + "--all", + "--json", + ], + environment: environment, + timeout: 5 + ) + #expect( + !result.timedOut, + Comment(rawValue: "\(command) --agent \(alias): \(result.stdout)") + ) + #expect( + result.status == 0, + Comment(rawValue: "\(command) --agent \(alias): \(result.stdout)") + ) + let payload = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) + as? [String: Any] + ) + #expect(payload["schema_version"] as? Int == 2) + if command == "list" { + #expect((payload["sessions"] as? [[String: Any]])?.isEmpty == true) + } else { + #expect((payload["nodes"] as? [[String: Any]])?.isEmpty == true) + #expect((payload["edges"] as? [[String: Any]])?.isEmpty == true) + } + } + } + } + + @Test func agentInspectionDiscoversConfiguredVaultSidecarsWithoutALiveObservation() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-configured-provider-\(UUID().uuidString)", isDirectory: true) + let stateDirectory = root.appendingPathComponent("state", isDirectory: true) + let configDirectory = root.appendingPathComponent(".config/cmux", isDirectory: true) + try FileManager.default.createDirectory( + at: stateDirectory, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: configDirectory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + + let provider = "custom-sidecar" + try Data(""" + { + // Offline inspection must use the configured provider catalog. + "vault": { + "agents": [{ + "id": "\(provider)", + "name": "Custom Sidecar", + "detect": { "processName": "custom-sidecar" }, + "sessionIdSource": { "type": "argvOption", "argvOption": "--session" }, + "resumeCommand": "custom-sidecar --session {{sessionId}}", + }], + }, + } + """.utf8).write( + to: configDirectory.appendingPathComponent("cmux.json"), + options: .atomic + ) + try writeAgentTreeStore( + parentIndices: [nil], + to: stateDirectory.appendingPathComponent("\(provider)-hook-sessions.json") + ) + + let cliPath = try bundledCLIPath() + let environment = isolatedAgentTreeEnvironment(home: root) + for subcommand in ["list", "tree"] { + for filters in [[], ["--agent", provider]] { + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", subcommand] + + filters + + ["--all", "--json", "--state-dir", stateDirectory.path], + environment: environment, + timeout: 5 + ) + let context = "agents \(subcommand) \(filters.joined(separator: " ")): \(result.stdout)" + + #expect(!result.timedOut, Comment(rawValue: context)) + #expect(result.status == 0, Comment(rawValue: context)) + let payload = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any], + Comment(rawValue: context) + ) + let rows = subcommand == "list" + ? try #require(payload["sessions"] as? [[String: Any]]) + : try #require(payload["nodes"] as? [[String: Any]]) + #expect(rows.contains { + ($0["agent"] as? String) == provider || ($0["provider"] as? String) == provider + }, Comment(rawValue: context)) + } + } + } + + @Test func agentInspectionDiscoversRegistryOnlyProvidersWithoutALiveObservation() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-registry-provider-\(UUID().uuidString)", isDirectory: true) + let stateDirectory = root.appendingPathComponent("state", isDirectory: true) + try FileManager.default.createDirectory( + at: stateDirectory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + + let provider = "custom-sqlite" + let registry = CmuxAgentSessionRegistry( + url: stateDirectory.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try seedAuthoritativeAgentSessions(count: 1, provider: provider, registry: registry) + + let cliPath = try bundledCLIPath() + let environment = isolatedAgentTreeEnvironment(home: root) + for subcommand in ["list", "tree"] { + for filters in [[], ["--agent", provider]] { + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", subcommand] + + filters + + ["--all", "--json", "--state-dir", stateDirectory.path], + environment: environment, + timeout: 5 + ) + let context = "agents \(subcommand) \(filters.joined(separator: " ")): \(result.stdout)" + + #expect(!result.timedOut, Comment(rawValue: context)) + #expect(result.status == 0, Comment(rawValue: context)) + let payload = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any], + Comment(rawValue: context) + ) + let rows = subcommand == "list" + ? try #require(payload["sessions"] as? [[String: Any]]) + : try #require(payload["nodes"] as? [[String: Any]]) + #expect(rows.contains { + ($0["agent"] as? String) == provider || ($0["provider"] as? String) == provider + }, Comment(rawValue: context)) + } + } + } + + @Test func exactCustomProviderIDWinsOverStaticAliasWhileUnclaimedAliasStillWorks() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-exact-alias-\(UUID().uuidString)", isDirectory: true) + let customState = root.appendingPathComponent("custom-state", isDirectory: true) + let aliasState = root.appendingPathComponent("alias-state", isDirectory: true) + try FileManager.default.createDirectory(at: customState, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: aliasState, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let registry = CmuxAgentSessionRegistry( + url: customState.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try seedAuthoritativeAgentSessions(count: 1, provider: "cursor-agent", registry: registry) + try writeAgentTreeStore( + parentIndices: [nil], + to: customState.appendingPathComponent("cursor-agent-hook-sessions.json") + ) + // Keep the aliased built-in sidecar present too, so the selected row + // proves which provider the exact filter resolved. + try writeAgentTreeStore( + parentIndices: [nil], + to: customState.appendingPathComponent("cursor-hook-sessions.json") + ) + try writeAgentTreeStore( + parentIndices: [nil], + to: aliasState.appendingPathComponent("cursor-hook-sessions.json") + ) + + let cliPath = try bundledCLIPath() + let environment = isolatedAgentTreeEnvironment(home: root) + for subcommand in ["list", "tree"] { + for (stateDirectory, expectedProvider) in [ + (customState, "cursor-agent"), + (aliasState, "cursor"), + ] { + let result = runProcess( + executablePath: cliPath, + arguments: [ + "agents", subcommand, "--agent", "cursor-agent", "--all", "--json", + "--state-dir", stateDirectory.path, + ], + environment: environment, + timeout: 5 + ) + let context = "agents \(subcommand) exact alias in \(stateDirectory.lastPathComponent): \(result.stdout)" + #expect(!result.timedOut, Comment(rawValue: context)) + #expect(result.status == 0, Comment(rawValue: context)) + let payload = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any], + Comment(rawValue: context) + ) + let rows = subcommand == "list" + ? try #require(payload["sessions"] as? [[String: Any]]) + : try #require(payload["nodes"] as? [[String: Any]]) + #expect(rows.count == 1, Comment(rawValue: context)) + let provider = rows.first?["agent"] as? String + ?? rows.first?["provider"] as? String + #expect(provider == expectedProvider, Comment(rawValue: context)) + } + } + } + + @Test func configurableBuiltInProviderUsesNearestConfiguredDisplayNameOnly() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-static-display-\(UUID().uuidString)", isDirectory: true) + let stateDirectory = root.appendingPathComponent("state", isDirectory: true) + let globalConfigDirectory = root.appendingPathComponent(".config/cmux", isDirectory: true) + let projectDirectory = root.appendingPathComponent("project", isDirectory: true) + let projectConfigDirectory = projectDirectory.appendingPathComponent(".cmux", isDirectory: true) + let workingDirectory = projectDirectory.appendingPathComponent("nested", isDirectory: true) + for directory in [ + stateDirectory, + globalConfigDirectory, + projectConfigDirectory, + workingDirectory, + ] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + defer { try? FileManager.default.removeItem(at: root) } + + func writeConfig(piName: String, codexName: String, to url: URL) throws { + try JSONSerialization.data(withJSONObject: [ + "vault": [ + "agents": [ + [ + "id": "pi", + "name": piName, + "sessionIdSource": ["type": "argvOption", "argvOption": "--session"], + "resumeCommand": "pi --session {{sessionId}}", + ], + [ + "id": "codex", + "name": codexName, + "sessionIdSource": ["type": "argvOption", "argvOption": "--session"], + "resumeCommand": "codex resume {{sessionId}}", + ], + ], + ], + ], options: [.sortedKeys]).write(to: url, options: .atomic) + } + try writeConfig( + piName: "Global Pi", + codexName: "Fake Global Codex", + to: globalConfigDirectory.appendingPathComponent("cmux.json") + ) + try writeConfig( + piName: "Project Pi", + codexName: "Fake Project Codex", + to: projectConfigDirectory.appendingPathComponent("cmux.json") + ) + for provider in ["pi", "codex"] { + try writeAgentTreeStore( + parentIndices: [nil], + to: stateDirectory.appendingPathComponent("\(provider)-hook-sessions.json") + ) + } + + var environment = isolatedAgentTreeEnvironment(home: root) + environment["PWD"] = workingDirectory.path + let cliPath = try bundledCLIPath() + for (provider, expectedDisplayName) in [ + ("pi", "Project Pi"), + ("codex", "Codex"), + ] { + let result = runProcess( + executablePath: cliPath, + arguments: [ + "agents", "list", "--agent", provider, "--all", "--json", + "--state-dir", stateDirectory.path, + ], + environment: environment, + timeout: 5 + ) + let context = "agents list --agent \(provider): \(result.stdout)" + #expect(!result.timedOut, Comment(rawValue: context)) + #expect(result.status == 0, Comment(rawValue: context)) + let payload = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any], + Comment(rawValue: context) + ) + let rows = try #require(payload["sessions"] as? [[String: Any]]) + #expect(rows.count == 1, Comment(rawValue: context)) + #expect( + rows.first?["agent_display_name"] as? String == expectedDisplayName, + Comment(rawValue: context) + ) + } + } + + @Test func exactAgentInspectionSurvivesRegistryProviderEnumerationOverflow() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-provider-overflow-\(UUID().uuidString)", isDirectory: true) + let stateDirectory = root.appendingPathComponent("state", isDirectory: true) + try FileManager.default.createDirectory( + at: stateDirectory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + + let targetProvider = "overflow-target" + let registry = CmuxAgentSessionRegistry( + url: stateDirectory.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try seedAuthoritativeAgentSessions(count: 1, provider: targetProvider, registry: registry) + try seedAuthoritativeAgentProviders( + (0.. AgentSessionGraphNode { + AgentSessionGraphNode( + provider: "opencode", sessionId: sessionID, runId: runID, + pid: nil, processStartedAt: nil, cmuxRuntime: nil, + workspaceId: "workspace-\(sessionID)", surfaceId: "surface-\(sessionID)", + processState: .unknown, sessionState: .active, + foregroundState: .idle, attentionState: .none, + activity: AgentActivitySnapshot(state: .idle, busy: false, modes: [], counts: .init()), + effectiveState: .idle, workloads: [], restoreAuthority: true, + startedAt: 100, updatedAt: updatedAt, endedAt: nil + ) + } + + let root = node(sessionID: "root", runID: "root-run", updatedAt: 100) + let child = node(sessionID: "child", runID: "child-run", updatedAt: 101) + let edge = AgentSessionGraphEdge( + fromRunId: root.runId, fromSessionId: root.sessionId, + toNodeId: child.nodeId, toRunId: child.runId, relationship: .spawned + ) + var iterator = AgentTreeTextLineSequence( + snapshot: AgentSessionGraphSnapshot(nodes: [root, child], edges: [edge]), + maximumDepth: 64 + ).makeIterator() + + #expect(iterator.next() == "opencode root IDLE restore-owner workspace:workspace-root surface:surface-root") + #expect(iterator.next() == "└── spawned opencode child IDLE restore-owner workspace:workspace-child surface:surface-child") + #expect(iterator.next() == nil) + } + + @Test func agentsTreeDepthLimitDoesNotReemitDescendantsAsRoots() throws { + func node(sessionID: String, runID: String) -> AgentSessionGraphNode { + AgentSessionGraphNode( + provider: "opencode", sessionId: sessionID, runId: runID, + pid: nil, processStartedAt: nil, cmuxRuntime: nil, + workspaceId: "workspace-\(sessionID)", surfaceId: "surface-\(sessionID)", + processState: .unknown, sessionState: .active, + foregroundState: .idle, attentionState: .none, + activity: AgentActivitySnapshot(state: .idle, busy: false, modes: [], counts: .init()), + effectiveState: .idle, workloads: [], restoreAuthority: true, + startedAt: 100, updatedAt: 100, endedAt: nil + ) + } + + let root = node(sessionID: "root", runID: "root-run") + let child = node(sessionID: "child", runID: "child-run") + let grandchild = node(sessionID: "grandchild", runID: "grandchild-run") + let snapshot = AgentSessionGraphSnapshot( + nodes: [root, child, grandchild], + edges: [ + AgentSessionGraphEdge( + fromRunId: root.runId, fromSessionId: root.sessionId, + toNodeId: child.nodeId, toRunId: child.runId, relationship: .spawned + ), + AgentSessionGraphEdge( + fromRunId: child.runId, fromSessionId: child.sessionId, + toNodeId: grandchild.nodeId, toRunId: grandchild.runId, relationship: .spawned + ), + ] + ) + var iterator = AgentTreeTextLineSequence(snapshot: snapshot, maximumDepth: 1).makeIterator() + + #expect(iterator.next()?.contains("opencode root ") == true) + #expect(iterator.next()?.contains("opencode child ") == true) + #expect(iterator.next() == nil) + } + + @Test func limitedAgentListRetainsOnlyTheExactSortedPrefix() { + var entries = SessionListEntryAccumulator(limit: 2) + entries.insert(updatedAt: 10, payload: ["session_id": "session-a"]) + entries.insert(updatedAt: 30, payload: ["session_id": "session-b"]) + entries.insert(updatedAt: 20, payload: ["session_id": "session-c"]) + entries.insert(updatedAt: 30, payload: ["session_id": "session-d"]) + + #expect(entries.totalCount == 4) + #expect(entries.retainedCount == 2) + #expect(entries.sortedPayloads.compactMap { $0["session_id"] as? String } == [ + "session-b", "session-d", + ]) + } + + @Test func limitedAgentListTieSelectionIsIndependentOfInsertionOrder() { + let payloads: [[String: Any]] = [ + [ + "session_id": NSNull(), "agent": "codex", "run_id": "run-b", + "workspace_id": "workspace-b", "surface_id": "surface-b", "pid": 3, + "process_started_at": 30.0, + ], + [ + "session_id": NSNull(), "agent": "claude", "run_id": "run-z", + "workspace_id": "workspace-z", "surface_id": "surface-z", "pid": 2, + "process_started_at": 20.0, + ], + [ + "session_id": NSNull(), "agent": "codex", "run_id": "run-a", + "workspace_id": "workspace-a", "surface_id": "surface-a", "pid": 1, + "process_started_at": 10.0, + ], + ] + let insertionOrders = [ + [0, 1, 2], [0, 2, 1], [1, 0, 2], + [1, 2, 0], [2, 0, 1], [2, 1, 0], + ] + + for order in insertionOrders { + var entries = SessionListEntryAccumulator(limit: 2) + for index in order { + entries.insert(updatedAt: 100, payload: payloads[index]) + } + #expect(entries.sortedPayloads.compactMap { $0["run_id"] as? String } == [ + "run-z", "run-a", + ]) + } + } + + @Test func registryAndFinalListHeapsRetainTheSameRandomizedUnicodeTies() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-list-heap-parity-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + let provider = "heap-parity" + let limit = 37 + let stringValues: [String?] = [ + nil, "", "alpha", "beta", "é", "e\u{301}", "日本語", "🙂", "Ω", + ] + var randomState: UInt64 = 0xC0FFEE_F00D_BAAD + func nextRandom() -> UInt64 { + randomState = randomState &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 + return randomState + } + func randomString() -> String? { + stringValues[Int(nextRandom() % UInt64(stringValues.count))] + } + + var keysBySessionID: [String: CmuxAgentSessionRegistry.HookListOrderKey] = [:] + var finalAccumulator = SessionListEntryAccumulator(limit: limit) + var records: [CmuxAgentSessionRegistry.Record] = [] + for index in 0..<512 { + let storedSessionID = String(format: "stored-%04d", index) + let sortValues = CmuxAgentSessionRegistry.HookListSortValues( + sessionID: randomString(), + agent: randomString(), + runID: randomString(), + workspaceID: randomString(), + surfaceID: randomString(), + identitySource: randomString(), + pid: nextRandom().isMultiple(of: 4) ? nil : Int(nextRandom() % 7), + processStartedAt: nextRandom().isMultiple(of: 4) + ? nil : TimeInterval(nextRandom() % 5) + ) + let key = CmuxAgentSessionRegistry.HookListOrderKey( + updatedAt: TimeInterval(nextRandom() % 5), + sortValues: sortValues + ) + keysBySessionID[storedSessionID] = key + finalAccumulator.insert( + updatedAt: key.updatedAt, + sortValues: key.sortValues, + payloadFactory: { [storedSessionID] in ["session_id": storedSessionID] } + ) + let json = try JSONSerialization.data(withJSONObject: [ + "sessionId": storedSessionID, + "workspaceId": "workspace-\(index)", + "surfaceId": "surface-\(index)", + "startedAt": 100.0, + "updatedAt": 100.0, + ], options: [.sortedKeys]) + records.append(.init( + provider: provider, + sessionID: storedSessionID, + updatedAt: 100, + json: json + )) + } + try registry.apply(provider: provider, records: records) + + let snapshots = try registry.globallyBoundedRecentSnapshotsImportingAdmittedLegacy( + sources: [.init( + provider: provider, + url: root.appendingPathComponent("unused-legacy.json") + )], + admissions: [], + maximumRecords: limit, + projectRecord: { projectedProvider, record in + guard projectedProvider == provider, + let key = keysBySessionID[record.sessionID] else { + throw CmuxAgentSessionRegistry.HookListProjectionValidationError( + provider: projectedProvider + ) + } + return key + } + ) + let registryRows = Set( + snapshots[provider]?.snapshot.records.map(\.sessionID) ?? [] + ) + let finalRows = Set(finalAccumulator.sortedPayloads.compactMap { + $0["session_id"] as? String + }) + + #expect(registryRows.count == limit) + #expect(finalRows.count == limit) + #expect(registryRows == finalRows) + } + + @Test func streamedAgentListPayloadsReleaseEachEnrichmentBeforeTheNextRow() throws { + let lifetime = AgentListPayloadLifetimeCounter() + var entries = SessionListEntryAccumulator(limit: .max) + for index in 0..<1_000 { + entries.insert( + updatedAt: Double(index), + payload: ["session_id": "session-\(index)"], + enrichment: { payload in + payload["lifetime_probe"] = AgentListPayloadLifetimeProbe(lifetime) + } + ) + } + + var visitedSessionIDs: [String] = [] + try entries.forEachSortedPayload { payload in + #expect(lifetime.live == 1) + visitedSessionIDs.append(try #require(payload["session_id"] as? String)) + } + + #expect(lifetime.live == 0) + #expect(lifetime.peak == 1) + #expect(visitedSessionIDs.first == "session-999") + #expect(visitedSessionIDs.last == "session-0") + } + + @Test func stagedAgentOutputPublishesNothingWhenDocumentConstructionFails() { + var published = Data() + + #expect(throws: AgentStagedOutputProbeError.self) { + try AgentStagedOutput(readChunkBytes: 1).publish( + build: { handle in + try handle.write(contentsOf: Data("partial".utf8)) + throw AgentStagedOutputProbeError.expected + }, + publishChunk: { published.append($0) } + ) + } + + #expect(published.isEmpty) + } + + @Test func unboundedAgentListDefersPayloadConstructionUntilSortedTraversal() throws { + var payloadConstructionCount = 0 + var entries = SessionListEntryAccumulator(limit: .max) + entries.insert( + updatedAt: 100, + sortValues: SessionListEntryAccumulator.SortValues( + sessionID: "session-a", + agent: "opencode", + runID: "run-a", + workspaceID: "workspace-a", + surfaceID: "surface-a", + identitySource: "hook_session", + pid: nil, + processStartedAt: nil + ), + payloadFactory: { + payloadConstructionCount += 1 + return ["session_id": "session-a"] + } + ) + + #expect(payloadConstructionCount == 0) + #expect(entries.retainedCount == 1) + try entries.forEachSortedPayload { payload in + let sessionID = try #require(payload["session_id"] as? String) + #expect(sessionID == "session-a") + } + #expect(payloadConstructionCount == 1) + } + + @Test func limitedAgentListBoundsTenThousandSameProcessPayloadEnrichmentsToTopK() { + var enrichmentCount = 0 + var entries = SessionListEntryAccumulator(limit: 100) + for index in 0..<10_000 { + entries.insert( + updatedAt: Double(index), + payload: [ + "session_id": "session-\(index)", + "process_key": "runtime-a\u{1F}surface-a\u{1F}42", + ], + enrichment: { payload in + enrichmentCount += 1 + payload["enriched"] = true + } + ) + } + + #expect(enrichmentCount == 0) + let payloads = entries.sortedPayloads + #expect(entries.totalCount == 10_000) + #expect(entries.retainedCount == 100) + #expect(payloads.count == 100) + #expect(enrichmentCount == 100) + #expect(payloads.allSatisfy { $0["enriched"] as? Bool == true }) + } + + @Test func terminalObservationCandidateRetentionIsBoundedForTenThousandSameProcessSessions() { + let observation = makeTerminalObservation(state: .working, lifecycleAuthoritative: false) + let activeSessionID = "session-9999" + let surfaceKey = AgentTerminalObservationJoiner.surfaceKey( + provider: observation.sessionProviderID, + runtimeID: observation.runtimeID, + surfaceID: observation.surfaceID.uuidString + ) + var accumulator = AgentTerminalObservationCandidateAccumulator( + observations: [observation], + activeSessionBySurface: [surfaceKey: activeSessionID] + ) + for index in 0..<10_000 { + accumulator.insert(makeTerminalNodeCandidate( + sessionID: "session-\(index)", + observation: observation, + effectiveState: .idle + )) + } + + var activeCandidates = accumulator.retainedCandidates + #expect(activeCandidates.count == 3) + #expect(AgentTerminalObservationJoiner().merge( + nodes: &activeCandidates, + observations: [observation], + activeSessionBySurface: [surfaceKey: activeSessionID] + )) + #expect(activeCandidates.count == 3) + #expect(activeCandidates.first { + $0.sessionId == activeSessionID + }?.effectiveState == .working) + #expect(!activeCandidates.contains { $0.identitySource == "terminal_process" }) + + var ambiguousAccumulator = AgentTerminalObservationCandidateAccumulator( + observations: [observation], + activeSessionBySurface: [:] + ) + for index in 0..<10_000 { + ambiguousAccumulator.insert(makeTerminalNodeCandidate( + sessionID: "session-\(index)", + observation: observation, + effectiveState: .idle + )) + } + var ambiguousCandidates = ambiguousAccumulator.retainedCandidates + #expect(ambiguousCandidates.count == 2) + #expect(AgentTerminalObservationJoiner().merge( + nodes: &ambiguousCandidates, + observations: [observation], + activeSessionBySurface: [:] + )) + #expect(ambiguousCandidates.count == 3) + #expect(ambiguousCandidates.filter { $0.identitySource == "terminal_process" }.count == 1) + } + + @Test func processIdentityRejectsPIDReuseBetweenMetadataReads() { + var executableProbeCount = 0 + var argumentsProbeCount = 0 + var verificationProbeCount = 0 + let identity = AgentStableProcessIdentityValidator().identity( + for: 42, + probedKernelStartTime: 100, + processStartTimeLookup: { _ in + verificationProbeCount += 1 + return 101 + }, + executablePathLookup: { _ in + executableProbeCount += 1 + return "/usr/bin/claude" + }, + argumentsLookup: { _ in + argumentsProbeCount += 1 + return ["claude", "--resume", "saved"] + } + ) + + #expect(identity == nil) + #expect(executableProbeCount == 1) + #expect(argumentsProbeCount == 1) + #expect(verificationProbeCount == 1) + } + + @Test func limitedAgentListTextAndJSONPreserveCountLimitAndOrdering() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-list-limit-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try writeAgentTreeStore( + parentIndices: [nil, nil, nil, nil], + to: root.appendingPathComponent("opencode-hook-sessions.json") + ) + let baseArguments = [ + "agents", "list", "--agent", "opencode", "--all", "--limit", "2", + "--state-dir", root.path, + ] + let environment = isolatedAgentTreeEnvironment(home: root) + + let text = runProcess( + executablePath: cliPath, + arguments: baseArguments, + environment: environment, + timeout: 5 + ) + let lines = text.stdout.split(separator: "\n").map(String.init) + #expect(text.status == 0, Comment(rawValue: text.stdout)) + #expect(lines.count == 3) + #expect(lines[0].contains("opencode session-00003 ")) + #expect(lines[1].contains("opencode session-00002 ")) + #expect(lines[2] == "... 2 more. Raise --limit .") + + let json = runProcess( + executablePath: cliPath, + arguments: baseArguments + ["--json"], + environment: environment, + timeout: 5 + ) + let object = try #require( + JSONSerialization.jsonObject(with: Data(json.stdout.utf8)) as? [String: Any] + ) + let sessions = try #require(object["sessions"] as? [[String: Any]]) + #expect(json.status == 0, Comment(rawValue: json.stdout)) + #expect(object["total_matches"] as? Int == 4) + #expect(object["limit"] as? Int == 2) + #expect(sessions.compactMap { $0["session_id"] as? String } == [ + "session-00003", "session-00002", + ]) + + let unlimitedJSON = runProcess( + executablePath: cliPath, + arguments: [ + "agents", "list", "--agent", "opencode", "--all", "--json", + "--state-dir", root.path, + ], + environment: environment, + timeout: 5 + ) + let unlimitedObject = try #require( + JSONSerialization.jsonObject(with: Data(unlimitedJSON.stdout.utf8)) as? [String: Any] + ) + let unlimitedSessions = try #require(unlimitedObject["sessions"] as? [[String: Any]]) + #expect(unlimitedJSON.status == 0, Comment(rawValue: unlimitedJSON.stdout)) + #expect(unlimitedSessions.count == 4) + for index in sessions.indices { + #expect( + NSDictionary(dictionary: sessions[index]).isEqual(to: unlimitedSessions[index]), + Comment(rawValue: "retained row \(index) diverged from unbounded output") + ) + } + } + + @Test func boundedAgentListReadsOnlyRecentCandidatesAtTheInspectionCeiling() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-list-bounded-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try seedAuthoritativeAgentSessions( + count: 20_000, + provider: "codex", + registry: registry + ) + let environment = isolatedAgentTreeEnvironment(home: root) + let bounded = try AgentHookSessionRegistryBridge.boundedRecentSnapshotsForList( + specifications: [(provider: "codex", suffix: "codex")], + stateDirectory: root.path, + environment: environment, + fileManager: .default, + maximumRecordsPerProvider: 100 + ) + let snapshot = try #require(bounded.snapshots["codex"]) + #expect(bounded.totalRecordCounts["codex"] == 20_000) + #expect(snapshot.records.count == 100) + #expect(snapshot.records.map(\.sessionID) == (19_900..<20_000).reversed().map { + String(format: "session-%05d", $0) + }) + + let metricsURL = root.appendingPathComponent("time-metrics.txt") + let result = runProcess( + executablePath: "/usr/bin/time", + arguments: [ + "-l", "-o", metricsURL.path, + cliPath, "agents", "list", "--agent", "codex", "--all", + "--limit", "100", "--json", "--state-dir", root.path, + ], + environment: environment, + timeout: 30 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let payload = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let sessions = try #require(payload["sessions"] as? [[String: Any]]) + #expect(payload["total_matches"] as? Int == 20_000) + #expect(sessions.count == 100) + #expect(sessions.compactMap { $0["session_id"] as? String } == + (19_900..<20_000).reversed().map { String(format: "session-%05d", $0) }) + let stores = try #require(payload["stores"] as? [[String: Any]]) + let codexStore = try #require(stores.first { $0["agent"] as? String == "codex" }) + #expect(codexStore["exists"] as? Bool == true) + #expect(codexStore["session_count"] as? Int == 20_000) + let metrics = try String(contentsOf: metricsURL, encoding: .utf8) + let maximumResidentBytes = metrics.split(separator: "\n").compactMap { line -> Int64? in + guard line.contains("maximum resident set size") else { return nil } + return line.split(whereSeparator: \.isWhitespace).first.flatMap { Int64($0) } + }.first + #expect(try #require(maximumResidentBytes) < 192 * 1_024 * 1_024) + + try seedAuthoritativeAgentSessions( + count: 1, + provider: "cursor", + registry: registry + ) + let aliasResult = runProcess( + executablePath: cliPath, + arguments: [ + "agents", "list", "--agent", "cursor-agent", "--all", + "--limit", "1", "--json", "--state-dir", root.path, + ], + environment: environment, + timeout: 10 + ) + #expect(aliasResult.status == 0, Comment(rawValue: aliasResult.stdout)) + let aliasPayload = try #require( + JSONSerialization.jsonObject(with: Data(aliasResult.stdout.utf8)) as? [String: Any] + ) + let aliasSessions = try #require(aliasPayload["sessions"] as? [[String: Any]]) + #expect(aliasSessions.count == 1) + #expect(aliasSessions.first?["agent"] as? String == "cursor") + + try seedAuthoritativeAgentSessions( + range: 20_000..<20_001, + provider: "codex", + registry: registry + ) + let limitStderrURL = root.appendingPathComponent("limit-stderr.txt") + let command = [ + cliPath, "agents", "list", "--agent", "codex", "--all", + "--limit", "100", "--json", "--state-dir", root.path, + ].map(shellQuoteAgentTreeArgument).joined(separator: " ") + let overLimit = runProcess( + executablePath: "/bin/sh", + arguments: ["-c", "\(command) 2>\(shellQuoteAgentTreeArgument(limitStderrURL.path))"], + environment: environment, + timeout: 10 + ) + #expect(!overLimit.timedOut, Comment(rawValue: overLimit.stdout)) + #expect(overLimit.status != 0) + let limitPayload = try #require( + JSONSerialization.jsonObject(with: Data(overLimit.stdout.utf8)) as? [String: Any] + ) + #expect((limitPayload["sessions"] as? [Any])?.isEmpty == true) + let limitError = try #require(limitPayload["error"] as? [String: Any]) + #expect(limitError["code"] as? String == "storage_limit_exceeded") + #expect(limitError["scope"] as? String == "registry_graph_nodes") + #expect(limitError["observed_count"] as? Int64 == 20_001) + #expect(limitError["maximum_count"] as? Int64 == 20_000) + } + + @Test func changedLegacyProjectionCountsOnlyNewCanonicalGraphIdentities() throws { + var roots: [URL] = [] + defer { + for root in roots { try? FileManager.default.removeItem(at: root) } + } + + func sessionObject( + sessionID: String, + runIDs: [String] + ) -> [String: Any] { + let primaryRunID = runIDs.first ?? "fallback-\(sessionID)" + return [ + "sessionId": sessionID, + "workspaceId": "workspace-\(sessionID)", + "surfaceId": "surface-\(sessionID)", + "runId": primaryRunID, + "activeRunId": primaryRunID, + "restoreAuthority": false, + "sessionState": "ended", + "foregroundState": "completed", + "startedAt": 100.0, + "updatedAt": 200.0, + "completedAt": 200.0, + "runs": runIDs.map { runID in + [ + "runId": runID, + "restoreAuthority": false, + "startedAt": 100.0, + "updatedAt": 200.0, + "endedAt": 200.0, + ] as [String: Any] + }, + ] + } + + func fixture( + legacySessions: [String: [String: Any]] + ) throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-agents-legacy-overlap-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + roots.append(root) + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try registry.apply(provider: "codex", records: [ + CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: "session-a", + updatedAt: 200, + json: try JSONSerialization.data( + withJSONObject: sessionObject(sessionID: "session-a", runIDs: ["run-a"]), + options: [.sortedKeys] + ) + ), + CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: "session-b", + updatedAt: 200, + json: try JSONSerialization.data( + withJSONObject: sessionObject(sessionID: "session-b", runIDs: ["run-b"]), + options: [.sortedKeys] + ) + ), + ]) + try JSONSerialization.data( + withJSONObject: ["version": 2, "sessions": legacySessions], + options: [.sortedKeys] + ).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + return root + } + + let exactSessions = [ + "session-a": sessionObject(sessionID: "session-a", runIDs: ["run-a"]), + "session-b": sessionObject(sessionID: "session-b", runIDs: ["run-b"]), + ] + let treeRoot = try fixture(legacySessions: exactSessions) + let tree = try AgentHookSessionRegistryBridge.snapshots( + specifications: [(provider: "codex", suffix: "codex")], + stateDirectory: treeRoot.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": treeRoot.path], + fileManager: .default, + maximumLegacyGraphNodes: 2 + ) + #expect(tree.snapshots["codex"]?.records.count == 2) + + let listRoot = try fixture(legacySessions: exactSessions) + let list = try AgentHookSessionRegistryBridge.boundedRecentSnapshotsForList( + specifications: [(provider: "codex", suffix: "codex")], + stateDirectory: listRoot.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": listRoot.path], + fileManager: .default, + maximumRecordsPerProvider: 1, + maximumLegacyGraphNodes: 2 + ) + #expect(list.totalRecordCounts["codex"] == 2) + #expect(list.snapshots["codex"]?.records.count == 1) + + let extraRunRoot = try fixture(legacySessions: [ + "session-a": sessionObject( + sessionID: "session-a", + runIDs: ["run-a", "run-added-by-legacy"] + ), + ]) + var extraRunFailure: AgentHookSessionStoreLoadFailure? + do { + _ = try AgentHookSessionRegistryBridge.snapshots( + specifications: [(provider: "codex", suffix: "codex")], + stateDirectory: extraRunRoot.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": extraRunRoot.path], + fileManager: .default, + maximumLegacyGraphNodes: 2 + ) + } catch let failure as AgentHookSessionStoreLoadFailure { + extraRunFailure = failure + } + #expect(try #require(extraRunFailure).scope == .legacyGraphNodes) + + let disjointRoot = try fixture(legacySessions: [ + "legacy-only": sessionObject(sessionID: "legacy-only", runIDs: ["legacy-run"]), + ]) + var disjointFailure: AgentHookSessionStoreLoadFailure? + do { + _ = try AgentHookSessionRegistryBridge.snapshots( + specifications: [(provider: "codex", suffix: "codex")], + stateDirectory: disjointRoot.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": disjointRoot.path], + fileManager: .default, + maximumLegacyGraphNodes: 2 + ) + } catch let failure as AgentHookSessionStoreLoadFailure { + disjointFailure = failure + } + #expect(try #require(disjointFailure).scope == .legacyGraphNodes) + } + + @Test func agentsTreeTextDoesNotOverflowTheStackBeyondTwoThousandFiveHundredLevels() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-tree-deep-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try writeAgentTreeStore( + parentIndices: (0..<3_000).map { $0 == 0 ? nil : $0 - 1 }, + to: root.appendingPathComponent("opencode-hook-sessions.json") + ) + + let command = [ + "exec", + shellQuoteAgentTreeArgument(cliPath), + "agents tree --agent opencode --all --depth 3000", + "--state-dir \(shellQuoteAgentTreeArgument(root.path))", + "> /dev/null", + ].joined(separator: " ") + let result = runProcess( + executablePath: "/bin/sh", + arguments: ["-c", command], + environment: isolatedAgentTreeEnvironment(home: root), + timeout: 30 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + } + + @Test func sessionOnlyGraphParentsStayWithinTheChildProvider() { + func node(provider: String, sessionID: String, runID: String, updatedAt: TimeInterval) -> AgentSessionGraphNode { + AgentSessionGraphNode( + provider: provider, sessionId: sessionID, runId: runID, + pid: nil, processStartedAt: nil, cmuxRuntime: nil, + workspaceId: "workspace", surfaceId: "surface-\(provider)-\(runID)", + processState: .unknown, sessionState: .active, + foregroundState: .idle, attentionState: .none, + activity: AgentActivitySnapshot(state: .idle, busy: false, modes: [], counts: .init()), + effectiveState: .idle, workloads: [], restoreAuthority: true, + startedAt: 100, updatedAt: updatedAt, endedAt: nil + ) + } + let codexParent = node(provider: "codex", sessionID: "shared-session", runID: "codex-parent", updatedAt: 200) + let claudeParent = node(provider: "claude", sessionID: "shared-session", runID: "claude-parent", updatedAt: 300) + let child = node(provider: "codex", sessionID: "child", runID: "child-run", updatedAt: 400) + let edge = AgentSessionGraphEdge( + fromRunId: nil, fromSessionId: "shared-session", + toNodeId: child.nodeId, toRunId: child.runId, relationship: .spawned + ) + + #expect( + AgentSessionGraphEdgeResolver(nodes: [codexParent, claudeParent, child]).parentNodeId(for: edge) + == codexParent.nodeId + ) + } + + @Test func runOnlyGraphParentsStayWithinTheChildProvider() { + func node(provider: String, sessionID: String, runID: String, updatedAt: TimeInterval) -> AgentSessionGraphNode { + AgentSessionGraphNode( + provider: provider, sessionId: sessionID, runId: runID, + pid: nil, processStartedAt: nil, cmuxRuntime: nil, + workspaceId: "workspace", surfaceId: "surface-\(provider)-\(sessionID)", + processState: .unknown, sessionState: .active, + foregroundState: .idle, attentionState: .none, + activity: AgentActivitySnapshot(state: .idle, busy: false, modes: [], counts: .init()), + effectiveState: .idle, workloads: [], restoreAuthority: true, + startedAt: 100, updatedAt: updatedAt, endedAt: nil + ) + } + let codexParent = node(provider: "codex", sessionID: "codex-parent", runID: "shared-run", updatedAt: 200) + let claudeParent = node(provider: "claude", sessionID: "claude-parent", runID: "shared-run", updatedAt: 300) + let child = node(provider: "codex", sessionID: "child", runID: "child-run", updatedAt: 400) + let edge = AgentSessionGraphEdge( + fromRunId: "shared-run", fromSessionId: nil, + toNodeId: child.nodeId, toRunId: child.runId, relationship: .spawned + ) + + #expect( + AgentSessionGraphEdgeResolver(nodes: [codexParent, claudeParent, child]).parentNodeId(for: edge) + == codexParent.nodeId + ) + } + + @Test func runOnlyGraphParentsRemainAmbiguousWithinTheChildProvider() { + let first = makeAgentSessionGraphTestNode( + provider: "codex", sessionID: "first", runID: "shared-run", updatedAt: 300 + ) + let second = makeAgentSessionGraphTestNode( + provider: "codex", sessionID: "second", runID: "shared-run", updatedAt: 200 + ) + let child = makeAgentSessionGraphTestNode( + provider: "codex", sessionID: "child", runID: "child-run", updatedAt: 400 + ) + let edge = AgentSessionGraphEdge( + fromRunId: "shared-run", fromSessionId: nil, + toNodeId: child.nodeId, toRunId: child.runId, relationship: .spawned + ) + + #expect( + AgentSessionGraphEdgeResolver(nodes: [first, second, child]).parentNodeId(for: edge) == nil + ) + } + + @Test func runOnlyGraphParentsResolveOneForeignProviderAndRejectGlobalAmbiguity() { + let foreignParent = makeAgentSessionGraphTestNode( + provider: "claude", sessionID: "foreign", runID: "shared-run", updatedAt: 200 + ) + let ambiguousForeignParent = makeAgentSessionGraphTestNode( + provider: "pi", sessionID: "other-foreign", runID: "shared-run", updatedAt: 300 + ) + let child = makeAgentSessionGraphTestNode( + provider: "codex", sessionID: "child", runID: "child-run", updatedAt: 400 + ) + let edge = AgentSessionGraphEdge( + fromRunId: "shared-run", fromSessionId: nil, + toNodeId: child.nodeId, toRunId: child.runId, relationship: .spawned + ) + + #expect( + AgentSessionGraphEdgeResolver(nodes: [foreignParent, child]).parentNodeId(for: edge) + == foreignParent.nodeId + ) + for nodes in [ + [foreignParent, ambiguousForeignParent, child], + [ambiguousForeignParent, child, foreignParent], + ] { + #expect(AgentSessionGraphEdgeResolver(nodes: nodes).parentNodeId(for: edge) == nil) + } + } + + @Test func exactRunAndSessionGraphParentsStayWithinTheChildProvider() { + let codexParent = makeAgentSessionGraphTestNode( + provider: "codex", sessionID: "shared-session", runID: "shared-run", updatedAt: 200 + ) + let claudeParent = makeAgentSessionGraphTestNode( + provider: "claude", sessionID: "shared-session", runID: "shared-run", updatedAt: 300 + ) + let child = makeAgentSessionGraphTestNode( + provider: "codex", sessionID: "child", runID: "child-run", updatedAt: 400 + ) + let edge = AgentSessionGraphEdge( + fromRunId: "shared-run", fromSessionId: "shared-session", + toNodeId: child.nodeId, toRunId: child.runId, relationship: .spawned + ) + + #expect( + AgentSessionGraphEdgeResolver(nodes: [claudeParent, codexParent, child]).parentNodeId(for: edge) + == codexParent.nodeId + ) + #expect( + AgentSessionGraphEdgeResolver(nodes: [claudeParent, child]).parentNodeId(for: edge) + == claudeParent.nodeId + ) + let ambiguousForeignParent = makeAgentSessionGraphTestNode( + provider: "pi", sessionID: "shared-session", runID: "shared-run", updatedAt: 350 + ) + #expect(AgentSessionGraphEdgeResolver( + nodes: [claudeParent, ambiguousForeignParent, child] + ).parentNodeId(for: edge) == nil) + } + + @Test func graphParentTieOrderingSurvivesSelfExclusion() { + let first = makeAgentSessionGraphTestNode( + provider: "codex", sessionID: "shared-session", runID: "a-run", updatedAt: 200 + ) + let second = makeAgentSessionGraphTestNode( + provider: "codex", sessionID: "shared-session", runID: "b-run", updatedAt: 200 + ) + let edge = AgentSessionGraphEdge( + fromRunId: nil, fromSessionId: "shared-session", + toNodeId: first.nodeId, toRunId: first.runId, relationship: .resumed + ) + + #expect( + AgentSessionGraphEdgeResolver(nodes: [second, first]).parentNodeId(for: edge) == second.nodeId + ) + } + + @Test func graphResolverRejectsEdgesWhoseChildIsOutsideTheVisibleGraph() { + let codex = makeAgentSessionGraphTestNode( + provider: "codex", sessionID: "shared-session", runID: "shared-run", updatedAt: 200 + ) + let claude = makeAgentSessionGraphTestNode( + provider: "claude", sessionID: "shared-session", runID: "shared-run", updatedAt: 200 + ) + let edge = AgentSessionGraphEdge( + fromRunId: nil, fromSessionId: "shared-session", + toNodeId: "missing-child", toRunId: "child-run", relationship: .resumed + ) + for nodes in [[codex, claude], [claude, codex]] { + #expect(AgentSessionGraphEdgeResolver(nodes: nodes).parentNodeId(for: edge) == nil) + } + } + + @Test func graphSnapshotOrderingIsTotalForSharedProcessGenerations() { + let nodes = [ + makeAgentSessionGraphTestNode( + provider: "codex", sessionID: "session-c", runID: "shared-run", updatedAt: 200 + ), + makeAgentSessionGraphTestNode( + provider: "claude", sessionID: "session-a", runID: "shared-run", updatedAt: 200 + ), + makeAgentSessionGraphTestNode( + provider: "codex", sessionID: "session-b", runID: "shared-run", updatedAt: 200 + ), + ] + let expectedNodeIDs = nodes.map(\.nodeId).sorted() + for permutation in [nodes, [nodes[1], nodes[2], nodes[0]], Array(nodes.reversed())] { + #expect( + Array(permutation).sorted(by: AgentSessionGraphOrdering().nodePrecedes).map(\.nodeId) + == expectedNodeIDs + ) + } + + let edges = [ + AgentSessionGraphEdge( + fromRunId: "parent-b", fromSessionId: nil, + toNodeId: nodes[0].nodeId, toRunId: "shared-run", relationship: .spawned + ), + AgentSessionGraphEdge( + fromRunId: nil, fromSessionId: "parent-session", + toNodeId: nodes[0].nodeId, toRunId: "shared-run", relationship: .spawned + ), + AgentSessionGraphEdge( + fromRunId: "parent-a", fromSessionId: "parent-session", + toNodeId: nodes[0].nodeId, toRunId: "shared-run", relationship: .spawned + ), + ] + let expectedParentRuns: [String?] = [nil, "parent-a", "parent-b"] + for permutation in [edges, [edges[1], edges[2], edges[0]], Array(edges.reversed())] { + #expect( + Array(permutation).sorted(by: AgentSessionGraphOrdering().edgePrecedes).map(\.fromRunId) + == expectedParentRuns + ) + } + } + + @Test func graphNodeIdentityCannotCollideThroughEmbeddedSeparators() { + let first = makeAgentSessionGraphTestNode( + provider: "codex", sessionID: "session\u{1F}shared", runID: "run", updatedAt: 200 + ) + let second = makeAgentSessionGraphTestNode( + provider: "codex\u{1F}session", sessionID: "shared", runID: "run", updatedAt: 200 + ) + + #expect(first.nodeId != second.nodeId) + #expect(AgentSessionGraphNodeIndex().indices([first, second]).count == 2) + } + + @Test func repeatedRunGraphResolutionStaysLinearAtTenThousandEdges() { + let count = 10_000 + var parents: [AgentSessionGraphNode] = [] + var children: [AgentSessionGraphNode] = [] + var edges: [AgentSessionGraphEdge] = [] + parents.reserveCapacity(count) + children.reserveCapacity(count) + edges.reserveCapacity(count) + + for index in 0..\(shellQuoteAgentTreeArgument(stderrURL.path))", + ], + environment: environment, + timeout: 5 + ) + #expect(!json.timedOut, Comment(rawValue: json.stdout)) + #expect(json.status != 0) + let payload = try #require( + JSONSerialization.jsonObject(with: Data(json.stdout.utf8)) as? [String: Any] + ) + let error = try #require(payload["error"] as? [String: Any]) + #expect(payload["schema_version"] as? Int == 2) + #expect(error["code"] as? String == "agent_graph_node_budget_exceeded") + #expect(error["limit"] as? Int == 3) + #expect(error["observed_at_least"] as? Int == 4) + #expect((payload["nodes"] as? [Any])?.isEmpty == true) + #expect((payload["edges"] as? [Any])?.isEmpty == true) + let stderr = try String(contentsOf: stderrURL, encoding: .utf8) + #expect(stderr.contains("agent_graph_node_budget_exceeded")) + + let filtered = runProcess( + executablePath: cliPath, + arguments: [ + "agents", "tree", "--agent", "opencode", "--session", "session-00001", + "--all", "--json", "--max-nodes", "1", "--state-dir", root.path, + ], + environment: environment, + timeout: 5 + ) + #expect(filtered.status == 0, Comment(rawValue: filtered.stdout)) + let filteredPayload = try #require( + JSONSerialization.jsonObject(with: Data(filtered.stdout.utf8)) as? [String: Any] + ) + let filteredNodes = try #require(filteredPayload["nodes"] as? [[String: Any]]) + #expect(filteredNodes.count == 1) + #expect(filteredNodes.first?["session_id"] as? String == "session-00001") + } + + @Test func agentsTreePreflightCountsSessionProcessCohortsAndCanonicalizesDuplicateRuns() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-cohort-budget-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + let runtime: [String: Any] = ["id": "runtime-a"] + let cohortRecords = try (0..<4).map { index in + let sessionID = index == 0 ? "selected-session" : "cohort-\(index)" + let runID = "run-\(index)" + let record: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": "workspace", + "surfaceId": "surface", + "runId": runID, + "activeRunId": runID, + "cmuxRuntime": runtime, + "startedAt": 100.0, + "updatedAt": 200.0, + "runs": [[ + "runId": runID, + "pid": 42, + "processStartedAt": 100.0, + "cmuxRuntime": runtime, + "restoreAuthority": true, + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + ] + return CmuxAgentSessionRegistry.Record( + provider: "opencode", + sessionID: sessionID, + updatedAt: 200, + json: try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + ) + } + try registry.apply(provider: "opencode", records: cohortRecords) + let environment = isolatedAgentTreeEnvironment(home: root) + let cohort = runProcess( + executablePath: cliPath, + arguments: [ + "agents", "tree", "--agent", "opencode", "--session", "selected-session", + "--all", "--max-nodes", "3", "--state-dir", root.path, + ], + environment: environment, + timeout: 5 + ) + #expect(cohort.status != 0) + #expect(cohort.stdout.contains("agent_graph_node_budget_exceeded")) + + let duplicateRuns: [[String: Any]] = (0..<5_000).map { index in + [ + "runId": "one-logical-run", + "restoreAuthority": index.isMultiple(of: 2), + "startedAt": 100.0, + "updatedAt": 200.0, + ] + } + let duplicateRecord: [String: Any] = [ + "sessionId": "duplicate-session", + "workspaceId": "workspace", + "surfaceId": "surface", + "runId": "one-logical-run", + "activeRunId": "one-logical-run", + "startedAt": 100.0, + "updatedAt": 200.0, + "runs": duplicateRuns, + ] + try registry.apply(provider: "gemini", records: [ + CmuxAgentSessionRegistry.Record( + provider: "gemini", + sessionID: "duplicate-session", + updatedAt: 200, + json: try JSONSerialization.data( + withJSONObject: duplicateRecord, + options: [.sortedKeys] + ) + ), + ]) + let duplicate = runProcess( + executablePath: cliPath, + arguments: [ + "agents", "tree", "--agent", "gemini", "--session", "duplicate-session", + "--all", "--json", "--max-nodes", "1", "--state-dir", root.path, + ], + environment: environment, + timeout: 10 + ) + #expect(!duplicate.timedOut, Comment(rawValue: duplicate.stdout)) + #expect(duplicate.status == 0, Comment(rawValue: duplicate.stdout)) + let duplicatePayload = try #require( + JSONSerialization.jsonObject(with: Data(duplicate.stdout.utf8)) as? [String: Any] + ) + #expect((duplicatePayload["nodes"] as? [Any])?.count == 1) + } + + @Test func agentsTreeRejectsInvalidBudgetsExcessiveDepthAndOversizedRawRecords() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-limits-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = isolatedAgentTreeEnvironment(home: root) + + for value in ["0", "not-a-number", "20001"] { + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--max-nodes", value, "--state-dir", root.path], + environment: environment, + timeout: 5 + ) + #expect(result.status != 0) + #expect(result.stdout.contains("--max-nodes must be an integer from 1 through 20000")) + } + + let depth = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--depth", "4097", "--state-dir", root.path], + environment: environment, + timeout: 5 + ) + #expect(depth.status != 0) + #expect(depth.stdout.contains("--depth must not exceed 4096")) + + let sessionID = "oversized-session" + var oversizedJSON = Data( + "{\"sessionId\":\"\(sessionID)\",\"workspaceId\":\"workspace\",\"surfaceId\":\"surface\",\"startedAt\":100,\"updatedAt\":200,\"padding\":\"".utf8 + ) + oversizedJSON.append(Data(repeating: 97, count: (4 * 1_024 * 1_024) + 1)) + oversizedJSON.append(Data("\"}".utf8)) + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try registry.apply(provider: "codex", records: [ + CmuxAgentSessionRegistry.Record( + provider: "codex", sessionID: sessionID, updatedAt: 200, json: oversizedJSON + ), + ]) + for subcommand in ["list", "tree"] { + let oversizedStderrURL = root.appendingPathComponent("oversized-\(subcommand)-stderr.txt") + let oversizedCommand = [ + cliPath, "agents", subcommand, "--agent", "codex", "--session", sessionID, + "--all", "--json", "--state-dir", root.path, + ].map(shellQuoteAgentTreeArgument).joined(separator: " ") + let oversized = runProcess( + executablePath: "/bin/sh", + arguments: [ + "-c", "\(oversizedCommand) 2>\(shellQuoteAgentTreeArgument(oversizedStderrURL.path))", + ], + environment: environment, + timeout: 15 + ) + #expect(!oversized.timedOut, Comment(rawValue: oversized.stdout)) + #expect(oversized.status != 0) + let oversizedPayload = try #require( + JSONSerialization.jsonObject(with: Data(oversized.stdout.utf8)) as? [String: Any] + ) + #expect(oversizedPayload["schema_version"] as? Int == 2) + let oversizedError = try #require(oversizedPayload["error"] as? [String: Any]) + #expect(oversizedError["code"] as? String == "storage_limit_exceeded") + #expect(oversizedError["provider"] as? String == "codex") + #expect(oversizedError["path"] as? String == registry.url.path) + #expect(oversizedError["scope"] as? String == "registry_record") + #expect(oversizedError["session_id"] as? String == sessionID) + #expect((oversizedError["observed_bytes"] as? Int64) ?? 0 > 4 * 1_024 * 1_024) + #expect(oversizedError["maximum_bytes"] as? Int64 == 4 * 1_024 * 1_024) + #expect(oversizedError["recovery_action"] as? String == "narrow_agent_selection") + #expect((oversizedError["guidance"] as? String)?.contains("--agent codex") == true) + if subcommand == "tree" { + #expect((oversizedPayload["nodes"] as? [Any])?.isEmpty == true) + #expect((oversizedPayload["edges"] as? [Any])?.isEmpty == true) + } else { + #expect((oversizedPayload["sessions"] as? [Any])?.isEmpty == true) + } + let stderr = try String(contentsOf: oversizedStderrURL, encoding: .utf8) + #expect(stderr.contains("Retry with --agent codex")) + #expect(stderr.contains(registry.url.path)) + #expect(stderr.contains(sessionID)) + } + } + + @Test func legacyStorageLimitGuidancePreservesTheCompatibilityFile() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-legacy-record-limit-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sessionID = "session-over-limit" + let legacyURL = root.appendingPathComponent("codex-hook-sessions.json") + var legacyData = Data( + "{\"version\":2,\"sessions\":{\"\(sessionID)\":{\"sessionId\":\"\(sessionID)\",\"workspaceId\":\"workspace\",\"surfaceId\":\"surface\",\"startedAt\":100,\"updatedAt\":200,\"padding\":\"".utf8 + ) + legacyData.append(Data(repeating: 97, count: (4 * 1_024 * 1_024) + 1)) + legacyData.append(Data("\"}}}".utf8)) + try legacyData.write(to: legacyURL, options: .atomic) + + let stderrURL = root.appendingPathComponent("legacy-limit-stderr.txt") + let command = [ + cliPath, "agents", "tree", "--agent", "codex", "--session", sessionID, + "--all", "--json", "--state-dir", root.path, + ].map(shellQuoteAgentTreeArgument).joined(separator: " ") + let result = runProcess( + executablePath: "/bin/sh", + arguments: ["-c", "\(command) 2>\(shellQuoteAgentTreeArgument(stderrURL.path))"], + environment: isolatedAgentTreeEnvironment(home: root), + timeout: 15 + ) + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status != 0) + let payload = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let error = try #require(payload["error"] as? [String: Any]) + #expect(error["code"] as? String == "storage_limit_exceeded") + #expect(error["scope"] as? String == "legacy_record") + #expect(error["session_id"] as? String == sessionID) + #expect(error["path"] as? String == legacyURL.path) + #expect(error["recovery_action"] as? String == "move_legacy_file_aside") + let stderr = try String(contentsOf: stderrURL, encoding: .utf8) + #expect(stderr.contains("Move \(legacyURL.path) aside without deleting it")) + #expect(stderr.contains(root.appendingPathComponent(CmuxAgentSessionRegistry.filename).path)) + #expect(stderr.contains("If that database has no codex rows")) + #expect(FileManager.default.fileExists(atPath: legacyURL.path)) + } + + @Test func agentsTreeRejectsUnknownAgentLikeAgentsList() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-unknown-provider-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + let result = runProcess( + executablePath: cliPath, + arguments: [ + "agents", "tree", "--agent", "definitely-not-an-agent", + "--state-dir", root.path, "--json", + ], + environment: environment, + timeout: 5 + ) + + #expect(result.status != 0) + #expect(result.stdout.contains("unknown agent 'definitely-not-an-agent'")) + } + + @Test func agentsTreeRejectsBlankAgentFilter() throws { + let cliPath = try bundledCLIPath() + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--agent", "", "--json"], + environment: environment, + timeout: 5 + ) + + #expect(result.status != 0) + #expect(result.stdout.contains("--agent requires a value")) + } + + @Test func agentsValueOptionsRejectFollowingFlagAsMissingValue() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-missing-option-value-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let cases: [(subcommand: String, option: String)] = [ + ("list", "--agent"), + ("list", "--session"), + ("list", "--workspace"), + ("list", "--surface"), + ("list", "--cwd"), + ("list", "--state-dir"), + ("list", "--codex-home"), + ("list", "--limit"), + ("list", "--state"), + ("list", "--activity"), + ("list", "--work-kind"), + ("tree", "--agent"), + ("tree", "--session"), + ("tree", "--workspace"), + ("tree", "--surface"), + ("tree", "--state-dir"), + ("tree", "--relation"), + ("tree", "--state"), + ("tree", "--activity"), + ("tree", "--work-kind"), + ("tree", "--depth"), + ("tree", "--max-nodes"), + ] + + for testCase in cases { + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", testCase.subcommand, testCase.option, "--json"], + environment: isolatedAgentTreeEnvironment(home: root), + timeout: 15 + ) + let context = "agents \(testCase.subcommand) \(testCase.option): \(result.stdout)" + + #expect(!result.timedOut, Comment(rawValue: context)) + #expect(result.status != 0, Comment(rawValue: context)) + #expect( + result.stdout.contains("\(testCase.option) requires a value"), + Comment(rawValue: context) + ) + } + } + + @Test func agentAndSessionListErrorsNameTheInvokedCommand() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-list-error-command-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let cases: [(command: String, arguments: [String], expectedPrefix: String)] = [ + ("agents", ["list", "--state-dir"], "agents list: --state-dir requires a value"), + ("agents", ["list", "--limit", "0", "--state-dir", root.path], "agents list: --limit must be a positive integer"), + ("sessions", ["list", "--state-dir"], "sessions list: --state-dir requires a value"), + ("sessions", ["list", "--limit", "0", "--state-dir", root.path], "sessions list: --limit must be a positive integer"), + ("sessions", ["list", "--state", "invalid", "--state-dir", root.path], "sessions list: unknown state 'invalid'"), + ("sessions", ["list", "--activity", "invalid", "--state-dir", root.path], "sessions list: unknown activity 'invalid'"), + ("sessions", ["list", "--work-kind", "invalid", "--state-dir", root.path], "sessions list: unknown workload kind 'invalid'"), + ] + + for testCase in cases { + let result = runProcess( + executablePath: cliPath, + arguments: [testCase.command] + testCase.arguments, + environment: isolatedAgentTreeEnvironment(home: root), + timeout: 5 + ) + let context = "\(testCase.command): \(result.stdout)" + + #expect(!result.timedOut, Comment(rawValue: context)) + #expect(result.status != 0, Comment(rawValue: context)) + #expect(result.stdout.contains(testCase.expectedPrefix), Comment(rawValue: context)) + } + } + + @Test func sessionsTreeErrorsNameTheInvokedCommand() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-sessions-tree-error-command-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let cases: [(arguments: [String], expectedPrefix: String)] = [ + (["tree", "--state", "invalid", "--state-dir", root.path], "sessions tree: unknown state 'invalid'"), + (["tree", "--depth", "0", "--state-dir", root.path], "sessions tree: --depth must be a positive integer"), + (["tree", "--state-dir"], "sessions tree: --state-dir requires a value"), + (["tree", "unexpected", "--state-dir", root.path], "sessions tree: unexpected argument 'unexpected'"), + ] + + for testCase in cases { + let result = runProcess( + executablePath: cliPath, + arguments: ["sessions"] + testCase.arguments, + environment: isolatedAgentTreeEnvironment(home: root), + timeout: 5 + ) + let context = "sessions tree: \(result.stdout)" + + #expect(!result.timedOut, Comment(rawValue: context)) + #expect(result.status != 0, Comment(rawValue: context)) + #expect(result.stdout.contains(testCase.expectedPrefix), Comment(rawValue: context)) + } + } + + @Test func agentAndSessionJSONParseErrorsUseCompleteStructuredOutput() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-json-parse-error-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let cases: [(command: String, subcommand: String)] = [ + ("agents", "list"), + ("sessions", "list"), + ("agents", "tree"), + ("sessions", "tree"), + ] + + for (index, testCase) in cases.enumerated() { + let expectedPrefix = "\(testCase.command) \(testCase.subcommand): unknown state 'invalid'" + let stderrURL = root.appendingPathComponent("parse-error-\(index).stderr") + let command = ([ + cliPath, + testCase.command, + testCase.subcommand, + "--state", + "invalid", + "--json", + "--state-dir", + root.path, + ]).map(shellQuoteAgentTreeArgument).joined(separator: " ") + let result = runProcess( + executablePath: "/bin/sh", + arguments: [ + "-c", "\(command) 2>\(shellQuoteAgentTreeArgument(stderrURL.path))", + ], + environment: isolatedAgentTreeEnvironment(home: root), + timeout: 5 + ) + let context = "\(testCase.command) \(testCase.subcommand): \(result.stdout)" + + #expect(!result.timedOut, Comment(rawValue: context)) + #expect(result.status != 0, Comment(rawValue: context)) + let payload = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any], + Comment(rawValue: context) + ) + let error = try #require(payload["error"] as? [String: Any]) + #expect(payload["schema_version"] as? Int == 2) + #expect(error["code"] as? String == "invalid_arguments") + #expect((error["message"] as? String)?.contains(expectedPrefix) == true) + if testCase.subcommand == "tree" { + #expect((payload["nodes"] as? [Any])?.isEmpty == true) + #expect((payload["edges"] as? [Any])?.isEmpty == true) + } else { + #expect((payload["sessions"] as? [Any])?.isEmpty == true) + } + let stderr = try String(contentsOf: stderrURL, encoding: .utf8) + #expect(stderr.contains(expectedPrefix), Comment(rawValue: stderr)) + } + } + + @Test func agentAndSessionJSONStateDirectoryFailuresUseCompleteStructuredOutput() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-json-state-error-\(UUID().uuidString)", isDirectory: true) + let stateDirectory = root.appendingPathComponent("state", isDirectory: true) + try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: stateDirectory.path + ) + try? FileManager.default.removeItem(at: root) + } + try FileManager.default.setAttributes( + [.posixPermissions: 0], + ofItemAtPath: stateDirectory.path + ) + + let cases: [(command: String, subcommand: String)] = [ + ("agents", "list"), + ("sessions", "list"), + ("agents", "tree"), + ("sessions", "tree"), + ] + + for (index, testCase) in cases.enumerated() { + let expectedPrefix = "\(testCase.command) \(testCase.subcommand):" + let stderrURL = root.appendingPathComponent("state-error-\(index).stderr") + let command = ([ + cliPath, + testCase.command, + testCase.subcommand, + "--json", + "--state-dir", + stateDirectory.path, + ]).map(shellQuoteAgentTreeArgument).joined(separator: " ") + let result = runProcess( + executablePath: "/bin/sh", + arguments: [ + "-c", "\(command) 2>\(shellQuoteAgentTreeArgument(stderrURL.path))", + ], + environment: isolatedAgentTreeEnvironment(home: root), + timeout: 5 + ) + let context = "\(testCase.command) \(testCase.subcommand): \(result.stdout)" + + #expect(!result.timedOut, Comment(rawValue: context)) + #expect(result.status != 0, Comment(rawValue: context)) + let payload = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any], + Comment(rawValue: context) + ) + let error = try #require(payload["error"] as? [String: Any]) + #expect(payload["schema_version"] as? Int == 2) + #expect(error["code"] as? String == "agent_state_unavailable") + #expect((error["message"] as? String)?.hasPrefix(expectedPrefix) == true) + if testCase.subcommand == "tree" { + #expect((payload["nodes"] as? [Any])?.isEmpty == true) + #expect((payload["edges"] as? [Any])?.isEmpty == true) + } else { + #expect((payload["sessions"] as? [Any])?.isEmpty == true) + } + let stderr = try String(contentsOf: stderrURL, encoding: .utf8) + #expect(stderr.contains(expectedPrefix), Comment(rawValue: stderr)) + } + } + + @Test func agentAndSessionJSONExplicitSocketFailuresUseCompleteStructuredOutput() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-json-socket-error-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let socketPath = root.appendingPathComponent("missing.sock").path + + let cases: [(command: String, subcommand: String)] = [ + ("agents", "list"), + ("sessions", "list"), + ("agents", "tree"), + ("sessions", "tree"), + ] + + for (index, testCase) in cases.enumerated() { + let expectedPrefix = "\(testCase.command) \(testCase.subcommand):" + let stderrURL = root.appendingPathComponent("socket-error-\(index).stderr") + let command = ([ + cliPath, + "--socket", + socketPath, + testCase.command, + testCase.subcommand, + "--json", + "--state-dir", + root.path, + ]).map(shellQuoteAgentTreeArgument).joined(separator: " ") + let result = runProcess( + executablePath: "/bin/sh", + arguments: [ + "-c", "\(command) 2>\(shellQuoteAgentTreeArgument(stderrURL.path))", + ], + environment: isolatedAgentTreeEnvironment(home: root), + timeout: 5 + ) + let context = "\(testCase.command) \(testCase.subcommand): \(result.stdout)" + + #expect(!result.timedOut, Comment(rawValue: context)) + #expect(result.status != 0, Comment(rawValue: context)) + let payload = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any], + Comment(rawValue: context) + ) + let error = try #require(payload["error"] as? [String: Any]) + #expect(payload["schema_version"] as? Int == 2) + #expect(error["code"] as? String == "agent_runtime_unavailable") + #expect((error["message"] as? String)?.hasPrefix(expectedPrefix) == true) + #expect(error["path"] as? String == socketPath) + if testCase.subcommand == "tree" { + #expect((payload["nodes"] as? [Any])?.isEmpty == true) + #expect((payload["edges"] as? [Any])?.isEmpty == true) + } else { + #expect((payload["sessions"] as? [Any])?.isEmpty == true) + } + let stderr = try String(contentsOf: stderrURL, encoding: .utf8) + #expect(stderr.contains(expectedPrefix), Comment(rawValue: stderr)) + } + } + + @Test func agentsEqualsOptionsPreserveDashLeadingValues() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-dash-value-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + for subcommand in ["list", "tree"] { + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", subcommand, "--agent=-definitely-not-an-agent", "--json"], + environment: isolatedAgentTreeEnvironment(home: root), + timeout: 5 + ) + let context = "agents \(subcommand): \(result.stdout)" + + #expect(!result.timedOut, Comment(rawValue: context)) + #expect(result.status != 0, Comment(rawValue: context)) + #expect(result.stdout.contains("unknown agent '-definitely-not-an-agent'"), Comment(rawValue: context)) + #expect(!result.stdout.contains("--agent requires a value"), Comment(rawValue: context)) + } + } + +} + +private func writeAgentTreeStore(parentIndices: [Int?], to url: URL) throws { + var sessions: [String: Any] = [:] + sessions.reserveCapacity(parentIndices.count) + for (index, parentIndex) in parentIndices.enumerated() { + let sessionID = String(format: "session-%05d", index) + let runID = String(format: "run-%05d", index) + var run: [String: Any] = [ + "runId": runID, + "restoreAuthority": parentIndex == nil, + "startedAt": Double(index), + "updatedAt": Double(index), + ] + var record: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": String(format: "workspace-%05d", index), + "surfaceId": String(format: "surface-%05d", index), + "runId": runID, + "activeRunId": runID, + "restoreAuthority": parentIndex == nil, + "foregroundState": "idle", + "attentionState": "none", + "sessionState": "active", + "startedAt": Double(index), + "updatedAt": Double(index), + ] + if let parentIndex { + let parentSessionID = String(format: "session-%05d", parentIndex) + let parentRunID = String(format: "run-%05d", parentIndex) + run["parentRunId"] = parentRunID + run["parentSessionId"] = parentSessionID + run["relationship"] = "spawned" + record["parentRunId"] = parentRunID + record["parentSessionId"] = parentSessionID + record["relationship"] = "spawned" + } + record["runs"] = [run] + sessions[sessionID] = record + } + try JSONSerialization.data( + withJSONObject: ["version": 2, "sessions": sessions], + options: [.sortedKeys] + ).write(to: url, options: .atomic) +} + +private func isolatedAgentTreeEnvironment(home: URL) -> [String: String] { + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["HOME"] = home.path + return environment +} + +private func seedAuthoritativeAgentSessions( + count: Int, + provider: String, + registry: CmuxAgentSessionRegistry +) throws { + try seedAuthoritativeAgentSessions(range: 0.., + provider: String, + registry: CmuxAgentSessionRegistry +) throws { + let records = range.map { index in + let sessionID = String(format: "session-%05d", index) + let json = Data(""" + {"sessionId":"\(sessionID)","workspaceId":"workspace-\(index % 100)","surfaceId":"surface-\(index)","runId":"run-\(index)","restoreAuthority":false,"sessionState":"ended","foregroundState":"idle","startedAt":\(index),"updatedAt":\(index),"completedAt":\(index)} + """.utf8) + return CmuxAgentSessionRegistry.Record( + provider: provider, + sessionID: sessionID, + updatedAt: TimeInterval(index), + json: json + ) + } + try registry.apply(provider: provider, records: records) +} + +private func seedAuthoritativeAgentProviders( + _ providers: [String], + registry: CmuxAgentSessionRegistry +) throws { + guard !providers.isEmpty else { return } + // One connection and transaction keep the 256-provider boundary fixture + // fast while exercising the same insert triggers as production writes. + var database: OpaquePointer? + guard sqlite3_open_v2( + registry.url.path, + &database, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, + nil + ) == SQLITE_OK, let database else { + defer { if let database { sqlite3_close(database) } } + throw CocoaError(.fileReadUnknown) + } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + database, + """ + INSERT INTO agent_sessions ( + provider, session_id, updated_at, writer_generation, record_json + ) VALUES (?1, ?2, ?3, ?4, ?5) + """, + -1, + &statement, + nil + ) == SQLITE_OK, let statement else { + throw CocoaError(.fileWriteUnknown) + } + defer { sqlite3_finalize(statement) } + guard sqlite3_exec(database, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { + throw CocoaError(.fileWriteUnknown) + } + do { + for (index, provider) in providers.enumerated() { + let sessionID = "provider-session-\(index)" + let json = try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "workspaceId": "provider-workspace-\(index)", + "surfaceId": "provider-surface-\(index)", + "startedAt": TimeInterval(index), + "updatedAt": TimeInterval(index), + ], options: [.sortedKeys]) + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + guard provider.withCString({ + sqlite3_bind_text(statement, 1, $0, -1, transient) + }) == SQLITE_OK, + sessionID.withCString({ + sqlite3_bind_text(statement, 2, $0, -1, transient) + }) == SQLITE_OK else { + throw CocoaError(.fileWriteUnknown) + } + sqlite3_bind_double(statement, 3, TimeInterval(index)) + sqlite3_bind_int64( + statement, + 4, + sqlite3_int64(CmuxAgentSessionRegistry.currentWriterGeneration) + ) + let blobStatus = json.withUnsafeBytes { bytes in + sqlite3_bind_blob(statement, 5, bytes.baseAddress, Int32(bytes.count), transient) + } + guard blobStatus == SQLITE_OK, sqlite3_step(statement) == SQLITE_DONE else { + throw CocoaError(.fileWriteUnknown) + } + } + guard sqlite3_exec(database, "COMMIT", nil, nil, nil) == SQLITE_OK else { + throw CocoaError(.fileWriteUnknown) + } + } catch { + sqlite3_exec(database, "ROLLBACK", nil, nil, nil) + throw error + } +} + +private func agentSessionRegistryRecord( + provider: String, + sessionID: String, + updatedAt: TimeInterval +) throws -> CmuxAgentSessionRegistry.Record { + CmuxAgentSessionRegistry.Record( + provider: provider, + sessionID: sessionID, + updatedAt: updatedAt, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "workspaceId": "workspace-\(sessionID)", + "surfaceId": "surface-\(sessionID)", + "startedAt": updatedAt, + "updatedAt": updatedAt, + ], options: [.sortedKeys]) + ) +} + +private func executeAgentSessionSQLite(at url: URL, sql: String) throws { + var database: OpaquePointer? + guard sqlite3_open_v2( + url.path, + &database, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, + nil + ) == SQLITE_OK, let database else { + defer { if let database { sqlite3_close(database) } } + throw CocoaError(.fileReadUnknown) + } + defer { sqlite3_close(database) } + var message: UnsafeMutablePointer? + let status = sqlite3_exec(database, sql, nil, nil, &message) + guard status == SQLITE_OK else { + let description = message.map { String(cString: $0) } ?? "SQLite test setup failed" + sqlite3_free(message) + throw NSError( + domain: "AgentSessionCLIRegressionTests.SQLite", + code: Int(status), + userInfo: [NSLocalizedDescriptionKey: description] + ) + } +} + +private func shellQuoteAgentTreeArgument(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" +} + +private func makeTerminalObservation( + state: CmuxAgentObservedState, + lifecycleAuthoritative: Bool, + workspaceID: UUID = UUID(), + surfaceID: UUID = UUID(), + surfaceGeneration: UInt64 = 9, + revision: UInt64 = 4, + publishedAt: TimeInterval = 200, + sessionProviderID: String? = nil, + processStartSeconds: Int64 = 100 +) -> CmuxAgentTerminalObservation { + CmuxAgentTerminalObservation( + runtimeID: "runtime-test", + workspaceID: workspaceID, + surfaceID: surfaceID, + surfaceGeneration: surfaceGeneration, + revision: revision, + familyID: "codex", + sessionProviderID: sessionProviderID ?? (lifecycleAuthoritative ? "claude" : "codex"), + lifecycleAuthoritative: lifecycleAuthoritative, + state: state, + pid: 42, + processStartSeconds: processStartSeconds, + processStartMicroseconds: 123, + cwd: "/tmp/project", + publishedAt: publishedAt + ) +} + +private func makeTerminalNodeCandidate( + sessionID: String, + observation: CmuxAgentTerminalObservation, + effectiveState: AgentEffectiveState +) -> AgentSessionGraphNode { + AgentSessionGraphNode( + provider: observation.sessionProviderID, + sessionId: sessionID, + runId: "run-\(sessionID)", + pid: Int(observation.pid), + processStartedAt: TimeInterval(observation.processStartSeconds) + + TimeInterval(observation.processStartMicroseconds) / 1_000_000, + cmuxRuntime: AgentCmuxRuntimeIdentity( + id: observation.runtimeID, socketPath: nil, bundleIdentifier: nil + ), + workspaceId: observation.workspaceID.uuidString, + surfaceId: observation.surfaceID.uuidString, + processState: .alive, + sessionState: .active, + foregroundState: .idle, + attentionState: .none, + activity: AgentActivitySnapshot(state: .idle, busy: false, modes: [], counts: .init()), + effectiveState: effectiveState, + workloads: [], + restoreAuthority: true, + startedAt: 100, + updatedAt: 150, + endedAt: nil + ) +} + +private func makeAgentSessionGraphTestNode( + provider: String, + sessionID: String, + runID: String, + updatedAt: TimeInterval +) -> AgentSessionGraphNode { + AgentSessionGraphNode( + provider: provider, + sessionId: sessionID, + runId: runID, + pid: nil, + processStartedAt: nil, + cmuxRuntime: nil, + workspaceId: "workspace-\(sessionID)", + surfaceId: "surface-\(sessionID)", + processState: .unknown, + sessionState: .active, + foregroundState: .idle, + attentionState: .none, + activity: AgentActivitySnapshot(state: .idle, busy: false, modes: [], counts: .init()), + effectiveState: .idle, + workloads: [], + restoreAuthority: true, + startedAt: 100, + updatedAt: updatedAt, + endedAt: nil + ) +} + +private final class AgentListPayloadLifetimeCounter { + var live = 0 + var peak = 0 +} + +private final class AgentListPayloadLifetimeProbe { + private let counter: AgentListPayloadLifetimeCounter + + init(_ counter: AgentListPayloadLifetimeCounter) { + self.counter = counter + counter.live += 1 + counter.peak = max(counter.peak, counter.live) + } + + deinit { + counter.live -= 1 + } +} + +private enum AgentStagedOutputProbeError: Error { + case expected +} diff --git a/cmuxTests/AgentSessionHibernationLifecycleTests.swift b/cmuxTests/AgentSessionHibernationLifecycleTests.swift new file mode 100644 index 000000000000..59b0b0472617 --- /dev/null +++ b/cmuxTests/AgentSessionHibernationLifecycleTests.swift @@ -0,0 +1,249 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension AgentHibernationTests { + @MainActor + @Test + func testRootExitInvalidatesInMemoryRestoreOwnerBeforeQueuedPersistence() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let binding = SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "codex resume root-session", + cwd: "/tmp/repo", + checkpointId: "root-session", + source: "agent-hook", + updatedAt: 10 + ) + workspace.surfaceResumeBindingsByPanelId[panelId] = binding + + workspace.markAgentRootExitLocally(panelId: panelId, binding: binding) + + expectNil(workspace.surfaceResumeBinding(panelId: panelId)) + expectEqual(workspace.restoredAgentResumeStatesByPanelId[panelId], .completedAgentExit) + } + + @MainActor + @Test + func testPromptIdleClearsDeadAgentPIDWithoutResumeBinding() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + workspace.recordAgentPID( + key: "codex.dead-without-binding", + pid: 999_999, + panelId: panelId, + refreshPorts: false + ) + + workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning) + workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) + + expectNil(workspace.agentPIDs["codex.dead-without-binding"]) + } + + @Test + func testPassiveMonitorPreventsHibernationEvenWhenProviderLifecycleSaysIdle() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-hibernation-monitor-\(UUID().uuidString)", isDirectory: true) + let storeURL = RestorableAgentKind.codex.hookStoreFileURL(homeDirectory: home.path) + try FileManager.default.createDirectory(at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + + let workspaceId = UUID() + let panelId = UUID() + let sessionId = "codex-passive-monitor" + let jsonObject: [String: Any] = [ + "version": 2, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": workspaceId.uuidString, + "surfaceId": panelId.uuidString, + "cwd": "/tmp/repo", + "agentLifecycle": "idle", + "updatedAt": Date().timeIntervalSince1970, + "workloads": [[ + "id": "monitor-1", + "kind": "monitor", + "phase": "watching", + "keepsSessionBusy": true, + "startedAt": Date().timeIntervalSince1970, + "updatedAt": Date().timeIntervalSince1970, + ]], + "launchCommand": [ + "launcher": "codex", + "executablePath": "/usr/local/bin/codex", + "arguments": ["/usr/local/bin/codex"], + "workingDirectory": "/tmp/repo", + ], + ], + ], + ] + try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]) + .write(to: storeURL, options: .atomic) + + let index = RestorableAgentSessionIndex.load(homeDirectory: home.path) + expectEqual(index.lifecycle(workspaceId: workspaceId, panelId: panelId), .running) + } + + @Test + func testObservedRootExitCompletesHookRecordAndCancelsOwnedWork() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-observed-exit-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let storeURL = root.appendingPathComponent("codex-hook-sessions.json") + let sessionId = "root-exit-session" + let jsonObject: [String: Any] = [ + "version": 2, + "sessions": [sessionId: [ + "sessionId": sessionId, + "workspaceId": UUID().uuidString, + "surfaceId": UUID().uuidString, + "activeRunId": "run-1", + "restoreAuthority": true, + "startedAt": 1.0, + "updatedAt": 2.0, + "runs": [[ + "runId": "run-1", + "restoreAuthority": true, + "startedAt": 1.0, + "updatedAt": 2.0, + ]], + "workloads": [[ + "id": "monitor-1", + "kind": "monitor", + "phase": "watching", + "keepsSessionBusy": true, + "startedAt": 1.0, + "updatedAt": 2.0, + ]], + ]], + "activeSessionsByWorkspace": ["workspace": ["sessionId": sessionId, "updatedAt": 2.0]], + "activeSessionsBySurface": ["surface": ["sessionId": sessionId, "updatedAt": 2.0]], + ] + try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]) + .write(to: storeURL, options: .atomic) + + AgentHookSessionStateWriter( + homeDirectory: root.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": root.path] + ).completeSynchronously(kind: .codex, sessionId: sessionId, now: 10.0) + + let saved = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any] + ) + let sessions = try #require(saved["sessions"] as? [String: Any]) + let record = try #require(sessions[sessionId] as? [String: Any]) + expectEqual(record["completedAt"] as? Double, 10.0) + expectEqual(record["restoreAuthority"] as? Bool, false) + expectNil(record["activeRunId"]) + let workloads = try #require(record["workloads"] as? [[String: Any]]) + expectEqual(workloads.first?["phase"] as? String, "cancelled") + expectEqual(workloads.first?["endReason"] as? String, "root_exited") + expectTrue((saved["activeSessionsByWorkspace"] as? [String: Any])?.isEmpty == true) + expectTrue((saved["activeSessionsBySurface"] as? [String: Any])?.isEmpty == true) + } + + @Test + func testHibernationAndRestoreTransitionsAreDurableAgentStates() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-hibernation-state-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let storeURL = root.appendingPathComponent("codex-hook-sessions.json") + let sessionId = "hibernated-session" + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionId: [ + "sessionId": sessionId, + "workspaceId": UUID().uuidString, + "surfaceId": UUID().uuidString, + "restoreAuthority": true, + "startedAt": 1.0, + "updatedAt": 2.0, + ]], + ], options: []).write(to: storeURL, options: .atomic) + let writer = AgentHookSessionStateWriter( + homeDirectory: root.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": root.path] + ) + + writer.setLifecycleSynchronously( + kind: .codex, + sessionId: sessionId, + state: .hibernated, + now: 3.0 + ) + var saved = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any] + ) + var sessions = try #require(saved["sessions"] as? [String: Any]) + var record = try #require(sessions[sessionId] as? [String: Any]) + expectEqual(record["sessionState"] as? String, "hibernated") + + writer.setLifecycleSynchronously( + kind: .codex, + sessionId: sessionId, + state: .restoring, + now: 4.0 + ) + saved = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + sessions = try #require(saved["sessions"] as? [String: Any]) + record = try #require(sessions[sessionId] as? [String: Any]) + expectEqual(record["sessionState"] as? String, "restoring") + expectEqual(record["restoreAuthority"] as? Bool, true) + } + + @Test + func testLifecycleAndRootExitWritesKeepHookStorePrivate() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-store-permissions-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let storeURL = root.appendingPathComponent("codex-hook-sessions.json") + let sessionId = "private-session" + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionId: [ + "sessionId": sessionId, + "workspaceId": "workspace", + "surfaceId": "surface", + "restoreAuthority": true, + "startedAt": 1.0, + "updatedAt": 2.0, + ]], + ], options: []).write(to: storeURL, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o600))], + ofItemAtPath: storeURL.path + ) + let writer = AgentHookSessionStateWriter( + homeDirectory: root.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": root.path] + ) + + writer.setLifecycleSynchronously( + kind: .codex, + sessionId: sessionId, + state: .hibernated, + now: 3 + ) + var attributes = try FileManager.default.attributesOfItem(atPath: storeURL.path) + var permissions = try #require(attributes[.posixPermissions] as? NSNumber) + expectEqual(permissions.intValue & 0o777, 0o600) + + writer.completeSynchronously(kind: .codex, sessionId: sessionId, now: 4) + attributes = try FileManager.default.attributesOfItem(atPath: storeURL.path) + permissions = try #require(attributes[.posixPermissions] as? NSNumber) + expectEqual(permissions.intValue & 0o777, 0o600) + } + +} diff --git a/cmuxTests/AgentSessionLifecycleAuthorityRegressionTests.swift b/cmuxTests/AgentSessionLifecycleAuthorityRegressionTests.swift new file mode 100644 index 000000000000..c943e7b2f8e0 --- /dev/null +++ b/cmuxTests/AgentSessionLifecycleAuthorityRegressionTests.swift @@ -0,0 +1,1827 @@ +import CmuxFoundation +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension CMUXCLIErrorOutputRegressionTests { + @Test func activeOneShotNeverPublishesRestoreAuthorityBeforeItsStopHook() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-live-one-shot-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.copyItem(atPath: "/usr/bin/yes", toPath: executable.path) + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = ["exec", "inspect this repository"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": root.appendingPathComponent("codex-hook-sessions.json").path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + "CMUX_RUNTIME_ID": "live-one-shot-runtime", + ], agentName: "codex") + let sessionID = "live-one-shot" + let launchCommand = AgentHookLaunchCommandRecord( + launcher: "codex", + executablePath: executable.path, + arguments: [executable.path, "exec", "inspect this repository"], + workingDirectory: root.path, + environment: nil, + capturedAt: Date().timeIntervalSince1970, + source: "rejected" + ) + #expect(try store.upsert( + sessionId: sessionID, + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: Int(process.processIdentifier), + launchCommand: launchCommand, + isRestorable: true, + markActive: true + )) + + let record = try #require(try store.lookup(sessionId: sessionID)) + let activeRunID = try #require(record.activeRunId) + #expect(record.completedAt == nil) + #expect(record.restoreAuthority == false) + #expect(record.runs?.first { $0.runId == activeRunID }?.restoreAuthority == false) + } + + @Test func lateStopFromExitedOneShotCompletesGenerationWithoutFallbackAuthority() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-exited-one-shot-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: executable.path, withDestinationPath: "/bin/sleep") + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = ["30"] + try process.run() + defer { + if process.isRunning { + process.terminate() + process.waitUntilExit() + } + } + let pid = Int(process.processIdentifier) + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let environment = [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + "CMUX_RUNTIME_ID": "one-shot-runtime", + ] + let store = ClaudeHookSessionStore(processEnv: environment, agentName: "codex") + let launchCommand = AgentHookLaunchCommandRecord( + launcher: "codex", + executablePath: executable.path, + arguments: [], + workingDirectory: root.path, + environment: nil, + capturedAt: Date().timeIntervalSince1970, + source: "rejected" + ) + let sessionID = "one-shot-session" + #expect(try store.upsert( + sessionId: sessionID, + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: launchCommand, + markActive: true + )) + let original = try #require(try store.lookup(sessionId: sessionID)) + let originalRunID = try #require(original.activeRunId) + #expect(original.runs?.first { $0.runId == originalRunID }?.processStartedAt != nil) + + process.terminate() + process.waitUntilExit() + + let firstLateStop = try store.recordPromptStop( + sessionId: sessionID, + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: launchCommand, + lastSubtitle: nil, + lastBody: nil + ) + #expect(!firstLateStop.accepted) + let completed = try #require(try store.lookup(sessionId: sessionID)) + #expect(completed.completedAt != nil) + #expect(completed.sessionState == .ended) + #expect(completed.restoreAuthority == false) + #expect(completed.activeRunId == nil) + #expect(completed.runs?.allSatisfy { $0.endedAt != nil && !$0.restoreAuthority } == true) + #expect(completed.runs?.contains { $0.runId.hasPrefix("runtime:") } == false) + #expect(store.snapshot().activeSessionsByWorkspace.isEmpty) + #expect(store.snapshot().activeSessionsBySurface.isEmpty) + + let firstCompletedAt = completed.completedAt + let firstUpdatedAt = completed.updatedAt + let duplicateLateStop = try store.recordPromptStop( + sessionId: sessionID, + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: launchCommand, + lastSubtitle: nil, + lastBody: nil + ) + #expect(!duplicateLateStop.accepted) + let afterDuplicate = try #require(try store.lookup(sessionId: sessionID)) + #expect(afterDuplicate.completedAt == firstCompletedAt) + #expect(afterDuplicate.updatedAt == firstUpdatedAt) + #expect(afterDuplicate.runs == completed.runs) + } + + @Test func lateStopCannotReplaceAnewerActiveProcessGeneration() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-stale-stop-newer-run-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: executable.path, withDestinationPath: "/bin/sleep") + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = ["30"] + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + let currentPID = Int(process.processIdentifier) + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + "CMUX_RUNTIME_ID": "current-runtime", + ], agentName: "codex") + let sessionID = "resumed-session" + #expect(try store.upsert( + sessionId: sessionID, + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: currentPID, + launchCommand: nil, + markActive: true + )) + let current = try #require(try store.lookup(sessionId: sessionID)) + let currentRunID = try #require(current.activeRunId) + + let staleStop = try store.recordPromptStop( + sessionId: sessionID, + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: Int(Int32.max) - 91, + launchCommand: nil, + lastSubtitle: nil, + lastBody: nil + ) + #expect(!staleStop.accepted) + let preserved = try #require(try store.lookup(sessionId: sessionID)) + #expect(preserved.completedAt == nil) + #expect(preserved.activeRunId == currentRunID) + #expect(preserved.runs?.first { $0.runId == currentRunID }?.endedAt == nil) + #expect(preserved.runs?.first { $0.runId == currentRunID }?.restoreAuthority == true) + #expect(store.snapshot().activeSessionsByWorkspace["workspace-a"]?.sessionId == sessionID) + #expect(store.snapshot().activeSessionsBySurface["surface-a"]?.sessionId == sessionID) + } + + @Test func reusedPIDStopCompletesTheRecordedGenerationInsteadOfPromotingTheReuse() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-reused-stop-pid-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: executable.path, withDestinationPath: "/bin/sleep") + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = ["30"] + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + let pid = Int(process.processIdentifier) + let liveLineage = AgentHookSessionLineageResolver().resolve( + agentName: "codex", + sessionId: "reused-pid-session", + pid: pid, + environment: [:] + ) + let liveStartedAt = try #require(liveLineage.processStartedAt) + let recordedStartedAt = liveStartedAt - 10 + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": ["reused-pid-session": [ + "sessionId": "reused-pid-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "pid": pid, + "activeRunId": "recorded-generation", + "runId": "recorded-generation", + "restoreAuthority": true, + "sessionState": "active", + "startedAt": recordedStartedAt, + "updatedAt": recordedStartedAt, + "runs": [[ + "runId": "recorded-generation", + "pid": pid, + "processStartedAt": recordedStartedAt, + "restoreAuthority": true, + "startedAt": recordedStartedAt, + "updatedAt": recordedStartedAt, + ]], + ]], + "activeSessionsByWorkspace": ["workspace-a": [ + "sessionId": "reused-pid-session", + "updatedAt": recordedStartedAt, + ]], + "activeSessionsBySurface": ["surface-a": [ + "sessionId": "reused-pid-session", + "updatedAt": recordedStartedAt, + ]], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + ], agentName: "codex") + + let reusedStop = try store.recordPromptStop( + sessionId: "reused-pid-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: nil, + lastSubtitle: nil, + lastBody: nil + ) + #expect(!reusedStop.accepted) + let completed = try #require(try store.lookup(sessionId: "reused-pid-session")) + #expect(completed.completedAt != nil) + #expect(completed.activeRunId == nil) + #expect(completed.runs?.count == 1) + #expect(completed.runs?.first?.runId == "recorded-generation") + #expect(completed.runs?.first?.endedAt != nil) + #expect(completed.runs?.first?.restoreAuthority == false) + #expect(store.snapshot().activeSessionsByWorkspace.isEmpty) + #expect(store.snapshot().activeSessionsBySurface.isEmpty) + } + + @Test func liveLegacyPIDRecordMigratesToAnExactProcessGeneration() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-legacy-stop-generation-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: executable.path, withDestinationPath: "/bin/sleep") + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = ["30"] + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + let pid = Int(process.processIdentifier) + let lineage = AgentHookSessionLineageResolver().resolve( + agentName: "codex", + sessionId: "legacy-live-session", + pid: pid, + environment: [:] + ) + let processStartedAt = try #require(lineage.processStartedAt) + let recordStartedAt = Date().timeIntervalSince1970 + #expect(processStartedAt <= recordStartedAt) + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try JSONSerialization.data(withJSONObject: [ + "version": 1, + "sessions": ["legacy-live-session": [ + "sessionId": "legacy-live-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "pid": pid, + "startedAt": recordStartedAt, + "updatedAt": recordStartedAt, + ]], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + ], agentName: "codex") + + let stop = try store.recordPromptStop( + sessionId: "legacy-live-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: nil, + lastSubtitle: nil, + lastBody: nil + ) + #expect(stop.accepted) + let migrated = try #require(try store.lookup(sessionId: "legacy-live-session")) + let runID = try #require(migrated.activeRunId) + let run = try #require(migrated.runs?.first { $0.runId == runID }) + let runStartedAt = try #require(run.processStartedAt) + #expect(migrated.completedAt == nil) + #expect(run.pid == pid) + #expect(abs(runStartedAt - processStartedAt) <= 0.001) + #expect(run.endedAt == nil) + } + + @Test func liveOneShotStopCompletesEverySupportedLaunchGeneration() throws { + let launches: [(agent: String, executable: String, arguments: [String])] = [ + ("codex", "codex", ["exec", "fix this"]), + ("codex", "codex", ["exec", "--future-output", "fix this"]), + ("codex", "codex", ["review"]), + ("kimi", "kimi", ["--print", "fix this", "--yolo"]), + ("gemini", "gemini", ["-p", "fix this", "--yolo"]), + ("grok", "grok", ["--single", "fix this", "--always-approve"]), + ("pi", "pi", ["-p", "fix this", "--verbose"]), + ("cursor", "cursor-agent", ["-p", "fix this", "--auto-review"]), + ("amp", "amp", ["-x", "fix this", "--no-archive-after-execute"]), + ("amp", "amp", ["-x", "fix this", "--plugin-ready-timeout", "30"]), + ("opencode", "opencode", ["run", "fix this", "--format", "json", "--pure"]), + ("claude", "claude", ["-p", "fix this"]), + ("claude", "claude", ["--print", "fix this"]), + ("gemini", "gemini", ["-p", "fix this"]), + ("gemini", "gemini", ["--prompt", "fix this"]), + ("cursor", "cursor-agent", ["--print", "fix this"]), + ("factory", "droid", ["exec", "fix this"]), + ("opencode", "opencode", ["run", "fix this"]), + ("grok", "grok", ["--single", "fix this"]), + ("pi", "pi", ["--print", "fix this"]), + ("omp", "omp", ["--print", "fix this"]), + ("campfire", "campfire", ["--print", "fix this"]), + ("amp", "amp", ["--execute", "fix this"]), + ("amp", "amp", ["--print", "fix this"]), + ("antigravity", "agy", ["--prompt", "fix this"]), + ("antigravity", "agy", ["--print", "fix this"]), + ("rovodev", "acli", ["rovodev", "run", "--prompt", "fix this"]), + ("rovodev", "acli", ["rovodev", "run", "fix this"]), + ("hermes-agent", "hermes", ["--oneshot", "fix this"]), + ("hermes-agent", "hermes", ["chat", "-q", "fix this"]), + ("copilot", "copilot", ["--prompt", "fix this"]), + ("codebuddy", "codebuddy", ["--print", "fix this"]), + ("qoder", "qodercli", ["--print", "fix this"]), + ("kiro", "kiro-cli", ["chat", "--no-interactive", "fix this"]), + ("kimi", "kimi", ["--print", "fix this"]), + ("kimi", "kimi", ["--quiet", "fix this"]), + ] + + for (index, launch) in launches.enumerated() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-live-one-shot-\(index)-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent(launch.executable, isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.copyItem(atPath: "/usr/bin/yes", toPath: executable.path) + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = launch.arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + let pid = Int(process.processIdentifier) + let sessionID = "live-one-shot-\(index)" + let command = AgentHookLaunchCommandRecord( + launcher: launch.agent, + executablePath: executable.path, + arguments: [executable.path] + launch.arguments, + workingDirectory: root.path, + environment: nil, + capturedAt: Date().timeIntervalSince1970, + source: "process" + ) + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": root.appendingPathComponent("hook-sessions.json").path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + "CMUX_RUNTIME_ID": "live-one-shot-runtime-\(index)", + ], agentName: launch.agent) + #expect(try store.upsert( + sessionId: sessionID, + workspaceId: "workspace-\(index)", + surfaceId: "surface-\(index)", + cwd: root.path, + pid: pid, + launchCommand: command, + markActive: true + )) + + let stop = try store.recordPromptStop( + sessionId: sessionID, + workspaceId: "workspace-\(index)", + surfaceId: "surface-\(index)", + cwd: root.path, + pid: pid, + launchCommand: command, + lastSubtitle: nil, + lastBody: nil + ) + + #expect(!stop.accepted, "\(launch.agent) \(launch.arguments) stayed active") + let completed = try #require(try store.lookup(sessionId: sessionID)) + #expect(completed.completedAt != nil) + #expect(completed.sessionState == .ended) + #expect(completed.restoreAuthority == false) + #expect(completed.activeRunId == nil) + #expect(completed.runs?.allSatisfy { $0.endedAt != nil && !$0.restoreAuthority } == true) + #expect(store.snapshot().activeSessionsByWorkspace.isEmpty) + #expect(store.snapshot().activeSessionsBySurface.isEmpty) + } + } + + @Test func nestedOneShotStopPreservesTheRootGenerationUntilTheFinalBoundary() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-nested-one-shot-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.copyItem(atPath: "/usr/bin/yes", toPath: executable.path) + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = ["exec", "fix this"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + let pid = Int(process.processIdentifier) + let command = AgentHookLaunchCommandRecord( + launcher: "codex", + executablePath: executable.path, + arguments: [executable.path, "exec", "fix this"], + workingDirectory: root.path, + environment: nil, + capturedAt: Date().timeIntervalSince1970, + source: "process" + ) + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": root.appendingPathComponent("hook-sessions.json").path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + ], agentName: "codex") + #expect(try store.upsert( + sessionId: "nested-one-shot", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: command, + markActive: true + )) + for _ in 0..<2 { + let submit = try store.recordPromptSubmit( + sessionId: "nested-one-shot", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: command + ) + #expect(submit.accepted) + } + + let nestedStop = try store.recordPromptStop( + sessionId: "nested-one-shot", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: command, + lastSubtitle: nil, + lastBody: nil + ) + #expect(nestedStop.accepted) + #expect(nestedStop.nested) + #expect(!nestedStop.completedGeneration) + let afterNestedStop = try #require(try store.lookup(sessionId: "nested-one-shot")) + #expect(afterNestedStop.activePromptDepth == 1) + #expect(afterNestedStop.completedAt == nil) + #expect(afterNestedStop.activeRunId != nil) + + let rootStop = try store.recordPromptStop( + sessionId: "nested-one-shot", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: command, + lastSubtitle: nil, + lastBody: nil + ) + #expect(!rootStop.accepted) + #expect(rootStop.completedGeneration) + #expect(rootStop.completionReason == .terminalLaunch) + } + + @Test func oneShotStopPreservesLiveBackgroundAuthority() throws { + for evidence in ["incoming-pending", "stored-workload"] { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-one-shot-background-\(evidence)-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("claude", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.copyItem(atPath: "/usr/bin/yes", toPath: executable.path) + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = ["--print", "fix this"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + let pid = Int(process.processIdentifier) + let command = AgentHookLaunchCommandRecord( + launcher: "claude", + executablePath: executable.path, + arguments: [executable.path, "--print", "fix this"], + workingDirectory: root.path, + environment: nil, + capturedAt: Date().timeIntervalSince1970, + source: "process" + ) + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": root.appendingPathComponent("hook-sessions.json").path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + ], agentName: "claude") + #expect(try store.upsert( + sessionId: evidence, + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: command, + markActive: true + )) + if evidence == "stored-workload" { + try store.reconcileSemanticState( + sessionId: evidence, + workloads: [AgentWorkloadRecord( + id: "background-terminal", + kind: .backgroundTerminal, + phase: .running, + keepsSessionBusy: true, + startedAt: Date().timeIntervalSince1970, + updatedAt: Date().timeIntervalSince1970, + endedAt: nil, + endReason: nil + )] + ) + } + + let stop: AgentPromptStopResult + if evidence == "incoming-pending" { + stop = try store.upsertPromptStop( + sessionId: evidence, + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: command, + agentLifecycle: .running, + hadPendingBackgroundWorkAtStop: true, + markActive: true + ) + } else { + stop = try store.recordPromptStop( + sessionId: evidence, + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: command, + lastSubtitle: nil, + lastBody: nil + ) + } + #expect(stop.accepted, "\(evidence) was consumed") + #expect(!stop.completedGeneration) + let active = try #require(try store.lookup(sessionId: evidence)) + #expect(active.completedAt == nil) + #expect(active.activeRunId != nil) + #expect(store.snapshot().activeSessionsByWorkspace["workspace-a"]?.sessionId == evidence) + + let drainedStop: AgentPromptStopResult + if evidence == "incoming-pending" { + drainedStop = try store.upsertPromptStop( + sessionId: evidence, + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: command, + agentLifecycle: .idle, + hadPendingBackgroundWorkAtStop: false, + markActive: true + ) + } else { + drainedStop = try store.recordPromptStop( + sessionId: evidence, + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + launchCommand: command, + lastSubtitle: nil, + lastBody: nil, + hadPendingBackgroundWorkAtStop: false + ) + } + #expect(!drainedStop.accepted) + #expect(drainedStop.completedGeneration) + #expect(drainedStop.completionReason == .terminalLaunch) + } + } + + @Test func liveInteractiveStopRemainsATurnBoundaryForEverySupportedAgent() throws { + let launches: [(agent: String, executable: String, arguments: [String])] = [ + ("codex", "codex", []), + ("codex", "codex", ["--future-launch-mode"]), + ("claude", "claude", []), + ("claude", "claude", ["--no-session-persistence"]), + ("claude", "claude", ["--background", "fix this"]), + ("claude", "claude", ["--print", "--input-format", "stream-json", "--output-format", "stream-json"]), + ("codex", "codex", ["app-server"]), + ("codex", "codex", ["mcp-server"]), + ("codex", "codex", ["exec-server"]), + ("gemini", "gemini", []), + ("gemini", "gemini", ["--prompt-interactive", "fix this"]), + ("cursor", "cursor-agent", []), + ("factory", "droid", []), + ("factory", "droid", ["exec", "--input-format", "stream-jsonrpc", "--output-format", "stream-jsonrpc"]), + ("factory", "droid", ["exec", "--future-protocol", "fix this"]), + ("opencode", "opencode", []), + ("opencode", "opencode", ["pr", "123"]), + ("opencode", "opencode", ["run", "--interactive", "fix this"]), + ("opencode", "opencode", ["run", "--future-protocol", "fix this"]), + ("opencode", "opencode", ["acp"]), + ("opencode", "opencode", ["serve"]), + ("opencode", "opencode", ["web"]), + ("grok", "grok", []), + ("grok", "grok", ["agent", "stdio", "--single", "fix this"]), + ("grok", "grok", ["agent", "serve"]), + ("grok", "grok", ["agent", "leader"]), + ("pi", "pi", []), + ("pi", "pi", ["--no-session"]), + ("pi", "pi", ["--mode", "rpc", "--print", "fix this"]), + ("omp", "omp", []), + ("omp", "omp", ["--prompt", "fix this"]), + ("omp", "omp", ["--mode", "rpc-ui", "--print", "fix this"]), + ("omp", "omp", ["acp", "--print", "fix this"]), + ("campfire", "campfire", []), + ("campfire", "campfire", ["--no-session"]), + ("campfire", "campfire", ["--prompt", "fix this"]), + ("campfire", "campfire", ["--mode", "rpc", "--print", "fix this"]), + ("amp", "amp", []), + ("antigravity", "agy", []), + ("antigravity", "agy", ["--prompt-interactive", "fix this"]), + ("rovodev", "acli", []), + ("rovodev", "acli", ["rovodev", "run"]), + ("rovodev", "acli", ["rovodev", "run", "--prompt-interactive", "fix this"]), + ("rovodev", "acli", ["rovodev", "config"]), + ("hermes-agent", "hermes", []), + ("hermes-agent", "hermes", ["-q", "fix this"]), + ("hermes-agent", "hermes", ["acp", "--oneshot", "fix this"]), + ("hermes-agent", "hermes", ["gateway", "run"]), + ("copilot", "copilot", []), + ("codebuddy", "codebuddy", []), + ("qoder", "qodercli", []), + ("qoder", "qodercli", ["--prompt-interactive", "fix this"]), + ("qoder", "qodercli", ["--acp", "--print", "fix this"]), + ("qoder", "qodercli", ["--input-format", "stream-json", "--print", "fix this"]), + ("kiro", "kiro-cli", []), + ("kiro", "kiro-cli", ["--no-interactive"]), + ("kiro", "kiro-cli", ["doctor", "--no-interactive"]), + ("kimi", "kimi", []), + ("kimi", "kimi", ["--prompt", "fix this"]), + ("kimi", "kimi", ["-p", "fix this"]), + ("kimi", "kimi", ["--acp", "--print", "fix this"]), + ("kimi", "kimi", ["acp"]), + ] + + for (index, launch) in launches.enumerated() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-live-interactive-\(index)-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent(launch.executable, isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.copyItem(atPath: "/usr/bin/yes", toPath: executable.path) + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = launch.arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + let pid = Int(process.processIdentifier) + let sessionID = "live-interactive-\(index)" + let command = AgentHookLaunchCommandRecord( + launcher: launch.agent, + executablePath: executable.path, + arguments: [executable.path] + launch.arguments, + workingDirectory: root.path, + environment: nil, + capturedAt: Date().timeIntervalSince1970, + source: "process" + ) + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": root.appendingPathComponent("hook-sessions.json").path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + ], agentName: launch.agent) + #expect(try store.upsert( + sessionId: sessionID, + workspaceId: "workspace-\(index)", + surfaceId: "surface-\(index)", + cwd: root.path, + pid: pid, + launchCommand: command, + markActive: true + )) + + let stop = try store.recordPromptStop( + sessionId: sessionID, + workspaceId: "workspace-\(index)", + surfaceId: "surface-\(index)", + cwd: root.path, + pid: pid, + launchCommand: command, + lastSubtitle: nil, + lastBody: nil + ) + + #expect(stop.accepted, "\(launch.agent) interactive Stop was consumed") + let active = try #require(try store.lookup(sessionId: sessionID)) + let runID = try #require(active.activeRunId) + #expect(active.completedAt == nil) + #expect(active.sessionState == .active) + #expect(active.runs?.first { $0.runId == runID }?.endedAt == nil) + #expect(active.runs?.first { $0.runId == runID }?.restoreAuthority == true) + } + } + + @Test func alreadyEndedPromptStopIsRejectedWithoutClearingAReplacement() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-ended-stop-replacement-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: executable.path, withDestinationPath: "/bin/sleep") + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = ["30"] + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + let pid = Int(process.processIdentifier) + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": root.appendingPathComponent("hook-sessions.json").path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + ], agentName: "codex") + #expect(try store.upsert( + sessionId: "ended-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + markActive: true + )) + _ = try #require(try store.consume( + sessionId: "ended-session", + workspaceId: nil, + surfaceId: nil + )) + #expect(try store.upsert( + sessionId: "replacement-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + markActive: true + )) + + let staleStop = try store.upsertPromptStop( + sessionId: "ended-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid + ) + + #expect(!staleStop.accepted) + #expect(!staleStop.completedGeneration) + #expect(staleStop.completionReason == nil) + #expect(!staleStop.clearedActiveBoundary) + #expect(store.snapshot().activeSessionsByWorkspace["workspace-a"]?.sessionId == "replacement-session") + #expect(store.snapshot().activeSessionsBySurface["surface-a"]?.sessionId == "replacement-session") + let replacement = try #require(try store.lookup(sessionId: "replacement-session")) + #expect(replacement.completedAt == nil) + #expect(replacement.restoreAuthority == true) + } + + @Test func reusedPIDStopCompletesOldRecordWithoutClearingTheLiveReplacement() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-reused-stop-replacement-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: executable.path, withDestinationPath: "/bin/sleep") + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = ["30"] + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + let pid = Int(process.processIdentifier) + let liveStartedAt = try #require(AgentHookSessionLineageResolver().resolve( + agentName: "codex", + sessionId: "replacement-session", + pid: pid, + environment: [:] + ).processStartedAt) + let recordedStartedAt = liveStartedAt - 10 + let stateURL = root.appendingPathComponent("hook-sessions.json") + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": ["old-session": [ + "sessionId": "old-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "pid": pid, + "activeRunId": "old-generation", + "runId": "old-generation", + "restoreAuthority": true, + "sessionState": "active", + "startedAt": recordedStartedAt, + "updatedAt": recordedStartedAt, + "runs": [[ + "runId": "old-generation", + "pid": pid, + "processStartedAt": recordedStartedAt, + "restoreAuthority": true, + "startedAt": recordedStartedAt, + "updatedAt": recordedStartedAt, + ]], + ]], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + ], agentName: "codex") + #expect(try store.upsert( + sessionId: "replacement-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + markActive: true + )) + + let staleStop = try store.upsertPromptStop( + sessionId: "old-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid + ) + + #expect(!staleStop.accepted) + #expect(staleStop.completedGeneration) + #expect(staleStop.completionReason == .processIdentityChanged) + #expect(!staleStop.clearedActiveBoundary) + #expect(store.snapshot().activeSessionsByWorkspace["workspace-a"]?.sessionId == "replacement-session") + #expect(store.snapshot().activeSessionsBySurface["surface-a"]?.sessionId == "replacement-session") + let replacement = try #require(try store.lookup(sessionId: "replacement-session")) + #expect(replacement.completedAt == nil) + #expect(replacement.restoreAuthority == true) + let old = try #require(try store.lookup(sessionId: "old-session")) + #expect(old.completedAt != nil) + #expect(old.restoreAuthority == false) + } + + @Test func noPIDStopCannotResurrectAnEndedRecordOverAReplacement() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-no-pid-ended-stop-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": root.appendingPathComponent("hook-sessions.json").path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + ], agentName: "codex") + #expect(try store.upsert( + sessionId: "ended-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + markActive: true + )) + _ = try #require(try store.consume( + sessionId: "ended-session", + workspaceId: nil, + surfaceId: nil + )) + #expect(try store.upsert( + sessionId: "replacement-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + markActive: true + )) + + let staleStop = try store.upsertPromptStop( + sessionId: "ended-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: nil + ) + + #expect(!staleStop.accepted) + #expect(!staleStop.completedGeneration) + #expect(store.snapshot().activeSessionsByWorkspace["workspace-a"]?.sessionId == "replacement-session") + #expect(store.snapshot().activeSessionsBySurface["surface-a"]?.sessionId == "replacement-session") + let ended = try #require(try store.lookup(sessionId: "ended-session")) + #expect(ended.completedAt != nil) + #expect(ended.sessionState == .ended) + #expect(ended.restoreAuthority == false) + } + + @Test func noPIDStopCannotMutateANewerLiveGenerationOfTheSameSession() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-no-pid-newer-generation-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: executable.path, withDestinationPath: "/bin/sleep") + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = ["30"] + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + let pid = Int(process.processIdentifier) + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": root.appendingPathComponent("hook-sessions.json").path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + ], agentName: "codex") + #expect(try store.upsert( + sessionId: "same-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: pid, + markActive: true + )) + let before = try #require(try store.lookup(sessionId: "same-session")) + let activeRunID = try #require(before.activeRunId) + + let staleStop = try store.recordPromptStop( + sessionId: "same-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: nil, + launchCommand: nil, + lastSubtitle: nil, + lastBody: nil + ) + + #expect(!staleStop.accepted) + #expect(!staleStop.completedGeneration) + let after = try #require(try store.lookup(sessionId: "same-session")) + #expect(after.updatedAt == before.updatedAt) + #expect(after.activeRunId == activeRunID) + #expect(after.runs == before.runs) + #expect(after.completedAt == nil) + #expect(store.snapshot().activeSessionsByWorkspace["workspace-a"]?.sessionId == "same-session") + #expect(store.snapshot().activeSessionsBySurface["surface-a"]?.sessionId == "same-session") + } + + @Test func rejectedLaunchSourceIsNonRestorableAcrossListTreeAndForkDiagnostics() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-rejected-restore-evidence-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let records: [String: Any] = [ + "rejected-nil": [ + "sessionId": "rejected-nil", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "startedAt": 100.0, + "updatedAt": 200.0, + "launchCommand": [ + "launcher": "opencode", + "executablePath": "opencode", + "arguments": ["opencode"], + "source": "rejected", + ], + ], + "rejected-true": [ + "sessionId": "rejected-true", + "workspaceId": "workspace-b", + "surfaceId": "surface-b", + "isRestorable": true, + "startedAt": 100.0, + "updatedAt": 201.0, + "launchCommand": [ + "launcher": "opencode", + "executablePath": "opencode", + "arguments": ["opencode"], + "source": "rejected", + ], + ], + ] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": records, + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("opencode-hook-sessions.json"), + options: .atomic + ) + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_AGENT_SESSION_REGISTRY_PATH"] = root.appendingPathComponent("sessions.sqlite3").path + + let historyList = runProcess( + executablePath: cliPath, + arguments: ["agents", "list", "--agent", "opencode", "--all", "--json"], + environment: environment, + timeout: 5 + ) + #expect(historyList.status == 0, Comment(rawValue: historyList.stdout)) + let historyListObject = try #require( + JSONSerialization.jsonObject(with: Data(historyList.stdout.utf8)) as? [String: Any] + ) + let historyRows = try #require(historyListObject["sessions"] as? [[String: Any]]) + #expect(Set(historyRows.compactMap { $0["session_id"] as? String }) == Set(records.keys)) + #expect(historyRows.allSatisfy { $0["hook_record_restorable"] as? Bool == false }) + #expect(historyRows.allSatisfy { $0["fork_supported"] as? Bool == false }) + + let defaultList = runProcess( + executablePath: cliPath, + arguments: ["agents", "list", "--agent", "opencode", "--json"], + environment: environment, + timeout: 5 + ) + #expect(defaultList.status == 0, Comment(rawValue: defaultList.stdout)) + let defaultListObject = try #require( + JSONSerialization.jsonObject(with: Data(defaultList.stdout.utf8)) as? [String: Any] + ) + #expect((defaultListObject["sessions"] as? [[String: Any]])?.isEmpty == true) + + let defaultTree = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--agent", "opencode", "--json"], + environment: environment, + timeout: 5 + ) + #expect(defaultTree.status == 0, Comment(rawValue: defaultTree.stdout)) + let defaultTreeObject = try #require( + JSONSerialization.jsonObject(with: Data(defaultTree.stdout.utf8)) as? [String: Any] + ) + #expect((defaultTreeObject["nodes"] as? [[String: Any]])?.isEmpty == true) + } + + @Test func lateHookFromCompletedProcessCannotReactivateSession() throws { + let pid = Int(getpid()) + let lineage = AgentHookSessionLineageResolver().resolve( + agentName: "codex", + sessionId: "completed-session", + pid: pid, + environment: [:] + ) + let processStartedAt = try #require(lineage.processStartedAt) + let now = Date().timeIntervalSince1970 + let recordData = try JSONSerialization.data(withJSONObject: [ + "sessionId": "completed-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "completedAt": now, + "startedAt": now - 10, + "updatedAt": now, + "runs": [[ + "runId": lineage.runId, + "pid": pid, + "processStartedAt": processStartedAt, + "restoreAuthority": false, + "startedAt": now - 10, + "updatedAt": now, + "endedAt": now, + ]], + ]) + let record = try JSONDecoder().decode(ClaudeHookSessionRecord.self, from: recordData) + + #expect(!AgentHookSessionActivationPolicy().canActivate( + record: record, + lineage: lineage, + hasIncomingPID: true + )) + #expect(!AgentSessionSemanticUpdatePolicy().canUpdate(record: record)) + } + + @Test func verifiedReplacementRootRegainsRestoreAuthority() throws { + let completedRoot = AgentSessionRunRecord( + runId: "stable-root-run", + pid: 101, + processStartedAt: nil, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: false, + startedAt: 100, + updatedAt: 110, + endedAt: 110 + ) + let replacement = AgentHookSessionLineage( + runId: "stable-root-run", + pid: 202, + processStartedAt: 200, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true + ) + + let runs = AgentSessionRunReconciler(maximumRecords: 128).reconciling( + [completedRoot], + activeRunId: completedRoot.runId, + lineage: replacement, + now: 210 + ) + let run = try #require(runs.first) + + #expect(run.restoreAuthority) + #expect(run.relationship == nil) + #expect(run.endedAt == nil) + } + + @Test func verifiedReplacementResetsStaleActiveRootWithoutRecordedStartTime() throws { + let staleRoot = AgentSessionRunRecord( + runId: "stable-active-root-run", + pid: 101, + processStartedAt: nil, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: false, + startedAt: 100, + updatedAt: 110, + endedAt: nil + ) + let replacement = AgentHookSessionLineage( + runId: staleRoot.runId, + pid: 202, + processStartedAt: 200, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true + ) + + let runs = AgentSessionRunReconciler(maximumRecords: 128).reconciling( + [staleRoot], + activeRunId: staleRoot.runId, + lineage: replacement, + now: 210 + ) + let run = try #require(runs.first) + + #expect(run.pid == 202) + #expect(run.processStartedAt == 200) + #expect(run.restoreAuthority) + #expect(run.startedAt == 210) + #expect(run.updatedAt == 210) + #expect(run.endedAt == nil) + } + + @Test func legacyRunReplacementRequiresDifferentPIDAndVerifiedStartTime() throws { + let legacyRoot = AgentSessionRunRecord( + runId: "legacy-root-run", + pid: 101, + processStartedAt: nil, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: false, + startedAt: 100, + updatedAt: 110, + endedAt: 110 + ) + + let incompleteReplacements: [(pid: Int, processStartedAt: TimeInterval?)] = [ + (101, 200), + (202, nil), + ] + for (pid, processStartedAt) in incompleteReplacements { + let incoming = AgentHookSessionLineage( + runId: legacyRoot.runId, + pid: pid, + processStartedAt: processStartedAt, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true + ) + let runs = AgentSessionRunReconciler(maximumRecords: 128).reconciling( + [legacyRoot], + activeRunId: legacyRoot.runId, + lineage: incoming, + now: 210 + ) + let run = try #require(runs.first) + + #expect(run.restoreAuthority == false) + #expect(run.startedAt == 100) + } + } + + @Test func childEvidenceSurvivesReplacementWhenRecordedStartTimeIsMissing() throws { + for evidence in [ + AgentSessionAuthorityEvidence.managedChild, + .provisionalAmbiguousChild, + ] { + let child = AgentSessionRunRecord( + runId: "stable-\(evidence.rawValue)-run", + pid: 101, + processStartedAt: nil, + parentRunId: "root-run", + parentSessionId: "root-session", + relationship: .spawned, + restoreAuthority: false, + authorityEvidence: evidence, + startedAt: 100, + updatedAt: 110, + endedAt: nil + ) + let replacement = AgentHookSessionLineage( + runId: child.runId, + pid: 202, + processStartedAt: 200, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true + ) + + let runs = AgentSessionRunReconciler(maximumRecords: 128).reconciling( + [child], + activeRunId: child.runId, + lineage: replacement, + now: 210 + ) + let run = try #require(runs.first) + + #expect(run.pid == 202) + #expect(run.processStartedAt == 200) + #expect(run.parentRunId == "root-run") + #expect(run.parentSessionId == "root-session") + #expect(run.relationship == .spawned) + #expect(run.restoreAuthority == false) + #expect(run.authorityEvidence == evidence) + } + } + + @Test func replacingActiveRunCreatesResumedEdge() throws { + let previous = AgentSessionRunRecord( + runId: "previous-root-run", + pid: 101, + processStartedAt: 100, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true, + startedAt: 100, + updatedAt: 110, + endedAt: nil + ) + let resumed = AgentHookSessionLineage( + runId: "resumed-root-run", + pid: 202, + processStartedAt: 200, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true + ) + + let runs = AgentSessionRunReconciler(maximumRecords: 128).reconciling( + [previous], + activeRunId: previous.runId, + lineage: resumed, + now: 210 + ) + let previousRun = try #require(runs.first { $0.runId == previous.runId }) + let resumedRun = try #require(runs.first { $0.runId == resumed.runId }) + + #expect(previousRun.endedAt == 210) + #expect(previousRun.restoreAuthority == false) + #expect(resumedRun.parentRunId == previous.runId) + #expect(resumedRun.relationship == .resumed) + } + + @Test func processStateRequiresMatchingLiveProcessGeneration() throws { + let pid = Int(getpid()) + let lineage = AgentHookSessionLineageResolver().resolve( + agentName: "codex", + sessionId: "live-session", + pid: pid, + environment: [:] + ) + let processStartedAt = try #require(lineage.processStartedAt) + let now = Date().timeIntervalSince1970 + let recordData = try JSONSerialization.data(withJSONObject: [ + "sessionId": "live-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "startedAt": now - 10, + "updatedAt": now, + ]) + let record = try JSONDecoder().decode(ClaudeHookSessionRecord.self, from: recordData) + let liveRun = AgentSessionRunRecord( + runId: lineage.runId, + pid: pid, + processStartedAt: processStartedAt, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true, + startedAt: now - 10, + updatedAt: now, + endedAt: nil + ) + var staleRun = liveRun + staleRun.processStartedAt = processStartedAt - 1 + + #expect(AgentSessionStateProjection(record: record, run: liveRun).process == .alive) + #expect(AgentSessionStateProjection(record: record, run: staleRun).process == .exited) + } + + @Test func exitedProcessCannotRemainEffectivelyWorking() throws { + let now = Date().timeIntervalSince1970 + let recordData = try JSONSerialization.data(withJSONObject: [ + "sessionId": "stale-working-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "runtimeStatus": "running", + "startedAt": now - 10, + "updatedAt": now, + ]) + let record = try JSONDecoder().decode(ClaudeHookSessionRecord.self, from: recordData) + let staleRun = AgentSessionRunRecord( + runId: "stale-working-run", + pid: Int(getpid()), + processStartedAt: 0, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true, + startedAt: now - 10, + updatedAt: now, + endedAt: nil + ) + + let projection = AgentSessionStateProjection(record: record, run: staleRun) + + #expect(projection.process == .exited) + #expect(projection.effective == .ended) + } + + @Test func hibernatedAndRestoringSessionsOutrankAnExitedProcessObservation() throws { + let now = Date().timeIntervalSince1970 + let staleRun = AgentSessionRunRecord( + runId: "hibernated-run", + pid: Int(getpid()), + processStartedAt: 0, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true, + startedAt: now - 10, + updatedAt: now, + endedAt: nil + ) + + for (storedState, expectedState) in [ + ("hibernated", AgentEffectiveState.hibernated), + ("restoring", AgentEffectiveState.restoring), + ] { + let recordData = try JSONSerialization.data(withJSONObject: [ + "sessionId": "lifecycle-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "sessionState": storedState, + "runtimeStatus": "running", + "startedAt": now - 10, + "updatedAt": now, + ]) + let record = try JSONDecoder().decode(ClaudeHookSessionRecord.self, from: recordData) + let projection = AgentSessionStateProjection(record: record, run: staleRun) + + #expect(projection.process == .exited) + #expect(projection.effective == expectedState) + } + } + + @Test func missingActivityEvidenceRemainsUnknown() throws { + let now = Date().timeIntervalSince1970 + let recordData = try JSONSerialization.data(withJSONObject: [ + "sessionId": "legacy-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "startedAt": now - 10, + "updatedAt": now, + ]) + let record = try JSONDecoder().decode(ClaudeHookSessionRecord.self, from: recordData) + let run = AgentSessionRunRecord( + runId: "legacy-run", + pid: nil, + processStartedAt: nil, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true, + startedAt: now - 10, + updatedAt: now, + endedAt: nil + ) + + let projection = AgentSessionStateProjection(record: record, run: run) + + #expect(projection.activity.state == .unknown) + #expect(!projection.activity.busy) + #expect(projection.effective == .unknown) + } + + @Test func queuedRootExitCannotCompleteNewerRecordGeneration() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-completion-fence-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let store: [String: Any] = [ + "version": 2, + "sessions": [ + "replacement-session": [ + "sessionId": "replacement-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "activeRunId": "replacement-run", + "restoreAuthority": true, + "sessionState": "active", + "startedAt": 100.0, + "updatedAt": 200.0, + "runs": [[ + "runId": "replacement-run", + "restoreAuthority": true, + "startedAt": 200.0, + "updatedAt": 200.0, + ]], + ], + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted, .sortedKeys]) + .write(to: stateURL, options: .atomic) + let writer = AgentHookSessionStateWriter( + homeDirectory: root.path, + environment: ["CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path] + ) + + writer.completeSynchronously( + kind: .codex, + sessionId: "replacement-session", + expectedRecordUpdatedAt: 150, + now: 210 + ) + + let saved = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any] + ) + let sessions = try #require(saved["sessions"] as? [String: Any]) + let record = try #require(sessions["replacement-session"] as? [String: Any]) + #expect(record["completedAt"] == nil) + #expect(record["sessionState"] as? String == "active") + #expect(record["activeRunId"] as? String == "replacement-run") + #expect(record["restoreAuthority"] as? Bool == true) + } + + @Test func queuedRootExitCannotDeleteNewerActiveSlot() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-completion-slot-fence-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let sessionID = "resumed-session" + let record: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "activeRunId": "old-run", + "restoreAuthority": true, + "startedAt": 50.0, + "updatedAt": 100.0, + ] + let active: [String: Any] = [ + "sessionId": sessionID, + "updatedAt": 300.0, + ] + let registry = CmuxAgentSessionRegistry(url: registryURL) + try registry.apply( + provider: "codex", + records: [CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: sessionID, + updatedAt: 100, + json: try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + )], + activeSlots: [CmuxAgentSessionRegistry.ActiveSlot( + provider: "codex", + scope: .surface, + scopeID: "surface-a", + sessionID: sessionID, + updatedAt: 300, + json: try JSONSerialization.data(withJSONObject: active, options: [.sortedKeys]) + )] + ) + let writer = AgentHookSessionStateWriter( + homeDirectory: root.path, + environment: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + ] + ) + + writer.completeSynchronously( + kind: .codex, + sessionId: sessionID, + expectedRecordUpdatedAt: 100, + now: 200 + ) + + let snapshot = try registry.snapshot(provider: "codex") + let slot = try #require(snapshot.activeSlots.first) + #expect(slot.sessionID == sessionID) + #expect(slot.updatedAt == 300) + } + + @Test func workloadHistoryAppliesHardCapWhenEveryRecordIsActive() { + let incoming = (0..<300).map { index in + AgentWorkloadRecord( + id: "monitor-\(index)", + kind: .monitor, + phase: .watching, + keepsSessionBusy: true, + startedAt: Double(index), + updatedAt: Double(index), + endedAt: nil, + endReason: nil + ) + } + + let reconciled = AgentSessionWorkloadReconciler().replacingActiveWorkloads( + [], + with: incoming, + now: 300 + ) + + #expect(reconciled.count == 256) + #expect(reconciled.allSatisfy { $0.phase.isActive }) + #expect(reconciled.map(\.id).contains("monitor-299")) + } + + @Test func childRunCannotGainRestoreAuthorityWhenAncestorEvidenceDisappears() throws { + let existing = AgentSessionRunRecord( + runId: "stable-child-run", + pid: 101, + processStartedAt: 100, + parentRunId: "root-run", + parentSessionId: "root-session", + relationship: .spawned, + restoreAuthority: false, + startedAt: 100, + updatedAt: 110, + endedAt: nil + ) + let missingEvidence = AgentHookSessionLineage( + runId: "stable-child-run", + pid: 101, + processStartedAt: 100, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true + ) + + let runs = AgentSessionRunReconciler(maximumRecords: 128).reconciling( + [existing], + activeRunId: existing.runId, + lineage: missingEvidence, + now: 120 + ) + let run = try #require(runs.first) + + #expect(run.restoreAuthority == false) + #expect(run.relationship == .spawned) + #expect(run.parentRunId == "root-run") + #expect(run.parentSessionId == "root-session") + } + + @Test func childRunCannotGainRestoreAuthorityAfterProcessGenerationChanges() throws { + let existing = AgentSessionRunRecord( + runId: "stable-child-run", + pid: 101, + processStartedAt: 100, + parentRunId: "root-run", + parentSessionId: "root-session", + relationship: .spawned, + restoreAuthority: false, + startedAt: 100, + updatedAt: 110, + endedAt: nil + ) + let replacement = AgentHookSessionLineage( + runId: "stable-child-run", + pid: 202, + processStartedAt: 200, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true + ) + + let runs = AgentSessionRunReconciler(maximumRecords: 128).reconciling( + [existing], + activeRunId: existing.runId, + lineage: replacement, + now: 210 + ) + let run = try #require(runs.first) + + #expect(run.processStartedAt == 200) + #expect(run.relationship == .spawned) + #expect(run.restoreAuthority == false) + } + + @Test func agentsTreeReportsMalformedProviderStore() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-malformed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data("{not-json".utf8) + .write(to: root.appendingPathComponent("codex-hook-sessions.json"), options: .atomic) + var environment = ProcessInfo.processInfo.environment + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--all", "--json"], + environment: environment, + timeout: 5 + ) + + #expect(!result.timedOut) + #expect(result.status != 0) + #expect(result.stdout.contains("codex-hook-sessions.json")) + } + + @Test func nonSessionStopCompletesTheLiveProcessGeneration() { + let lineage = AgentHookSessionLineage( + runId: "utility-run", + pid: 4242, + processStartedAt: 100, + processDescribesAgent: true, + processLaunchMode: .nonSession, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: false + ) + + #expect( + AgentPromptStopLineagePolicy().decision( + record: nil, + lineage: lineage, + incomingPID: 4242 + ) == .completeRecordedGeneration(.terminalLaunch) + ) + } + + @Test func unknownLiveNativeArgvCannotPublishRestoreAuthority() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-live-unknown-argv-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.copyItem(atPath: "/usr/bin/yes", toPath: executable.path) + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.arguments = ["--future-one-shot-mode"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": root.appendingPathComponent("codex-hook-sessions.json").path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + ], agentName: "codex") + #expect(try store.upsert( + sessionId: "unknown-live-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: Int(process.processIdentifier), + markActive: true + )) + + let record = try #require(try store.lookup(sessionId: "unknown-live-session")) + #expect(record.completedAt == nil) + #expect(record.restoreAuthority == false) + #expect(record.runs?.first { $0.runId == record.activeRunId }?.restoreAuthority == false) + } + + @Test func legacyUnknownLineageWithoutPIDRetainsRestoreAuthority() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-legacy-no-pid-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": root.appendingPathComponent("hook-sessions.json").path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + ], agentName: "future-agent") + #expect(try store.upsert( + sessionId: "legacy-no-pid-session", + workspaceId: "workspace-a", + surfaceId: "surface-a", + cwd: root.path, + pid: nil, + markActive: true + )) + + let record = try #require(try store.lookup(sessionId: "legacy-no-pid-session")) + #expect(record.restoreAuthority == true) + } +} diff --git a/cmuxTests/AgentSessionLifecycleRaceRegressionTests.swift b/cmuxTests/AgentSessionLifecycleRaceRegressionTests.swift new file mode 100644 index 000000000000..d301c018cf20 --- /dev/null +++ b/cmuxTests/AgentSessionLifecycleRaceRegressionTests.swift @@ -0,0 +1,1866 @@ +import CMUXAgentLaunch +import CmuxFoundation +import Darwin +import Dispatch +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension CMUXCLIErrorOutputRegressionTests { + @Test func legacyRecordLevelEvidenceDemotesCLIAndRestoreProjection() throws { + let structuralEvidence: [[String: Any]] = [ + ["relationship": "spawned"], + ["authorityEvidence": "managed_child"], + ["authorityEvidence": "provisional_ambiguous_child"], + ["completedAt": 200], + ] + + for evidence in structuralEvidence { + var object: [String: Any] = [ + "sessionId": "legacy-session", + "workspaceId": "workspace", + "surfaceId": "surface", + "restoreAuthority": true, + "startedAt": 100, + "updatedAt": 200, + ] + object.merge(evidence) { _, incoming in incoming } + let data = try JSONSerialization.data(withJSONObject: object) + + let cliRecord = try JSONDecoder().decode(ClaudeHookSessionRecord.self, from: data) + #expect( + !AgentSessionRunCanonicalizer() + .projectedRun(record: cliRecord, provider: "codex") + .restoreAuthority + ) + + let restoreRecord = try JSONDecoder().decode( + RestorableAgentHookSessionRecord.self, + from: data + ) + #expect(!restoreRecord.projectedRestoreAuthority) + } + } + + @Test func cliCanonicalizerRejectsRuntimeProcessGenerationConflicts() throws { + func runtime(processID: Int) throws -> AgentCmuxRuntimeIdentity { + let data = try JSONSerialization.data(withJSONObject: [ + "id": "shared-runtime", + "socketPath": "/tmp/shared-runtime.sock", + "bundleIdentifier": "com.cmux.runtime", + "processId": processID, + "processStartSeconds": processID, + "processStartMicroseconds": processID + 1, + ]) + return try JSONDecoder().decode(AgentCmuxRuntimeIdentity.self, from: data) + } + + let first = AgentSessionRunRecord( + runId: "shared-run", pid: 42, processStartedAt: 100, + cmuxRuntime: try runtime(processID: 101), + parentRunId: nil, parentSessionId: nil, relationship: nil, + restoreAuthority: true, + startedAt: 100, updatedAt: 200, endedAt: nil + ) + var conflicting = first + conflicting.cmuxRuntime = try runtime(processID: 202) + + for runs in [[first, conflicting, first], [conflicting, first, first]] { + let projected = AgentSessionRunCanonicalizer().projectedRun( + record: ClaudeHookSessionRecord( + sessionId: "session", + workspaceId: "workspace", + surfaceId: "surface", + startedAt: 100, + updatedAt: 200, + runs: runs, + activeRunId: "shared-run" + ), + provider: "codex" + ) + #expect(!projected.restoreAuthority) + #expect(projected.identityConflict == true) + #expect(projected.cmuxRuntime == nil) + } + } + + @Test func laterDuplicateCannotReviveDurableChildAuthority() throws { + let laterRoot = AgentSessionRunRecord( + runId: "shared-run", pid: 42, processStartedAt: 100, + parentRunId: "parent-run", parentSessionId: "parent-session", + relationship: .forked, restoreAuthority: true, + authorityEvidence: .verifiedForkRoot, + startedAt: 100, updatedAt: 300, endedAt: nil + ) + + for (evidence, expectedEvidence): ( + AgentSessionAuthorityEvidence?, + AgentSessionAuthorityEvidence + ) in [(.managedChild, .managedChild), (nil, .legacyChild)] { + let child = AgentSessionRunRecord( + runId: "shared-run", pid: 42, processStartedAt: 100, + parentRunId: "parent-run", parentSessionId: "parent-session", + relationship: .spawned, restoreAuthority: false, + authorityEvidence: evidence, + startedAt: 100, updatedAt: 200, endedAt: nil + ) + for runs in [[child, laterRoot], [laterRoot, child]] { + let projected = AgentSessionRunCanonicalizer().projectedRun( + record: ClaudeHookSessionRecord( + sessionId: "session", + workspaceId: "workspace", + surfaceId: "surface", + startedAt: 100, + updatedAt: 300, + runs: runs, + activeRunId: "shared-run" + ), + provider: "codex" + ) + #expect(!projected.restoreAuthority) + #expect(projected.relationship == .spawned) + #expect(projected.authorityEvidence == expectedEvidence) + } + } + } + + @Test func laterVerifiedForkRootRecoversProvisionalAuthority() { + let provisional = AgentSessionRunRecord( + runId: "shared-run", pid: 42, processStartedAt: 100, + parentRunId: "parent-run", parentSessionId: "parent-session", + relationship: .spawned, restoreAuthority: false, + authorityEvidence: .provisionalAmbiguousChild, + startedAt: 100, updatedAt: 200, endedAt: nil + ) + let verifiedRoot = AgentSessionRunRecord( + runId: "shared-run", pid: 42, processStartedAt: 100, + parentRunId: "parent-run", parentSessionId: "parent-session", + relationship: .forked, restoreAuthority: true, + authorityEvidence: .verifiedForkRoot, + startedAt: 100, updatedAt: 300, endedAt: nil + ) + + let projected = AgentSessionRunCanonicalizer().projectedRun( + record: ClaudeHookSessionRecord( + sessionId: "session", + workspaceId: "workspace", + surfaceId: "surface", + startedAt: 100, + updatedAt: 300, + runs: [verifiedRoot, provisional], + activeRunId: "shared-run" + ), + provider: "codex" + ) + #expect(projected.restoreAuthority) + #expect(projected.relationship == .forked) + #expect(projected.authorityEvidence == .verifiedForkRoot) + } + + @Test func provisionalChildNeedsCompleteForkRootProofToRecover() { + let provisional = AgentSessionRunRecord( + runId: "shared-run", pid: 42, processStartedAt: 100, + parentRunId: "parent-run", parentSessionId: "parent-session", + relationship: .spawned, restoreAuthority: false, + authorityEvidence: .provisionalAmbiguousChild, + startedAt: 100, updatedAt: 200, endedAt: nil + ) + + for (relationship, evidence): ( + AgentSessionRelationship?, + AgentSessionAuthorityEvidence? + ) in [(nil, nil), (.forked, nil), (nil, .verifiedForkRoot)] { + let incompleteRoot = AgentSessionRunRecord( + runId: "shared-run", pid: 42, processStartedAt: 100, + parentRunId: nil, parentSessionId: nil, + relationship: relationship, restoreAuthority: true, + authorityEvidence: evidence, + startedAt: 100, updatedAt: 300, endedAt: nil + ) + let projected = AgentSessionRunCanonicalizer().projectedRun( + record: ClaudeHookSessionRecord( + sessionId: "session", + workspaceId: "workspace", + surfaceId: "surface", + startedAt: 100, + updatedAt: 300, + runs: [provisional, incompleteRoot], + activeRunId: "shared-run" + ), + provider: "codex" + ) + #expect(!projected.restoreAuthority) + #expect(projected.relationship == .spawned) + #expect(projected.authorityEvidence == .provisionalAmbiguousChild) + } + } + + @Test func canonicalRunChildEvidenceCannotRetainRestoreAuthority() throws { + let childEvidence: [AgentSessionAuthorityEvidence] = [ + .managedChild, + .explicitSpawnedChild, + .verifiedAncestorChild, + .provisionalAmbiguousChild, + .legacyChild, + ] + let canonicalizer = AgentSessionRunCanonicalizer() + + func projectedRun( + relationship: AgentSessionRelationship? = nil, + authorityEvidence: AgentSessionAuthorityEvidence? = nil + ) -> AgentSessionRunRecord { + let run = AgentSessionRunRecord( + runId: "run", + pid: nil, + processStartedAt: nil, + parentRunId: nil, + parentSessionId: nil, + relationship: relationship, + restoreAuthority: true, + authorityEvidence: authorityEvidence, + startedAt: 100, + updatedAt: 200, + endedAt: nil + ) + return canonicalizer.projectedRun( + record: ClaudeHookSessionRecord( + sessionId: "session", + workspaceId: "workspace", + surfaceId: "surface", + startedAt: 100, + updatedAt: 200, + runs: [run], + activeRunId: run.runId + ), + provider: "codex" + ) + } + + #expect(projectedRun(relationship: .spawned).restoreAuthority == false) + for evidence in childEvidence { + #expect( + projectedRun(authorityEvidence: evidence).restoreAuthority == false, + Comment(rawValue: evidence.rawValue) + ) + } + #expect( + projectedRun( + relationship: .forked, + authorityEvidence: .verifiedForkRoot + ).restoreAuthority + ) + } + + @Test func projectedRestoreAuthorityIgnoresStaleCompatibilityFieldInEitherDuplicateOrder() throws { + func readAuthorities( + recordRestoreAuthority: Bool, + rawRuns: [AgentSessionRunRecord], + suffix: String + ) throws -> (raw: Bool?, projected: Bool?) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-canonical-child-observer-\(suffix)-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sessionID = "canonical-child-observer" + let stateURL = root.appendingPathComponent("claude-hook-sessions.json") + let environment = [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root + .appendingPathComponent(CmuxAgentSessionRegistry.filename).path, + ] + var state = ClaudeHookSessionStoreFile() + state.sessions[sessionID] = ClaudeHookSessionRecord( + sessionId: sessionID, + workspaceId: "workspace", + surfaceId: "surface", + pid: nil, + runtimeStatus: .idle, + startedAt: 10, + updatedAt: 20, + foregroundState: .idle, + attentionState: AgentAttentionState.none, + sessionState: .active, + runs: rawRuns, + activeRunId: rawRuns[0].runId, + runId: rawRuns[0].runId, + restoreAuthority: recordRestoreAuthority + ) + try JSONEncoder().encode(state).write(to: stateURL, options: .atomic) + + let store = ClaudeHookSessionStore(processEnv: environment, agentName: "claude") + return ( + try store.lookup(sessionId: sessionID)?.restoreAuthority, + try store.projectedRestoreAuthority(sessionId: sessionID) + ) + } + + let runID = "session:claude:canonical-child-observer" + let authoritative = AgentSessionRunRecord( + runId: runID, + pid: nil, + processStartedAt: nil, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true, + startedAt: 10, + updatedAt: 20, + endedAt: nil + ) + var verifiedFork = authoritative + verifiedFork.relationship = .forked + verifiedFork.authorityEvidence = .verifiedForkRoot + + for (index, rawRuns) in [ + [authoritative, verifiedFork], + [verifiedFork, authoritative], + ].enumerated() { + let authority = try readAuthorities( + recordRestoreAuthority: false, + rawRuns: rawRuns, + suffix: "owner-\(index)" + ) + #expect(authority.raw == false, Comment(rawValue: "owner order \(index)")) + #expect(authority.projected == true, Comment(rawValue: "owner order \(index)")) + } + + var spawnedChild = authoritative + spawnedChild.relationship = .spawned + spawnedChild.restoreAuthority = false + spawnedChild.authorityEvidence = .managedChild + for (index, rawRuns) in [ + [authoritative, spawnedChild], + [spawnedChild, authoritative], + ].enumerated() { + let authority = try readAuthorities( + recordRestoreAuthority: true, + rawRuns: rawRuns, + suffix: "child-\(index)" + ) + #expect(authority.raw == true, Comment(rawValue: "child order \(index)")) + #expect(authority.projected == false, Comment(rawValue: "child order \(index)")) + } + } + + @Test func canonicalRunConflictsCannotAuthorizeResumeOrStopMutations() throws { + let attemptID = UUID() + let authoritative = AgentSessionRunRecord( + runId: "shared-run", pid: 42, processStartedAt: 100, + parentRunId: nil, parentSessionId: nil, relationship: nil, + restoreAuthority: true, + cmuxHibernationResumeAttemptId: attemptID.uuidString, + startedAt: 100, updatedAt: 200, endedAt: nil + ) + var conflictingProof = authoritative + conflictingProof.cmuxHibernationResumeAttemptId = UUID().uuidString + var conflictingIdentity = authoritative + conflictingIdentity.pid = 84 + conflictingIdentity.processStartedAt = 101 + let resumeLineage = AgentHookSessionLineage( + runId: authoritative.runId, + pid: authoritative.pid, + processStartedAt: authoritative.processStartedAt, + processDescribesAgent: true, + processLaunchMode: .unknown, + hibernationResumeAttemptId: attemptID, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true + ) + + for conflict in [conflictingProof, conflictingIdentity] { + for rawRuns in [[authoritative, conflict], [conflict, authoritative]] { + var record = ClaudeHookSessionRecord( + sessionId: "resume-session", + workspaceId: "workspace", + surfaceId: "surface", + pid: authoritative.pid, + startedAt: 100, + updatedAt: 200, + sessionState: .active, + runs: rawRuns, + activeRunId: authoritative.runId, + runId: authoritative.runId, + restoreAuthority: true + ) + record.runs = AgentSessionRunCanonicalizer().runs( + record: record, + provider: "local-agent" + ) + + #expect( + AgentHookSessionActivationPolicy().decision( + record: record, + lineage: resumeLineage, + hasIncomingPID: true + ) == .reject + ) + } + } + + var ended = authoritative + ended.restoreAuthority = false + ended.cmuxHibernationResumeAttemptId = nil + ended.endedAt = 250 + for rawRuns in [[authoritative, ended], [ended, authoritative]] { + var record = ClaudeHookSessionRecord( + sessionId: "stop-session", + workspaceId: "workspace", + surfaceId: "surface", + pid: authoritative.pid, + startedAt: 100, + updatedAt: 200, + sessionState: .active, + runs: rawRuns, + activeRunId: authoritative.runId, + runId: authoritative.runId, + restoreAuthority: true + ) + record.runs = AgentSessionRunCanonicalizer().runs( + record: record, + provider: "local-agent" + ) + var stopLineage = resumeLineage + stopLineage.hibernationResumeAttemptId = nil + + #expect( + AgentPromptStopLineagePolicy().decision( + record: record, + lineage: stopLineage, + incomingPID: authoritative.pid + ) == .rejectStaleGeneration + ) + } + } + + @MainActor + @Test func supersededPreviousSurfaceRejectsRestoredHibernationAdoption() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-superseded-surface-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "superseded-surface-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let sessionID = "superseded-surface-restored" + let occupantSessionID = "superseded-surface-occupant" + let previousWorkspaceID = UUID() + let previousSurfaceID = UUID() + let targetWorkspaceID = UUID() + let targetSurfaceID = UUID() + let restoredSlot: [String: Any] = ["sessionId": sessionID, "updatedAt": 10.0] + let occupantSlot: [String: Any] = ["sessionId": occupantSessionID, "updatedAt": 30.0] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + sessionID: [ + "sessionId": sessionID, + "workspaceId": previousWorkspaceID.uuidString, + "surfaceId": previousSurfaceID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 1.0, + "updatedAt": 10.0, + ], + occupantSessionID: [ + "sessionId": occupantSessionID, + "workspaceId": previousWorkspaceID.uuidString, + "surfaceId": previousSurfaceID.uuidString, + "sessionState": "active", + "restoreAuthority": true, + "startedAt": 25.0, + "updatedAt": 30.0, + ], + ], + "activeSessionsByWorkspace": [previousWorkspaceID.uuidString: restoredSlot], + "activeSessionsBySurface": [previousSurfaceID.uuidString: occupantSlot], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + let agent = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: root.path, + launchCommand: nil + ) + + let adopted = AgentHookSessionStateWriter.recordRestoredHibernation( + agent: agent, + previousWorkspaceId: previousWorkspaceID, + previousSurfaceId: previousSurfaceID, + workspaceId: targetWorkspaceID, + surfaceId: targetSurfaceID + ) + + #expect(!adopted) + let snapshot = try CmuxAgentSessionRegistry(url: registryURL).snapshot(provider: "codex") + let restoredRecord = try #require(snapshot.records.first { $0.sessionID == sessionID }) + let restoredObject = try #require( + JSONSerialization.jsonObject(with: restoredRecord.json) as? [String: Any] + ) + #expect(restoredObject["workspaceId"] as? String == previousWorkspaceID.uuidString) + #expect(restoredObject["surfaceId"] as? String == previousSurfaceID.uuidString) + #expect(snapshot.activeSlots.first { + $0.scope == .workspace && $0.scopeID == previousWorkspaceID.uuidString + }?.sessionID == sessionID) + #expect(snapshot.activeSlots.first { + $0.scope == .surface && $0.scopeID == previousSurfaceID.uuidString + }?.sessionID == occupantSessionID) + #expect(!snapshot.activeSlots.contains { + $0.scopeID == targetWorkspaceID.uuidString || $0.scopeID == targetSurfaceID.uuidString + }) + } + + @MainActor + @Test func repeatedRestoredHibernationAdoptionIsIdempotent() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-idempotent-adoption-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "idempotent-adoption-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let sessionID = "idempotent-adoption-session" + let previousWorkspaceID = UUID() + let previousSurfaceID = UUID() + let targetWorkspaceID = UUID() + let targetSurfaceID = UUID() + let slot: [String: Any] = ["sessionId": sessionID, "updatedAt": 10.0] + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: [ + "sessionId": sessionID, + "workspaceId": previousWorkspaceID.uuidString, + "surfaceId": previousSurfaceID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 1.0, + "updatedAt": 10.0, + ]], + "activeSessionsByWorkspace": [previousWorkspaceID.uuidString: slot], + "activeSessionsBySurface": [previousSurfaceID.uuidString: slot], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let descriptor = open( + stateURL.path + ".lock", + O_CREAT | O_RDWR, + mode_t(S_IRUSR | S_IWUSR) + ) + #expect(descriptor >= 0) + guard descriptor >= 0 else { return } + defer { Darwin.close(descriptor) } + #expect(flock(descriptor, LOCK_SH | LOCK_NB) == 0) + defer { _ = flock(descriptor, LOCK_UN) } + let agent = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: root.path, + launchCommand: nil + ) + + let firstAdoption = AgentHookSessionStateWriter.recordRestoredHibernation( + agent: agent, + previousWorkspaceId: previousWorkspaceID, + previousSurfaceId: previousSurfaceID, + workspaceId: targetWorkspaceID, + surfaceId: targetSurfaceID + ) + let repeatedAdoption = AgentHookSessionStateWriter.recordRestoredHibernation( + agent: agent, + previousWorkspaceId: previousWorkspaceID, + previousSurfaceId: previousSurfaceID, + workspaceId: targetWorkspaceID, + surfaceId: targetSurfaceID + ) + + #expect(firstAdoption) + #expect(repeatedAdoption) + let snapshot = try CmuxAgentSessionRegistry(url: registryURL).snapshot(provider: "codex") + let record = try #require(snapshot.records.first { $0.sessionID == sessionID }) + let object = try #require(JSONSerialization.jsonObject(with: record.json) as? [String: Any]) + #expect(object["workspaceId"] as? String == targetWorkspaceID.uuidString) + #expect(object["surfaceId"] as? String == targetSurfaceID.uuidString) + #expect(snapshot.activeSlots.count == 2) + #expect(snapshot.activeSlots.first { + $0.scope == .workspace && $0.scopeID == targetWorkspaceID.uuidString + }?.sessionID == sessionID) + #expect(snapshot.activeSlots.first { + $0.scope == .surface && $0.scopeID == targetSurfaceID.uuidString + }?.sessionID == sessionID) + } + + @Test func hibernationAndRestoreOwnLateTeardownHooks() throws { + for lifecycle in ["hibernated", "restoring"] { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-late-teardown-\(lifecycle)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let sessionID = "\(lifecycle)-session" + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: [ + "sessionId": sessionID, + "workspaceId": "workspace", + "surfaceId": "surface", + "sessionState": lifecycle, + "restoreAuthority": true, + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let store = ClaudeHookSessionStore( + processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + ], + agentName: "codex" + ) + + #expect(try store.consume( + sessionId: sessionID, + workspaceId: "workspace", + surfaceId: "surface" + ) == nil) + #expect(try store.lookup(sessionId: sessionID)?.sessionState?.rawValue == lifecycle) + } + } + + @Test func completedRunWithoutPriorStartAcceptsVerifiedNewProcess() { + let prior = AgentSessionRunRecord( + runId: "stable-run", pid: nil, processStartedAt: nil, + parentRunId: nil, parentSessionId: nil, relationship: nil, + restoreAuthority: false, startedAt: 100, updatedAt: 200, endedAt: 200 + ) + let record = ClaudeHookSessionRecord( + sessionId: "resumed-session", workspaceId: "workspace", surfaceId: "surface", + startedAt: 100, updatedAt: 200, sessionState: .ended, + runs: [prior], completedAt: 200 + ) + let replacement = AgentHookSessionLineage( + runId: prior.runId, pid: 202, processStartedAt: 300, + parentRunId: nil, parentSessionId: nil, relationship: nil, restoreAuthority: true + ) + + #expect(AgentHookSessionActivationPolicy().canActivate( + record: record, lineage: replacement, hasIncomingPID: true + )) + } + + @Test func completedLegacyRecordRejectsHooksFromItsOriginalProcessGeneration() { + let record = ClaudeHookSessionRecord( + sessionId: "legacy-completed", + workspaceId: "workspace", + surfaceId: "surface", + startedAt: 100, + updatedAt: 200, + runs: nil, + completedAt: 200 + ) + let originalProcess = AgentHookSessionLineage( + runId: "pid:123@100", + pid: 123, + processStartedAt: 100, + parentRunId: nil, + parentSessionId: nil, + relationship: nil, + restoreAuthority: true + ) + + #expect(!AgentHookSessionActivationPolicy().canActivate( + record: record, + lineage: originalProcess, + hasIncomingPID: true + )) + } + + @Test func pidlessEventCannotBorrowVerifiedActiveProcessGeneration() { + let activeRun = AgentSessionRunRecord( + runId: "resumed-run", pid: 202, processStartedAt: 200, + parentRunId: nil, parentSessionId: nil, relationship: nil, + restoreAuthority: true, startedAt: 200, updatedAt: 210, endedAt: nil + ) + let record = ClaudeHookSessionRecord( + sessionId: "session", workspaceId: "workspace", surfaceId: "surface", + pid: 202, startedAt: 100, updatedAt: 210, + runs: [activeRun], activeRunId: activeRun.runId + ) + let borrowedLineage = AgentHookSessionLineage( + runId: activeRun.runId, pid: activeRun.pid, + processStartedAt: activeRun.processStartedAt, + parentRunId: nil, parentSessionId: nil, relationship: nil, + restoreAuthority: true + ) + + #expect(!AgentHookSessionActivationPolicy().canActivate( + record: record, + lineage: borrowedLineage, + hasIncomingPID: false + )) + } + + @Test func liveHookMovesSurvivingRunIntoConnectedCmuxRuntime() throws { + let oldRuntime = AgentCmuxRuntimeIdentity( + id: "old-runtime", socketPath: "/tmp/old.sock", bundleIdentifier: "com.cmuxterm.old" + ) + let currentRuntime = AgentCmuxRuntimeIdentity( + id: "current-runtime", socketPath: "/tmp/current.sock", bundleIdentifier: "com.cmuxterm.current" + ) + let stored = AgentSessionRunRecord( + runId: "surviving-run", pid: 123, processStartedAt: 100, cmuxRuntime: oldRuntime, + parentRunId: nil, parentSessionId: nil, relationship: nil, + restoreAuthority: true, startedAt: 100, updatedAt: 110, endedAt: nil + ) + let liveHook = AgentHookSessionLineage( + runId: "surviving-run", pid: 123, processStartedAt: 100, cmuxRuntime: currentRuntime, + parentRunId: nil, parentSessionId: nil, relationship: nil, restoreAuthority: true + ) + + let updated = AgentSessionRunReconciler(maximumRecords: 128).reconciling( + [stored], activeRunId: stored.runId, lineage: liveHook, now: 120 + ) + + #expect(try #require(updated.first).cmuxRuntime == currentRuntime) + } + + @Test func completedGenerationRejectsApprovalResponseVisibleMutations() throws { + let harness = ClaudeHookSurfaceResolutionSwiftTests() + let context = try harness.makeClaudeHookContext(name: "ended-approval") + defer { context.cleanup() } + let stateURL = context.root.appendingPathComponent("hermes-agent-hook-sessions.json") + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": ["completed-session": [ + "sessionId": "completed-session", + "workspaceId": context.workspaceId, + "surfaceId": context.surfaceId, + "completedAt": 200.0, "sessionState": "ended", "startedAt": 100.0, "updatedAt": 200.0, + "runs": [[ + "runId": "completed-run", "restoreAuthority": false, + "startedAt": 100.0, "updatedAt": 200.0, "endedAt": 200.0, + ]], + ]], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let ttyName = "ttys-ended-approval" + let handled = harness.startClaudeSurfaceResolutionServer( + context: context, + surfaces: [(context.surfaceId, "surface:1", true)], + ttyName: ttyName, + ttySurfaceId: context.surfaceId + ) + var environment = harness.claudeHookEnvironment( + context: context, + surfaceId: context.surfaceId, + ttyName: ttyName, + storeURL: stateURL + ) + environment["CMUX_AGENT_HOOK_STATE_DIR"] = context.root.path + + let result = harness.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "hermes-agent", "approval-response"], + environment: environment, + standardInput: #"{"session_id":"completed-session","hook_event_name":"post_approval_response"}"#, + timeout: 5 + ) + + #expect(handled.wait(timeout: .now() + 5) == .success) + #expect(!result.timedOut) + #expect(result.status == 0) + let commands = context.state.snapshot() + #expect(!commands.contains { command in + command.contains("set_status hermes-agent ") + || command.contains("set_agent_pid hermes-agent ") + || command.contains("clear_notifications") + || command.contains(#""method":"surface.resume.set""#) + }) + } + + @Test func codexTeamsThreadIdentityDoesNotLeakAcrossProviders() { + let inheritedEnvironment = [ + "CMUX_CODEX_TEAMS_THREAD_ID": "codex-thread", + "CMUX_CODEX_TEAMS_PARENT_THREAD_ID": "codex-parent", + ] + let resolver = AgentHookSessionLineageResolver() + + let claude = resolver.resolve( + agentName: "claude", + sessionId: "claude-session", + pid: nil, + environment: inheritedEnvironment + ) + #expect(claude.runId == "session:claude:claude-session") + #expect(claude.parentRunId == nil) + + let codex = resolver.resolve( + agentName: "codex", + sessionId: "codex-session", + pid: nil, + environment: inheritedEnvironment + ) + #expect(codex.runId == "codex-thread") + #expect(codex.parentRunId == "codex-parent") + } + + @Test func olderProcessGenerationCannotReplaceActiveRun() { + let currentRun = AgentSessionRunRecord( + runId: "current-run", pid: 202, processStartedAt: 200, + parentRunId: nil, parentSessionId: nil, relationship: nil, + restoreAuthority: true, startedAt: 200, updatedAt: 210, endedAt: nil + ) + let record = ClaudeHookSessionRecord( + sessionId: "session", workspaceId: "workspace", surfaceId: "surface", + startedAt: 100, updatedAt: 210, runs: [currentRun], activeRunId: currentRun.runId + ) + let staleLineage = AgentHookSessionLineage( + runId: "old-run", pid: 101, processStartedAt: 100, + parentRunId: nil, parentSessionId: nil, relationship: nil, restoreAuthority: true + ) + + #expect(!AgentHookSessionActivationPolicy().canActivate( + record: record, lineage: staleLineage, hasIncomingPID: true + )) + } + + @Test func completedGenerationCannotReactivateThroughSameProcessLineage() { + let completedRun = AgentSessionRunRecord( + runId: "completed-run", pid: 123, processStartedAt: 100, + parentRunId: nil, parentSessionId: nil, relationship: nil, + restoreAuthority: false, startedAt: 100, updatedAt: 200, endedAt: 200 + ) + let record = ClaudeHookSessionRecord( + sessionId: "completed-session", workspaceId: "workspace", surfaceId: "surface", + startedAt: 100, updatedAt: 200, sessionState: .ended, + runs: [completedRun], completedAt: 200 + ) + let sameProcess = AgentHookSessionLineage( + runId: completedRun.runId, pid: completedRun.pid, + processStartedAt: completedRun.processStartedAt, + parentRunId: nil, parentSessionId: nil, relationship: nil, restoreAuthority: true + ) + + #expect(!AgentHookSessionActivationPolicy().canActivate( + record: record, lineage: sameProcess, hasIncomingPID: true + )) + } + + @Test func hibernatedAndRestoringRowsRequireANewerProcessGenerationToActivate() { + let savedRun = AgentSessionRunRecord( + runId: "stable-run", pid: 123, processStartedAt: 100, + parentRunId: nil, parentSessionId: nil, relationship: nil, + restoreAuthority: true, startedAt: 100, updatedAt: 200, endedAt: nil + ) + let sameGeneration = AgentHookSessionLineage( + runId: savedRun.runId, pid: savedRun.pid, + processStartedAt: savedRun.processStartedAt, + parentRunId: nil, parentSessionId: nil, relationship: nil, + restoreAuthority: true + ) + let resumedGeneration = AgentHookSessionLineage( + runId: savedRun.runId, pid: 456, processStartedAt: 300, + processDescribesAgent: true, processLaunchMode: .interactive, + parentRunId: nil, parentSessionId: nil, relationship: .resumed, + restoreAuthority: true + ) + + for state in [AgentSessionLifecycleState.hibernated, .restoring] { + let record = ClaudeHookSessionRecord( + sessionId: "protected-session", workspaceId: "workspace", surfaceId: "surface", + pid: savedRun.pid, startedAt: 100, updatedAt: 200, sessionState: state, + runs: [savedRun], activeRunId: savedRun.runId + ) + #expect(!AgentHookSessionActivationPolicy().canActivate( + record: record, lineage: sameGeneration, hasIncomingPID: true + )) + #expect(AgentHookSessionActivationPolicy().canActivate( + record: record, lineage: resumedGeneration, hasIncomingPID: true + )) + } + } + + @Test func hibernatedAndRestoringRowsRejectNonInteractiveNewerProcessGenerations() { + let savedRun = AgentSessionRunRecord( + runId: "stable-run", pid: 123, processStartedAt: 100, + parentRunId: nil, parentSessionId: nil, relationship: nil, + restoreAuthority: true, startedAt: 100, updatedAt: 200, endedAt: nil + ) + let nonInteractiveGenerations = [ + AgentHookSessionLineage( + runId: savedRun.runId, pid: 456, processStartedAt: 300, + processDescribesAgent: true, processLaunchMode: .oneShot, + parentRunId: nil, parentSessionId: nil, relationship: .resumed, + restoreAuthority: true + ), + AgentHookSessionLineage( + runId: savedRun.runId, pid: 457, processStartedAt: 301, + processDescribesAgent: true, processLaunchMode: .nonSession, + parentRunId: nil, parentSessionId: nil, relationship: .resumed, + restoreAuthority: true + ), + AgentHookSessionLineage( + runId: savedRun.runId, pid: 458, processStartedAt: 302, + processDescribesAgent: true, processLaunchMode: .unknown, + parentRunId: nil, parentSessionId: nil, relationship: .resumed, + restoreAuthority: true + ), + ] + + for state in [AgentSessionLifecycleState.hibernated, .restoring] { + let record = ClaudeHookSessionRecord( + sessionId: "protected-session", workspaceId: "workspace", surfaceId: "surface", + pid: savedRun.pid, startedAt: 100, updatedAt: 200, sessionState: state, + runs: [savedRun], activeRunId: savedRun.runId + ) + for lineage in nonInteractiveGenerations { + #expect(!AgentHookSessionActivationPolicy().canActivate( + record: record, lineage: lineage, hasIncomingPID: true + )) + } + } + } + + @Test func protectedLifecycleRequiresInteractiveOrExactRootCustomResumeEvidence() { + let savedRun = AgentSessionRunRecord( + runId: "stable-run", pid: 123, processStartedAt: 100, + parentRunId: nil, parentSessionId: nil, relationship: nil, + restoreAuthority: true, startedAt: 100, updatedAt: 200, endedAt: nil + ) + let attemptID = UUID() + let matchingCustomResume = AgentHookSessionLineage( + runId: savedRun.runId, pid: 456, processStartedAt: 300, + processDescribesAgent: true, processLaunchMode: .unknown, + hibernationResumeAttemptId: attemptID, + parentRunId: nil, parentSessionId: nil, relationship: .resumed, + restoreAuthority: true + ) + var mismatchedCustomResume = matchingCustomResume + mismatchedCustomResume.hibernationResumeAttemptId = UUID() + var nestedCustomResume = matchingCustomResume + nestedCustomResume.relationship = .spawned + nestedCustomResume.restoreAuthority = false + var unrecognizedCustomResume = matchingCustomResume + unrecognizedCustomResume.processDescribesAgent = false + var oneShotCustomResume = matchingCustomResume + oneShotCustomResume.processLaunchMode = .oneShot + var nonSessionCustomResume = matchingCustomResume + nonSessionCustomResume.processLaunchMode = .nonSession + var nestedInteractiveResume = matchingCustomResume + nestedInteractiveResume.processLaunchMode = .interactive + nestedInteractiveResume.relationship = .spawned + nestedInteractiveResume.restoreAuthority = false + let rejected = [ + mismatchedCustomResume, + nestedCustomResume, + unrecognizedCustomResume, + oneShotCustomResume, + nonSessionCustomResume, + nestedInteractiveResume, + ] + + for state in [AgentSessionLifecycleState.hibernated, .restoring] { + let record = ClaudeHookSessionRecord( + sessionId: "protected-session", workspaceId: "workspace", surfaceId: "surface", + pid: savedRun.pid, startedAt: 100, updatedAt: 200, sessionState: state, + runs: [savedRun], activeRunId: savedRun.runId, + cmuxHibernationResumeAttemptId: attemptID.uuidString + ) + #expect(AgentHookSessionActivationPolicy().canActivate( + record: record, lineage: matchingCustomResume, hasIncomingPID: true + )) + for lineage in rejected { + #expect(!AgentHookSessionActivationPolicy().canActivate( + record: record, lineage: lineage, hasIncomingPID: true + )) + } + } + } + + @Test func exactCustomResumeActivationRetainsFutureRestoreAuthorityThroughStore() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-custom-resume-authority-\(UUID().uuidString)", isDirectory: true) + let customAgent = root.appendingPathComponent("local-agent") + let fakeCmux = root.appendingPathComponent("cmux") + let pidFile = root.appendingPathComponent("agent-pid") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: customAgent.path, withDestinationPath: "/bin/sleep") + try FileManager.default.copyItem(atPath: "/bin/sh", toPath: fakeCmux.path) + defer { try? FileManager.default.removeItem(at: root) } + + let terminalHost = Process() + terminalHost.executableURL = fakeCmux + terminalHost.arguments = [ + "-c", + "\(customAgent.path) 30 & echo $! > \(pidFile.path); wait", + ] + try terminalHost.run() + defer { + if terminalHost.isRunning { terminalHost.terminate() } + terminalHost.waitUntilExit() + } + + let deadline = Date().addingTimeInterval(2) + var agentPID: Int? + repeat { + if let contents = try? String(contentsOf: pidFile, encoding: .utf8) { + agentPID = Int(contents.trimmingCharacters(in: .whitespacesAndNewlines)) + } + if agentPID != nil { break } + usleep(10_000) + } while Date() < deadline + let resumedPID = try #require(agentPID) + defer { kill(pid_t(resumedPID), SIGTERM) } + + let sessionID = "custom-resumed-session" + let mismatchedSessionID = "custom-mismatched-session" + let resumeAttemptID = UUID() + let mismatchedAttemptID = UUID() + let stateURL = root.appendingPathComponent("local-agent-hook-sessions.json") + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + sessionID: [ + "sessionId": sessionID, + "workspaceId": "workspace-before", + "surfaceId": "surface-before", + "pid": 123, + "runId": "saved-run", + "activeRunId": "saved-run", + "sessionState": "restoring", + "restoreAuthority": true, + "cmuxHibernationResumeAttemptId": resumeAttemptID.uuidString, + "cmuxHibernationResumeStartedAt": 20.0, + "startedAt": 10.0, + "updatedAt": 20.0, + "runs": [[ + "runId": "saved-run", + "pid": 123, + "processStartedAt": 10.0, + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + ], + mismatchedSessionID: [ + "sessionId": mismatchedSessionID, + "workspaceId": "mismatch-workspace-before", + "surfaceId": "mismatch-surface-before", + "sessionState": "restoring", + "restoreAuthority": true, + "cmuxHibernationResumeAttemptId": mismatchedAttemptID.uuidString, + "cmuxHibernationResumeStartedAt": 20.0, + "startedAt": 10.0, + "updatedAt": 20.0, + ], + ], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let store = ClaudeHookSessionStore(processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root.appendingPathComponent("sessions.sqlite3").path, + AgentHibernationResumeEvidence.environmentKey: resumeAttemptID.uuidString, + ], agentName: "local-agent") + + #expect(try store.upsert( + sessionId: sessionID, + workspaceId: "workspace-after", + surfaceId: "surface-after", + cwd: root.path, + pid: resumedPID, + markActive: true + )) + + let activated = try #require(try store.lookup(sessionId: sessionID)) + let activeRunID = try #require(activated.activeRunId) + let activeRun = try #require(activated.runs?.first { $0.runId == activeRunID }) + #expect(activated.sessionState == .active) + #expect(activated.cmuxHibernationResumeAttemptId == nil) + #expect(activated.restoreAuthority == true) + #expect(activeRun.restoreAuthority) + #expect(activeRun.cmuxHibernationResumeAttemptId == resumeAttemptID.uuidString) + + #expect(try store.upsert( + sessionId: sessionID, + workspaceId: "workspace-after", + surfaceId: "surface-after", + cwd: root.path, + pid: resumedPID, + markActive: true + )) + let afterDuplicateHook = try #require(try store.lookup(sessionId: sessionID)) + #expect(afterDuplicateHook.activeRunId == activeRunID) + #expect(afterDuplicateHook.restoreAuthority == true) + #expect(afterDuplicateHook.runs?.first { $0.runId == activeRunID }?.restoreAuthority == true) + #expect( + afterDuplicateHook.runs?.first { $0.runId == activeRunID }?.cmuxHibernationResumeAttemptId + == resumeAttemptID.uuidString + ) + + #expect(!(try store.upsert( + sessionId: mismatchedSessionID, + workspaceId: "mismatch-workspace-after", + surfaceId: "mismatch-surface-after", + cwd: root.path, + pid: resumedPID, + markActive: true + ))) + let rejected = try #require(try store.lookup(sessionId: mismatchedSessionID)) + #expect(rejected.sessionState == .restoring) + #expect(rejected.cmuxHibernationResumeAttemptId == mismatchedAttemptID.uuidString) + #expect(rejected.restoreAuthority == true) + #expect(rejected.activeRunId == nil) + #expect((rejected.runs ?? []).isEmpty) + #expect(store.snapshot().activeSessionsByWorkspace["mismatch-workspace-after"] == nil) + #expect(store.snapshot().activeSessionsBySurface["mismatch-surface-after"] == nil) + } + + @Test func lineageResolverReadsValidatedHibernationResumeAttemptEvidence() { + let attemptID = UUID() + let valid = AgentHookSessionLineageResolver().resolve( + agentName: "local-agent", + sessionId: "custom-session", + pid: nil, + environment: [AgentHibernationResumeEvidence.environmentKey: attemptID.uuidString] + ) + let malformed = AgentHookSessionLineageResolver().resolve( + agentName: "local-agent", + sessionId: "custom-session", + pid: nil, + environment: [AgentHibernationResumeEvidence.environmentKey: "not-a-uuid"] + ) + + #expect(valid.hibernationResumeAttemptId == attemptID) + #expect(malformed.hibernationResumeAttemptId == nil) + } + + @Test func exactLaunchCaptureRecoversCollapsedInterpreterProcessModes() throws { + let providers = [ + (kind: "pi", title: "pi", processDescribesAgent: true), + (kind: "omp", title: "omp", processDescribesAgent: true), + (kind: "kimi", title: "Kimi Code", processDescribesAgent: false), + ] + for provider in providers { + try withCollapsedInterpreterProcess( + title: provider.title, + hostExecutableName: "cmux.app/Contents/MacOS/cmux" + ) { pid, root in + let unassisted = AgentHookSessionLineageResolver().resolve( + agentName: provider.kind, + sessionId: "\(provider.kind)-collapsed-unassisted", + pid: pid, + environment: [:] + ) + #expect( + unassisted.processDescribesAgent == provider.processDescribesAgent, + Comment(rawValue: provider.kind) + ) + #expect(unassisted.processLaunchMode == .unknown, Comment(rawValue: provider.kind)) + + var environment = exactAgentLaunchEnvironment( + kind: provider.kind, + arguments: [provider.kind] + ) + environment["CMUX_CLAUDE_HOOK_STATE_PATH"] = root + .appendingPathComponent("\(provider.kind)-hook-sessions.json").path + environment["CMUX_AGENT_SESSION_REGISTRY_PATH"] = root + .appendingPathComponent("\(provider.kind)-sessions.sqlite3").path + environment["CMUX_RUNTIME_ID"] = "\(provider.kind)-collapsed-runtime" + + let recovered = AgentHookSessionLineageResolver().resolve( + agentName: provider.kind, + sessionId: "\(provider.kind)-collapsed-recovered", + pid: pid, + environment: environment + ) + #expect(recovered.processLaunchMode == .interactive, Comment(rawValue: provider.kind)) + #expect(recovered.restoreAuthority, Comment(rawValue: provider.kind)) + #expect(recovered.relationship == nil, Comment(rawValue: provider.kind)) + + let store = ClaudeHookSessionStore(processEnv: environment, agentName: provider.kind) + let sessionID = "\(provider.kind)-collapsed-store" + #expect(try store.upsert( + sessionId: sessionID, + workspaceId: "workspace-\(provider.kind)", + surfaceId: "surface-\(provider.kind)", + cwd: root.path, + pid: pid, + isRestorable: true, + markActive: true + )) + let record = try #require(try store.lookup(sessionId: sessionID)) + let activeRunID = try #require(record.activeRunId) + #expect(record.restoreAuthority == true, Comment(rawValue: provider.kind)) + #expect( + record.runs?.first { $0.runId == activeRunID }?.restoreAuthority == true, + Comment(rawValue: provider.kind) + ) + } + } + } + + @Test func inheritedCrossProviderCaptureCannotRecoverCollapsedProcessMode() throws { + try withCollapsedInterpreterProcess( + title: "pi", + hostExecutableName: "cmux.app/Contents/MacOS/cmux" + ) { pid, _ in + let lineage = AgentHookSessionLineageResolver().resolve( + agentName: "pi", + sessionId: "pi-cross-provider-capture", + pid: pid, + environment: exactAgentLaunchEnvironment( + kind: "claude", + arguments: ["claude", "--model", "sonnet"] + ) + ) + + #expect(lineage.processDescribesAgent) + #expect(lineage.processLaunchMode == .unknown) + } + } + + @Test func exactOneShotCaptureCannotGainAuthorityFromCollapsedProcessTitle() throws { + try withCollapsedInterpreterProcess( + title: "pi", + hostExecutableName: "cmux.app/Contents/MacOS/cmux" + ) { pid, root in + var environment = exactAgentLaunchEnvironment( + kind: "pi", + arguments: ["pi", "--print", "reply once"] + ) + environment["CMUX_CLAUDE_HOOK_STATE_PATH"] = root + .appendingPathComponent("pi-one-shot-hook-sessions.json").path + environment["CMUX_AGENT_SESSION_REGISTRY_PATH"] = root + .appendingPathComponent("pi-one-shot-sessions.sqlite3").path + + let lineage = AgentHookSessionLineageResolver().resolve( + agentName: "pi", + sessionId: "pi-collapsed-one-shot", + pid: pid, + environment: environment + ) + #expect(lineage.processLaunchMode == .oneShot) + + let store = ClaudeHookSessionStore(processEnv: environment, agentName: "pi") + #expect(try store.upsert( + sessionId: "pi-collapsed-one-shot", + workspaceId: "workspace-pi-one-shot", + surfaceId: "surface-pi-one-shot", + cwd: root.path, + pid: pid, + isRestorable: true, + markActive: true + )) + let record = try #require(try store.lookup(sessionId: "pi-collapsed-one-shot")) + let activeRunID = try #require(record.activeRunId) + #expect(record.restoreAuthority == false) + #expect(record.runs?.first { $0.runId == activeRunID }?.restoreAuthority == false) + } + } + + @Test func exactLaunchCaptureNeverOverridesNestedAgentAuthority() throws { + try withCollapsedInterpreterProcess(title: "pi", hostExecutableName: "codex") { pid, _ in + let lineage = AgentHookSessionLineageResolver().resolve( + agentName: "pi", + sessionId: "nested-pi-collapsed-title", + pid: pid, + environment: exactAgentLaunchEnvironment( + kind: "pi", + arguments: ["pi"] + ) + ) + + #expect(lineage.processLaunchMode == .interactive) + #expect(lineage.relationship == .spawned) + #expect(!lineage.restoreAuthority) + #expect(lineage.parentRunId != nil) + } + } + + @MainActor + @Test func staleResumeRollbackCannotRevokeNewerAttempt() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-stale-resume-rollback-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let runtimeID = "stale-resume-rollback-runtime" + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root + .appendingPathComponent(CmuxAgentSessionRegistry.filename).path, + "CMUX_RUNTIME_ID": runtimeID, + ] + let previousEnvironment = overrides.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let staleAttemptID = UUID() + let currentAttemptID = UUID() + let fixture = try installProtectedLifecycleAuthority( + root: root, + runtimeID: runtimeID, + sessionID: "stale-resume-rollback-session", + lifecycle: .restoring, + hibernationAttemptID: UUID(), + resumeAttemptID: currentAttemptID + ) + + AgentHookSessionStateWriter.releaseFailedHibernatedResumeAuthority( + .init( + agent: fixture.agent, + workspaceId: fixture.workspaceID, + surfaceId: fixture.surfaceID, + attemptId: staleAttemptID + ), + now: 40 + ) + + let snapshot = try fixture.registry.snapshot(provider: "codex") + let record = try #require(snapshot.records.first) + let object = try #require( + JSONSerialization.jsonObject(with: record.json) as? [String: Any] + ) + #expect(object["sessionState"] as? String == "restoring") + #expect(object["cmuxHibernationResumeAttemptId"] as? String == currentAttemptID.uuidString) + #expect(object["cmuxHibernationResumeStartedAt"] as? TimeInterval == 30) + #expect(snapshot.activeSlots.count == 2) + } + + @MainActor + @Test func stalePortalCloseCompensationCannotDetachNewerHibernationAttempt() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-stale-portal-compensation-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let runtimeID = "stale-portal-compensation-runtime" + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root + .appendingPathComponent(CmuxAgentSessionRegistry.filename).path, + "CMUX_RUNTIME_ID": runtimeID, + ] + let previousEnvironment = overrides.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let staleAttemptID = UUID() + let currentAttemptID = UUID() + let fixture = try installProtectedLifecycleAuthority( + root: root, + runtimeID: runtimeID, + sessionID: "stale-portal-compensation-session", + lifecycle: .hibernated, + hibernationAttemptID: currentAttemptID + ) + + await AgentHookSessionStateWriter.releaseFailedHibernationAuthority( + agent: fixture.agent, + workspaceId: fixture.workspaceID, + surfaceId: fixture.surfaceID, + attemptId: staleAttemptID, + now: 40 + ) + + let snapshot = try fixture.registry.snapshot(provider: "codex") + let record = try #require(snapshot.records.first) + let object = try #require( + JSONSerialization.jsonObject(with: record.json) as? [String: Any] + ) + #expect(object["sessionState"] as? String == "hibernated") + #expect(object["cmuxHibernationAttemptId"] as? String == currentAttemptID.uuidString) + #expect(object["cmuxHibernationDetached"] == nil) + #expect(snapshot.activeSlots.count == 2) + } + + @Test func queuedLifecycleCannotOverwriteNewerRecordGeneration() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-lifecycle-fence-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let store: [String: Any] = [ + "version": 2, + "sessions": [ + "replacement-session": [ + "sessionId": "replacement-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "activeRunId": "replacement-run", + "restoreAuthority": true, + "sessionState": "active", + "cmuxRuntime": ["id": "replacement-runtime"], + "startedAt": 100.0, + "updatedAt": 200.0, + "runs": [[ + "runId": "replacement-run", + "restoreAuthority": true, + "cmuxRuntime": ["id": "replacement-runtime"], + "startedAt": 200.0, + "updatedAt": 200.0, + ]], + ], + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted, .sortedKeys]) + .write(to: stateURL, options: .atomic) + let writer = AgentHookSessionStateWriter( + homeDirectory: root.path, + environment: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_RUNTIME_ID": "stale-runtime", + ] + ) + + writer.setLifecycleSynchronously( + kind: .codex, + sessionId: "replacement-session", + state: .restoring, + now: 150 + ) + + let saved = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any] + ) + let sessions = try #require(saved["sessions"] as? [String: Any]) + let record = try #require(sessions["replacement-session"] as? [String: Any]) + #expect(record["sessionState"] as? String == "active") + #expect(record["updatedAt"] as? TimeInterval == 200) + let runtime = try #require(record["cmuxRuntime"] as? [String: Any]) + #expect(runtime["id"] as? String == "replacement-runtime") + } + + @Test func agentsTreeDoesNotAttachCurrentWorkloadsToHistoricalRuns() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-historical-workloads-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let store: [String: Any] = [ + "version": 2, + "sessions": [ + "resumed-session": [ + "sessionId": "resumed-session", + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "activeRunId": "current-run", + "restoreAuthority": true, + "foregroundState": "working", + "attentionState": "none", + "sessionState": "active", + "startedAt": 100.0, + "updatedAt": 300.0, + "runs": [ + [ + "runId": "historical-run", + "restoreAuthority": false, + "startedAt": 100.0, + "updatedAt": 200.0, + "endedAt": 200.0, + ], + [ + "runId": "current-run", + "restoreAuthority": true, + "startedAt": 200.0, + "updatedAt": 300.0, + ], + ], + "workloads": [[ + "id": "live-monitor", + "kind": "monitor", + "phase": "watching", + "keepsSessionBusy": true, + "startedAt": 250.0, + "updatedAt": 300.0, + ]], + ], + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted, .sortedKeys]) + .write(to: root.appendingPathComponent("codex-hook-sessions.json"), options: .atomic) + var environment = ProcessInfo.processInfo.environment + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--all", "--json"], + environment: environment, + timeout: 5 + ) + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let output = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let nodes = try #require(output["nodes"] as? [[String: Any]]) + let historical = try #require(nodes.first { $0["run_id"] as? String == "historical-run" }) + let current = try #require(nodes.first { $0["run_id"] as? String == "current-run" }) + let historicalActivity = try #require(historical["activity"] as? [String: Any]) + let currentActivity = try #require(current["activity"] as? [String: Any]) + #expect(historicalActivity["busy"] as? Bool == false) + #expect((historical["workloads"] as? [[String: Any]])?.isEmpty == true) + #expect(currentActivity["busy"] as? Bool == true) + #expect((current["workloads"] as? [[String: Any]])?.count == 1) + + let filtered = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--all", "--work-kind", "monitor", "--json"], + environment: environment, + timeout: 5 + ) + #expect(!filtered.timedOut, Comment(rawValue: filtered.stdout)) + #expect(filtered.status == 0, Comment(rawValue: filtered.stdout)) + let filteredOutput = try #require( + JSONSerialization.jsonObject(with: Data(filtered.stdout.utf8)) as? [String: Any] + ) + let filteredNodes = try #require(filteredOutput["nodes"] as? [[String: Any]]) + #expect(filteredNodes.compactMap { $0["run_id"] as? String } == ["current-run"]) + } + + @Test func verifiedForkRootRecoversOnlyFromProvisionalChildEvidence() throws { + func storedRun(evidence: String, processStartedAt: TimeInterval) throws -> AgentSessionRunRecord { + let data = try JSONSerialization.data(withJSONObject: [ + "runId": "fork-run", + "pid": 101, + "processStartedAt": processStartedAt, + "parentRunId": "parent-run", + "parentSessionId": "parent-session", + "relationship": "spawned", + "restoreAuthority": false, + "authorityEvidence": evidence, + "startedAt": 100.0, + "updatedAt": 110.0, + ]) + return try JSONDecoder().decode(AgentSessionRunRecord.self, from: data) + } + + let verifiedFork = AgentHookSessionLineage( + runId: "fork-run", + pid: 202, + processStartedAt: 200, + parentRunId: nil, + parentSessionId: nil, + relationship: .forked, + restoreAuthority: true + ) + let reconciler = AgentSessionRunReconciler(maximumRecords: 128) + for processStartedAt in [100.0, 200.0] { + let provisional = try storedRun( + evidence: "provisional_ambiguous_child", + processStartedAt: processStartedAt + ) + let recovered = reconciler.reconciling( + [provisional], + activeRunId: provisional.runId, + lineage: verifiedFork, + now: 210 + ) + let recoveredRun = try #require(recovered.first) + #expect(recoveredRun.relationship == .forked) + #expect(recoveredRun.restoreAuthority) + #expect(recoveredRun.parentRunId == "parent-run") + #expect(recoveredRun.parentSessionId == "parent-session") + } + + for evidence in ["managed_child", "explicit_spawned_child", "verified_ancestor_child"] { + let durable = try storedRun(evidence: evidence, processStartedAt: 100) + let runs = reconciler.reconciling( + [durable], + activeRunId: durable.runId, + lineage: verifiedFork, + now: 210 + ) + let run = try #require(runs.first) + #expect(run.relationship == .spawned, Comment(rawValue: evidence)) + #expect(!run.restoreAuthority, Comment(rawValue: evidence)) + } + } + + @Test func agentLauncherAboveCmuxHostCannotDemoteRootSession() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-host-boundary-\(UUID().uuidString)", isDirectory: true) + let fakeAgent = root.appendingPathComponent("codex") + let fakeCmux = root.appendingPathComponent("cmux.app/Contents/MacOS/cmux") + let pidFile = root.appendingPathComponent("pids") + try FileManager.default.createDirectory( + at: fakeCmux.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.copyItem(atPath: "/bin/sh", toPath: fakeAgent.path) + try FileManager.default.copyItem(atPath: "/bin/sh", toPath: fakeCmux.path) + defer { try? FileManager.default.removeItem(at: root) } + + let launcher = Process() + launcher.executableURL = fakeAgent + launcher.arguments = [ + "-c", + "\(fakeCmux.path) -c 'sleep 30 & echo $$ $! > \(pidFile.path); wait'", + ] + try launcher.run() + defer { if launcher.isRunning { launcher.terminate() } } + + let deadline = Date().addingTimeInterval(2) + var processIDs: [Int] = [] + repeat { + if let contents = try? String(contentsOf: pidFile, encoding: .utf8) { + processIDs = contents.split(whereSeparator: \.isWhitespace).compactMap { Int($0) } + } + if processIDs.count == 2 { break } + usleep(10_000) + } while Date() < deadline + let cmuxPID = try #require(processIDs.first) + let rootAgentPID = try #require(processIDs.last) + defer { + kill(pid_t(rootAgentPID), SIGTERM) + kill(pid_t(cmuxPID), SIGTERM) + } + + let lineage = AgentHookSessionLineageResolver().resolve( + agentName: "codex", + sessionId: "root-session", + pid: rootAgentPID, + environment: [:] + ) + + #expect(lineage.restoreAuthority) + #expect(lineage.relationship == nil) + #expect(lineage.parentRunId == nil) + } + + @Test func nestedCustomAgentCannotClaimRootRestoreAuthority() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-custom-agent-ancestry-\(UUID().uuidString)", isDirectory: true) + let customAgent = root.appendingPathComponent("local-agent") + let pidFile = root.appendingPathComponent("child-pid") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.copyItem(atPath: "/bin/sh", toPath: customAgent.path) + defer { try? FileManager.default.removeItem(at: root) } + + let parent = Process() + parent.executableURL = customAgent + parent.arguments = [ + "-c", + "\(customAgent.path) -c 'sleep 30 & wait' & echo $! > \(pidFile.path); wait", + ] + try parent.run() + defer { + if parent.isRunning { parent.terminate() } + parent.waitUntilExit() + } + + let deadline = Date().addingTimeInterval(2) + var childPID: Int? + repeat { + if let contents = try? String(contentsOf: pidFile, encoding: .utf8) { + childPID = Int(contents.trimmingCharacters(in: .whitespacesAndNewlines)) + } + if childPID != nil { break } + usleep(10_000) + } while Date() < deadline + let resolvedChildPID = try #require(childPID) + defer { kill(pid_t(resolvedChildPID), SIGTERM) } + + let lineage = AgentHookSessionLineageResolver().resolve( + agentName: "local-agent", + sessionId: "nested-custom-session", + pid: resolvedChildPID, + environment: [:] + ) + + #expect(lineage.processDescribesAgent) + #expect(lineage.processLaunchMode == .unknown) + #expect(!lineage.restoreAuthority) + #expect(lineage.relationship == .spawned) + } + + private func installProtectedLifecycleAuthority( + root: URL, + runtimeID: String, + sessionID: String, + lifecycle: AgentSessionLifecycleState, + hibernationAttemptID: UUID, + resumeAttemptID: UUID? = nil + ) throws -> ( + agent: SessionRestorableAgentSnapshot, + workspaceID: UUID, + surfaceID: UUID, + registry: CmuxAgentSessionRegistry + ) { + let workspaceID = UUID() + let surfaceID = UUID() + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + var record: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": workspaceID.uuidString, + "surfaceId": surfaceID.uuidString, + "sessionState": lifecycle.rawValue, + "restoreAuthority": true, + "cmuxHibernationAttemptId": hibernationAttemptID.uuidString, + "cmuxRuntime": ["id": runtimeID], + "startedAt": 10.0, + "updatedAt": 30.0, + ] + if let resumeAttemptID { + record["cmuxHibernationResumeAttemptId"] = resumeAttemptID.uuidString + record["cmuxHibernationResumeStartedAt"] = 30.0 + record["cmuxHibernationResumeFromAttemptId"] = hibernationAttemptID.uuidString + } + let slot: [String: Any] = ["sessionId": sessionID, "updatedAt": 30.0] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: record], + "activeSessionsByWorkspace": [workspaceID.uuidString: slot], + "activeSessionsBySurface": [surfaceID.uuidString: slot], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + + let recordJSON = try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + let slotJSON = try JSONSerialization.data(withJSONObject: slot, options: [.sortedKeys]) + let registry = CmuxAgentSessionRegistry(url: registryURL) + try registry.apply( + provider: "codex", + records: [.init( + provider: "codex", + sessionID: sessionID, + updatedAt: 30, + json: recordJSON + )], + activeSlots: [ + .init( + provider: "codex", + scope: .workspace, + scopeID: workspaceID.uuidString, + sessionID: sessionID, + updatedAt: 30, + json: slotJSON + ), + .init( + provider: "codex", + scope: .surface, + scopeID: surfaceID.uuidString, + sessionID: sessionID, + updatedAt: 30, + json: slotJSON + ), + ] + ) + return ( + SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: root.path, + launchCommand: nil + ), + workspaceID, + surfaceID, + registry + ) + } + + private func exactAgentLaunchEnvironment( + kind: String, + arguments: [String] + ) -> [String: String] { + var bytes = Data() + for argument in arguments { + bytes.append(contentsOf: argument.utf8) + bytes.append(0) + } + return [ + "CMUX_AGENT_LAUNCH_KIND": kind, + "CMUX_AGENT_LAUNCH_EXECUTABLE": arguments.first ?? kind, + "CMUX_AGENT_LAUNCH_ARGV_B64": bytes.base64EncodedString(), + ] + } + + private func withCollapsedInterpreterProcess( + title: String, + hostExecutableName: String, + body: (Int, URL) throws -> Void + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-collapsed-agent-title-\(UUID().uuidString)", isDirectory: true) + let host = root.appendingPathComponent(hostExecutableName) + try FileManager.default.createDirectory( + at: host.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.copyItem(atPath: "/bin/bash", toPath: host.path) + defer { try? FileManager.default.removeItem(at: root) } + + let launcher = Process() + let readyPipe = Pipe() + launcher.executableURL = host + launcher.arguments = [ + "-c", + "exec -a \(title) /usr/bin/ruby -e 'sleep 30' & child=$!; printf '%s\\n' \"$child\"; wait \"$child\"", + ] + launcher.standardOutput = readyPipe + launcher.standardError = FileHandle.nullDevice + try launcher.run() + var childPID: Int? + defer { + if let childPID { + kill(pid_t(childPID), SIGTERM) + } else if launcher.isRunning { + launcher.terminate() + } + launcher.waitUntilExit() + try? readyPipe.fileHandleForReading.close() + } + + var pidBytes = Data() + while let byte = try readyPipe.fileHandleForReading.read(upToCount: 1)?.first, + byte != UInt8(ascii: "\n") { + pidBytes.append(byte) + } + let resolvedPID = try #require( + Int(String(decoding: pidBytes, as: UTF8.self)) + ) + childPID = resolvedPID + + try body(resolvedPID, root) + } +} diff --git a/cmuxTests/AgentSessionLineageAuthorityRegressionTests.swift b/cmuxTests/AgentSessionLineageAuthorityRegressionTests.swift new file mode 100644 index 000000000000..eb3ddddb7fd3 --- /dev/null +++ b/cmuxTests/AgentSessionLineageAuthorityRegressionTests.swift @@ -0,0 +1,89 @@ +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite +struct AgentSessionLineageAuthorityRegressionTests { + @Test func explicitHookAgentNameOwnsVisibleMutationLineage() { + let resolver = AgentVisibleMutationOwnershipAgentName() + #expect( + resolver.resolve( + explicitAgentName: "gemini", + environment: [:] + ) == "gemini" + ) + #expect( + resolver.resolve( + explicitAgentName: "gemini", + environment: ["CMUX_AGENT_LAUNCH_KIND": "codex"] + ) == "gemini" + ) + #expect( + resolver.resolve( + explicitAgentName: nil, + environment: ["CMUX_AGENT_LAUNCH_KIND": "codex"] + ) == "codex" + ) + } + + @Test func inheritedForkMetadataCannotPromoteAManagedChild() { + let lineage = AgentHookSessionLineageResolver().resolve( + agentName: "codex", + sessionId: "child-session", + pid: nil, + environment: [ + "CMUX_AGENT_MANAGED_SUBAGENT": "1", + "CMUX_AGENT_RELATIONSHIP": "forked", + "CMUX_AGENT_PARENT_SESSION_ID": "root-session", + ] + ) + + #expect(lineage.relationship == .spawned) + #expect(lineage.restoreAuthority == false) + } + + @Test func unresolvedProcessAncestryCannotGrantRestoreAuthority() { + let authority = AgentHookSessionAuthorityPolicy().classify( + managedChild: false, + explicitRelationship: nil, + processIdentityAvailable: true, + hasAgentAncestor: false, + ancestryProvenAbsent: false + ) + + #expect(authority.relationship == .spawned) + #expect(authority.restoreAuthority == false) + } + + @Test func explicitForkOwnsRestoreOnlyAfterAncestryIsProvenAbsent() { + let authority = AgentHookSessionAuthorityPolicy().classify( + managedChild: false, + explicitRelationship: .forked, + processIdentityAvailable: true, + hasAgentAncestor: false, + ancestryProvenAbsent: true + ) + + #expect(authority.relationship == .forked) + #expect(authority.restoreAuthority) + #expect(authority.evidence == .verifiedForkRoot) + } + + @Test func inheritedForkMetadataCannotOverrideAmbiguousAncestry() { + let authority = AgentHookSessionAuthorityPolicy().classify( + managedChild: false, + explicitRelationship: .forked, + processIdentityAvailable: true, + hasAgentAncestor: false, + ancestryProvenAbsent: false + ) + + #expect(authority.relationship == .spawned) + #expect(authority.restoreAuthority == false) + #expect(authority.evidence == .provisionalAmbiguousChild) + } +} diff --git a/cmuxTests/AgentSessionProjectionParityTests.swift b/cmuxTests/AgentSessionProjectionParityTests.swift new file mode 100644 index 000000000000..53159da22da1 --- /dev/null +++ b/cmuxTests/AgentSessionProjectionParityTests.swift @@ -0,0 +1,451 @@ +import CmuxFoundation +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite("Agent session projection parity") +struct AgentSessionProjectionParityTests { + struct ParityCase: Sendable, CustomTestStringConvertible { + let name: String + let record: RecordFixture + let expected: ExpectedProjection + + var testDescription: String { name } + } + + struct ExpectedProjection: Sendable { + let restoreAuthority: Bool + let runId: String? + let relationship: String? + let authorityEvidence: String? + let endedAt: TimeInterval? + let identityConflict: Bool? + let resumeAttemptId: String? + let runtimeProcessId: Int? + let runtimeProcessStartSeconds: Int64? + let runtimeProcessStartMicroseconds: Int64? + + init( + restoreAuthority: Bool, + runId: String? = nil, + relationship: String? = nil, + authorityEvidence: String? = nil, + endedAt: TimeInterval? = nil, + identityConflict: Bool? = nil, + resumeAttemptId: String? = nil, + runtimeProcessId: Int? = nil, + runtimeProcessStartSeconds: Int64? = nil, + runtimeProcessStartMicroseconds: Int64? = nil + ) { + self.restoreAuthority = restoreAuthority + self.runId = runId + self.relationship = relationship + self.authorityEvidence = authorityEvidence + self.endedAt = endedAt + self.identityConflict = identityConflict + self.resumeAttemptId = resumeAttemptId + self.runtimeProcessId = runtimeProcessId + self.runtimeProcessStartSeconds = runtimeProcessStartSeconds + self.runtimeProcessStartMicroseconds = runtimeProcessStartMicroseconds + } + } + + struct RecordFixture: Codable, Sendable { + var sessionId: String + var workspaceId: String + var surfaceId: String + var restoreAuthority: Bool? + var relationship: String? + var authorityEvidence: String? + var completedAt: TimeInterval? + var startedAt: TimeInterval + var updatedAt: TimeInterval + var runs: [RunFixture]? + var activeRunId: String? + } + + struct RunFixture: Codable, Equatable, Sendable { + var runId: String + var pid: Int? + var processStartedAt: TimeInterval? + var cmuxRuntime: RuntimeFixture? + var parentRunId: String? + var parentSessionId: String? + var relationship: String? + var restoreAuthority: Bool + var authorityEvidence: String? + var cmuxHibernationResumeAttemptId: String? + var startedAt: TimeInterval + var updatedAt: TimeInterval + var endedAt: TimeInterval? + var identityConflict: Bool? + } + + struct RuntimeFixture: Codable, Equatable, Sendable { + var id: String + var socketPath: String? + var bundleIdentifier: String? + var processId: Int? + var processStartSeconds: Int64? + var processStartMicroseconds: Int64? + } + + struct ProjectionFixture: Equatable { + var restoreAuthority: Bool + var run: RunFixture? + } + + static let parityCases: [ParityCase] = { + let equalRoot = run( + relationship: "forked", + restoreAuthority: true, + authorityEvidence: "verified_fork_root" + ) + let equalManagedChild = run( + parentRunId: "parent-run", + parentSessionId: "parent-session", + relationship: "spawned", + restoreAuthority: false, + authorityEvidence: "managed_child" + ) + let olderManagedChild = run( + parentRunId: "parent-run", + parentSessionId: "parent-session", + relationship: "spawned", + restoreAuthority: false, + authorityEvidence: "managed_child", + updatedAt: 200 + ) + let olderProvisionalChild = run( + parentRunId: "parent-run", + parentSessionId: "parent-session", + relationship: "spawned", + restoreAuthority: false, + authorityEvidence: "provisional_ambiguous_child", + updatedAt: 200 + ) + let newerVerifiedRoot = run( + relationship: "forked", + restoreAuthority: true, + authorityEvidence: "verified_fork_root", + updatedAt: 300 + ) + let newerIncompleteRoot = run(restoreAuthority: true, updatedAt: 300) + let runtime101 = runtime(processId: 101, seconds: 10, microseconds: 1) + let runtime202 = runtime(processId: 202, seconds: 20, microseconds: 2) + let runtimeProcess101 = run(cmuxRuntime: runtime101) + let runtimeProcess202 = run(cmuxRuntime: runtime202) + let firstResumeProof = run(resumeAttemptId: "attempt-a") + let secondResumeProof = run(resumeAttemptId: "attempt-b") + let complementaryRuntimeFields = [ + run(cmuxRuntime: runtime(processId: 101, seconds: nil, microseconds: 1)), + run(cmuxRuntime: runtime(processId: nil, seconds: 10, microseconds: nil)), + ] + let olderProcessGeneration = run(cmuxRuntime: runtime101, updatedAt: 200) + let newerProcessGeneration = run(cmuxRuntime: runtime202, updatedAt: 300) + let olderRun = run("older-run", updatedAt: 200) + let newerRun = run("newer-run", updatedAt: 300) + let endedRun = run("ended-run", updatedAt: 200, endedAt: 250) + + return [ + ParityCase( + name: "equal duplicate keeps durable child evidence", + record: record(runs: [equalRoot, equalManagedChild], activeRunId: "shared-run"), + expected: ExpectedProjection( + restoreAuthority: false, + runId: "shared-run", + relationship: "spawned", + authorityEvidence: "managed_child" + ) + ), + ParityCase( + name: "equal duplicate keeps durable child evidence in reverse order", + record: record(runs: [equalManagedChild, equalRoot], activeRunId: "shared-run"), + expected: ExpectedProjection( + restoreAuthority: false, + runId: "shared-run", + relationship: "spawned", + authorityEvidence: "managed_child" + ) + ), + ParityCase( + name: "equal duplicate rejects conflicting runtime process generations", + record: record( + runs: [runtimeProcess101, runtimeProcess202], + activeRunId: "shared-run" + ), + expected: ExpectedProjection( + restoreAuthority: false, + runId: "shared-run", + identityConflict: true + ) + ), + ParityCase( + name: "equal duplicate merges complementary runtime generation fields", + record: record(runs: complementaryRuntimeFields, activeRunId: "shared-run"), + expected: ExpectedProjection( + restoreAuthority: true, + runId: "shared-run", + runtimeProcessId: 101, + runtimeProcessStartSeconds: 10, + runtimeProcessStartMicroseconds: 1 + ) + ), + ParityCase( + name: "equal duplicate rejects conflicting hibernation resume proofs", + record: record( + runs: [firstResumeProof, secondResumeProof], + activeRunId: "shared-run" + ), + expected: ExpectedProjection(restoreAuthority: false, runId: "shared-run") + ), + ParityCase( + name: "non-equal duplicate cannot revive durable child authority", + record: record( + runs: [olderManagedChild, newerVerifiedRoot], + activeRunId: "shared-run" + ), + expected: ExpectedProjection( + restoreAuthority: false, + runId: "shared-run", + relationship: "spawned", + authorityEvidence: "managed_child" + ) + ), + ParityCase( + name: "non-equal duplicate recovers provisional verified fork root", + record: record( + runs: [olderProvisionalChild, newerVerifiedRoot], + activeRunId: "shared-run" + ), + expected: ExpectedProjection( + restoreAuthority: true, + runId: "shared-run", + relationship: "forked", + authorityEvidence: "verified_fork_root" + ) + ), + ParityCase( + name: "non-equal duplicate keeps provisional child without complete proof", + record: record( + runs: [olderProvisionalChild, newerIncompleteRoot], + activeRunId: "shared-run" + ), + expected: ExpectedProjection( + restoreAuthority: false, + runId: "shared-run", + relationship: "spawned", + authorityEvidence: "provisional_ambiguous_child" + ) + ), + ParityCase( + name: "non-equal duplicate accepts a newer process generation", + record: record( + runs: [olderProcessGeneration, newerProcessGeneration], + activeRunId: "shared-run" + ), + expected: ExpectedProjection( + restoreAuthority: true, + runId: "shared-run", + runtimeProcessId: 202, + runtimeProcessStartSeconds: 20, + runtimeProcessStartMicroseconds: 2 + ) + ), + ParityCase( + name: "legacy spawned record fails closed without runs", + record: record(relationship: "spawned", runs: nil), + expected: ExpectedProjection(restoreAuthority: false) + ), + ParityCase( + name: "legacy child evidence fails closed without runs", + record: record(authorityEvidence: "managed_child", runs: nil), + expected: ExpectedProjection(restoreAuthority: false) + ), + ParityCase( + name: "legacy completed record fails closed without runs", + record: record(completedAt: 250, runs: nil), + expected: ExpectedProjection(restoreAuthority: false) + ), + ParityCase( + name: "legacy verified fork root retains authority without runs", + record: record( + relationship: "forked", + authorityEvidence: "verified_fork_root", + runs: nil + ), + expected: ExpectedProjection(restoreAuthority: true) + ), + ParityCase( + name: "missing active run id selects the newest run", + record: record(runs: [newerRun, olderRun], activeRunId: nil), + expected: ExpectedProjection(restoreAuthority: true, runId: "newer-run") + ), + ParityCase( + name: "unknown active run id selects the newest run", + record: record(runs: [olderRun, newerRun], activeRunId: "missing-run"), + expected: ExpectedProjection(restoreAuthority: true, runId: "newer-run") + ), + ParityCase( + name: "active ended run remains authoritative over a newer live run", + record: record( + runs: [newerRun, endedRun], + activeRunId: "ended-run" + ), + expected: ExpectedProjection( + restoreAuthority: false, + runId: "ended-run", + endedAt: 250 + ) + ), + ] + }() + + @Test(arguments: parityCases) + func foundationAndCLIProjectIdenticalJSON(testCase: ParityCase) throws { + let recordJSON = try JSONEncoder().encode(testCase.record) + let foundationProjection = try foundationProjection(recordJSON: recordJSON) + let cliProjection = try cliProjection(recordJSON: recordJSON) + let comment = Comment(rawValue: testCase.name) + + #expect(foundationProjection == cliProjection, comment) + #expect(foundationProjection.restoreAuthority == testCase.expected.restoreAuthority, comment) + #expect(foundationProjection.run?.runId == testCase.expected.runId, comment) + #expect(foundationProjection.run?.relationship == testCase.expected.relationship, comment) + #expect( + foundationProjection.run?.authorityEvidence == testCase.expected.authorityEvidence, + comment + ) + #expect(foundationProjection.run?.endedAt == testCase.expected.endedAt, comment) + #expect( + foundationProjection.run?.identityConflict == testCase.expected.identityConflict, + comment + ) + #expect( + foundationProjection.run?.cmuxHibernationResumeAttemptId + == testCase.expected.resumeAttemptId, + comment + ) + #expect( + foundationProjection.run?.cmuxRuntime?.processId + == testCase.expected.runtimeProcessId, + comment + ) + #expect( + foundationProjection.run?.cmuxRuntime?.processStartSeconds + == testCase.expected.runtimeProcessStartSeconds, + comment + ) + #expect( + foundationProjection.run?.cmuxRuntime?.processStartMicroseconds + == testCase.expected.runtimeProcessStartMicroseconds, + comment + ) + } + + private func foundationProjection(recordJSON: Data) throws -> ProjectionFixture { + let projection = try #require( + CmuxAgentSessionRunAuthorityProjection().projection(recordJSON: recordJSON) + ) + let run = try projection.run.map { try runFixture($0) } + return ProjectionFixture(restoreAuthority: projection.restoreAuthority, run: run) + } + + private func cliProjection(recordJSON: Data) throws -> ProjectionFixture { + let record = try JSONDecoder().decode(ClaudeHookSessionRecord.self, from: recordJSON) + let projectedRun = AgentSessionRunCanonicalizer().projectedRun( + record: record, + provider: "parity-agent" + ) + let run: RunFixture? + if record.runs?.isEmpty == false { + run = try runFixture(projectedRun) + } else { + // Foundation intentionally returns no canonical run for compatibility + // records, so legacy parity compares the authority projection only. + run = nil + } + return ProjectionFixture(restoreAuthority: projectedRun.restoreAuthority, run: run) + } + + private func runFixture(_ value: Value) throws -> RunFixture { + try JSONDecoder().decode(RunFixture.self, from: JSONEncoder().encode(value)) + } + + private static func record( + restoreAuthority: Bool? = true, + relationship: String? = nil, + authorityEvidence: String? = nil, + completedAt: TimeInterval? = nil, + runs: [RunFixture]?, + activeRunId: String? = nil + ) -> RecordFixture { + RecordFixture( + sessionId: "parity-session", + workspaceId: "parity-workspace", + surfaceId: "parity-surface", + restoreAuthority: restoreAuthority, + relationship: relationship, + authorityEvidence: authorityEvidence, + completedAt: completedAt, + startedAt: 100, + updatedAt: 400, + runs: runs, + activeRunId: activeRunId + ) + } + + private static func run( + _ runId: String = "shared-run", + pid: Int? = 42, + processStartedAt: TimeInterval? = 100, + cmuxRuntime: RuntimeFixture? = nil, + parentRunId: String? = nil, + parentSessionId: String? = nil, + relationship: String? = nil, + restoreAuthority: Bool = true, + authorityEvidence: String? = nil, + resumeAttemptId: String? = nil, + startedAt: TimeInterval = 100, + updatedAt: TimeInterval = 200, + endedAt: TimeInterval? = nil, + identityConflict: Bool? = nil + ) -> RunFixture { + RunFixture( + runId: runId, + pid: pid, + processStartedAt: processStartedAt, + cmuxRuntime: cmuxRuntime, + parentRunId: parentRunId, + parentSessionId: parentSessionId, + relationship: relationship, + restoreAuthority: restoreAuthority, + authorityEvidence: authorityEvidence, + cmuxHibernationResumeAttemptId: resumeAttemptId, + startedAt: startedAt, + updatedAt: updatedAt, + endedAt: endedAt, + identityConflict: identityConflict + ) + } + + private static func runtime( + processId: Int?, + seconds: Int64?, + microseconds: Int64? + ) -> RuntimeFixture { + RuntimeFixture( + id: "parity-runtime", + socketPath: "/tmp/parity-runtime.sock", + bundleIdentifier: "com.cmux.parity", + processId: processId, + processStartSeconds: seconds, + processStartMicroseconds: microseconds + ) + } +} diff --git a/cmuxTests/AgentSessionRuntimeIdentityRegressionTests.swift b/cmuxTests/AgentSessionRuntimeIdentityRegressionTests.swift new file mode 100644 index 000000000000..d1620df8ff69 --- /dev/null +++ b/cmuxTests/AgentSessionRuntimeIdentityRegressionTests.swift @@ -0,0 +1,762 @@ +import CmuxFoundation +import Foundation +import SQLite3 +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension CMUXCLIErrorOutputRegressionTests { + @Test func restoreLoaderFallsBackToLastCompleteRegistrySnapshot() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-restore-corrupt-legacy-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let legacyURL = root.appendingPathComponent("codex-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let sessionID = "restore-last-complete" + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: [ + "sessionId": sessionID, + "workspaceId": "11111111-1111-1111-1111-111111111111", + "surfaceId": "22222222-2222-2222-2222-222222222222", + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + ]).write(to: legacyURL, options: .atomic) + let environment = ["CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path] + let decoder = JSONDecoder() + #expect(RestorableAgentHookSessionStoreFile.load( + provider: "codex", + legacyURL: legacyURL, + environment: environment, + fileManager: .default, + decoder: decoder + )?.sessions[sessionID] != nil) + + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: "partial-record"], + ]).write(to: legacyURL, options: .atomic) + + #expect(RestorableAgentHookSessionStoreFile.load( + provider: "codex", + legacyURL: legacyURL, + environment: environment, + fileManager: .default, + decoder: decoder + )?.sessions[sessionID] != nil) + } + + @Test func restoreLoaderRejectsPartiallyDecodableRegistrySnapshot() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-restore-partial-registry-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let legacyURL = root.appendingPathComponent("codex-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let legacySessionID = "legacy-complete" + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [legacySessionID: [ + "sessionId": legacySessionID, + "workspaceId": "11111111-1111-1111-1111-111111111111", + "surfaceId": "22222222-2222-2222-2222-222222222222", + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + ]).write(to: legacyURL, options: .atomic) + let environment = ["CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path] + let decoder = JSONDecoder() + #expect(RestorableAgentHookSessionStoreFile.load( + provider: "codex", legacyURL: legacyURL, environment: environment, + fileManager: .default, decoder: decoder + )?.sessions[legacySessionID] != nil) + + let registry = CmuxAgentSessionRegistry(url: registryURL) + let registryOnlySessionID = "registry-only" + let registryOnlyRecord = try JSONSerialization.data(withJSONObject: [ + "sessionId": registryOnlySessionID, + "workspaceId": "33333333-3333-3333-3333-333333333333", + "surfaceId": "44444444-4444-4444-4444-444444444444", + "startedAt": 300.0, + "updatedAt": 400.0, + ]) + try registry.apply(provider: "codex", records: [ + CmuxAgentSessionRegistry.Record( + provider: "codex", sessionID: registryOnlySessionID, + updatedAt: 400, json: registryOnlyRecord + ), + CmuxAgentSessionRegistry.Record( + provider: "codex", sessionID: "malformed", + updatedAt: 500, json: Data("{}".utf8) + ), + ]) + + let loaded = RestorableAgentHookSessionStoreFile.load( + provider: "codex", legacyURL: legacyURL, environment: environment, + fileManager: .default, decoder: decoder + ) + #expect( + loaded == nil, + "One malformed authoritative record must reject the provider instead of reviving stale legacy state." + ) + } + + @Test func restoreLoaderRejectsStaleLegacyWhenRegistryFileIsCorrupt() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-restore-corrupt-registry-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let legacyURL = root.appendingPathComponent("codex-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let sessionID = "stale-legacy-session" + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: [ + "sessionId": sessionID, + "workspaceId": "11111111-1111-1111-1111-111111111111", + "surfaceId": "22222222-2222-2222-2222-222222222222", + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + ]).write(to: legacyURL, options: .atomic) + try Data("not-a-sqlite-database".utf8).write(to: registryURL, options: .atomic) + + let loaded = RestorableAgentHookSessionStoreFile.load( + provider: "codex", + legacyURL: legacyURL, + environment: ["CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path], + fileManager: .default, + decoder: JSONDecoder() + ) + + #expect( + loaded == nil, + "An existing unreadable registry is authoritative and must not revive stale legacy state." + ) + } + + @Test func restoreLoaderRejectsStaleLegacyWhenCanonicalSlotScopeIsCorrupt() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-restore-corrupt-slot-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let legacyURL = root.appendingPathComponent("codex-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let sessionID = "stale-active-legacy-session" + let workspaceID = "11111111-1111-1111-1111-111111111111" + let surfaceID = "22222222-2222-2222-2222-222222222222" + let activeRecord: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": workspaceID, + "surfaceId": surfaceID, + "restoreAuthority": true, + "sessionState": "active", + "startedAt": 100.0, + "updatedAt": 200.0, + ] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: activeRecord], + "activeSessionsBySurface": [surfaceID: [ + "sessionId": sessionID, + "updatedAt": 200.0, + ]], + ], options: [.sortedKeys]).write(to: legacyURL, options: .atomic) + let environment = ["CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path] + let decoder = JSONDecoder() + #expect(RestorableAgentHookSessionStoreFile.load( + provider: "codex", + legacyURL: legacyURL, + environment: environment, + fileManager: .default, + decoder: decoder + )?.sessions[sessionID]?.restoreAuthority == true) + + var completedRecord = activeRecord + completedRecord["restoreAuthority"] = false + completedRecord["sessionState"] = "ended" + completedRecord["completedAt"] = 300.0 + completedRecord["updatedAt"] = 300.0 + let registry = CmuxAgentSessionRegistry(url: registryURL) + try registry.apply( + provider: "codex", + records: [CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: sessionID, + updatedAt: 300, + json: try JSONSerialization.data(withJSONObject: completedRecord, options: [.sortedKeys]) + )], + activeSlots: [CmuxAgentSessionRegistry.ActiveSlot( + provider: "codex", + scope: .surface, + scopeID: surfaceID, + sessionID: sessionID, + updatedAt: 300, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "updatedAt": 300.0, + ], options: [.sortedKeys]) + )] + ) + + var database: OpaquePointer? + #expect(sqlite3_open(registryURL.path, &database) == SQLITE_OK) + let openedDatabase = try #require(database) + defer { sqlite3_close(openedDatabase) } + #expect(sqlite3_exec( + openedDatabase, + "UPDATE agent_active_slots SET scope = 'bogus' WHERE provider = 'codex'", + nil, + nil, + nil + ) == SQLITE_OK) + #expect(sqlite3_changes(openedDatabase) == 1) + + let loaded = RestorableAgentHookSessionStoreFile.load( + provider: "codex", + legacyURL: legacyURL, + environment: environment, + fileManager: .default, + decoder: decoder + ) + #expect( + loaded == nil, + "A valid registry with corrupt typed slot data must not revive stale active legacy state." + ) + } + + @Test func appRestoreLoaderHonorsExplicitAgentRegistryPath() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-explicit-registry-\(UUID().uuidString)", isDirectory: true) + let legacyDirectory = root.appendingPathComponent("legacy", isDirectory: true) + let registryURL = root.appendingPathComponent("custom-agent-sessions.sqlite3") + try FileManager.default.createDirectory(at: legacyDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sessionID = "configured-registry-session" + let record: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": "workspace", + "surfaceId": "surface", + "startedAt": 100.0, + "updatedAt": 200.0, + ] + try CmuxAgentSessionRegistry(url: registryURL).apply(provider: "codex", records: [ + CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: sessionID, + updatedAt: 200, + json: try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + ), + ]) + + let snapshots = RestorableAgentSessionIndex.agentRegistrySnapshots( + [(.codex, legacyDirectory.appendingPathComponent("codex-hook-sessions.json"))], + fileManager: .default, + environment: ["CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path] + ) + + #expect(snapshots?["codex"]?.records.contains { $0.sessionID == sessionID } == true) + } + + @Test func hibernationRegistryLoadSelectsOpenPanelOwnersAndExactDetections() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-registry-projection-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let registry = CmuxAgentSessionRegistry(url: registryURL) + let workspaceID = UUID() + let panelID = UUID() + let activeSessionID = "active-owner" + let detectedSessionID = "detected-session" + let unrelatedSessionID = "unrelated-history" + + func record(_ sessionID: String, workspaceID: UUID, panelID: UUID) throws + -> CmuxAgentSessionRegistry.Record { + CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: sessionID, + updatedAt: 100, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "workspaceId": workspaceID.uuidString, + "surfaceId": panelID.uuidString, + "restoreAuthority": true, + "updatedAt": 100.0, + ], options: [.sortedKeys]) + ) + } + let slotJSON = try JSONSerialization.data(withJSONObject: [ + "sessionId": activeSessionID, + "updatedAt": 100.0, + ], options: [.sortedKeys]) + try registry.apply( + provider: "codex", + records: [ + try record(activeSessionID, workspaceID: workspaceID, panelID: panelID), + try record(detectedSessionID, workspaceID: UUID(), panelID: UUID()), + try record(unrelatedSessionID, workspaceID: UUID(), panelID: UUID()), + ], + activeSlots: [ + .init( + provider: "codex", + scope: .workspace, + scopeID: workspaceID.uuidString, + sessionID: activeSessionID, + updatedAt: 100, + json: slotJSON + ), + .init( + provider: "codex", + scope: .surface, + scopeID: panelID.uuidString, + sessionID: activeSessionID, + updatedAt: 100, + json: slotJSON + ), + ] + ) + + let result = RestorableAgentSessionIndex.agentRegistryHibernationSnapshots( + [(.codex, root.appendingPathComponent("codex-hook-sessions.json"))], + panelKeys: [.init(workspaceId: workspaceID, panelId: panelID)], + exactSessionIDsByProvider: ["codex": [detectedSessionID]], + fileManager: .default, + environment: ["CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path] + ) + + #expect(result.failedProviders.isEmpty) + #expect(Set(result.snapshots["codex"]?.records.map(\.sessionID) ?? []) == [ + activeSessionID, detectedSessionID, + ]) + #expect(result.snapshots["codex"]?.activeSlots.count == 1) + } + + @Test func hibernationRegistryRefreshPrioritizesKnownRelevantProviders() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-refresh-priority-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let registry = CmuxAgentSessionRegistry(url: registryURL) + let workspaceID = UUID() + let panelID = UUID() + let panelProvider = "zz-panel-relevant" + let panelSessionID = "panel-owner" + let exactProvider = "zy-exact-relevant" + let exactSessionID = "exact-owner" + + func recordJSON( + sessionID: String, + workspaceID: UUID, + panelID: UUID, + padding: Int = 0 + ) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "workspaceId": workspaceID.uuidString, + "surfaceId": panelID.uuidString, + "startedAt": 1.0, + "updatedAt": 2.0, + "padding": String(repeating: "x", count: padding), + ], options: [.sortedKeys]) + } + + func legacyData( + sessionID: String, + workspaceID: UUID, + panelID: UUID, + padding: Int = 0 + ) throws -> Data { + let record = try JSONSerialization.jsonObject(with: recordJSON( + sessionID: sessionID, + workspaceID: workspaceID, + panelID: panelID, + padding: padding + )) + return try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: record], + "activeSessionsByWorkspace": [:], + "activeSessionsBySurface": [:], + ], options: [.sortedKeys]) + } + + let panelRecordJSON = try recordJSON( + sessionID: panelSessionID, + workspaceID: workspaceID, + panelID: panelID + ) + let panelSlotJSON = try JSONSerialization.data(withJSONObject: [ + "sessionId": panelSessionID, + "updatedAt": 2.0, + ], options: [.sortedKeys]) + try registry.apply( + provider: panelProvider, + records: [.init( + provider: panelProvider, + sessionID: panelSessionID, + updatedAt: 2, + json: panelRecordJSON + )], + activeSlots: [.init( + provider: panelProvider, + scope: .surface, + scopeID: panelID.uuidString, + sessionID: panelSessionID, + updatedAt: 2, + json: panelSlotJSON + )] + ) + + let panelLegacy = try legacyData( + sessionID: panelSessionID, + workspaceID: workspaceID, + panelID: panelID, + padding: 2_048 + ) + let exactLegacy = try legacyData( + sessionID: exactSessionID, + workspaceID: UUID(), + panelID: UUID(), + padding: 2_048 + ) + let panelURL = root.appendingPathComponent("\(panelProvider)-hook-sessions.json") + let exactURL = root.appendingPathComponent("\(exactProvider)-hook-sessions.json") + try panelLegacy.write(to: panelURL, options: .atomic) + try exactLegacy.write(to: exactURL, options: .atomic) + + let noiseLegacy = try legacyData( + sessionID: "noise", + workspaceID: UUID(), + panelID: UUID() + ) + var noiseProviders = Set() + var sources: [(kind: RestorableAgentKind, fileURL: URL)] = [] + for index in 0..<32 { + let provider = String(format: "aa-noise-%02d", index) + let url = root.appendingPathComponent("\(provider)-hook-sessions.json") + try noiseLegacy.write(to: url, options: .atomic) + sources.append((.custom(provider), url)) + noiseProviders.insert(provider) + } + sources.append((.custom(panelProvider), panelURL)) + sources.append((.custom(exactProvider), exactURL)) + #expect(Int64(noiseLegacy.count * 32) >= Int64(panelLegacy.count + exactLegacy.count)) + + let result = RestorableAgentSessionIndex.agentRegistryHibernationSnapshots( + sources, + panelKeys: [.init(workspaceId: workspaceID, panelId: panelID)], + exactSessionIDsByProvider: [exactProvider: [exactSessionID]], + maximumLegacySourceReadBytes: Int64(panelLegacy.count + exactLegacy.count), + fileManager: .default, + environment: ["CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path] + ) + + #expect(result.failedProviders == noiseProviders) + #expect(result.snapshots[panelProvider]?.records.map(\.sessionID) == [panelSessionID]) + #expect(result.snapshots[panelProvider]?.activeSlots.map(\.sessionID) == [panelSessionID]) + #expect(result.snapshots[exactProvider]?.records.map(\.sessionID) == [exactSessionID]) + #expect(result.snapshots[exactProvider]?.activeSlots.isEmpty == true) + #expect(noiseProviders.allSatisfy { result.snapshots[$0]?.records.isEmpty == true }) + } + + @Test func hibernationRegistryLoadFailsClosedPerProviderAtProjectionLimit() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-registry-limit-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let registry = CmuxAgentSessionRegistry(url: registryURL) + for sessionID in ["first", "second"] { + try registry.apply(provider: "codex", records: [.init( + provider: "codex", + sessionID: sessionID, + updatedAt: 100, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "workspaceId": UUID().uuidString, + "surfaceId": UUID().uuidString, + "updatedAt": 100.0, + ], options: [.sortedKeys]) + )]) + } + + let result = RestorableAgentSessionIndex.agentRegistryHibernationSnapshots( + [(.codex, root.appendingPathComponent("codex-hook-sessions.json"))], + panelKeys: [], + exactSessionIDsByProvider: ["codex": ["first", "second"]], + maximumRecords: 1, + fileManager: .default, + environment: ["CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path] + ) + + #expect(result.failedProviders == ["codex"]) + #expect(result.snapshots["codex"]?.records.isEmpty == true) + #expect(result.snapshots["codex"]?.activeSlots.isEmpty == true) + } + + @Test func hibernationRegistryLoadIgnoresIrrelevantProviderCap() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-registry-provider-cap-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let registry = CmuxAgentSessionRegistry(url: registryURL) + let workspaceID = UUID() + let panelID = UUID() + let provider = "provider-64" + let sessionID = "relevant-owner" + let recordJSON = try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "workspaceId": workspaceID.uuidString, + "surfaceId": panelID.uuidString, + "updatedAt": 100.0, + ], options: [.sortedKeys]) + let slotJSON = try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "updatedAt": 100.0, + ], options: [.sortedKeys]) + try registry.apply( + provider: provider, + records: [.init( + provider: provider, + sessionID: sessionID, + updatedAt: 100, + json: recordJSON + )], + activeSlots: [.init( + provider: provider, + scope: .surface, + scopeID: panelID.uuidString, + sessionID: sessionID, + updatedAt: 100, + json: slotJSON + )] + ) + let sources: [(kind: RestorableAgentKind, fileURL: URL)] = (0..<65).map { index in + let id = "provider-\(index)" + return (.custom(id), root.appendingPathComponent("\(id)-hook-sessions.json")) + } + + let result = RestorableAgentSessionIndex.agentRegistryHibernationSnapshots( + sources, + panelKeys: [.init(workspaceId: workspaceID, panelId: panelID)], + exactSessionIDsByProvider: [:], + fileManager: .default, + environment: ["CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path] + ) + + #expect(!result.failedProviders.contains(provider)) + #expect(result.snapshots[provider]?.records.map(\.sessionID) == [sessionID]) + #expect(result.snapshots[provider]?.activeSlots.map(\.sessionID) == [sessionID]) + } + + @Test func hibernationRegistrySnapshotDoesNotFallBackAfterMalformedCanonicalProjection() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-registry-malformed-provider-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let legacyURL = root.appendingPathComponent("broken-hook-sessions.json") + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": ["stale": [ + "sessionId": "stale", + "workspaceId": UUID().uuidString, + "surfaceId": UUID().uuidString, + "startedAt": 1.0, + "updatedAt": 1.0, + ]], + ], options: [.sortedKeys]).write(to: legacyURL) + let snapshots = [ + "broken": CmuxAgentSessionRegistry.Snapshot( + records: [.init( + provider: "broken", + sessionID: "malformed", + updatedAt: 2, + json: Data("{}".utf8) + )], + activeSlots: [] + ), + ] + + let state = RestorableAgentSessionIndex.agentHookState( + kind: .custom("broken"), + fileURL: legacyURL, + snapshots: snapshots, + fileManager: .default, + decoder: JSONDecoder() + ) + + #expect(state == nil, "A malformed canonical provider must not revive stale sidecar state.") + } + + @Test func hibernationIndexDoesNotInspectUnrelatedHistoricalProcesses() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-index-projection-\(UUID().uuidString)", isDirectory: true) + let stateDirectory = root.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let selectedWorkspaceID = UUID() + let selectedPanelID = UUID() + let selectedSessionID = "selected-session" + let selectedPID = 91_001 + let unrelatedSessionID = "unrelated-session" + let unrelatedPID = 91_002 + let registry = CmuxAgentSessionRegistry( + url: stateDirectory.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + + func record( + sessionID: String, + workspaceID: UUID, + panelID: UUID, + pid: Int + ) throws -> CmuxAgentSessionRegistry.Record { + let object: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": workspaceID.uuidString, + "surfaceId": panelID.uuidString, + "cwd": root.path, + "pid": pid, + "isRestorable": true, + "restoreAuthority": true, + "updatedAt": 100.0, + "launchCommand": [ + "launcher": "codex", + "executablePath": "/usr/local/bin/codex", + "arguments": ["/usr/local/bin/codex"], + "workingDirectory": root.path, + "capturedAt": 100.0, + "source": "test", + ], + ] + return .init( + provider: "codex", + sessionID: sessionID, + updatedAt: 100, + json: try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + ) + } + + let slotJSON = try JSONSerialization.data(withJSONObject: [ + "sessionId": selectedSessionID, + "updatedAt": 100.0, + ], options: [.sortedKeys]) + try registry.apply( + provider: "codex", + records: [ + try record( + sessionID: selectedSessionID, + workspaceID: selectedWorkspaceID, + panelID: selectedPanelID, + pid: selectedPID + ), + try record( + sessionID: unrelatedSessionID, + workspaceID: UUID(), + panelID: UUID(), + pid: unrelatedPID + ), + ], + activeSlots: [ + .init( + provider: "codex", + scope: .surface, + scopeID: selectedPanelID.uuidString, + sessionID: selectedSessionID, + updatedAt: 100, + json: slotJSON + ), + ] + ) + + var inspectedProcessIDs = Set() + let index = RestorableAgentSessionIndex.load( + homeDirectory: root.path, + fileManager: .default, + registry: CmuxVaultAgentRegistry(registrations: []), + detectedSnapshots: [:], + hibernationPanelKeys: [ + .init(workspaceId: selectedWorkspaceID, panelId: selectedPanelID), + ], + processArgumentsProvider: { pid in + inspectedProcessIDs.insert(pid) + return nil + } + ) + + #expect(index.exactEntry( + workspaceId: selectedWorkspaceID, + panelId: selectedPanelID + )?.snapshot.sessionId == selectedSessionID) + #expect(inspectedProcessIDs == [selectedPID]) + #expect(!inspectedProcessIDs.contains(unrelatedPID)) + } + + @Test func agentHookRuntimeIdentityPrefersTheConnectedSocketOverMissingOrStaleEnvironment() throws { + let socketCapabilities: [String: Any] = [ + "runtime_id": "socket-runtime", + "socket_path": "/tmp/cmux-debug-current.sock", + "bundle_identifier": "com.cmuxterm.current", + ] + + let missing = try #require(AgentCmuxRuntimeIdentity.resolve( + environment: [:], + socketCapabilities: socketCapabilities + )) + #expect(missing.id == "socket-runtime") + #expect(missing.socketPath == "/tmp/cmux-debug-current.sock") + #expect(missing.bundleIdentifier == "com.cmuxterm.current") + + let stale = try #require(AgentCmuxRuntimeIdentity.resolve( + environment: [ + "CMUX_RUNTIME_ID": "stale-runtime", + "CMUX_SOCKET_PATH": "/tmp/cmux-debug-current.sock", + "CMUX_BUNDLE_ID": "com.cmuxterm.stale", + ], + socketCapabilities: socketCapabilities + )) + #expect(stale == missing) + + let storeEnvironment = stale.applying(to: [:]) + #expect(storeEnvironment["CMUX_RUNTIME_ID"] == "socket-runtime") + #expect(storeEnvironment["CMUX_SOCKET_PATH"] == "/tmp/cmux-debug-current.sock") + #expect(storeEnvironment["CMUX_BUNDLE_ID"] == "com.cmuxterm.current") + + let legacy = try #require(AgentCmuxRuntimeIdentity.resolve( + environment: ["CMUX_RUNTIME_ID": "legacy-runtime"], + socketCapabilities: [:] + )) + #expect(legacy.id == "legacy-runtime") + } + + @Test func cliRuntimeIdentityIgnoresNewerAppMetadataFields() throws { + let data = try JSONSerialization.data(withJSONObject: [ + "id": "forward-compatible-runtime", + "socketPath": "/tmp/cmux-forward-compatible.sock", + "bundleIdentifier": "com.cmuxterm.forward-compatible", + "processId": 42, + "processStartSeconds": 1_234, + "processStartMicroseconds": 567_890, + "futureRuntimeMetadata": ["generation": 9], + ], options: [.sortedKeys]) + + let runtime = try JSONDecoder().decode(AgentCmuxRuntimeIdentity.self, from: data) + + #expect(runtime == AgentCmuxRuntimeIdentity( + id: "forward-compatible-runtime", + socketPath: "/tmp/cmux-forward-compatible.sock", + bundleIdentifier: "com.cmuxterm.forward-compatible" + )) + } +} diff --git a/cmuxTests/AgentSessionRuntimeScopingRegressionTests.swift b/cmuxTests/AgentSessionRuntimeScopingRegressionTests.swift new file mode 100644 index 000000000000..1dd73d1859d8 --- /dev/null +++ b/cmuxTests/AgentSessionRuntimeScopingRegressionTests.swift @@ -0,0 +1,5993 @@ +import AppKit +import CmuxFoundation +import CmuxSettings +import CmuxTerminal +import Darwin +import Foundation +import os +import SQLite3 +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension CMUXCLIErrorOutputRegressionTests { + @MainActor + @Test func restoredHibernationAdoptsCurrentRuntimeAndPanelBindingBeforeAgentQueries() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-hibernation-runtime-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let sessionID = "restored-hibernation" + let runtimeID = "restored-runtime" + let environmentOverrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": runtimeID, + "CMUX_SOCKET_PATH": "/tmp/cmux-restored-runtime.sock", + "CMUX_BUNDLE_ID": "com.cmuxterm.restored-runtime", + ] + let previousEnvironment = environmentOverrides.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environmentOverrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let source = Workspace() + let sourcePanelID = try #require(source.focusedPanelId) + let sourcePanel = try #require(source.terminalPanel(for: sourcePanelID)) + let sourcePaneID = try #require(source.paneId(forPanelId: sourcePanelID)) + _ = try #require(source.newTerminalSurface(inPane: sourcePaneID, focus: true)) + let agent = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: root.path, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex"], + workingDirectory: root.path, + environment: nil, + capturedAt: 90, + source: "agent-hook" + ) + ) + #expect(sourcePanel.enterAgentHibernation( + agent: agent, + lastActivityAt: Date(timeIntervalSince1970: 90), + hibernatedAt: Date(timeIntervalSince1970: 100) + )) + + let oldRuntime = provablyDeadRuntime(id: "previous-runtime") + let activeSlot: [String: Any] = ["sessionId": sessionID, "updatedAt": 100.0] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: [ + "sessionId": sessionID, + "workspaceId": source.id.uuidString, + "surfaceId": sourcePanelID.uuidString, + "runId": "run", + "activeRunId": "run", + "restoreAuthority": true, + "foregroundState": "completed", + "sessionState": "hibernated", + "cmuxRuntime": oldRuntime, + "runs": [[ + "runId": "run", + "restoreAuthority": true, + "cmuxRuntime": oldRuntime, + "startedAt": 90.0, + "updatedAt": 100.0, + ]], + "startedAt": 90.0, + "updatedAt": 100.0, + ]], + "activeSessionsByWorkspace": [source.id.uuidString: activeSlot], + "activeSessionsBySurface": [sourcePanelID.uuidString: activeSlot], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": ["unrelated-claude": [ + "sessionId": "unrelated-claude", + "workspaceId": UUID().uuidString, + "surfaceId": UUID().uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 90.0, + "updatedAt": 100.0, + ]], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("claude-hook-sessions.json"), + options: .atomic + ) + let registry = CmuxAgentSessionRegistry(url: registryURL) + + let sourceSnapshot = source.sessionSnapshot(includeScrollback: false) + let selectedShellWorkspace = Workspace() + let selectedShellSnapshot = selectedShellWorkspace.sessionSnapshot(includeScrollback: false) + let restoredWindowID = UUID() + let appDelegate = try #require(AppDelegate.shared) + defer { + _ = appDelegate.closeMainWindow(windowId: restoredWindowID, recordHistory: false) + } + let appSnapshot = AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: 100, + windows: [SessionWindowSnapshot( + windowId: restoredWindowID, + frame: nil, + display: nil, + tabManager: SessionTabManagerSnapshot( + selectedWorkspaceIndex: 0, + workspaces: [selectedShellSnapshot, sourceSnapshot] + ), + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: nil) + )] + ) + #expect(appDelegate.restorePreviousSessionSnapshot(appSnapshot, shouldActivate: false)) + let restoredManager = try #require(appDelegate.tabManagerFor(windowId: restoredWindowID)) + let restored = try #require(restoredManager.tabs.first { workspace in + workspace.sessionSnapshot(includeScrollback: false).panels.contains { + $0.terminal?.agent?.sessionId == sessionID + } + }) + let restoredPanelSnapshot = try #require( + restored.sessionSnapshot(includeScrollback: false).panels.first { + $0.terminal?.agent?.sessionId == sessionID + } + ) + let restoredPanelID = restoredPanelSnapshot.id + #expect(restored.id != source.id) + #expect(restoredPanelID != sourcePanelID) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) + #expect(restoredPanel.isAgentHibernated) + #expect(!restoredPanel.hostedView.debugPortalActive) + + let snapshot = try registry.snapshot(provider: "codex") + let unrelatedProviderSnapshot = try registry.snapshot(provider: "claude") + #expect(unrelatedProviderSnapshot.records.isEmpty) + let record = try #require(snapshot.records.first(where: { $0.sessionID == sessionID })) + let recordObject = try #require( + JSONSerialization.jsonObject(with: record.json) as? [String: Any] + ) + #expect(recordObject["sessionState"] as? String == "hibernated") + let runs = try #require(recordObject["runs"] as? [[String: Any]]) + #expect((runs.first?["cmuxRuntime"] as? [String: Any])?["id"] as? String == runtimeID) + let sessionSlots = snapshot.activeSlots.filter { $0.sessionID == sessionID } + #expect(Set(sessionSlots.map { $0.scopeID }) == [restored.id.uuidString, restoredPanelID.uuidString]) + #expect(Set(sessionSlots.map { $0.scope.rawValue }) == ["workspace", "surface"]) + + var cliEnvironment = ProcessInfo.processInfo.environment + for key in Array(cliEnvironment.keys) where key.hasPrefix("CMUX_") { + cliEnvironment.removeValue(forKey: key) + } + cliEnvironment.merge(environmentOverrides) { _, new in new } + cliEnvironment["CMUX_CLI_SENTRY_DISABLED"] = "1" + cliEnvironment["HOME"] = root.path + for command in [["agents", "list", "--json"], ["agents", "tree", "--json"]] { + let result = runProcess( + executablePath: cliPath, + arguments: command, + environment: cliEnvironment, + timeout: 5 + ) + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let output = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let rows = (output["sessions"] as? [[String: Any]]) ?? (output["nodes"] as? [[String: Any]]) + let restoredRow = try #require(rows?.first { $0["session_id"] as? String == sessionID }) + #expect(restoredRow["workspace_id"] as? String == restored.id.uuidString) + #expect(restoredRow["surface_id"] as? String == restoredPanelID.uuidString) + #expect(restoredRow["session_state"] as? String == "hibernated") + } + + restored.setAgentHibernationAutoResumePresentationVisible(true) + #expect(!restoredPanel.isAgentHibernated) + _ = restored.reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + #expect(restoredPanel.hostedView.debugPortalActive) + } + + @MainActor + @Test func restoredHibernationAdoptionFailsFastWhileLegacyWriterIsLocked() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-hibernation-legacy-lock-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environmentOverrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "legacy-lock-runtime", + ] + let previousEnvironment = environmentOverrides.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environmentOverrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "legacy-lock-session" + ) + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [fixture.agent.sessionId: [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + + let descriptor = open( + stateURL.path + ".lock", + O_CREAT | O_RDWR, + mode_t(S_IRUSR | S_IWUSR) + ) + #expect(descriptor >= 0) + guard descriptor >= 0 else { return } + defer { Darwin.close(descriptor) } + #expect(flock(descriptor, LOCK_EX | LOCK_NB) == 0) + defer { _ = flock(descriptor, LOCK_UN) } + + let targetSurfaceID = UUID() + let elapsed = ContinuousClock().measure { + let outcomes = AgentHookSessionStateWriter.recordRestoredHibernationOutcomes([ + .init( + agent: fixture.agent, + previousWorkspaceId: fixture.source.id, + previousSurfaceId: fixture.sourcePanelID, + workspaceId: UUID(), + surfaceId: targetSurfaceID + ), + ]) + #expect(outcomes[targetSurfaceID] == .unavailable) + } + #expect(elapsed < .seconds(1)) + #expect(!FileManager.default.fileExists(atPath: registryURL.path)) + } + + @MainActor + @Test func backgroundAdoptionWakesWhenLegacyWriterUnlocks() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-legacy-unlock-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "legacy-unlock-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture(root: root, sessionID: "legacy-unlock-session") + let registry = try installHibernatedAuthority( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID + ) + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let descriptor = open( + stateURL.path + ".lock", + O_CREAT | O_RDWR, + mode_t(S_IRUSR | S_IWUSR) + ) + let lockDescriptor = try #require(descriptor >= 0 ? descriptor : nil) + defer { Darwin.close(lockDescriptor) } + #expect(flock(lockDescriptor, LOCK_EX | LOCK_NB) == 0) + defer { _ = flock(lockDescriptor, LOCK_UN) } + + let targetWorkspaceID = UUID() + let targetSurfaceID = UUID() + let request = AgentHookSessionStateWriter.RestoredHibernationAdoptionRequest( + agent: fixture.agent, + previousWorkspaceId: fixture.source.id, + previousSurfaceId: fixture.sourcePanelID, + workspaceId: targetWorkspaceID, + surfaceId: targetSurfaceID + ) + let waitStarted = AgentSessionAsyncGate() + let operation = Task { + await AgentHookSessionStateWriter.waitForRestoredHibernationOutcomes( + [request], + busyTimeoutMilliseconds: 2_000, + legacyReadLockWaitWillBegin: { + Task { await waitStarted.open() } + } + ) + } + await waitStarted.waitUntilOpen() + #expect(flock(lockDescriptor, LOCK_UN) == 0) + + let outcomes = await operation.value + #expect(outcomes[targetSurfaceID] == .adopted) + let adopted = try #require( + try registry.hookRecord(provider: "codex", sessionID: fixture.agent.sessionId) + ) + let object = try #require( + JSONSerialization.jsonObject(with: adopted.json) as? [String: Any] + ) + #expect(object["workspaceId"] as? String == targetWorkspaceID.uuidString) + #expect(object["surfaceId"] as? String == targetSurfaceID.uuidString) + } + + @MainActor + @Test func backgroundAdoptionLegacyLockWaitHonorsCancellationAndDeadline() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-legacy-cancel-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root + .appendingPathComponent(CmuxAgentSessionRegistry.filename).path, + "CMUX_RUNTIME_ID": "legacy-cancel-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let agent = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: "legacy-cancel-session", + workingDirectory: root.path, + launchCommand: nil + ) + let targetSurfaceID = UUID() + let request = AgentHookSessionStateWriter.RestoredHibernationAdoptionRequest( + agent: agent, + previousWorkspaceId: UUID(), + previousSurfaceId: UUID(), + workspaceId: UUID(), + surfaceId: targetSurfaceID + ) + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let descriptor = open( + stateURL.path + ".lock", + O_CREAT | O_RDWR, + mode_t(S_IRUSR | S_IWUSR) + ) + let lockDescriptor = try #require(descriptor >= 0 ? descriptor : nil) + defer { Darwin.close(lockDescriptor) } + #expect(flock(lockDescriptor, LOCK_EX | LOCK_NB) == 0) + defer { _ = flock(lockDescriptor, LOCK_UN) } + + let waitStarted = AgentSessionAsyncGate() + let canceledOperation = Task { + await AgentHookSessionStateWriter.waitForRestoredHibernationOutcomes( + [request], + busyTimeoutMilliseconds: 5_000, + legacyReadLockWaitWillBegin: { + Task { await waitStarted.open() } + } + ) + } + await waitStarted.waitUntilOpen() + let cancellationStart = ContinuousClock.now + canceledOperation.cancel() + let canceledOutcomes = await canceledOperation.value + let cancellationElapsed = cancellationStart.duration(to: .now) + #expect(canceledOutcomes[targetSurfaceID] == .unavailable) + #expect(cancellationElapsed < .seconds(1)) + + let deadlineStart = ContinuousClock.now + let deadlineOutcomes = await AgentHookSessionStateWriter.waitForRestoredHibernationOutcomes( + [request], + busyTimeoutMilliseconds: 100 + ) + let deadlineElapsed = deadlineStart.duration(to: .now) + #expect(deadlineOutcomes[targetSurfaceID] == .unavailable) + #expect(deadlineElapsed < .seconds(1)) + } + + @MainActor + @Test func multipleProviderLegacyLocksShareOneBusyBudget() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-legacy-budget-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": root + .appendingPathComponent(CmuxAgentSessionRegistry.filename).path, + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let kinds: [RestorableAgentKind] = [.claude, .codex] + var descriptors: [Int32] = [] + for kind in kinds { + let stateURL = kind.hookStoreFileURL( + homeDirectory: root.path, + environment: overrides + ) + let descriptor = open( + stateURL.path + ".lock", + O_CREAT | O_RDWR, + mode_t(S_IRUSR | S_IWUSR) + ) + let lockDescriptor = try #require(descriptor >= 0 ? descriptor : nil) + #expect(flock(lockDescriptor, LOCK_EX | LOCK_NB) == 0) + descriptors.append(lockDescriptor) + } + defer { + for descriptor in descriptors { + _ = flock(descriptor, LOCK_UN) + Darwin.close(descriptor) + } + } + + let requests = kinds.map { kind in + AgentHookSessionStateWriter.RestoredHibernationAdoptionRequest( + agent: SessionRestorableAgentSnapshot( + kind: kind, + sessionId: "\(kind.rawValue)-budget-session", + workingDirectory: root.path, + launchCommand: nil + ), + previousWorkspaceId: UUID(), + previousSurfaceId: UUID(), + workspaceId: UUID(), + surfaceId: UUID() + ) + } + let started = ContinuousClock.now + let outcomes = await AgentHookSessionStateWriter.waitForRestoredHibernationOutcomes( + requests, + busyTimeoutMilliseconds: 500 + ) + let elapsed = started.duration(to: .now) + #expect(outcomes.values.allSatisfy { $0 == .unavailable }) + #expect(outcomes.count == 2) + #expect(elapsed < .milliseconds(800)) + } + + @MainActor + @Test func legacyProjectionDoesNotOverwriteAWriterThatClaimsTheAdoptedTarget() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-projection-target-race-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let sessionID = "restored-session" + let otherSessionID = "intervening-session" + let previousWorkspaceID = UUID() + let previousSurfaceID = UUID() + let targetWorkspaceID = UUID() + let targetSurfaceID = UUID() + let restoredSlot: [String: Any] = ["sessionId": sessionID, "updatedAt": 10.0] + let interveningSlot: [String: Any] = ["sessionId": otherSessionID, "updatedAt": 30.0] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + sessionID: [ + "sessionId": sessionID, + "workspaceId": previousWorkspaceID.uuidString, + "surfaceId": previousSurfaceID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 1.0, + "updatedAt": 10.0, + ], + otherSessionID: [ + "sessionId": otherSessionID, + "workspaceId": targetWorkspaceID.uuidString, + "surfaceId": targetSurfaceID.uuidString, + "sessionState": "active", + "restoreAuthority": true, + "startedAt": 25.0, + "updatedAt": 30.0, + ], + ], + "activeSessionsByWorkspace": [ + previousWorkspaceID.uuidString: restoredSlot, + targetWorkspaceID.uuidString: interveningSlot, + ], + "activeSessionsBySurface": [ + previousSurfaceID.uuidString: restoredSlot, + targetSurfaceID.uuidString: interveningSlot, + ], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let agent = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: root.path, + launchCommand: nil + ) + let request = AgentHookSessionStateWriter.RestoredHibernationAdoptionRequest( + agent: agent, + previousWorkspaceId: previousWorkspaceID, + previousSurfaceId: previousSurfaceID, + workspaceId: targetWorkspaceID, + surfaceId: targetSurfaceID, + rebindWorkspaceActiveSlot: true + ) + + AgentHookSessionStateWriter( + homeDirectory: root.path, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": root.path] + ).projectRestoredHibernationsToLegacy( + provider: "codex", + stateURL: stateURL, + requests: [request], + now: 20 + ) + + let rootObject = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any] + ) + let sessions = try #require(rootObject["sessions"] as? [String: [String: Any]]) + #expect(sessions[sessionID]?["workspaceId"] as? String == previousWorkspaceID.uuidString) + #expect(sessions[sessionID]?["surfaceId"] as? String == previousSurfaceID.uuidString) + let workspaceSlots = try #require( + rootObject["activeSessionsByWorkspace"] as? [String: [String: Any]] + ) + let surfaceSlots = try #require( + rootObject["activeSessionsBySurface"] as? [String: [String: Any]] + ) + #expect(workspaceSlots[targetWorkspaceID.uuidString]?["sessionId"] as? String == otherSessionID) + #expect(surfaceSlots[targetSurfaceID.uuidString]?["sessionId"] as? String == otherSessionID) + #expect(workspaceSlots[previousWorkspaceID.uuidString]?["sessionId"] as? String == sessionID) + #expect(surfaceSlots[previousSurfaceID.uuidString]?["sessionId"] as? String == sessionID) + } + + @MainActor + @Test func sameWorkspaceHibernationsTransferEverySurfaceAndOneWorkspaceOwnerInEitherOrder() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-same-workspace-batch-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + for reversed in [false, true] { + try { () throws -> Void in + let scenarioRoot = root.appendingPathComponent(reversed ? "reversed" : "forward", isDirectory: true) + try FileManager.default.createDirectory(at: scenarioRoot, withIntermediateDirectories: true) + let registryURL = scenarioRoot.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": scenarioRoot.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "same-workspace-runtime", + ] + let previous = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previous { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let previousWorkspaceID = UUID() + let targetWorkspaceID = UUID() + let sessionA = "same-workspace-a" + let sessionB = "same-workspace-b" + let previousSurfaceA = UUID() + let previousSurfaceB = UUID() + let targetSurfaceA = UUID() + let targetSurfaceB = UUID() + let slotA: [String: Any] = ["sessionId": sessionA, "updatedAt": 10.0] + let slotB: [String: Any] = ["sessionId": sessionB, "updatedAt": 10.0] + func record(_ sessionID: String, surfaceID: UUID) -> [String: Any] { + [ + "sessionId": sessionID, + "workspaceId": previousWorkspaceID.uuidString, + "surfaceId": surfaceID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "cmuxRuntime": provablyDeadRuntime( + id: "same-workspace-retired-runtime" + ), + "startedAt": 1.0, + "updatedAt": 10.0, + ] + } + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + sessionA: record(sessionA, surfaceID: previousSurfaceA), + sessionB: record(sessionB, surfaceID: previousSurfaceB), + ], + "activeSessionsByWorkspace": [previousWorkspaceID.uuidString: slotA], + "activeSessionsBySurface": [ + previousSurfaceA.uuidString: slotA, + previousSurfaceB.uuidString: slotB, + ], + ], options: [.sortedKeys]).write( + to: scenarioRoot.appendingPathComponent("pi-hook-sessions.json"), + options: .atomic + ) + func request( + _ sessionID: String, + kind: RestorableAgentKind, + previous: UUID, + target: UUID + ) -> AgentHookSessionStateWriter.RestoredHibernationAdoptionRequest { + .init( + agent: SessionRestorableAgentSnapshot( + kind: kind, + sessionId: sessionID, + workingDirectory: scenarioRoot.path, + launchCommand: nil + ), + previousWorkspaceId: previousWorkspaceID, + previousSurfaceId: previous, + workspaceId: targetWorkspaceID, + surfaceId: target + ) + } + let requestA = request( + sessionA, + kind: .pi, + previous: previousSurfaceA, + target: targetSurfaceA + ) + let requestB = request( + sessionB, + kind: .custom("pi"), + previous: previousSurfaceB, + target: targetSurfaceB + ) + let adopted = AgentHookSessionStateWriter.recordRestoredHibernations( + reversed ? [requestB, requestA] : [requestA, requestB], + now: 20 + ) + + #expect(adopted == [targetSurfaceA, targetSurfaceB]) + let snapshot = try CmuxAgentSessionRegistry(url: registryURL).snapshot(provider: "pi") + let records = try Dictionary(uniqueKeysWithValues: snapshot.records.map { row in + let object = try #require( + JSONSerialization.jsonObject(with: row.json) as? [String: Any] + ) + return (row.sessionID, object) + }) + #expect(records[sessionA]?["workspaceId"] as? String == targetWorkspaceID.uuidString) + #expect(records[sessionA]?["surfaceId"] as? String == targetSurfaceA.uuidString) + #expect(records[sessionB]?["workspaceId"] as? String == targetWorkspaceID.uuidString) + #expect(records[sessionB]?["surfaceId"] as? String == targetSurfaceB.uuidString) + let workspaceSlots = snapshot.activeSlots.filter { $0.scope == .workspace } + #expect(workspaceSlots.count == 1) + #expect(workspaceSlots.first?.sessionID == sessionA) + #expect(workspaceSlots.first?.scopeID == targetWorkspaceID.uuidString) + let surfaceSlots = snapshot.activeSlots.filter { $0.scope == .surface } + #expect(Dictionary(uniqueKeysWithValues: surfaceSlots.map { ($0.scopeID, $0.sessionID) }) == [ + targetSurfaceA.uuidString: sessionA, + targetSurfaceB.uuidString: sessionB, + ]) + }() + } + } + + @MainActor + @Test func collidingTargetSurfaceRejectsOnlyThatSiblingInSameProviderBatch() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-same-provider-collision-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "same-provider-collision-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let previousWorkspaceID = UUID() + let targetWorkspaceID = UUID() + let sessionA = "valid-sibling" + let sessionB = "colliding-sibling" + let collisionSession = "newer-surface-owner" + let previousSurfaceA = UUID() + let previousSurfaceB = UUID() + let targetSurfaceA = UUID() + let targetSurfaceB = UUID() + let collisionWorkspace = UUID() + let slotA: [String: Any] = ["sessionId": sessionA, "updatedAt": 10.0] + let slotB: [String: Any] = ["sessionId": sessionB, "updatedAt": 10.0] + let collisionSlot: [String: Any] = ["sessionId": collisionSession, "updatedAt": 30.0] + func record( + _ sessionID: String, + workspaceID: UUID, + surfaceID: UUID, + state: String, + updatedAt: TimeInterval + ) -> [String: Any] { + [ + "sessionId": sessionID, + "workspaceId": workspaceID.uuidString, + "surfaceId": surfaceID.uuidString, + "sessionState": state, + "restoreAuthority": true, + "cmuxRuntime": provablyDeadRuntime( + id: "collision-retired-\(sessionID)" + ), + "startedAt": 1.0, + "updatedAt": updatedAt, + ] + } + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + sessionA: record( + sessionA, + workspaceID: previousWorkspaceID, + surfaceID: previousSurfaceA, + state: "hibernated", + updatedAt: 10 + ), + sessionB: record( + sessionB, + workspaceID: previousWorkspaceID, + surfaceID: previousSurfaceB, + state: "hibernated", + updatedAt: 10 + ), + collisionSession: record( + collisionSession, + workspaceID: collisionWorkspace, + surfaceID: targetSurfaceB, + state: "active", + updatedAt: 30 + ), + ], + "activeSessionsByWorkspace": [previousWorkspaceID.uuidString: slotA], + "activeSessionsBySurface": [ + previousSurfaceA.uuidString: slotA, + previousSurfaceB.uuidString: slotB, + targetSurfaceB.uuidString: collisionSlot, + ], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + func request(_ sessionID: String, previous: UUID, target: UUID) -> AgentHookSessionStateWriter.RestoredHibernationAdoptionRequest { + .init( + agent: SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: root.path, + launchCommand: nil + ), + previousWorkspaceId: previousWorkspaceID, + previousSurfaceId: previous, + workspaceId: targetWorkspaceID, + surfaceId: target + ) + } + let requestA = request(sessionA, previous: previousSurfaceA, target: targetSurfaceA) + let requestB = request(sessionB, previous: previousSurfaceB, target: targetSurfaceB) + + let adopted = AgentHookSessionStateWriter.recordRestoredHibernations( + [requestB, requestA], + now: 20 + ) + + #expect(adopted == [targetSurfaceA]) + let snapshot = try CmuxAgentSessionRegistry(url: registryURL).snapshot(provider: "codex") + let records = try Dictionary(uniqueKeysWithValues: snapshot.records.map { row in + let object = try #require(JSONSerialization.jsonObject(with: row.json) as? [String: Any]) + return (row.sessionID, object) + }) + #expect(records[sessionA]?["workspaceId"] as? String == targetWorkspaceID.uuidString) + #expect(records[sessionA]?["surfaceId"] as? String == targetSurfaceA.uuidString) + #expect(records[sessionB]?["workspaceId"] as? String == previousWorkspaceID.uuidString) + #expect(records[sessionB]?["surfaceId"] as? String == previousSurfaceB.uuidString) + #expect(records[collisionSession]?["surfaceId"] as? String == targetSurfaceB.uuidString) + let workspaceSlots = snapshot.activeSlots.filter { $0.scope == .workspace } + #expect(workspaceSlots.count == 1) + #expect(workspaceSlots.first?.sessionID == sessionA) + #expect(workspaceSlots.first?.scopeID == targetWorkspaceID.uuidString) + let surfaceSlots = Dictionary(uniqueKeysWithValues: snapshot.activeSlots + .filter { $0.scope == .surface } + .map { ($0.scopeID, $0.sessionID) }) + #expect(surfaceSlots == [ + targetSurfaceA.uuidString: sessionA, + previousSurfaceB.uuidString: sessionB, + targetSurfaceB.uuidString: collisionSession, + ]) + } + + @MainActor + @Test func visibleClosedPanelRestoreRetriesAfterSQLiteOwnershipStoreRecovers() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-closed-panel-locked-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environmentOverrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "closed-panel-lock-runtime", + ] + let previousEnvironment = environmentOverrides.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environmentOverrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "closed-panel-lock-session" + ) + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let activeSlot: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "updatedAt": 20.0, + ] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [fixture.agent.sessionId: [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "cmuxRuntime": provablyDeadRuntime(id: "closed-panel-retired-runtime"), + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "activeSessionsByWorkspace": [fixture.source.id.uuidString: activeSlot], + "activeSessionsBySurface": [fixture.sourcePanelID.uuidString: activeSlot], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let registry = CmuxAgentSessionRegistry(url: registryURL) + _ = try registry.snapshotImportingLegacy( + provider: "codex", + legacyURL: stateURL, + fileManager: .default + ) + var database: OpaquePointer? + #expect(sqlite3_open(registryURL.path, &database) == SQLITE_OK) + let lockedDatabase = try #require(database) + defer { sqlite3_close(lockedDatabase) } + #expect(sqlite3_exec(lockedDatabase, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK) + defer { sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) } + + let destination = Workspace() + let adoptionFinished = AgentSessionAsyncGate() + destination.debugRestoredAgentHibernationAdoptionWaitDidFinish = { _ in + Task { await adoptionFinished.open() } + } + destination.setAgentHibernationAutoResumePresentationVisible(true) + let destinationPane = try #require(destination.bonsplitController.allPaneIds.first) + let panelSnapshot = try #require( + fixture.snapshot.panels.first { $0.id == fixture.sourcePanelID } + ) + let entry = ClosedPanelHistoryEntry( + workspaceId: destination.id, + paneId: destinationPane.id, + tabIndex: 0, + snapshot: panelSnapshot + ) + + let restoredPanelID = try #require(destination.restoreClosedPanel(entry)) + let restoredPanel = try #require(destination.terminalPanel(for: restoredPanelID)) + #expect(restoredPanel.isAgentHibernated) + #expect(destination.restoredAgentSnapshotForTesting(panelId: restoredPanelID)?.sessionId == fixture.agent.sessionId) + #expect(!restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(restoredPanel.surface.debugPendingSocketInputForTesting().items == 0) + #expect(destination.debugRestoredAgentHibernationAdoptionWaitOperationCount == 1) + #expect(destination.debugRestoredAgentHibernationAdoptionWaitInFlightCount == 1) + + let lockedSnapshot = try registry.snapshot(provider: "codex") + let lockedRecord = try #require( + lockedSnapshot.records.first { $0.sessionID == fixture.agent.sessionId } + ) + let lockedObject = try #require( + JSONSerialization.jsonObject(with: lockedRecord.json) as? [String: Any] + ) + #expect(lockedObject["workspaceId"] as? String == fixture.source.id.uuidString) + #expect(lockedObject["surfaceId"] as? String == fixture.sourcePanelID.uuidString) + + #expect(sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) == SQLITE_OK) + await adoptionFinished.waitUntilOpen() + + #expect(!restoredPanel.isAgentHibernated) + #expect(destination.debugRestoredAgentHibernationAdoptionWaitInFlightCount == 0) + #expect( + restoredPanel.surface.debugInitialInputForTesting() + == (try #require(fixture.agent.resumeCommand)) + "\n" + ) + #expect(restoredPanel.surface.debugPendingSocketInputForTesting().items == 0) + let adoptedSnapshot = try registry.snapshot(provider: "codex") + let adoptedRecord = try #require( + adoptedSnapshot.records.first { $0.sessionID == fixture.agent.sessionId } + ) + let adoptedObject = try #require( + JSONSerialization.jsonObject(with: adoptedRecord.json) as? [String: Any] + ) + #expect(adoptedObject["workspaceId"] as? String == destination.id.uuidString) + #expect(adoptedObject["surfaceId"] as? String == restoredPanelID.uuidString) + #expect(adoptedObject["sessionState"] as? String == "restoring") + } + + @MainActor + @Test func visibleClosedPanelRestoreAdoptsOwnershipBeforeOneResume() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-closed-panel-visible-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environmentOverrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "closed-panel-visible-runtime", + ] + let previousEnvironment = environmentOverrides.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environmentOverrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "closed-panel-visible-session" + ) + let destination = Workspace() + destination.setAgentHibernationAutoResumePresentationVisible(true) + let destinationPane = try #require(destination.bonsplitController.allPaneIds.first) + let activeSlot: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "updatedAt": 20.0, + ] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [fixture.agent.sessionId: [ + "sessionId": fixture.agent.sessionId, + "workspaceId": destination.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "activeSessionsByWorkspace": [destination.id.uuidString: activeSlot], + "activeSessionsBySurface": [fixture.sourcePanelID.uuidString: activeSlot], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + let panelSnapshot = try #require( + fixture.snapshot.panels.first { $0.id == fixture.sourcePanelID } + ) + let entry = ClosedPanelHistoryEntry( + workspaceId: destination.id, + paneId: destinationPane.id, + tabIndex: 0, + snapshot: panelSnapshot + ) + + let restoredPanelID = try #require(destination.restoreClosedPanel(entry)) + let restoredPanel = try #require(destination.terminalPanel(for: restoredPanelID)) + let expectedResumeInput = try #require(fixture.agent.resumeCommand) + "\n" + let registry = try CmuxAgentSessionRegistry(url: registryURL).snapshot(provider: "codex") + let record = try #require(registry.records.first { $0.sessionID == fixture.agent.sessionId }) + let object = try #require(JSONSerialization.jsonObject(with: record.json) as? [String: Any]) + + #expect(object["workspaceId"] as? String == destination.id.uuidString) + #expect(object["surfaceId"] as? String == restoredPanelID.uuidString) + #expect(Set(registry.activeSlots.map(\.scopeID)) == [ + destination.id.uuidString, + restoredPanelID.uuidString, + ]) + #expect(!restoredPanel.isAgentHibernated) + #expect(restoredPanel.surface.debugInitialInputForTesting() == expectedResumeInput) + + destination.setAgentHibernationAutoResumePresentationVisible(true) + #expect(restoredPanel.surface.debugInitialInputForTesting() == expectedResumeInput) + #expect(restoredPanel.surface.debugPendingSocketInputForTesting().items == 0) + } + + @MainActor + @Test func closingPanelCancelsPendingBackgroundAdoptionWithoutApplyingItsResult() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-adoption-cancel-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "restore-cancel-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture(root: root, sessionID: "restore-cancel-session") + let registry = try installHibernatedAuthority( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID + ) + var database: OpaquePointer? + #expect(sqlite3_open(registryURL.path, &database) == SQLITE_OK) + let lockedDatabase = try #require(database) + defer { sqlite3_close(lockedDatabase) } + #expect(sqlite3_exec(lockedDatabase, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK) + defer { sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) } + + let handlerStarted = AgentSessionAsyncGate() + let releaseHandler = AgentSessionAsyncGate() + let handlerFinished = AgentSessionAsyncGate() + let destination = Workspace() + destination.debugRestoredAgentHibernationAdoptionWaitHandler = { requests in + await handlerStarted.open() + await releaseHandler.waitUntilOpen() + return Dictionary(uniqueKeysWithValues: requests.map { ($0.surfaceId, .adopted) }) + } + destination.debugRestoredAgentHibernationAdoptionWaitDidFinish = { _ in + Task { await handlerFinished.open() } + } + destination.setAgentHibernationAutoResumePresentationVisible(true) + let paneId = try #require(destination.bonsplitController.allPaneIds.first) + let panelSnapshot = try #require( + fixture.snapshot.panels.first { $0.id == fixture.sourcePanelID } + ) + let panelId = try #require(destination.restoreClosedPanel(.init( + workspaceId: destination.id, + paneId: paneId.id, + tabIndex: 0, + snapshot: panelSnapshot + ))) + await handlerStarted.waitUntilOpen() + + #expect(destination.closePanel(panelId, force: true)) + #expect(destination.terminalPanel(for: panelId) == nil) + #expect(destination.debugRestoredAgentHibernationAdoptionWaitCancellationCount == 1) + #expect(sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) == SQLITE_OK) + await releaseHandler.open() + await handlerFinished.waitUntilOpen() + + #expect(destination.debugRestoredAgentHibernationAdoptionWaitInFlightCount == 0) + let snapshot = try registry.snapshot(provider: "codex") + let record = try #require(snapshot.records.first) + let object = try #require(JSONSerialization.jsonObject(with: record.json) as? [String: Any]) + #expect(object["workspaceId"] as? String == fixture.source.id.uuidString) + #expect(object["surfaceId"] as? String == fixture.sourcePanelID.uuidString) + #expect(object["sessionState"] as? String == "hibernated") + } + + @MainActor + @Test func repeatedVisibilityDoesNotDuplicatePendingBackgroundAdoption() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-adoption-dedup-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "restore-dedup-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture(root: root, sessionID: "restore-dedup-session") + _ = try installHibernatedAuthority( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID + ) + var database: OpaquePointer? + #expect(sqlite3_open(registryURL.path, &database) == SQLITE_OK) + let lockedDatabase = try #require(database) + defer { sqlite3_close(lockedDatabase) } + #expect(sqlite3_exec(lockedDatabase, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK) + defer { sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) } + + let handlerStarted = AgentSessionAsyncGate() + let releaseHandler = AgentSessionAsyncGate() + let handlerFinished = AgentSessionAsyncGate() + let destination = Workspace() + destination.debugRestoredAgentHibernationAdoptionWaitHandler = { requests in + await handlerStarted.open() + await releaseHandler.waitUntilOpen() + return Dictionary(uniqueKeysWithValues: requests.map { ($0.surfaceId, .unavailable) }) + } + destination.debugRestoredAgentHibernationAdoptionWaitDidFinish = { _ in + Task { await handlerFinished.open() } + } + destination.setAgentHibernationAutoResumePresentationVisible(true) + let paneId = try #require(destination.bonsplitController.allPaneIds.first) + let panelSnapshot = try #require( + fixture.snapshot.panels.first { $0.id == fixture.sourcePanelID } + ) + let panelId = try #require(destination.restoreClosedPanel(.init( + workspaceId: destination.id, + paneId: paneId.id, + tabIndex: 0, + snapshot: panelSnapshot + ))) + let panel = try #require(destination.terminalPanel(for: panelId)) + await handlerStarted.waitUntilOpen() + + for _ in 0..<5 { + destination.setAgentHibernationAutoResumePresentationVisible(true) + } + #expect(destination.debugRestoredAgentHibernationAdoptionWaitOperationCount == 1) + #expect(destination.debugRestoredAgentHibernationAdoptionWaitInFlightCount == 1) + #expect(panel.isAgentHibernated) + #expect(!panel.surface.debugInitialInputMetadata().hasInitialInput) + + #expect(sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) == SQLITE_OK) + await releaseHandler.open() + await handlerFinished.waitUntilOpen() + #expect(destination.debugRestoredAgentHibernationAdoptionWaitOperationCount == 1) + #expect(destination.debugRestoredAgentHibernationAdoptionWaitInFlightCount == 0) + #expect(panel.isAgentHibernated) + #expect(!panel.surface.debugInitialInputMetadata().hasInitialInput) + } + + @MainActor + @Test func closingPanelAfterAdoptionCommitReleasesExactDurableGeneration() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-adoption-postcommit-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "restore-postcommit-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture(root: root, sessionID: "restore-postcommit-session") + let registry = try installHibernatedAuthority( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID + ) + var database: OpaquePointer? + #expect(sqlite3_open(registryURL.path, &database) == SQLITE_OK) + let lockedDatabase = try #require(database) + defer { sqlite3_close(lockedDatabase) } + #expect(sqlite3_exec(lockedDatabase, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK) + defer { sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) } + + let handlerStarted = AgentSessionAsyncGate() + let allowCommit = AgentSessionAsyncGate() + let commitFinished = AgentSessionAsyncGate() + let allowDelivery = AgentSessionAsyncGate() + let handlerFinished = AgentSessionAsyncGate() + let destination = Workspace() + destination.debugRestoredAgentHibernationAdoptionWaitHandler = { requests in + await handlerStarted.open() + await allowCommit.waitUntilOpen() + let outcomes = await AgentHookSessionStateWriter.waitForRestoredHibernationOutcomes( + requests + ) + await commitFinished.open() + await allowDelivery.waitUntilOpen() + return outcomes + } + destination.debugRestoredAgentHibernationAdoptionWaitDidFinish = { _ in + Task { await handlerFinished.open() } + } + destination.setAgentHibernationAutoResumePresentationVisible(true) + let paneId = try #require(destination.bonsplitController.allPaneIds.first) + let panelSnapshot = try #require( + fixture.snapshot.panels.first { $0.id == fixture.sourcePanelID } + ) + let panelId = try #require(destination.restoreClosedPanel(.init( + workspaceId: destination.id, + paneId: paneId.id, + tabIndex: 0, + snapshot: panelSnapshot + ))) + await handlerStarted.waitUntilOpen() + + #expect(sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) == SQLITE_OK) + await allowCommit.open() + await commitFinished.waitUntilOpen() + let committed = try registry.snapshot(provider: "codex") + let committedRecord = try #require(committed.records.first) + let committedObject = try #require( + JSONSerialization.jsonObject(with: committedRecord.json) as? [String: Any] + ) + #expect(committedObject["surfaceId"] as? String == panelId.uuidString) + #expect(committedObject["cmuxRestoreAdoptionId"] is String) + + #expect(destination.closePanel(panelId, force: true)) + await allowDelivery.open() + await handlerFinished.waitUntilOpen() + + let released = try registry.snapshot(provider: "codex") + let releasedRecord = try #require(released.records.first) + let releasedObject = try #require( + JSONSerialization.jsonObject(with: releasedRecord.json) as? [String: Any] + ) + #expect(releasedObject["restoreAuthority"] as? Bool == false) + #expect(releasedObject["sessionState"] as? String == "ended") + #expect(releasedObject["completedAt"] is TimeInterval) + #expect(releasedObject["cmuxRestoreAdoptionId"] == nil) + #expect(!released.activeSlots.contains { $0.sessionID == fixture.agent.sessionId }) + #expect(destination.debugRestoredAgentHibernationAdoptionWaitInFlightCount == 0) + } + + @MainActor + @Test func workspaceTeardownAfterAdoptionCommitPreservesNextLaunchAuthority() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-adoption-teardown-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "restore-teardown-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture(root: root, sessionID: "restore-teardown-session") + let registry = try installHibernatedAuthority( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID + ) + var database: OpaquePointer? + #expect(sqlite3_open(registryURL.path, &database) == SQLITE_OK) + let lockedDatabase = try #require(database) + defer { sqlite3_close(lockedDatabase) } + #expect(sqlite3_exec(lockedDatabase, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK) + defer { sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) } + + let handlerStarted = AgentSessionAsyncGate() + let allowCommit = AgentSessionAsyncGate() + let commitFinished = AgentSessionAsyncGate() + let allowDelivery = AgentSessionAsyncGate() + let ownerReleased = AgentSessionAsyncGate() + weak var weakDestination: Workspace? + var destination: Workspace? = Workspace() + weakDestination = destination + var destinationWorkspaceID: UUID? + var restoredPanelID: UUID? + do { + let workspace = try #require(destination) + destinationWorkspaceID = workspace.id + workspace.debugRestoredAgentHibernationAdoptionWaitHandler = { requests in + await handlerStarted.open() + await allowCommit.waitUntilOpen() + let outcomes = await AgentHookSessionStateWriter.waitForRestoredHibernationOutcomes( + requests + ) + await commitFinished.open() + await allowDelivery.waitUntilOpen() + return outcomes + } + workspace.debugRestoredAgentHibernationAdoptionWaitOwnerReleased = { + Task { await ownerReleased.open() } + } + workspace.setAgentHibernationAutoResumePresentationVisible(true) + let paneId = try #require(workspace.bonsplitController.allPaneIds.first) + let panelSnapshot = try #require( + fixture.snapshot.panels.first { $0.id == fixture.sourcePanelID } + ) + restoredPanelID = try #require(workspace.restoreClosedPanel(.init( + workspaceId: workspace.id, + paneId: paneId.id, + tabIndex: 0, + snapshot: panelSnapshot + ))) + await handlerStarted.waitUntilOpen() + } + + #expect(sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) == SQLITE_OK) + await allowCommit.open() + await commitFinished.waitUntilOpen() + let workspaceID = try #require(destinationWorkspaceID) + let panelID = try #require(restoredPanelID) + let committed = try registry.snapshot(provider: "codex") + let committedRecord = try #require(committed.records.first) + let committedObject = try #require( + JSONSerialization.jsonObject(with: committedRecord.json) as? [String: Any] + ) + #expect(committedObject["workspaceId"] as? String == workspaceID.uuidString) + #expect(committedObject["surfaceId"] as? String == panelID.uuidString) + #expect(committedObject["cmuxRestoreAdoptionId"] is String) + + destination = nil + #expect(weakDestination == nil) + await allowDelivery.open() + await ownerReleased.waitUntilOpen() + + let preserved = try registry.snapshot(provider: "codex") + let preservedRecord = try #require(preserved.records.first) + let preservedObject = try #require( + JSONSerialization.jsonObject(with: preservedRecord.json) as? [String: Any] + ) + #expect(preservedObject["workspaceId"] as? String == workspaceID.uuidString) + #expect(preservedObject["surfaceId"] as? String == panelID.uuidString) + #expect(preservedObject["restoreAuthority"] as? Bool == true) + #expect(preservedObject["sessionState"] as? String == "hibernated") + #expect(preservedObject["completedAt"] == nil) + #expect(preservedObject["cmuxRestoreAdoptionId"] is String) + #expect(preserved.activeSlots.contains { slot in + slot.sessionID == fixture.agent.sessionId + && (slot.scopeID == workspaceID.uuidString || slot.scopeID == panelID.uuidString) + }) + } + + @MainActor + @Test func workspaceTeardownBeforeAdoptionCommitKeepsCanonicalBindingAdoptableByNextLaunch() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-adoption-precommit-teardown-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "restore-precommit-teardown-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + weak var weakSource: Workspace? + let fixture: ( + snapshot: SessionWorkspaceSnapshot, + sourceWorkspaceID: UUID, + sourcePanelID: UUID, + agent: SessionRestorableAgentSnapshot + ) = try { + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "restore-precommit-teardown-session" + ) + weakSource = fixture.source + _ = try installHibernatedAuthority( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID + ) + return ( + fixture.snapshot, + fixture.source.id, + fixture.sourcePanelID, + fixture.agent + ) + }() + #expect(weakSource == nil) + let registry = CmuxAgentSessionRegistry(url: registryURL) + + var database: OpaquePointer? + #expect(sqlite3_open(registryURL.path, &database) == SQLITE_OK) + let lockedDatabase = try #require(database) + defer { sqlite3_close(lockedDatabase) } + #expect(sqlite3_exec(lockedDatabase, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK) + var databaseIsLocked = true + defer { + if databaseIsLocked { + sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) + } + } + + let adoptionWaitStarted = AgentSessionAsyncGate() + let allowCanceledWaitToFinish = AgentSessionAsyncGate() + let launchAOwnerReleased = AgentSessionAsyncGate() + weak var weakLaunchA: Workspace? + var launchA: Workspace? = Workspace() + weakLaunchA = launchA + var launchAWorkspaceID: UUID? + var launchAPanelID: UUID? + var launchASnapshot: SessionWorkspaceSnapshot? + do { + let workspace = try #require(launchA) + launchAWorkspaceID = workspace.id + workspace.debugRestoredAgentHibernationAdoptionWaitHandler = { requests in + await adoptionWaitStarted.open() + await allowCanceledWaitToFinish.waitUntilOpen() + return Dictionary(uniqueKeysWithValues: requests.map { ($0.surfaceId, .unavailable) }) + } + workspace.debugRestoredAgentHibernationAdoptionWaitOwnerReleased = { + Task { await launchAOwnerReleased.open() } + } + workspace.setAgentHibernationAutoResumePresentationVisible(true) + let mapping = workspace.restoreSessionSnapshot(fixture.snapshot) + let panelID = try #require(mapping[fixture.sourcePanelID]) + launchAPanelID = panelID + let panel = try #require(workspace.terminalPanel(for: panelID)) + #expect(panel.isAgentHibernated) + await adoptionWaitStarted.waitUntilOpen() + launchASnapshot = workspace.sessionSnapshot(includeScrollback: false) + } + let workspaceIDAfterFirstRestore = try #require(launchAWorkspaceID) + let panelIDAfterFirstRestore = try #require(launchAPanelID) + let snapshotAfterFirstRestore = try #require(launchASnapshot) + #expect(workspaceIDAfterFirstRestore != fixture.sourceWorkspaceID) + #expect(snapshotAfterFirstRestore.workspaceId == workspaceIDAfterFirstRestore) + #expect(snapshotAfterFirstRestore.panels.contains { $0.id == panelIDAfterFirstRestore }) + + launchA = nil + #expect(weakLaunchA == nil) + #expect(sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) == SQLITE_OK) + databaseIsLocked = false + await allowCanceledWaitToFinish.open() + await launchAOwnerReleased.waitUntilOpen() + + let precommitSnapshot = try registry.snapshot(provider: "codex") + let precommitRecord = try #require( + precommitSnapshot.records.first { $0.sessionID == fixture.agent.sessionId } + ) + let precommitObject = try #require( + JSONSerialization.jsonObject(with: precommitRecord.json) as? [String: Any] + ) + #expect(precommitObject["workspaceId"] as? String == fixture.sourceWorkspaceID.uuidString) + #expect(precommitObject["surfaceId"] as? String == fixture.sourcePanelID.uuidString) + #expect(Set(precommitSnapshot.activeSlots.map(\.scopeID)) == [ + fixture.sourceWorkspaceID.uuidString, + fixture.sourcePanelID.uuidString, + ]) + + let launchB = Workspace() + let launchBMapping = launchB.restoreSessionSnapshot(snapshotAfterFirstRestore) + let launchBPanelID = try #require(launchBMapping[panelIDAfterFirstRestore]) + let launchBPanel = try #require(launchB.terminalPanel(for: launchBPanelID)) + #expect(launchB.id != workspaceIDAfterFirstRestore) + #expect(launchBPanel.isAgentHibernated) + #expect( + launchB.restoredAgentSnapshotForTesting(panelId: launchBPanelID)?.sessionId + == fixture.agent.sessionId + ) + + let reboundSnapshot = try registry.snapshot(provider: "codex") + let reboundRecord = try #require( + reboundSnapshot.records.first { $0.sessionID == fixture.agent.sessionId } + ) + let reboundObject = try #require( + JSONSerialization.jsonObject(with: reboundRecord.json) as? [String: Any] + ) + #expect(reboundObject["workspaceId"] as? String == launchB.id.uuidString) + #expect(reboundObject["surfaceId"] as? String == launchBPanelID.uuidString) + #expect(reboundObject["sessionState"] as? String == "hibernated") + #expect(Set(reboundSnapshot.activeSlots.map(\.scopeID)) == [ + launchB.id.uuidString, + launchBPanelID.uuidString, + ]) + } + + @MainActor + @Test func hiddenPanelRetainsCommittedAdoptionAndResumesWhenVisibleAgain() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-adoption-hidden-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "restore-hidden-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture(root: root, sessionID: "restore-hidden-session") + let registry = try installHibernatedAuthority( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID + ) + var database: OpaquePointer? + #expect(sqlite3_open(registryURL.path, &database) == SQLITE_OK) + let lockedDatabase = try #require(database) + defer { sqlite3_close(lockedDatabase) } + #expect(sqlite3_exec(lockedDatabase, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK) + defer { sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) } + + let handlerStarted = AgentSessionAsyncGate() + let releaseHandler = AgentSessionAsyncGate() + let handlerFinished = AgentSessionAsyncGate() + let destination = Workspace() + destination.debugRestoredAgentHibernationAdoptionWaitHandler = { requests in + await handlerStarted.open() + await releaseHandler.waitUntilOpen() + return await AgentHookSessionStateWriter.waitForRestoredHibernationOutcomes(requests) + } + destination.debugRestoredAgentHibernationAdoptionWaitDidFinish = { _ in + Task { await handlerFinished.open() } + } + destination.setAgentHibernationAutoResumePresentationVisible(true) + let paneId = try #require(destination.bonsplitController.allPaneIds.first) + let panelSnapshot = try #require( + fixture.snapshot.panels.first { $0.id == fixture.sourcePanelID } + ) + let panelId = try #require(destination.restoreClosedPanel(.init( + workspaceId: destination.id, + paneId: paneId.id, + tabIndex: 0, + snapshot: panelSnapshot + ))) + let panel = try #require(destination.terminalPanel(for: panelId)) + await handlerStarted.waitUntilOpen() + + destination.setAgentHibernationAutoResumePresentationVisible(false) + #expect(sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) == SQLITE_OK) + await releaseHandler.open() + await handlerFinished.waitUntilOpen() + + #expect(panel.isAgentHibernated) + #expect(!panel.surface.debugInitialInputMetadata().hasInitialInput) + let adopted = try registry.snapshot(provider: "codex") + let adoptedRecord = try #require(adopted.records.first) + let adoptedObject = try #require( + JSONSerialization.jsonObject(with: adoptedRecord.json) as? [String: Any] + ) + #expect(adoptedObject["workspaceId"] as? String == destination.id.uuidString) + #expect(adoptedObject["surfaceId"] as? String == panelId.uuidString) + #expect(adoptedObject["sessionState"] as? String == "hibernated") + + destination.setAgentHibernationAutoResumePresentationVisible(true) + #expect(!panel.isAgentHibernated) + #expect(panel.surface.debugInitialInputForTesting() == (try #require(fixture.agent.resumeCommand)) + "\n") + let resumed = try registry.snapshot(provider: "codex") + let resumedRecord = try #require(resumed.records.first) + let resumedObject = try #require( + JSONSerialization.jsonObject(with: resumedRecord.json) as? [String: Any] + ) + #expect(resumedObject["sessionState"] as? String == "restoring") + #expect(resumedObject["cmuxRestoreAdoptionId"] == nil) + } + + @MainActor + @Test func legacyOnlyRestoredHibernationImportsAtSharedAdoptionBoundary() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-hibernation-legacy-only-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environmentOverrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "legacy-only-runtime", + ] + let previousEnvironment = environmentOverrides.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environmentOverrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "legacy-only-session" + ) + let activeSlot: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "updatedAt": 20.0, + ] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [fixture.agent.sessionId: [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "cmuxRuntime": provablyDeadRuntime(id: "legacy-only-retired-runtime"), + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "activeSessionsByWorkspace": [fixture.source.id.uuidString: activeSlot], + "activeSessionsBySurface": [fixture.sourcePanelID.uuidString: activeSlot], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + #expect(!FileManager.default.fileExists(atPath: registryURL.path)) + + let restored = Workspace() + let mapping = restored.restoreSessionSnapshot(fixture.snapshot) + let restoredPanelID = try #require(mapping[fixture.sourcePanelID]) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) + + #expect(restoredPanel.isAgentHibernated) + #expect(restored.restoredAgentSnapshotForTesting(panelId: restoredPanelID)?.sessionId == fixture.agent.sessionId) + let registrySnapshot = try CmuxAgentSessionRegistry(url: registryURL).snapshot(provider: "codex") + let record = try #require( + registrySnapshot.records.first { $0.sessionID == fixture.agent.sessionId } + ) + let object = try #require(JSONSerialization.jsonObject(with: record.json) as? [String: Any]) + #expect(object["workspaceId"] as? String == restored.id.uuidString) + #expect(object["surfaceId"] as? String == restoredPanelID.uuidString) + #expect(object["sessionState"] as? String == "hibernated") + #expect(Set(registrySnapshot.activeSlots.map(\.scopeID)) == [ + restored.id.uuidString, + restoredPanelID.uuidString, + ]) + + #expect(!restoredPanel.hostedView.debugPortalActive) + restored.setAgentHibernationAutoResumePresentationVisible(true) + #expect(!restoredPanel.isAgentHibernated) + _ = restored.reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + #expect(restoredPanel.hostedView.debugPortalActive) + } + + @MainActor + @Test func legacyOnlyUnknownRuntimeOwnerRemainsInertAfterImport() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-restored-hibernation-legacy-unknown-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "legacy-unknown-current-runtime", + ] + let previousEnvironment = environment.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environment { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "legacy-unknown-session" + ) + let slot: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "updatedAt": 20.0, + ] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [fixture.agent.sessionId: [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "cmuxRuntime": ["id": "metadata-only-foreign-runtime"], + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "activeSessionsByWorkspace": [fixture.source.id.uuidString: slot], + "activeSessionsBySurface": [fixture.sourcePanelID.uuidString: slot], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + let targetWorkspaceID = UUID() + let targetSurfaceID = UUID() + + let outcomes = AgentHookSessionStateWriter.recordRestoredHibernationOutcomes([ + .init( + agent: fixture.agent, + previousWorkspaceId: fixture.source.id, + previousSurfaceId: fixture.sourcePanelID, + workspaceId: targetWorkspaceID, + surfaceId: targetSurfaceID + ), + ], now: 30) + + #expect(outcomes[targetSurfaceID] == .unavailable) + let snapshot = try CmuxAgentSessionRegistry(url: registryURL).snapshot(provider: "codex") + let record = try #require(snapshot.records.first) + let object = try #require( + JSONSerialization.jsonObject(with: record.json) as? [String: Any] + ) + #expect(object["workspaceId"] as? String == fixture.source.id.uuidString) + #expect(object["surfaceId"] as? String == fixture.sourcePanelID.uuidString) + #expect(object["sessionState"] as? String == "hibernated") + #expect(snapshot.activeSlots.allSatisfy { $0.scopeID != targetSurfaceID.uuidString }) + } + + @MainActor + @Test(arguments: ["hibernated", "restoring"]) + func currentAttachedPendingOwnerSurvivesImmediateClosedHistoryAdoption( + _ lifecycle: String + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-current-pending-closed-history-\(lifecycle)-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let runtimeID = "portal-close-current-runtime" + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": runtimeID, + ] + let previousEnvironment = environment.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environment { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "current-pending-\(lifecycle)" + ) + var object: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": lifecycle, + "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeID], + "startedAt": 10.0, + "updatedAt": 20.0, + ] + if lifecycle == "restoring" { + object["cmuxHibernationResumeAttemptId"] = UUID().uuidString + } + let slotJSON = try JSONSerialization.data(withJSONObject: [ + "sessionId": fixture.agent.sessionId, + "updatedAt": 20.0, + ], options: [.sortedKeys]) + let registry = CmuxAgentSessionRegistry(url: registryURL) + try registry.apply( + provider: "codex", + records: [.init( + provider: "codex", + sessionID: fixture.agent.sessionId, + updatedAt: 20, + json: try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + )], + activeSlots: [.init( + provider: "codex", + scope: .surface, + scopeID: fixture.sourcePanelID.uuidString, + sessionID: fixture.agent.sessionId, + updatedAt: 20, + json: slotJSON + )] + ) + let targetWorkspaceID = UUID() + let targetSurfaceID = UUID() + + let outcomes = AgentHookSessionStateWriter.recordRestoredHibernationOutcomes([ + .init( + agent: fixture.agent, + previousWorkspaceId: fixture.source.id, + previousSurfaceId: fixture.sourcePanelID, + workspaceId: targetWorkspaceID, + surfaceId: targetSurfaceID + ), + ], now: 30) + + #expect(outcomes[targetSurfaceID] == .unavailable) + let retained = try #require(registry.snapshot(provider: "codex").records.first) + let retainedObject = try #require( + JSONSerialization.jsonObject(with: retained.json) as? [String: Any] + ) + #expect(retainedObject["workspaceId"] as? String == fixture.source.id.uuidString) + #expect(retainedObject["surfaceId"] as? String == fixture.sourcePanelID.uuidString) + #expect(retainedObject["sessionState"] as? String == lifecycle) + } + + @MainActor + @Test func alreadyVisibleWorkspaceResumesOnceAfterRestoredOwnershipAdoption() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-hibernation-already-visible-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environmentOverrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "already-visible-runtime", + ] + let previousEnvironment = environmentOverrides.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environmentOverrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "already-visible-session" + ) + let activeSlot: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "updatedAt": 20.0, + ] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [fixture.agent.sessionId: [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "cmuxRuntime": provablyDeadRuntime(id: "already-visible-retired-runtime"), + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "activeSessionsByWorkspace": [fixture.source.id.uuidString: activeSlot], + "activeSessionsBySurface": [fixture.sourcePanelID.uuidString: activeSlot], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + + let restored = Workspace() + restored.setAgentHibernationAutoResumePresentationVisible(true) + let adoptionBatch = RestoredAgentHibernationAdoptionBatch() + let mapping = restored.restoreSessionSnapshot( + fixture.snapshot, + restoredAgentHibernationAdoptionBatch: adoptionBatch + ) + let restoredPanelID = try #require(mapping[fixture.sourcePanelID]) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) + let expectedResumeInput = try #require(fixture.agent.resumeCommand) + "\n" + #expect(restoredPanel.isAgentHibernated) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let contentView = try #require(window.contentView) + restoredPanel.hostedView.frame = contentView.bounds + contentView.addSubview(restoredPanel.hostedView) + restoredPanel.hostedView.setVisibleInUI(true) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + + adoptionBatch.finalize() + + #expect(!restoredPanel.isAgentHibernated) + #expect(restoredPanel.surface.debugInitialInputForTesting() == expectedResumeInput) + #expect(restoredPanel.surface.debugDesiredFocusState()) + #expect(restoredPanel.hostedView.debugRenderStats().desiredFocus) + let registry = try CmuxAgentSessionRegistry(url: registryURL).snapshot(provider: "codex") + let record = try #require(registry.records.first { $0.sessionID == fixture.agent.sessionId }) + let object = try #require(JSONSerialization.jsonObject(with: record.json) as? [String: Any]) + #expect(object["workspaceId"] as? String == restored.id.uuidString) + #expect(object["surfaceId"] as? String == restoredPanelID.uuidString) + + restored.setAgentHibernationAutoResumePresentationVisible(true) + #expect(restoredPanel.surface.debugInitialInputForTesting() == expectedResumeInput) + #expect(restoredPanel.surface.debugPendingSocketInputForTesting().items == 0) + } + + @MainActor + @Test func unavailableResumePreparationDoesNotConsumeDurableAuthority() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-resume-preflight-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "resume-preflight-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture(root: root, sessionID: "resume-preflight-session") + let activeSlot: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "updatedAt": 20.0, + ] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [fixture.agent.sessionId: [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "activeSessionsByWorkspace": [fixture.source.id.uuidString: activeSlot], + "activeSessionsBySurface": [fixture.sourcePanelID.uuidString: activeSlot], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + + let restored = Workspace() + let mapping = restored.restoreSessionSnapshot(fixture.snapshot) + let restoredPanelID = try #require(mapping[fixture.sourcePanelID]) + let panel = try #require(restored.terminalPanel(for: restoredPanelID)) + #expect(panel.isAgentHibernated) + panel.surface.beginPortalCloseLifecycle(reason: "test.resumePreflight") + + #expect(!restored.resumeAgentHibernation(panelId: restoredPanelID, focus: false)) + #expect(panel.isAgentHibernated) + #expect(!panel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(panel.surface.debugPendingSocketInputForTesting().items == 0) + let registry = try CmuxAgentSessionRegistry(url: registryURL).snapshot(provider: "codex") + let record = try #require(registry.records.first) + let object = try #require(JSONSerialization.jsonObject(with: record.json) as? [String: Any]) + #expect(object["sessionState"] as? String == "hibernated") + #expect(object["workspaceId"] as? String == restored.id.uuidString) + #expect(object["surfaceId"] as? String == restoredPanelID.uuidString) + } + + @MainActor + @Test func pendingInputImmediatelyResumesAfterDurableHibernationCommit() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-live-hibernation-immediate-resume-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let runtimeID = "live-hibernation-runtime" + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": runtimeID, + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let workspace = Workspace(workingDirectory: root.path) + let panelID = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.terminalPanel(for: panelID)) + let sessionID = "live-hibernation-session" + let agent = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: root.path, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex"], + workingDirectory: root.path, + environment: nil, + capturedAt: 10, + source: "agent-hook" + ) + ) + #expect(workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + kind: agent.kind.rawValue, + command: try #require(agent.resumeCommand), + cwd: root.path, + checkpointId: sessionID, + source: "agent-hook", + autoResume: false, + updatedAt: 20 + ), + panelId: panelID + )) + let runtime: [String: Any] = ["id": runtimeID] + let record: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": workspace.id.uuidString, + "surfaceId": panelID.uuidString, + "sessionState": "active", + "restoreAuthority": true, + "activeRunId": "live-run", + "cmuxRuntime": runtime, + "runs": [[ + "runId": "live-run", + "restoreAuthority": true, + "cmuxRuntime": runtime, + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "startedAt": 10.0, + "updatedAt": 20.0, + ] + let slotObject: [String: Any] = ["sessionId": sessionID, "updatedAt": 20.0] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: record], + "activeSessionsByWorkspace": [workspace.id.uuidString: slotObject], + "activeSessionsBySurface": [panelID.uuidString: slotObject], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + let slotJSON = try JSONSerialization.data(withJSONObject: slotObject, options: [.sortedKeys]) + let registry = CmuxAgentSessionRegistry(url: registryURL) + try registry.apply( + provider: "codex", + records: [.init( + provider: "codex", + sessionID: sessionID, + updatedAt: 20, + json: try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + )], + activeSlots: [ + .init( + provider: "codex", + scope: .workspace, + scopeID: workspace.id.uuidString, + sessionID: sessionID, + updatedAt: 20, + json: slotJSON + ), + .init( + provider: "codex", + scope: .surface, + scopeID: panelID.uuidString, + sessionID: sessionID, + updatedAt: 20, + json: slotJSON + ), + ] + ) + + panel.surface.installRuntimeSurfaceForTesting( + UnsafeMutableRawPointer(bitPattern: 0x7867)! + ) + let nativeFreeStarted = AgentSessionAsyncGate() + let releaseNativeFree = DispatchSemaphore(value: 0) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + Task { await nativeFreeStarted.open() } + releaseNativeFree.wait() + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + let hibernation = Task { @MainActor in + await workspace.enterAgentHibernation( + panelId: panelID, + agent: agent, + lastActivityAt: Date(timeIntervalSince1970: 10), + finalValidation: { true } + ) + } + await nativeFreeStarted.waitUntilOpen() + #expect(panel.surface.sendNamedKey("enter") == .queued) + #expect(panel.surface.debugPendingSocketInputForTesting().items == 1) + releaseNativeFree.signal() + + #expect(await hibernation.value) + #expect(!panel.isAgentHibernated) + let resumeCommand = try #require(agent.resumeCommand) + #expect(panel.surface.debugInitialInputForTesting() == resumeCommand + "\n") + let saved = try registry.snapshot(provider: "codex") + let savedRecord = try #require(saved.records.first) + let savedObject = try #require( + JSONSerialization.jsonObject(with: savedRecord.json) as? [String: Any] + ) + #expect(savedObject["sessionState"] as? String == "restoring") + #expect((savedObject["cmuxRuntime"] as? [String: Any])?["id"] as? String == runtimeID) + } + + @MainActor + @Test func lockedDurableHibernationCommitRestoresExactLiveRuntime() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-live-hibernation-contention-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let runtimeID = "live-hibernation-contention-runtime" + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": runtimeID, + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeLiveHibernationAuthorityFixture( + root: root, + runtimeID: runtimeID, + sessionID: "live-hibernation-contention-session" + ) + let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + fixture.panel.surface.installRuntimeSurfaceForTesting(runtimeSurface) + let nativeFreeCalled = AtomicBooleanGate(false) + let cleanupFinished = AgentSessionAsyncGate() + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { pointer in + nativeFreeCalled.storeRelease(true) + pointer.deallocate() + Task { await cleanupFinished.open() } + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + var database: OpaquePointer? + #expect(sqlite3_open(registryURL.path, &database) == SQLITE_OK) + let lockedDatabase = try #require(database) + defer { sqlite3_close(lockedDatabase) } + #expect(sqlite3_exec(lockedDatabase, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK) + defer { sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) } + + let didHibernate = await fixture.workspace.enterAgentHibernation( + panelId: fixture.panelID, + agent: fixture.agent, + lastActivityAt: Date(timeIntervalSince1970: 10), + finalValidation: { true } + ) + + #expect(!didHibernate) + #expect(!fixture.panel.isAgentHibernated) + #expect(fixture.panel.surface.surface == runtimeSurface) + #expect(!nativeFreeCalled.loadAcquire()) + #expect(fixture.panel.surface.debugPendingSocketInputForTesting().items == 0) + let saved = try fixture.registry.snapshot(provider: "codex") + let savedRecord = try #require(saved.records.first) + let savedObject = try #require( + JSONSerialization.jsonObject(with: savedRecord.json) as? [String: Any] + ) + #expect(savedObject["sessionState"] as? String == "active") + + fixture.panel.surface.teardownSurface() + await cleanupFinished.waitUntilOpen() + } + + @MainActor + @Test func portalCloseAfterDurableHibernationCommitReleasesExactAuthority() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-live-hibernation-close-race-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let runtimeID = "live-hibernation-close-race-runtime" + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": runtimeID, + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeLiveHibernationAuthorityFixture( + root: root, + runtimeID: runtimeID, + sessionID: "live-hibernation-close-race-session" + ) + let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + fixture.panel.surface.installRuntimeSurfaceForTesting(runtimeSurface) + let nativeFreeStarted = AgentSessionAsyncGate() + let releaseNativeFree = DispatchSemaphore(value: 0) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { pointer in + Task { await nativeFreeStarted.open() } + releaseNativeFree.wait() + pointer.deallocate() + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + let hibernation = Task { @MainActor in + await fixture.workspace.enterAgentHibernation( + panelId: fixture.panelID, + agent: fixture.agent, + lastActivityAt: Date(timeIntervalSince1970: 10), + finalValidation: { true } + ) + } + await nativeFreeStarted.waitUntilOpen() + + // The native free starts only after the durable hibernation write. A + // close at this point must compensate that exact committed generation. + fixture.panel.surface.teardownSurface() + releaseNativeFree.signal() + + #expect(!(await hibernation.value)) + let saved = try fixture.registry.snapshot(provider: "codex") + let savedRecord = try #require(saved.records.first) + let savedObject = try #require( + JSONSerialization.jsonObject(with: savedRecord.json) as? [String: Any] + ) + #expect(savedObject["sessionState"] as? String == "hibernated") + #expect(savedObject["restoreAuthority"] as? Bool != false) + #expect(savedObject["cmuxHibernationDetached"] as? Bool == true) + #expect(saved.activeSlots.isEmpty) + } + + @MainActor + @Test func detachedClosedHibernationCanBeAdoptedWithoutStaleActiveSlots() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-detached-hibernation-adoption-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let runtimeID = "detached-hibernation-adoption-runtime" + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": runtimeID, + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "detached-hibernation-adoption-session" + ) + let runtime: [String: Any] = ["id": runtimeID] + let record: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "activeRunId": "detached-run", + "cmuxRuntime": runtime, + "runs": [[ + "runId": "detached-run", + "restoreAuthority": true, + "cmuxRuntime": runtime, + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "cmuxHibernationAttemptId": UUID().uuidString, + "cmuxHibernationDetached": true, + "startedAt": 10.0, + "updatedAt": 20.0, + ] + let registry = CmuxAgentSessionRegistry(url: registryURL) + try registry.apply( + provider: "codex", + records: [.init( + provider: "codex", + sessionID: fixture.agent.sessionId, + updatedAt: 20, + json: try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + )], + activeSlots: [] + ) + + let restored = Workspace() + let mapping = restored.restoreSessionSnapshot(fixture.snapshot) + let restoredPanelID = try #require(mapping[fixture.sourcePanelID]) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) + #expect(restoredPanel.isAgentHibernated) + #expect(!restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + + let saved = try registry.snapshot(provider: "codex") + let savedRecord = try #require(saved.records.first) + let savedObject = try #require( + JSONSerialization.jsonObject(with: savedRecord.json) as? [String: Any] + ) + #expect(savedObject["workspaceId"] as? String == restored.id.uuidString) + #expect(savedObject["surfaceId"] as? String == restoredPanelID.uuidString) + #expect(savedObject["cmuxHibernationDetached"] as? Bool == false) + #expect(saved.activeSlots.count == 1) + #expect(saved.activeSlots.first?.scope == .surface) + #expect(saved.activeSlots.first?.scopeID == restoredPanelID.uuidString) + } + + @MainActor + @Test func liveHibernationSnapshotCarriesCanonicalAuthorityGeneration() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-live-hibernation-generation-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let runtimeID = "live-hibernation-generation-runtime" + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": runtimeID, + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeLiveHibernationAuthorityFixture( + root: root, + runtimeID: runtimeID, + sessionID: "live-hibernation-generation-session" + ) + let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + fixture.panel.surface.installRuntimeSurfaceForTesting(runtimeSurface) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { pointer in + pointer.deallocate() + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + #expect(await fixture.workspace.enterAgentHibernation( + panelId: fixture.panelID, + agent: fixture.agent, + lastActivityAt: Date(timeIntervalSince1970: 10), + finalValidation: { true } + )) + #expect(fixture.panel.isAgentHibernated) + + let panelSnapshot = try #require( + fixture.workspace.sessionSnapshot(includeScrollback: false).panels.first { + $0.id == fixture.panelID + } + ) + let hibernatedAt = try #require(panelSnapshot.terminal?.hibernation?.hibernatedAt) + let registrySnapshot = try fixture.registry.snapshot(provider: "codex") + let canonicalRecord = try #require(registrySnapshot.records.first) + let canonicalObject = try #require( + JSONSerialization.jsonObject(with: canonicalRecord.json) as? [String: Any] + ) + let canonicalUpdatedAt = try #require(canonicalObject["updatedAt"] as? TimeInterval) + + #expect(hibernatedAt.bitPattern == canonicalRecord.updatedAt.bitPattern) + #expect(hibernatedAt.bitPattern == canonicalUpdatedAt.bitPattern) + } + + @MainActor + @Test func transientRegistryContentionKeepsManualResumeRetryable() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-resume-contention-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "resume-contention-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture(root: root, sessionID: "resume-contention-session") + let activeSlot: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "updatedAt": 20.0, + ] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [fixture.agent.sessionId: [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "activeSessionsByWorkspace": [fixture.source.id.uuidString: activeSlot], + "activeSessionsBySurface": [fixture.sourcePanelID.uuidString: activeSlot], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + + let restored = Workspace() + let mapping = restored.restoreSessionSnapshot(fixture.snapshot) + let panelID = try #require(mapping[fixture.sourcePanelID]) + let panel = try #require(restored.terminalPanel(for: panelID)) + #expect(panel.isAgentHibernated) + + var database: OpaquePointer? + #expect(sqlite3_open(registryURL.path, &database) == SQLITE_OK) + let lockedDatabase = try #require(database) + defer { sqlite3_close(lockedDatabase) } + #expect(sqlite3_exec(lockedDatabase, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK) + defer { sqlite3_exec(lockedDatabase, "ROLLBACK", nil, nil, nil) } + + #expect(!restored.resumeAgentHibernation(panelId: panelID, focus: false)) + #expect(panel.isAgentHibernated) + #expect(restored.restoredAgentSnapshotForTesting(panelId: panelID)?.sessionId == fixture.agent.sessionId) + #expect(!panel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(panel.surface.debugPendingSocketInputForTesting().items == 0) + } + + @MainActor + @Test func backgroundAdoptionCannotResumeAfterForeignTakeoverBeforeVisibility() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-hibernation-late-takeover-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "late-takeover-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "late-takeover-session" + ) + let activeSlot: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "updatedAt": 20.0, + ] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [fixture.agent.sessionId: [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "activeSessionsByWorkspace": [fixture.source.id.uuidString: activeSlot], + "activeSessionsBySurface": [fixture.sourcePanelID.uuidString: activeSlot], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + + let restored = Workspace() + let mapping = restored.restoreSessionSnapshot(fixture.snapshot) + let restoredPanelID = try #require(mapping[fixture.sourcePanelID]) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) + #expect(restoredPanel.isAgentHibernated) + + let foreignWorkspaceID = UUID().uuidString + let foreignSurfaceID = UUID().uuidString + let takeoverAt = Date().timeIntervalSince1970 + let foreignRecord: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "workspaceId": foreignWorkspaceID, + "surfaceId": foreignSurfaceID, + "sessionState": "active", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": takeoverAt, + ] + let foreignSlot = try JSONSerialization.data(withJSONObject: [ + "sessionId": fixture.agent.sessionId, + "updatedAt": takeoverAt, + ], options: [.sortedKeys]) + let registry = CmuxAgentSessionRegistry(url: registryURL) + try registry.apply( + provider: "codex", + records: [.init( + provider: "codex", + sessionID: fixture.agent.sessionId, + updatedAt: takeoverAt, + json: try JSONSerialization.data(withJSONObject: foreignRecord, options: [.sortedKeys]) + )], + activeSlots: [ + .init( + provider: "codex", + scope: .workspace, + scopeID: foreignWorkspaceID, + sessionID: fixture.agent.sessionId, + updatedAt: takeoverAt, + json: foreignSlot + ), + .init( + provider: "codex", + scope: .surface, + scopeID: foreignSurfaceID, + sessionID: fixture.agent.sessionId, + updatedAt: takeoverAt, + json: foreignSlot + ), + ], + deletedSlots: [ + CmuxAgentSessionRegistry.slotKey(scope: .workspace, scopeID: restored.id.uuidString), + CmuxAgentSessionRegistry.slotKey(scope: .surface, scopeID: restoredPanelID.uuidString), + ] + ) + + restored.setAgentHibernationAutoResumePresentationVisible(true) + + #expect(!restoredPanel.isAgentHibernated) + #expect(restored.restoredAgentSnapshotForTesting(panelId: restoredPanelID) == nil) + #expect(!restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(restoredPanel.surface.debugPendingSocketInputForTesting().items == 0) + let snapshot = try registry.snapshot(provider: "codex") + let record = try #require(snapshot.records.first) + let object = try #require(JSONSerialization.jsonObject(with: record.json) as? [String: Any]) + #expect(object["workspaceId"] as? String == foreignWorkspaceID) + #expect(object["surfaceId"] as? String == foreignSurfaceID) + #expect(object["sessionState"] as? String == "active") + #expect(Set(snapshot.activeSlots.map(\.scopeID)) == [foreignWorkspaceID, foreignSurfaceID]) + } + + @MainActor + @Test func sameBindingUnknownForeignRuntimeKeepsRestoredAgentInert() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-foreign-runtime-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let currentRuntime = "same-binding-current-runtime" + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": currentRuntime, + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture(root: root, sessionID: "same-binding-session") + let initialSlot: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "updatedAt": 20.0, + ] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [fixture.agent.sessionId: [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "activeSessionsByWorkspace": [fixture.source.id.uuidString: initialSlot], + "activeSessionsBySurface": [fixture.sourcePanelID.uuidString: initialSlot], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + + let restored = Workspace() + let mapping = restored.restoreSessionSnapshot(fixture.snapshot) + let restoredPanelID = try #require(mapping[fixture.sourcePanelID]) + let panel = try #require(restored.terminalPanel(for: restoredPanelID)) + #expect(panel.isAgentHibernated) + + let foreignRuntime: [String: Any] = ["id": "foreign-runtime"] + let takeoverAt = Date().timeIntervalSince1970 + 100 + let foreignRecord: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "workspaceId": restored.id.uuidString, + "surfaceId": restoredPanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "activeRunId": "foreign-run", + "cmuxRuntime": foreignRuntime, + "runs": [[ + "runId": "foreign-run", + "restoreAuthority": true, + "cmuxRuntime": foreignRuntime, + "startedAt": 10.0, + "updatedAt": takeoverAt, + ]], + "startedAt": 10.0, + "updatedAt": takeoverAt, + ] + let foreignSlot = try JSONSerialization.data(withJSONObject: [ + "sessionId": fixture.agent.sessionId, + "updatedAt": takeoverAt, + ], options: [.sortedKeys]) + let registry = CmuxAgentSessionRegistry(url: registryURL) + try registry.apply( + provider: "codex", + records: [.init( + provider: "codex", + sessionID: fixture.agent.sessionId, + updatedAt: takeoverAt, + json: try JSONSerialization.data(withJSONObject: foreignRecord, options: [.sortedKeys]) + )], + activeSlots: [ + .init( + provider: "codex", + scope: .workspace, + scopeID: restored.id.uuidString, + sessionID: fixture.agent.sessionId, + updatedAt: takeoverAt, + json: foreignSlot + ), + .init( + provider: "codex", + scope: .surface, + scopeID: restoredPanelID.uuidString, + sessionID: fixture.agent.sessionId, + updatedAt: takeoverAt, + json: foreignSlot + ), + ] + ) + + restored.setAgentHibernationAutoResumePresentationVisible(true) + + #expect(panel.isAgentHibernated) + #expect( + restored.restoredAgentSnapshotForTesting(panelId: restoredPanelID)?.sessionId + == fixture.agent.sessionId + ) + #expect(!panel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(panel.surface.debugPendingSocketInputForTesting().items == 0) + let snapshot = try registry.snapshot(provider: "codex") + let record = try #require(snapshot.records.first) + let object = try #require(JSONSerialization.jsonObject(with: record.json) as? [String: Any]) + #expect(object["sessionState"] as? String == "hibernated") + #expect((object["cmuxRuntime"] as? [String: Any])?["id"] as? String == "foreign-runtime") + let runs = try #require(object["runs"] as? [[String: Any]]) + #expect((runs.first?["cmuxRuntime"] as? [String: Any])?["id"] as? String == "foreign-runtime") + #expect(Set(snapshot.activeSlots.map(\.scopeID)) == [ + restored.id.uuidString, + restoredPanelID.uuidString, + ]) + } + + @MainActor + @Test(arguments: ["current-owned-same", "foreign-owned-same", "foreign-distinct"]) + func preAdoptionForeignRuntimeRequiresADistinctLiveSocket( + ownershipCase: String + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-pre-adoption-runtime-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let suffix = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) + let liveSocketPath = NSTemporaryDirectory() + "cmux-owner-\(suffix).sock" + let foreignRecordUsesCurrentSocket = ownershipCase != "foreign-distinct" + let currentListenerOwnsPath = ownershipCase == "current-owned-same" + let currentSocketPath = foreignRecordUsesCurrentSocket + ? liveSocketPath + : NSTemporaryDirectory() + "cmux-current-\(suffix).sock" + let listener = try makeListeningUnixSocket(at: liveSocketPath) + defer { + Darwin.close(listener) + unlink(liveSocketPath) + } + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "new-runtime", + "CMUX_SOCKET_PATH": currentSocketPath, + "CMUX_ALLOW_SOCKET_OVERRIDE": "1", + "CMUX_BUNDLE_ID": "com.cmuxterm.app.debug.restore-runtime-test", + ] + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "pre-adoption-\(ownershipCase)" + ) + let registry = try installHibernatedAuthority( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID, + runtime: [ + "id": "old-runtime", + "socketPath": liveSocketPath, + ] + ) + let targetWorkspaceId = UUID() + let targetSurfaceId = UUID() + let writer = AgentHookSessionStateWriter( + homeDirectory: root.path, + environment: environment, + currentSocketStateResolver: { preferredPath in + ( + activePath: currentSocketPath, + pathOwnedByCurrentListener: currentListenerOwnsPath + && SocketControlSettings.pathsMatch(preferredPath, currentSocketPath) + ) + } + ) + let adopted = writer.recordRestoredHibernationSynchronously( + kind: .codex, + sessionId: fixture.agent.sessionId, + previousWorkspaceId: fixture.source.id.uuidString, + previousSurfaceId: fixture.sourcePanelID.uuidString, + workspaceId: targetWorkspaceId.uuidString, + surfaceId: targetSurfaceId.uuidString, + now: 30 + ) + let snapshot = try registry.snapshot(provider: "codex") + let stored = try #require(snapshot.records.first) + let object = try #require(JSONSerialization.jsonObject(with: stored.json) as? [String: Any]) + + if currentListenerOwnsPath { + #expect(adopted) + #expect(object["workspaceId"] as? String == targetWorkspaceId.uuidString) + #expect(object["surfaceId"] as? String == targetSurfaceId.uuidString) + #expect((object["cmuxRuntime"] as? [String: Any])?["id"] as? String == "new-runtime") + } else { + #expect(!adopted) + #expect(object["workspaceId"] as? String == fixture.source.id.uuidString) + #expect(object["surfaceId"] as? String == fixture.sourcePanelID.uuidString) + #expect((object["cmuxRuntime"] as? [String: Any])?["id"] as? String == "old-runtime") + } + } + + @MainActor + @Test(arguments: [true, false]) + func preAdoptionForeignRuntimeUsesProcessGenerationWhenSocketIsUnavailable( + foreignProcessIsLive: Bool + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-pre-adoption-process-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let currentIdentity = try #require(AgentPIDProcessIdentity(pid: getpid())) + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "new-runtime", + ] + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: foreignProcessIsLive ? "live-process-owner" : "recycled-process-owner" + ) + let foreignRuntime: [String: Any] = [ + "id": "old-runtime", + "processId": Int(currentIdentity.pid), + "processStartSeconds": foreignProcessIsLive + ? currentIdentity.startSeconds + : currentIdentity.startSeconds - 1, + "processStartMicroseconds": currentIdentity.startMicroseconds, + ] + let registry = try installHibernatedAuthority( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID, + runtime: foreignRuntime + ) + let targetWorkspaceId = UUID() + let targetSurfaceId = UUID() + let writer = AgentHookSessionStateWriter( + homeDirectory: root.path, + environment: environment + ) + + let adopted = writer.recordRestoredHibernationSynchronously( + kind: .codex, + sessionId: fixture.agent.sessionId, + previousWorkspaceId: fixture.source.id.uuidString, + previousSurfaceId: fixture.sourcePanelID.uuidString, + workspaceId: targetWorkspaceId.uuidString, + surfaceId: targetSurfaceId.uuidString, + now: 30 + ) + let snapshot = try registry.snapshot(provider: "codex") + let stored = try #require(snapshot.records.first) + let object = try #require(JSONSerialization.jsonObject(with: stored.json) as? [String: Any]) + + #expect(adopted == !foreignProcessIsLive) + #expect(object["workspaceId"] as? String == ( + foreignProcessIsLive ? fixture.source.id.uuidString : targetWorkspaceId.uuidString + )) + #expect(object["surfaceId"] as? String == ( + foreignProcessIsLive ? fixture.sourcePanelID.uuidString : targetSurfaceId.uuidString + )) + } + + @MainActor + @Test(arguments: [true, false]) + func currentListenerOwnershipUsesConfiguredAndOverrideSocketPaths( + usesEnvironmentOverride: Bool + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-current-socket-resolution-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let bundleId = "com.cmuxterm.app.debug.restore-socket-resolution" + var environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "configured-new-runtime", + "CMUX_BUNDLE_ID": bundleId, + "CMUX_TAG": "rscfg", + ] + if usesEnvironmentOverride { + environment["CMUX_SOCKET_PATH"] = "/tmp/cmux-restore-override-\(UUID().uuidString).sock" + environment["CMUX_ALLOW_SOCKET_OVERRIDE"] = "1" + } + let expectedSocketPath = SocketControlSettings.socketPath( + environment: environment, + bundleIdentifier: bundleId + ) + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: usesEnvironmentOverride + ? "override-current-socket" + : "configured-current-socket" + ) + let registry = try installHibernatedAuthority( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID, + runtime: [ + "id": "configured-old-runtime", + "socketPath": expectedSocketPath, + ] + ) + let targetWorkspaceId = UUID() + let targetSurfaceId = UUID() + let writer = AgentHookSessionStateWriter( + homeDirectory: root.path, + environment: environment, + currentSocketStateResolver: { preferredPath in + ( + activePath: expectedSocketPath, + pathOwnedByCurrentListener: SocketControlSettings.pathsMatch( + preferredPath, + expectedSocketPath + ) + ) + } + ) + + #expect(writer.recordRestoredHibernationSynchronously( + kind: .codex, + sessionId: fixture.agent.sessionId, + previousWorkspaceId: fixture.source.id.uuidString, + previousSurfaceId: fixture.sourcePanelID.uuidString, + workspaceId: targetWorkspaceId.uuidString, + surfaceId: targetSurfaceId.uuidString, + now: 30 + )) + let snapshot = try registry.snapshot(provider: "codex") + let stored = try #require(snapshot.records.first) + let object = try #require(JSONSerialization.jsonObject(with: stored.json) as? [String: Any]) + #expect(object["workspaceId"] as? String == targetWorkspaceId.uuidString) + #expect(object["surfaceId"] as? String == targetSurfaceId.uuidString) + #expect((object["cmuxRuntime"] as? [String: Any])?["id"] as? String == "configured-new-runtime") + } + + @MainActor + @Test func resumeAuthorityClaimSurvivesWallClockRollback() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hibernation-clock-rollback-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let runtimeID = "clock-rollback-runtime" + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": runtimeID, + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "clock-rollback-session" + let agent = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: root.path, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex"], + workingDirectory: root.path, + environment: nil, + capturedAt: 400, + source: "agent-hook" + ) + ) + let record: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": workspaceID.uuidString, + "surfaceId": surfaceID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeID], + "startedAt": 400.0, + "updatedAt": 500.0, + ] + let slot = try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "updatedAt": 500.0, + ], options: [.sortedKeys]) + let registry = CmuxAgentSessionRegistry(url: registryURL) + try registry.apply( + provider: "codex", + records: [.init( + provider: "codex", + sessionID: sessionID, + updatedAt: 500, + json: try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + )], + activeSlots: [.init( + provider: "codex", + scope: .surface, + scopeID: surfaceID.uuidString, + sessionID: sessionID, + updatedAt: 500, + json: slot + )] + ) + + #expect(AgentHookSessionStateWriter.acquireHibernatedResumeAuthority( + agent: agent, + workspaceId: workspaceID, + surfaceId: surfaceID, + now: 100 + ) == .acquired) + + let snapshot = try registry.snapshot(provider: "codex") + let saved = try #require(snapshot.records.first) + let object = try #require(JSONSerialization.jsonObject(with: saved.json) as? [String: Any]) + let savedSlot = try #require(snapshot.activeSlots.first) + let slotObject = try #require(JSONSerialization.jsonObject(with: savedSlot.json) as? [String: Any]) + #expect(object["sessionState"] as? String == "restoring") + #expect(saved.updatedAt == 500) + #expect(object["updatedAt"] as? TimeInterval == 500) + #expect(savedSlot.updatedAt == 500) + #expect(slotObject["updatedAt"] as? TimeInterval == 500) + } + + @MainActor + @Test func resumeAuthorityClaimsUseCanonicalRunAuthorityAndRuntime() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-canonical-resume-authority-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let currentRuntimeID = "canonical-current-runtime" + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": currentRuntimeID, + ] + let previousEnvironment = overrides.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + struct Case { + let name: String + let rootAuthority: Bool + let rootRuntimeID: String + let runAuthority: Bool + let runRuntimeID: String + let storesActiveRunID: Bool + let expected: AgentHookSessionStateWriter.HibernatedResumeAuthorityOutcome + } + let cases = [ + Case( + name: "canonical-denied", + rootAuthority: true, + rootRuntimeID: currentRuntimeID, + runAuthority: false, + runRuntimeID: currentRuntimeID, + storesActiveRunID: true, + expected: .rejected + ), + Case( + name: "canonical-authorized", + rootAuthority: false, + rootRuntimeID: currentRuntimeID, + runAuthority: true, + runRuntimeID: currentRuntimeID, + storesActiveRunID: true, + expected: .acquired + ), + Case( + name: "newest-foreign", + rootAuthority: true, + rootRuntimeID: currentRuntimeID, + runAuthority: true, + runRuntimeID: "foreign-runtime", + storesActiveRunID: false, + expected: .rejected + ), + Case( + name: "newest-current", + rootAuthority: true, + rootRuntimeID: "stale-root-runtime", + runAuthority: true, + runRuntimeID: currentRuntimeID, + storesActiveRunID: false, + expected: .acquired + ), + ] + let registry = CmuxAgentSessionRegistry(url: registryURL) + var records: [CmuxAgentSessionRegistry.Record] = [] + var slots: [CmuxAgentSessionRegistry.ActiveSlot] = [] + var requests: [(Case, SessionRestorableAgentSnapshot, UUID, UUID)] = [] + for (index, testCase) in cases.enumerated() { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "canonical-claim-\(testCase.name)" + let runID = "run-\(testCase.name)" + var object: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": workspaceID.uuidString, + "surfaceId": surfaceID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": testCase.rootAuthority, + "cmuxRuntime": ["id": testCase.rootRuntimeID], + "runs": [[ + "runId": runID, + "restoreAuthority": testCase.runAuthority, + "cmuxRuntime": ["id": testCase.runRuntimeID], + "startedAt": 10.0, + "updatedAt": 20.0 + Double(index), + ]], + "startedAt": 10.0, + "updatedAt": 20.0 + Double(index), + ] + if testCase.storesActiveRunID { object["activeRunId"] = runID } + records.append(.init( + provider: "codex", + sessionID: sessionID, + updatedAt: 20.0 + Double(index), + json: try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + )) + slots.append(.init( + provider: "codex", + scope: .surface, + scopeID: surfaceID.uuidString, + sessionID: sessionID, + updatedAt: 20.0 + Double(index), + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "updatedAt": 20.0 + Double(index), + ], options: [.sortedKeys]) + )) + requests.append(( + testCase, + SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: root.path, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex"], + workingDirectory: root.path, + environment: nil, + capturedAt: 20.0, + source: "agent-hook" + ) + ), + workspaceID, + surfaceID + )) + } + try registry.apply(provider: "codex", records: records, activeSlots: slots) + + for (testCase, agent, workspaceID, surfaceID) in requests { + #expect( + AgentHookSessionStateWriter.acquireHibernatedResumeAuthority( + agent: agent, + workspaceId: workspaceID, + surfaceId: surfaceID, + now: 100 + ) == testCase.expected, + Comment(rawValue: testCase.name) + ) + } + } + + @MainActor + @Test func windowRestoreBatchesManyContendedWorkspacesIntoOneAdoptionOperation() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-many-workspaces-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let overrides = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "many-workspaces-runtime", + ] + let previousEnvironment = overrides.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (key, value) in overrides { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let workspaceCount = 32 + let fixtures = try (0..() + + let elapsed = ContinuousClock().measure { + failedKinds = RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore( + &snapshot, + homeDirectory: root.path, + environment: environment + ) + } + + #expect(elapsed < .seconds(1)) + #expect(failedKinds == [.pi]) + let terminals = snapshot.windows[0].tabManager.workspaces[0].panels.compactMap(\.terminal) + #expect(terminals.count == SessionPersistencePolicy.maxPanelsPerWorkspace) + #expect(terminals.allSatisfy { + $0.agent == nil && + $0.hibernation == nil && + $0.resumeBinding == nil && + $0.wasAgentRunning == false + }) + } + + @MainActor + @Test func malformedChangedLegacySidecarPreservesOnlyExactCanonicalHibernationAndRetriesRewrite() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restored-hibernation-corrupt-sidecar-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let canonicalSessionID = "canonical-hibernation" + let legacySessionID = "legacy-only-hibernation" + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: canonicalSessionID + ) + var workspace = fixture.snapshot + var legacyPanel = try #require( + workspace.panels.first { $0.id == fixture.sourcePanelID } + ) + legacyPanel.id = UUID() + var legacyTerminal = try #require(legacyPanel.terminal) + var legacyAgent = try #require(legacyTerminal.agent) + legacyAgent.sessionId = legacySessionID + legacyTerminal.agent = legacyAgent + var legacyBinding = try #require(legacyTerminal.resumeBinding) + legacyBinding.checkpointId = legacySessionID + legacyTerminal.resumeBinding = legacyBinding + legacyPanel.terminal = legacyTerminal + workspace.panels.append(legacyPanel) + + let workspaceID = fixture.source.id.uuidString + func record(sessionID: String, surfaceID: UUID) -> [String: Any] { + [ + "sessionId": sessionID, + "workspaceId": workspaceID, + "surfaceId": surfaceID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + ] + } + func slot(sessionID: String) -> [String: Any] { + ["sessionId": sessionID, "updatedAt": 20.0] + } + func legacyStoreData() throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + canonicalSessionID: record( + sessionID: canonicalSessionID, + surfaceID: fixture.sourcePanelID + ), + legacySessionID: record( + sessionID: legacySessionID, + surfaceID: legacyPanel.id + ), + ], + "activeSessionsBySurface": [ + fixture.sourcePanelID.uuidString: slot(sessionID: canonicalSessionID), + legacyPanel.id.uuidString: slot(sessionID: legacySessionID), + ], + ], options: [.sortedKeys]) + } + + try legacyStoreData().write(to: stateURL, options: .atomic) + let registry = CmuxAgentSessionRegistry(url: registryURL) + _ = try registry.snapshotImportingLegacy( + provider: "codex", + legacyURL: stateURL + ) + let canonicalSlotJSON = try JSONSerialization.data( + withJSONObject: slot(sessionID: canonicalSessionID), + options: [.sortedKeys] + ) + try registry.apply( + provider: "codex", + records: [.init( + provider: "codex", + sessionID: canonicalSessionID, + updatedAt: 20, + json: try JSONSerialization.data( + withJSONObject: record( + sessionID: canonicalSessionID, + surfaceID: fixture.sourcePanelID + ), + options: [.sortedKeys] + ) + )], + activeSlots: [.init( + provider: "codex", + scope: .surface, + scopeID: fixture.sourcePanelID.uuidString, + sessionID: canonicalSessionID, + updatedAt: 20, + json: canonicalSlotJSON + )] + ) + try Data("{broken".utf8).write(to: stateURL, options: .atomic) + + let persistedSnapshot = AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: 20, + windows: [SessionWindowSnapshot( + windowId: UUID(), + frame: nil, + display: nil, + tabManager: SessionTabManagerSnapshot( + selectedWorkspaceIndex: 0, + workspaces: [workspace] + ), + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: nil) + )] + ) + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + ] + func restoredSessionIDs(_ snapshot: AppSessionSnapshot) -> Set { + Set(snapshot.windows[0].tabManager.workspaces[0].panels.compactMap { + $0.terminal?.hibernation == nil ? nil : $0.terminal?.agent?.sessionId + }) + } + + var corruptSnapshot = persistedSnapshot + let failedKinds = RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore( + &corruptSnapshot, + homeDirectory: root.path, + environment: environment + ) + + #expect(failedKinds == [.codex]) + #expect(restoredSessionIDs(corruptSnapshot) == [canonicalSessionID]) + let afterCorruption = try registry.records( + provider: "codex", + sessionIDs: [canonicalSessionID] + ) + #expect(afterCorruption.first?.writerGeneration == CmuxAgentSessionRegistry.currentWriterGeneration) + let expectedCanonicalJSON = try JSONSerialization.data( + withJSONObject: record( + sessionID: canonicalSessionID, + surfaceID: fixture.sourcePanelID + ), + options: [.sortedKeys] + ) + #expect(afterCorruption.first?.json == expectedCanonicalJSON) + + var repeatedCorruptSnapshot = persistedSnapshot + let repeatedFailures = RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore( + &repeatedCorruptSnapshot, + homeDirectory: root.path, + environment: environment + ) + #expect(repeatedFailures == [.codex]) + #expect(restoredSessionIDs(repeatedCorruptSnapshot) == [canonicalSessionID]) + + try legacyStoreData().write(to: stateURL, options: .atomic) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 40)], + ofItemAtPath: stateURL.path + ) + var repairedSnapshot = persistedSnapshot + let retriedFailures = RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore( + &repairedSnapshot, + homeDirectory: root.path, + environment: environment + ) + + #expect(retriedFailures.isEmpty) + #expect(restoredSessionIDs(repairedSnapshot) == [canonicalSessionID, legacySessionID]) + } + + // MARK: - Startup hibernation reconciliation + + @Test func runtimeOwnershipProbeCapsAndDeduplicatesExternalProcessChecks() { + let calls = OSAllocatedUnfairLock(initialState: [pid_t: Int]()) + var probe = AgentRuntimeOwnershipProbe( + environment: ["CMUX_RUNTIME_ID": "current-runtime"], + currentSocketStateResolver: { + (activePath: $0, pathOwnedByCurrentListener: false) + }, + processIdentityResolver: { pid in + calls.withLock { $0[pid, default: 0] += 1 } + return AgentPIDProcessIdentity( + pid: pid, + startSeconds: 10, + startMicroseconds: 20 + ) + }, + maximumExternalProbes: 3 + ) + func record(pid: pid_t) -> [String: Any] { + ["cmuxRuntime": [ + "id": "foreign-\(pid)", + "processId": Int(pid), + "processStartSeconds": 10, + "processStartMicroseconds": 20, + ]] + } + + for _ in 0..<100 { + #expect(probe.evidence(for: record(pid: 41_001)) == .provablyLiveForeign) + } + #expect(probe.evidence(for: record(pid: 41_002)) == .provablyLiveForeign) + #expect(probe.evidence(for: record(pid: 41_003)) == .provablyLiveForeign) + #expect(probe.evidence(for: record(pid: 41_004)) == .unknownForeign) + #expect(probe.externalProbeCount == 3) + let callSnapshot = calls.withLock { $0 } + #expect(callSnapshot.values.reduce(0, +) == 3) + #expect(callSnapshot[41_001] == 1) + + #expect(probe.evidence(for: [ + "cmuxRuntime": ["id": "current-runtime"], + ]) == .current) + #expect(probe.externalProbeCount == 3) + } + + @Test func runtimeOwnershipProbeIgnoresCurrentRootWhenCanonicalRunOwnerIsDead() throws { + let currentIdentity = try #require(AgentPIDProcessIdentity(pid: getpid())) + var probe = AgentRuntimeOwnershipProbe( + environment: ["CMUX_RUNTIME_ID": "current-runtime"], + currentSocketStateResolver: { + (activePath: $0, pathOwnedByCurrentListener: false) + }, + processIdentityResolver: { AgentPIDProcessIdentity(pid: $0) } + ) + let record: [String: Any] = [ + "cmuxRuntime": ["id": "current-runtime"], + "activeRunId": "canonical-run", + "runs": [[ + "runId": "canonical-run", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + "cmuxRuntime": [ + "id": "foreign-runtime", + "processId": Int(currentIdentity.pid), + "processStartSeconds": currentIdentity.startSeconds + 1, + "processStartMicroseconds": currentIdentity.startMicroseconds, + ], + ]], + ] + + #expect(probe.evidence(for: record) == .provablyDeadForeign) + } + + @Test func runtimeOwnershipProbeUsesCurrentCanonicalRunOverDeadRootOwner() throws { + let currentIdentity = try #require(AgentPIDProcessIdentity(pid: getpid())) + var probe = AgentRuntimeOwnershipProbe( + environment: ["CMUX_RUNTIME_ID": "current-runtime"], + currentSocketStateResolver: { + (activePath: $0, pathOwnedByCurrentListener: false) + }, + processIdentityResolver: { AgentPIDProcessIdentity(pid: $0) } + ) + let record: [String: Any] = [ + "cmuxRuntime": [ + "id": "stale-root-runtime", + "processId": Int(currentIdentity.pid), + "processStartSeconds": currentIdentity.startSeconds + 1, + "processStartMicroseconds": currentIdentity.startMicroseconds, + ], + "activeRunId": "canonical-run", + "runs": [[ + "runId": "canonical-run", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + "cmuxRuntime": ["id": "current-runtime"], + ]], + ] + + #expect(probe.evidence(for: record) == .current) + } + + @Test func runtimeOwnershipProbeFallsBackToNewestCanonicalRun() { + var probe = AgentRuntimeOwnershipProbe( + environment: ["CMUX_RUNTIME_ID": "current-runtime"], + currentSocketStateResolver: { + (activePath: $0, pathOwnedByCurrentListener: false) + }, + processIdentityResolver: { _ in nil } + ) + let record: [String: Any] = [ + "runs": [ + [ + "runId": "older-run", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + "cmuxRuntime": ["id": "foreign-runtime"], + ], + [ + "runId": "newest-run", + "restoreAuthority": true, + "startedAt": 20.0, + "updatedAt": 30.0, + "cmuxRuntime": ["id": "current-runtime"], + ], + ], + ] + + #expect(probe.evidence(for: record) == .current) + } + + @MainActor + @Test func startupReconciliationTurnsSavedActiveCanonicalHibernationIntoInertPlaceholder() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-startup-active-to-hibernated-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "startup-reconcile-runtime", + ] + let previousEnvironment = environment.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environment { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "startup-canonical-hibernated" + ) + var savedActive = fixture.snapshot + let panelIndex = try #require( + savedActive.panels.firstIndex { $0.id == fixture.sourcePanelID } + ) + var terminal = try #require(savedActive.panels[panelIndex].terminal) + terminal.hibernation = nil + terminal.wasAgentRunning = true + if var binding = terminal.resumeBinding { + binding.autoResume = true + terminal.resumeBinding = binding + } + savedActive.panels[panelIndex].terminal = terminal + try installStartupCanonicalOwner( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID, + lifecycle: "hibernated", + hibernatedAt: 30 + ) + var appSnapshot = appSessionSnapshot(containing: savedActive) + + let failedKinds = RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore( + &appSnapshot, + homeDirectory: root.path, + environment: environment + ) + + #expect(failedKinds.isEmpty) + let reconciledWorkspace = appSnapshot.windows[0].tabManager.workspaces[0] + let reconciledTerminal = try #require( + reconciledWorkspace.panels.first { $0.id == fixture.sourcePanelID }?.terminal + ) + #expect(reconciledTerminal.agent?.sessionId == fixture.agent.sessionId) + #expect(reconciledTerminal.hibernation?.hibernatedAt == 30) + #expect(reconciledTerminal.wasAgentRunning == false) + #expect(reconciledTerminal.resumeBinding?.checkpointId == fixture.agent.sessionId) + + let restored = Workspace() + let mapping = restored.restoreSessionSnapshot(reconciledWorkspace) + let restoredPanelID = try #require(mapping[fixture.sourcePanelID]) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) + #expect(restoredPanel.isAgentHibernated) + #expect(restoredPanel.surface.debugInitialCommand() == nil) + #expect(!restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(restoredPanel.surface.debugPendingSocketInputForTesting().items == 0) + } + + @MainActor + @Test func startupReconciliationUsesCanonicalRunAuthority() throws { + for (name, recordAuthority, runAuthority) in [ + ("stale-promote", true, false), + ("stale-demote", false, true), + ] { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-startup-canonical-run-\(name)-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "startup-canonical-run-runtime", + ] + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "startup-canonical-run-\(name)" + ) + var savedActive = fixture.snapshot + let panelIndex = try #require( + savedActive.panels.firstIndex { $0.id == fixture.sourcePanelID } + ) + var terminal = try #require(savedActive.panels[panelIndex].terminal) + terminal.hibernation = nil + terminal.wasAgentRunning = true + savedActive.panels[panelIndex].terminal = terminal + try installStartupCanonicalOwner( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID, + lifecycle: "hibernated", + hibernatedAt: 30, + runtime: ["id": "startup-canonical-run-runtime"], + recordRestoreAuthority: recordAuthority, + runRestoreAuthority: runAuthority + ) + var appSnapshot = appSessionSnapshot(containing: savedActive) + + let failures = RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore( + &appSnapshot, + homeDirectory: root.path, + environment: environment + ) + + #expect(failures.isEmpty) + let reconciled = try #require( + appSnapshot.windows[0].tabManager.workspaces[0] + .panels.first { $0.id == fixture.sourcePanelID }?.terminal + ) + #expect((reconciled.hibernation != nil) == runAuthority) + #expect(reconciled.wasAgentRunning == false) + #expect((reconciled.agent != nil) == runAuthority) + #expect((reconciled.resumeBinding != nil) == runAuthority) + } + } + + @MainActor + @Test func startupReconciliationPreservesCanonicalHibernationOverStaleActiveLegacy() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-startup-canonical-over-stale-active-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "startup-canonical-current-runtime", + ] + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "startup-canonical-over-stale-active" + ) + var savedActive = fixture.snapshot + let panelIndex = try #require( + savedActive.panels.firstIndex { $0.id == fixture.sourcePanelID } + ) + var terminal = try #require(savedActive.panels[panelIndex].terminal) + terminal.hibernation = nil + terminal.wasAgentRunning = true + savedActive.panels[panelIndex].terminal = terminal + + let canonicalRecord: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "cmuxHibernatedAt": 30.0, + "startedAt": 10.0, + "updatedAt": 30.0, + ] + let canonicalSlot: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "updatedAt": 30.0, + ] + let registry = CmuxAgentSessionRegistry(url: registryURL) + try registry.apply( + provider: "codex", + records: [.init( + provider: "codex", + sessionID: fixture.agent.sessionId, + updatedAt: 30, + json: try JSONSerialization.data( + withJSONObject: canonicalRecord, + options: [.sortedKeys] + ) + )], + activeSlots: [.init( + provider: "codex", + scope: .surface, + scopeID: fixture.sourcePanelID.uuidString, + sessionID: fixture.agent.sessionId, + updatedAt: 30, + json: try JSONSerialization.data( + withJSONObject: canonicalSlot, + options: [.sortedKeys] + ) + )] + ) + let staleSlot: [String: Any] = [ + "sessionId": fixture.agent.sessionId, + "updatedAt": 20.0, + ] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [fixture.agent.sessionId: [ + "sessionId": fixture.agent.sessionId, + "workspaceId": fixture.source.id.uuidString, + "surfaceId": fixture.sourcePanelID.uuidString, + "sessionState": "active", + "restoreAuthority": true, + "cmuxRuntime": ["id": "stale-active-runtime"], + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "activeSessionsBySurface": [fixture.sourcePanelID.uuidString: staleSlot], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + var appSnapshot = appSessionSnapshot(containing: savedActive) + + let failures = RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore( + &appSnapshot, + homeDirectory: root.path, + environment: environment + ) + + #expect(failures.isEmpty) + let reconciled = try #require( + appSnapshot.windows[0].tabManager.workspaces[0] + .panels.first { $0.id == fixture.sourcePanelID }?.terminal + ) + #expect(reconciled.hibernation?.hibernatedAt == 30) + #expect(reconciled.wasAgentRunning == false) + let retained = try #require(registry.snapshot(provider: "codex").records.first) + let retainedObject = try #require( + JSONSerialization.jsonObject(with: retained.json) as? [String: Any] + ) + #expect(retained.writerGeneration == CmuxAgentSessionRegistry.currentWriterGeneration) + #expect(retainedObject["sessionState"] as? String == "hibernated") + #expect(retainedObject["updatedAt"] as? Double == 30) + } + + @MainActor + @Test func startupReconciliationKeepsUnknownRestoringAttemptInert() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-startup-restoring-unknown-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "startup-restoring-new-runtime", + ] + let previousEnvironment = environment.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environment { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "startup-restoring-unknown" + ) + var savedActive = fixture.snapshot + let panelIndex = try #require( + savedActive.panels.firstIndex { $0.id == fixture.sourcePanelID } + ) + var terminal = try #require(savedActive.panels[panelIndex].terminal) + terminal.hibernation = nil + terminal.wasAgentRunning = true + if var binding = terminal.resumeBinding { + binding.autoResume = true + terminal.resumeBinding = binding + } + savedActive.panels[panelIndex].terminal = terminal + let attemptID = UUID() + let runtime: [String: Any] = ["id": "metadata-only-foreign-runtime"] + let registry = try installStartupCanonicalOwner( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID, + lifecycle: "restoring", + hibernatedAt: 30, + resumeAttemptId: attemptID, + runtime: runtime + ) + var appSnapshot = appSessionSnapshot(containing: savedActive) + + let failedKinds = RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore( + &appSnapshot, + homeDirectory: root.path, + environment: environment + ) + + #expect(failedKinds.isEmpty) + let reconciledWorkspace = appSnapshot.windows[0].tabManager.workspaces[0] + let reconciledTerminal = try #require( + reconciledWorkspace.panels.first { $0.id == fixture.sourcePanelID }?.terminal + ) + #expect(reconciledTerminal.hibernation != nil) + #expect(reconciledTerminal.wasAgentRunning == false) + + let restored = Workspace() + let mapping = restored.restoreSessionSnapshot(reconciledWorkspace) + let restoredPanelID = try #require(mapping[fixture.sourcePanelID]) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) + #expect(restoredPanel.isAgentHibernated) + #expect(restoredPanel.surface.debugInitialCommand() == nil) + #expect(!restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(restoredPanel.surface.debugPendingSocketInputForTesting().items == 0) + + let canonical = try #require(registry.snapshot(provider: "codex").records.first) + let stored = try #require( + JSONSerialization.jsonObject(with: canonical.json) as? [String: Any] + ) + #expect(stored["sessionState"] as? String == "restoring") + #expect(stored["cmuxHibernationResumeAttemptId"] as? String == attemptID.uuidString) + #expect((stored["cmuxRuntime"] as? [String: Any])?["id"] as? String == runtime["id"] as? String) + } + + @MainActor + @Test func startupReconciliationNormalizesProvablyDeadRestoringAttempt() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-startup-restoring-dead-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "startup-dead-new-runtime", + ] + let previousEnvironment = environment.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environment { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "startup-restoring-dead" + ) + var savedActive = fixture.snapshot + let panelIndex = try #require( + savedActive.panels.firstIndex { $0.id == fixture.sourcePanelID } + ) + var terminal = try #require(savedActive.panels[panelIndex].terminal) + terminal.hibernation = nil + terminal.wasAgentRunning = true + if var binding = terminal.resumeBinding { + binding.autoResume = true + terminal.resumeBinding = binding + } + savedActive.panels[panelIndex].terminal = terminal + let attemptID = UUID() + let deadRuntime: [String: Any] = [ + "id": "dead-foreign-runtime", + "processId": Int(Int32.max), + "processStartSeconds": 1, + "processStartMicroseconds": 1, + ] + let registry = try installStartupCanonicalOwner( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID, + lifecycle: "restoring", + hibernatedAt: 30, + resumeAttemptId: attemptID, + runtime: deadRuntime + ) + var appSnapshot = appSessionSnapshot(containing: savedActive) + + let failedKinds = RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore( + &appSnapshot, + homeDirectory: root.path, + environment: environment + ) + + #expect(failedKinds.isEmpty) + let reconciledWorkspace = appSnapshot.windows[0].tabManager.workspaces[0] + let reconciledTerminal = try #require( + reconciledWorkspace.panels.first { $0.id == fixture.sourcePanelID }?.terminal + ) + #expect(reconciledTerminal.hibernation != nil) + #expect(reconciledTerminal.wasAgentRunning == false) + let normalizedRecord = try #require( + registry.snapshot(provider: "codex").records.first + ) + let normalized = try #require( + JSONSerialization.jsonObject(with: normalizedRecord.json) as? [String: Any] + ) + #expect(normalized["sessionState"] as? String == "hibernated") + #expect(normalized["cmuxHibernationResumeAttemptId"] == nil) + #expect(normalized["cmuxHibernationResumeStartedAt"] == nil) + #expect(normalized["cmuxHibernationResumeFromAttemptId"] == nil) + + let restored = Workspace() + let mapping = restored.restoreSessionSnapshot(reconciledWorkspace) + let restoredPanelID = try #require(mapping[fixture.sourcePanelID]) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) + #expect(restoredPanel.isAgentHibernated) + #expect(restoredPanel.surface.debugInitialCommand() == nil) + #expect(!restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(restoredPanel.surface.debugPendingSocketInputForTesting().items == 0) + } + + @MainActor + @Test func startupReconciliationKeepsDeadRestoringWithoutAttemptInert() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-startup-restoring-dead-without-attempt-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "startup-dead-no-attempt-current-runtime", + ] + let previousEnvironment = environment.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environment { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "startup-restoring-dead-without-attempt" + ) + var savedActive = fixture.snapshot + let panelIndex = try #require( + savedActive.panels.firstIndex { $0.id == fixture.sourcePanelID } + ) + var terminal = try #require(savedActive.panels[panelIndex].terminal) + terminal.hibernation = nil + terminal.wasAgentRunning = true + savedActive.panels[panelIndex].terminal = terminal + let registry = try installStartupCanonicalOwner( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID, + lifecycle: "restoring", + hibernatedAt: 30, + runtime: provablyDeadRuntime(id: "dead-restoring-without-attempt") + ) + var appSnapshot = appSessionSnapshot(containing: savedActive) + + let failures = RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore( + &appSnapshot, + homeDirectory: root.path, + environment: environment + ) + + #expect(failures.isEmpty) + let reconciledWorkspace = appSnapshot.windows[0].tabManager.workspaces[0] + let reconciledTerminal = try #require( + reconciledWorkspace.panels.first { $0.id == fixture.sourcePanelID }?.terminal + ) + #expect(reconciledTerminal.hibernation != nil) + #expect(reconciledTerminal.wasAgentRunning == false) + let restored = Workspace() + let mapping = restored.restoreSessionSnapshot(reconciledWorkspace) + let restoredPanelID = try #require(mapping[fixture.sourcePanelID]) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) + #expect(restoredPanel.isAgentHibernated) + #expect(!restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(restoredPanel.surface.debugPendingSocketInputForTesting().items == 0) + + let retained = try #require(registry.snapshot(provider: "codex").records.first) + let retainedObject = try #require( + JSONSerialization.jsonObject(with: retained.json) as? [String: Any] + ) + #expect(retainedObject["sessionState"] as? String == "restoring") + #expect(retainedObject["cmuxHibernationResumeAttemptId"] == nil) + } + + @MainActor + @Test func startupReconciliationKeepsProvablyLiveRestoringAttemptPending() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-startup-restoring-live-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "startup-live-new-runtime", + ] + let previousEnvironment = environment.keys.map { + ($0, ProcessInfo.processInfo.environment[$0]) + } + for (key, value) in environment { setenv(key, value, 1) } + defer { + for (key, value) in previousEnvironment { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "startup-restoring-live" + ) + var savedActive = fixture.snapshot + let panelIndex = try #require( + savedActive.panels.firstIndex { $0.id == fixture.sourcePanelID } + ) + var terminal = try #require(savedActive.panels[panelIndex].terminal) + terminal.hibernation = nil + terminal.wasAgentRunning = true + if var binding = terminal.resumeBinding { + binding.autoResume = true + terminal.resumeBinding = binding + } + savedActive.panels[panelIndex].terminal = terminal + let processIdentity = try #require(AgentPIDProcessIdentity(pid: getpid())) + let attemptID = UUID() + let liveRuntime: [String: Any] = [ + "id": "live-foreign-runtime", + "processId": Int(processIdentity.pid), + "processStartSeconds": processIdentity.startSeconds, + "processStartMicroseconds": processIdentity.startMicroseconds, + ] + let registry = try installStartupCanonicalOwner( + root: root, + registryURL: registryURL, + agent: fixture.agent, + workspaceId: fixture.source.id, + surfaceId: fixture.sourcePanelID, + lifecycle: "restoring", + hibernatedAt: 30, + resumeAttemptId: attemptID, + runtime: liveRuntime + ) + var appSnapshot = appSessionSnapshot(containing: savedActive) + + let failedKinds = RestorableAgentSessionIndex.prepareAgentRegistryForSessionRestore( + &appSnapshot, + homeDirectory: root.path, + environment: environment + ) + + #expect(failedKinds.isEmpty) + let reconciledWorkspace = appSnapshot.windows[0].tabManager.workspaces[0] + let reconciledTerminal = try #require( + reconciledWorkspace.panels.first { $0.id == fixture.sourcePanelID }?.terminal + ) + #expect(reconciledTerminal.hibernation != nil) + #expect(reconciledTerminal.wasAgentRunning == false) + let unchangedRecord = try #require( + registry.snapshot(provider: "codex").records.first + ) + let unchanged = try #require( + JSONSerialization.jsonObject(with: unchangedRecord.json) as? [String: Any] + ) + #expect(unchanged["sessionState"] as? String == "restoring") + #expect(unchanged["cmuxHibernationResumeAttemptId"] as? String == attemptID.uuidString) + + let restored = Workspace() + let mapping = restored.restoreSessionSnapshot(reconciledWorkspace) + let restoredPanelID = try #require(mapping[fixture.sourcePanelID]) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) + #expect(restoredPanel.isAgentHibernated) + #expect(restoredPanel.surface.debugInitialCommand() == nil) + #expect(!restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(restoredPanel.surface.debugPendingSocketInputForTesting().items == 0) + let stillCanonical = try #require( + registry.snapshot(provider: "codex").records.first + ) + let stillRestoring = try #require( + JSONSerialization.jsonObject(with: stillCanonical.json) as? [String: Any] + ) + #expect(stillRestoring["sessionState"] as? String == "restoring") + #expect(stillRestoring["cmuxHibernationResumeAttemptId"] as? String == attemptID.uuidString) + } + + @MainActor + @Test func startupReconciliationFailsClosedBeforeOpeningAnOversizedProjection() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-startup-reconcile-cap-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let environment = [ + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + ] + let fixture = try makeHibernatedRestoreFixture( + root: root, + sessionID: "startup-cap-session" + ) + let panelTemplate = try #require( + fixture.snapshot.panels.first { $0.id == fixture.sourcePanelID } + ) + var workspaces: [SessionWorkspaceSnapshot] = [] + var remaining = RestorableAgentSessionIndex.maximumHibernationPanelContexts + 1 + while remaining > 0 { + var workspace = fixture.snapshot + workspace.workspaceId = UUID() + let count = min(SessionPersistencePolicy.maxPanelsPerWorkspace, remaining) + workspace.panels = (0.. [String: Any] { + [ + "sessionId": sessionId, + "workspaceId": "workspace-\(runtimeId)", + "surfaceId": "surface-\(runtimeId)", + "transcriptPath": "/tmp/\(sessionId).jsonl", + "runId": runId, + "activeRunId": runId, + "restoreAuthority": true, + "foregroundState": "completed", + "workloads": [[ + "id": "monitor-\(runtimeId)", + "kind": "monitor", + "phase": "watching", + "keepsSessionBusy": true, + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + "cmuxRuntime": [ + "id": runtimeId, + "socketPath": "/tmp/cmux-debug-\(runtimeId).sock", + "bundleIdentifier": "com.cmuxterm.app.debug.\(runtimeId)", + ], + "runs": [[ + "runId": runId, + "restoreAuthority": true, + "cmuxRuntime": [ + "id": runtimeId, + "socketPath": "/tmp/cmux-debug-\(runtimeId).sock", + "bundleIdentifier": "com.cmuxterm.app.debug.\(runtimeId)", + ], + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + "startedAt": 100.0, + "updatedAt": 200.0, + ] + } + + let store: [String: Any] = [ + "version": 2, + "sessions": [ + "current-session": record( + sessionId: "current-session", + runId: "current-run", + runtimeId: "current" + ), + "other-session": record( + sessionId: "other-session", + runId: "other-run", + runtimeId: "other" + ), + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.sortedKeys]) + .write(to: root.appendingPathComponent("codex-hook-sessions.json"), options: .atomic) + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_RUNTIME_ID"] = "current" + + let scoped = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--json"], + environment: environment, + timeout: 5 + ) + #expect(!scoped.timedOut, Comment(rawValue: scoped.stdout)) + #expect(scoped.status == 0, Comment(rawValue: scoped.stdout)) + let scopedOutput = try #require( + JSONSerialization.jsonObject(with: Data(scoped.stdout.utf8)) as? [String: Any] + ) + let scopedNodes = try #require(scopedOutput["nodes"] as? [[String: Any]]) + #expect(scopedNodes.map { $0["session_id"] as? String } == ["current-session"]) + + for filter in [ + ["--state", "monitoring"], + ["--activity", "busy"], + ["--work-kind", "monitor"], + ] { + let filteredList = runProcess( + executablePath: cliPath, + arguments: ["agents", "list"] + filter + ["--json"], + environment: environment, + timeout: 5 + ) + #expect(!filteredList.timedOut, Comment(rawValue: filteredList.stdout)) + #expect(filteredList.status == 0, Comment(rawValue: filteredList.stdout)) + let filteredOutput = try #require( + JSONSerialization.jsonObject(with: Data(filteredList.stdout.utf8)) as? [String: Any] + ) + let filteredSessions = try #require(filteredOutput["sessions"] as? [[String: Any]]) + #expect(filteredSessions.map { $0["session_id"] as? String } == ["current-session"]) + } + + let history = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--all", "--json"], + environment: environment, + timeout: 5 + ) + #expect(!history.timedOut, Comment(rawValue: history.stdout)) + #expect(history.status == 0, Comment(rawValue: history.stdout)) + let historyOutput = try #require( + JSONSerialization.jsonObject(with: Data(history.stdout.utf8)) as? [String: Any] + ) + let historyNodes = try #require(historyOutput["nodes"] as? [[String: Any]]) + #expect(Set(historyNodes.compactMap { $0["session_id"] as? String }) == ["current-session", "other-session"]) + } + + @Test func agentsDefaultViewsExcludeEndedRunsFromTheCurrentRuntime() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-live-default-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let runtime: [String: Any] = ["id": "current-runtime"] + func record( + sessionId: String, + runId: String, + foregroundState: String, + endedAt: TimeInterval? = nil + ) -> [String: Any] { + var run: [String: Any] = [ + "runId": runId, + "restoreAuthority": true, + "cmuxRuntime": runtime, + "startedAt": 100.0, + "updatedAt": 200.0, + ] + run["endedAt"] = endedAt + var result: [String: Any] = [ + "sessionId": sessionId, + "workspaceId": "workspace", + "surfaceId": "surface-\(sessionId)", + "runId": runId, + "activeRunId": runId, + "restoreAuthority": true, + "foregroundState": foregroundState, + "cmuxRuntime": runtime, + "runs": [run], + "startedAt": 100.0, + "updatedAt": 200.0, + ] + result["completedAt"] = endedAt + return result + } + + let codexStore: [String: Any] = [ + "version": 2, + "sessions": [ + "ended-root": record( + sessionId: "ended-root", + runId: "ended-root-run", + foregroundState: "completed", + endedAt: 150 + ), + "ended-child": record( + sessionId: "ended-child", + runId: "ended-child-run", + foregroundState: "completed", + endedAt: 160 + ), + "live-codex": record( + sessionId: "live-codex", + runId: "live-codex-run", + foregroundState: "idle" + ), + ], + ] + let claudeStore: [String: Any] = [ + "version": 2, + "sessions": [ + "live-claude": record( + sessionId: "live-claude", + runId: "live-claude-run", + foregroundState: "working" + ), + ], + ] + try JSONSerialization.data(withJSONObject: codexStore, options: [.sortedKeys]) + .write(to: root.appendingPathComponent("codex-hook-sessions.json"), options: .atomic) + try JSONSerialization.data(withJSONObject: claudeStore, options: [.sortedKeys]) + .write(to: root.appendingPathComponent("claude-hook-sessions.json"), options: .atomic) + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_RUNTIME_ID"] = "current-runtime" + + for command in [["agents", "tree", "--json"], ["agents", "list", "--json"]] { + let result = runProcess( + executablePath: cliPath, + arguments: command, + environment: environment, + timeout: 5 + ) + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let output = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let rows = (output["nodes"] as? [[String: Any]]) ?? (output["sessions"] as? [[String: Any]]) + let sessionIds = Set(try #require(rows).compactMap { $0["session_id"] as? String }) + #expect(sessionIds == ["live-claude", "live-codex"], Comment(rawValue: result.stdout)) + } + + let history = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--all", "--json"], + environment: environment, + timeout: 5 + ) + #expect(!history.timedOut, Comment(rawValue: history.stdout)) + #expect(history.status == 0, Comment(rawValue: history.stdout)) + let historyOutput = try #require( + JSONSerialization.jsonObject(with: Data(history.stdout.utf8)) as? [String: Any] + ) + let historyNodes = try #require(historyOutput["nodes"] as? [[String: Any]]) + #expect(Set(historyNodes.compactMap { $0["session_id"] as? String }) == [ + "ended-root", "ended-child", "live-claude", "live-codex", + ]) + } + + @Test func agentsTreeKeepsDistinctSessionsThatShareAProcessGeneration() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-shared-process-run-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + func writeStore(provider: String, sessionId: String) throws { + let runtime: [String: Any] = ["id": "current-runtime"] + let store: [String: Any] = [ + "version": 2, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": "workspace", + "surfaceId": "surface-\(provider)", + "runId": "pid:4242@100", + "activeRunId": "pid:4242@100", + "restoreAuthority": true, + "cmuxRuntime": runtime, + "runs": [[ + "runId": "pid:4242@100", + "pid": 4242, + "processStartedAt": 100.0, + "restoreAuthority": true, + "cmuxRuntime": runtime, + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + "startedAt": 100.0, + "updatedAt": 200.0, + ], + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.sortedKeys]) + .write(to: root.appendingPathComponent("\(provider)-hook-sessions.json"), options: .atomic) + } + try writeStore(provider: "codex", sessionId: "codex-session") + try writeStore(provider: "kimi", sessionId: "kimi-session") + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_RUNTIME_ID"] = "current-runtime" + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--json"], + environment: environment, + timeout: 5 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let output = try #require( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let nodes = try #require(output["nodes"] as? [[String: Any]]) + #expect(Set(nodes.compactMap { $0["session_id"] as? String }) == ["codex-session", "kimi-session"]) + } + + @Test func agentsTreeNestsAChildThatSortsBeforeItsParent() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-child-before-parent-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let runtime: [String: Any] = ["id": "current-runtime"] + func writeStore( + provider: String, + sessionId: String, + runId: String, + parentRunId: String? = nil, + parentSessionId: String? = nil, + relationship: String? = nil, + restoreAuthority: Bool, + startedAt: TimeInterval + ) throws { + var run: [String: Any] = [ + "runId": runId, + "restoreAuthority": restoreAuthority, + "cmuxRuntime": runtime, + "startedAt": startedAt, + "updatedAt": 300.0, + ] + run["parentRunId"] = parentRunId + run["parentSessionId"] = parentSessionId + run["relationship"] = relationship + let storeURL = root.appendingPathComponent("\(provider)-hook-sessions.json") + var sessions: [String: Any] = [:] + if let existingData = try? Data(contentsOf: storeURL), + let existingStore = try? JSONSerialization.jsonObject(with: existingData) as? [String: Any], + let existingSessions = existingStore["sessions"] as? [String: Any] { + sessions = existingSessions + } + sessions[sessionId] = [ + "sessionId": sessionId, + "workspaceId": "workspace", + "surfaceId": "surface", + "runId": runId, + "activeRunId": runId, + "restoreAuthority": restoreAuthority, + "cmuxRuntime": runtime, + "runs": [run], + "startedAt": startedAt, + "updatedAt": 300.0, + ] + let store: [String: Any] = [ + "version": 2, + "sessions": sessions, + ] + try JSONSerialization.data(withJSONObject: store, options: [.sortedKeys]) + .write(to: storeURL, options: .atomic) + } + + // The child deliberately sorts before its parent in the flat node list. + // Root selection must use composite graph identity so rendering still + // starts at the parent and preserves the edge. + try writeStore( + provider: "claude", + sessionId: "child-session", + runId: "child-run", + parentRunId: "parent-run", + parentSessionId: "parent-session", + relationship: "spawned", + restoreAuthority: false, + startedAt: 100.0 + ) + try writeStore( + provider: "claude", + sessionId: "parent-session", + runId: "parent-run", + restoreAuthority: true, + startedAt: 200.0 + ) + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_RUNTIME_ID"] = "current-runtime" + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree"], + environment: environment, + timeout: 5 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let lines = result.stdout.split(separator: "\n").map(String.init) + #expect(lines.count == 2, Comment(rawValue: result.stdout)) + #expect(lines.first?.hasPrefix("claude parent-session") == true, Comment(rawValue: result.stdout)) + #expect(lines.last?.hasPrefix("└── spawned claude child-session") == true, Comment(rawValue: result.stdout)) + } + + @MainActor + private func makeLiveHibernationAuthorityFixture( + root: URL, + runtimeID: String, + sessionID: String + ) throws -> ( + workspace: Workspace, + panelID: UUID, + panel: TerminalPanel, + agent: SessionRestorableAgentSnapshot, + registry: CmuxAgentSessionRegistry + ) { + let workspace = Workspace(workingDirectory: root.path) + let panelID = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.terminalPanel(for: panelID)) + let agent = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: root.path, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex"], + workingDirectory: root.path, + environment: nil, + capturedAt: 10, + source: "agent-hook" + ) + ) + #expect(workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + kind: agent.kind.rawValue, + command: try #require(agent.resumeCommand), + cwd: root.path, + checkpointId: sessionID, + source: "agent-hook", + autoResume: false, + updatedAt: 20 + ), + panelId: panelID + )) + let runtime: [String: Any] = ["id": runtimeID] + let record: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": workspace.id.uuidString, + "surfaceId": panelID.uuidString, + "sessionState": "active", + "restoreAuthority": true, + "activeRunId": "live-run", + "cmuxRuntime": runtime, + "runs": [[ + "runId": "live-run", + "restoreAuthority": true, + "cmuxRuntime": runtime, + "startedAt": 10.0, + "updatedAt": 20.0, + ]], + "startedAt": 10.0, + "updatedAt": 20.0, + ] + let slotObject: [String: Any] = ["sessionId": sessionID, "updatedAt": 20.0] + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: record], + "activeSessionsByWorkspace": [workspace.id.uuidString: slotObject], + "activeSessionsBySurface": [panelID.uuidString: slotObject], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + let slotJSON = try JSONSerialization.data(withJSONObject: slotObject, options: [.sortedKeys]) + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try registry.apply( + provider: "codex", + records: [.init( + provider: "codex", + sessionID: sessionID, + updatedAt: 20, + json: try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + )], + activeSlots: [ + .init( + provider: "codex", + scope: .workspace, + scopeID: workspace.id.uuidString, + sessionID: sessionID, + updatedAt: 20, + json: slotJSON + ), + .init( + provider: "codex", + scope: .surface, + scopeID: panelID.uuidString, + sessionID: sessionID, + updatedAt: 20, + json: slotJSON + ), + ] + ) + return (workspace, panelID, panel, agent, registry) + } + + private func appSessionSnapshot( + containing workspace: SessionWorkspaceSnapshot + ) -> AppSessionSnapshot { + AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: 40, + windows: [SessionWindowSnapshot( + windowId: UUID(), + frame: nil, + display: nil, + tabManager: SessionTabManagerSnapshot( + selectedWorkspaceIndex: 0, + workspaces: [workspace] + ), + sidebar: SessionSidebarSnapshot( + isVisible: true, + selection: .tabs, + width: nil + ) + )] + ) + } + + @discardableResult + private func installStartupCanonicalOwner( + root: URL, + registryURL: URL, + agent: SessionRestorableAgentSnapshot, + workspaceId: UUID, + surfaceId: UUID, + lifecycle: String?, + hibernatedAt: TimeInterval, + resumeAttemptId: UUID? = nil, + runtime: [String: Any]? = nil, + recordRestoreAuthority: Bool = true, + runRestoreAuthority: Bool = true, + completedAt: TimeInterval? = nil, + detached: Bool = false, + includeSurfaceSlot: Bool = true, + surfaceSlotSessionId: String? = nil + ) throws -> CmuxAgentSessionRegistry { + let updatedAt = hibernatedAt + let activeSlot: [String: Any] = [ + "sessionId": surfaceSlotSessionId ?? agent.sessionId, + "updatedAt": updatedAt, + ] + var record: [String: Any] = [ + "sessionId": agent.sessionId, + "workspaceId": workspaceId.uuidString, + "surfaceId": surfaceId.uuidString, + "restoreAuthority": recordRestoreAuthority, + "cmuxHibernatedAt": hibernatedAt, + "startedAt": 10.0, + "updatedAt": updatedAt, + ] + if let lifecycle { + record["sessionState"] = lifecycle + } + if let completedAt { + record["completedAt"] = completedAt + } + if detached { + record["cmuxHibernationDetached"] = true + } + if let resumeAttemptId { + record["cmuxHibernationResumeAttemptId"] = resumeAttemptId.uuidString + record["cmuxHibernationResumeStartedAt"] = updatedAt + record["cmuxHibernationResumeFromAttemptId"] = UUID().uuidString + } + if let runtime { + record["activeRunId"] = "startup-owner-run" + record["cmuxRuntime"] = runtime + record["runs"] = [[ + "runId": "startup-owner-run", + "restoreAuthority": runRestoreAuthority, + "cmuxRuntime": runtime, + "startedAt": 10.0, + "updatedAt": updatedAt, + ]] + } + var store: [String: Any] = [ + "version": 2, + "sessions": [agent.sessionId: record], + "activeSessionsByWorkspace": [workspaceId.uuidString: activeSlot], + ] + if includeSurfaceSlot { + store["activeSessionsBySurface"] = [surfaceId.uuidString: activeSlot] + } + try JSONSerialization.data(withJSONObject: store, options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + return CmuxAgentSessionRegistry(url: registryURL) + } + + @MainActor + private func makeHibernatedRestoreFixture( + root: URL, + sessionID: String + ) throws -> ( + source: Workspace, + snapshot: SessionWorkspaceSnapshot, + sourcePanelID: UUID, + agent: SessionRestorableAgentSnapshot + ) { + let source = Workspace() + let sourcePanelID = try #require(source.focusedPanelId) + let sourcePaneID = try #require(source.paneId(forPanelId: sourcePanelID)) + _ = try #require(source.newTerminalSurface(inPane: sourcePaneID, focus: true)) + source.focusPanel(sourcePanelID) + let agent = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: root.path, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex"], + workingDirectory: root.path, + environment: nil, + capturedAt: 10, + source: "agent-hook" + ) + ) + var snapshot = source.sessionSnapshot(includeScrollback: false) + let panelIndex = try #require(snapshot.panels.firstIndex { $0.id == sourcePanelID }) + var terminal = try #require(snapshot.panels[panelIndex].terminal) + terminal.agent = agent + terminal.resumeBinding = SurfaceResumeBindingSnapshot( + kind: agent.kind.rawValue, + command: try #require(agent.resumeCommand), + cwd: root.path, + checkpointId: sessionID, + source: "agent-hook", + autoResume: false, + updatedAt: 20 + ) + terminal.hibernation = SessionAgentHibernationSnapshot( + hibernatedAt: 20, + lastActivityAt: 10 + ) + terminal.wasAgentRunning = true + snapshot.panels[panelIndex].terminal = terminal + return ( + source: source, + snapshot: snapshot, + sourcePanelID: sourcePanelID, + agent: agent + ) + } + + private func installHibernatedAuthority( + root: URL, + registryURL: URL, + agent: SessionRestorableAgentSnapshot, + workspaceId: UUID, + surfaceId: UUID, + runtime: [String: Any]? = nil + ) throws -> CmuxAgentSessionRegistry { + let runtime = runtime ?? provablyDeadRuntime( + id: "retired-\(agent.kind.rawValue)-runtime" + ) + let activeSlot: [String: Any] = [ + "sessionId": agent.sessionId, + "updatedAt": 20.0, + ] + var record: [String: Any] = [ + "sessionId": agent.sessionId, + "workspaceId": workspaceId.uuidString, + "surfaceId": surfaceId.uuidString, + "sessionState": "hibernated", + "restoreAuthority": true, + "startedAt": 10.0, + "updatedAt": 20.0, + ] + record["activeRunId"] = "restored-run" + record["cmuxRuntime"] = runtime + record["runs"] = [[ + "runId": "restored-run", + "restoreAuthority": true, + "cmuxRuntime": runtime, + "startedAt": 10.0, + "updatedAt": 20.0, + ]] + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [agent.sessionId: record], + "activeSessionsByWorkspace": [workspaceId.uuidString: activeSlot], + "activeSessionsBySurface": [surfaceId.uuidString: activeSlot], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let registry = CmuxAgentSessionRegistry(url: registryURL) + _ = try registry.snapshotImportingLegacy( + provider: "codex", + legacyURL: stateURL, + fileManager: .default + ) + return registry + } + + private func provablyDeadRuntime(id: String) -> [String: Any] { + [ + "id": id, + "processId": Int(Int32.max), + "processStartSeconds": 1, + "processStartMicroseconds": 1, + ] + } + + private func makeListeningUnixSocket(at path: String) throws -> Int32 { + unlink(path) + let descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { + throw NSError(domain: "cmux.tests", code: Int(errno)) + } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.utf8) + let capacity = MemoryLayout.size(ofValue: address.sun_path) + guard bytes.count < capacity else { + Darwin.close(descriptor) + throw NSError(domain: "cmux.tests", code: Int(ENAMETOOLONG)) + } + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + buffer.initializeMemory(as: UInt8.self, repeating: 0) + buffer.copyBytes(from: bytes) + } + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + guard bindResult == 0, Darwin.listen(descriptor, 8) == 0 else { + let code = errno + Darwin.close(descriptor) + throw NSError(domain: "cmux.tests", code: Int(code)) + } + return descriptor + } + +} + +private actor AgentSessionAsyncGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func open() { + guard !isOpen else { return } + isOpen = true + let waiters = waiters + self.waiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + func waitUntilOpen() async { + if isOpen { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} diff --git a/cmuxTests/AgentSessionSocketRuntimeScopingRegressionTests.swift b/cmuxTests/AgentSessionSocketRuntimeScopingRegressionTests.swift new file mode 100644 index 000000000000..b8bd40756dfd --- /dev/null +++ b/cmuxTests/AgentSessionSocketRuntimeScopingRegressionTests.swift @@ -0,0 +1,1364 @@ +import CmuxFoundation +import Foundation +import XCTest +import Darwin + +extension CLINotifyProcessIntegrationRegressionTests { + func testExactCustomProviderDoesNotAbsorbBuiltInAliasObservationsInListOrTree() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-exact-observation-owner-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let socketPath = makeSocketPath("agent-exact-observation-owner") + let listenerFD = try bindUnixSocket(at: socketPath) + defer { + Darwin.shutdown(listenerFD, SHUT_RDWR) + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let runtimeID = "target-runtime" + let customProvider = "cursor-agent" + let customSessionID = "custom-cursor-session" + let customRecord = Data(""" + {"sessionId":"\(customSessionID)","workspaceId":"custom-workspace","surfaceId":"custom-surface","runId":"custom-run","activeRunId":"custom-run","restoreAuthority":true,"sessionState":"active","foregroundState":"idle","startedAt":100,"updatedAt":200,"runs":[{"runId":"custom-run","restoreAuthority":true,"startedAt":100,"updatedAt":200}]} + """.utf8) + let registry = CmuxAgentSessionRegistry( + url: root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try registry.apply(provider: customProvider, records: [ + CmuxAgentSessionRegistry.Record( + provider: customProvider, + sessionID: customSessionID, + updatedAt: 200, + json: customRecord + ), + ]) + + let observation = CmuxAgentTerminalObservation( + runtimeID: runtimeID, + workspaceID: UUID(), + surfaceID: UUID(), + surfaceGeneration: 1, + revision: 1, + familyID: "cursor-agent", + sessionProviderID: "cursor", + lifecycleAuthoritative: false, + state: .working, + pid: 202, + processStartSeconds: 200, + processStartMicroseconds: 2, + cwd: "/tmp/builtin-cursor", + publishedAt: 300 + ) + let observationObjects = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode([observation])) as? [Any] + ) + let state = MockSocketServerState() + let serverHandled = startMockServer( + listenerFD: listenerFD, + state: state, + connectionCount: 2 + ) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + switch method { + case "system.capabilities": + return self.v2Response(id: id, ok: true, result: [ + "runtime_id": runtimeID, + "socket_path": socketPath, + "bundle_identifier": "com.cmuxterm.app.debug.target", + "methods": ["agents.observations"], + ]) + case "agents.observations": + return self.v2Response(id: id, ok: true, result: [ + "runtime_id": runtimeID, + "observations": observationObjects, + ]) + default: + return self.v2Response( + id: id, + ok: false, + error: ["code": "unknown_method", "message": method] + ) + } + } + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + + for (subcommand, resultKey) in [("list", "sessions"), ("tree", "nodes")] { + let result = runProcess( + executablePath: cliPath, + arguments: [ + "agents", subcommand, "--agent", customProvider, "--all", "--json", + "--state-dir", root.path, + ], + environment: environment, + timeout: 5 + ) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let rows = try XCTUnwrap(payload[resultKey] as? [[String: Any]]) + XCTAssertEqual(rows.count, 1, result.stdout) + XCTAssertEqual(rows.first?["session_id"] as? String, customSessionID) + let returnedProvider = rows.first?["agent"] as? String + ?? rows.first?["provider"] as? String + XCTAssertEqual(returnedProvider, customProvider) + XCTAssertFalse(rows.contains { $0["pid"] as? Int == 202 }, result.stdout) + } + wait(for: [serverHandled], timeout: 1) + } + + func testAgentsListIncludesLiveProcessOnlyAgentsAndJoinsDurableSessions() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-live-list-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let socketPath = makeSocketPath("agent-live-list") + let listenerFD = try bindUnixSocket(at: socketPath) + defer { + Darwin.shutdown(listenerFD, SHUT_RDWR) + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let runtimeID = "target-runtime" + let durableWorkspaceID = UUID() + let durableSurfaceID = UUID() + let durableSessionID = "durable-codex-session" + let durablePID = getpid() + var processInfo = kinfo_proc() + var processInfoSize = MemoryLayout.stride + var processMIB: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, durablePID] + let processInfoResult = sysctl( + &processMIB, + u_int(processMIB.count), + &processInfo, + &processInfoSize, + nil, + 0 + ) + XCTAssertEqual(processInfoResult, 0) + let durableStartSeconds = Int64(processInfo.kp_proc.p_un.__p_starttime.tv_sec) + let durableStartMicroseconds = Int64(processInfo.kp_proc.p_un.__p_starttime.tv_usec) + let durableStartedAt = TimeInterval(durableStartSeconds) + + TimeInterval(durableStartMicroseconds) / 1_000_000 + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [durableSessionID: [ + "sessionId": durableSessionID, + "workspaceId": durableWorkspaceID.uuidString, + "surfaceId": durableSurfaceID.uuidString, + "runId": "durable-codex-run", + "activeRunId": "durable-codex-run", + "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeID], + "runs": [[ + "runId": "durable-codex-run", + "pid": durablePID, + "processStartedAt": durableStartedAt, + "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeID], + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + "activeSessionsByWorkspace": [durableWorkspaceID.uuidString: [ + "sessionId": durableSessionID, + "updatedAt": 200.0, + ]], + "activeSessionsBySurface": [durableSurfaceID.uuidString: [ + "sessionId": durableSessionID, + "updatedAt": 200.0, + ]], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + + func observation( + workspaceID: UUID, + surfaceID: UUID, + familyID: String, + provider: String, + lifecycleAuthoritative: Bool, + state: CmuxAgentObservedState, + pid: Int32, + startSeconds: Int64, + startMicroseconds: Int64, + cwd: String + ) -> CmuxAgentTerminalObservation { + CmuxAgentTerminalObservation( + runtimeID: runtimeID, + workspaceID: workspaceID, + surfaceID: surfaceID, + surfaceGeneration: 1, + revision: 1, + familyID: familyID, + sessionProviderID: provider, + lifecycleAuthoritative: lifecycleAuthoritative, + state: state, + pid: pid, + processStartSeconds: startSeconds, + processStartMicroseconds: startMicroseconds, + cwd: cwd, + publishedAt: 300.0 + Double(pid) + ) + } + + let observations = [ + observation( + workspaceID: durableWorkspaceID, + surfaceID: durableSurfaceID, + familyID: "codex", + provider: "codex", + lifecycleAuthoritative: false, + state: .blocked, + pid: durablePID, + startSeconds: durableStartSeconds, + startMicroseconds: durableStartMicroseconds, + cwd: "/tmp/durable" + ), + observation( + workspaceID: UUID(), + surfaceID: UUID(), + familyID: "codex", + provider: "codex", + lifecycleAuthoritative: false, + state: .working, + pid: 202, + startSeconds: 200, + startMicroseconds: 2, + cwd: "/tmp/codex-exec" + ), + observation( + workspaceID: UUID(), + surfaceID: UUID(), + familyID: "claude-code", + provider: "claude", + lifecycleAuthoritative: false, + state: .idle, + pid: 303, + startSeconds: 300, + startMicroseconds: 3, + cwd: "/tmp/claude-print" + ), + observation( + workspaceID: UUID(), + surfaceID: UUID(), + familyID: "kimi", + provider: "kimi", + lifecycleAuthoritative: true, + state: .blocked, + pid: 404, + startSeconds: 400, + startMicroseconds: 4, + cwd: "/tmp/kimi" + ), + observation( + workspaceID: UUID(), + surfaceID: UUID(), + familyID: "devin", + provider: "devin", + lifecycleAuthoritative: false, + state: .working, + pid: 505, + startSeconds: 500, + startMicroseconds: 5, + cwd: "/tmp/devin" + ), + ] + let observationsData = try JSONEncoder().encode(observations) + + let state = MockSocketServerState() + let serverHandled = startMockServer( + listenerFD: listenerFD, + state: state, + connectionCount: 5 + ) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + switch method { + case "system.capabilities": + return self.v2Response(id: id, ok: true, result: [ + "runtime_id": runtimeID, + "socket_path": socketPath, + "bundle_identifier": "com.cmuxterm.app.debug.target", + "methods": ["agents.observations"], + ]) + case "agents.observations": + let observationObjects = (try? JSONSerialization.jsonObject(with: observationsData)) as? [Any] ?? [] + return self.v2Response(id: id, ok: true, result: [ + "runtime_id": runtimeID, + "observations": observationObjects, + ]) + default: + return self.v2Response( + id: id, + ok: false, + error: ["code": "unknown_method", "message": method] + ) + } + } + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + + let fullList = runProcess( + executablePath: cliPath, + arguments: ["agents", "list", "--json", "--state-dir", root.path], + environment: environment, + timeout: 5 + ) + XCTAssertEqual(fullList.status, 0, fullList.stderr) + let fullPayload = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(fullList.stdout.utf8)) as? [String: Any] + ) + let rows = try XCTUnwrap(fullPayload["sessions"] as? [[String: Any]]) + XCTAssertEqual(rows.count, 5, fullList.stdout) + let durable = try XCTUnwrap(rows.first { $0["session_id"] as? String == durableSessionID }) + XCTAssertEqual(durable["identity_source"] as? String, "hook_session") + XCTAssertEqual(durable["effective_state"] as? String, "needs_input") + XCTAssertEqual(durable["state_source"] as? String, "terminal") + XCTAssertEqual(rows.filter { $0["pid"] as? Int32 == durablePID || $0["pid"] as? Int == Int(durablePID) }.count, 1) + + let processRows = rows.filter { $0["identity_source"] as? String == "terminal_process" } + XCTAssertEqual( + Set(processRows.compactMap { $0["agent"] as? String }), + ["claude", "codex", "devin", "kimi"] + ) + XCTAssertTrue(processRows.allSatisfy { + $0.keys.contains("session_id") && $0["session_id"] is NSNull + }) + XCTAssertTrue(processRows.allSatisfy { $0["restore_authority"] as? Bool == false }) + XCTAssertTrue(processRows.allSatisfy { $0["is_restorable"] as? Bool == false }) + XCTAssertEqual( + processRows.first { $0["pid"] as? Int == 202 }?["effective_state"] as? String, + "working" + ) + XCTAssertEqual( + processRows.first { $0["pid"] as? Int == 404 }?["effective_state"] as? String, + "needs_input" + ) + + let sessionFiltered = runProcess( + executablePath: cliPath, + arguments: [ + "agents", "list", "--session", durableSessionID, + "--json", "--state-dir", root.path, + ], + environment: environment, + timeout: 5 + ) + XCTAssertEqual(sessionFiltered.status, 0, sessionFiltered.stderr) + let sessionPayload = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(sessionFiltered.stdout.utf8)) as? [String: Any] + ) + let sessionRows = try XCTUnwrap(sessionPayload["sessions"] as? [[String: Any]]) + XCTAssertEqual(sessionRows.count, 1) + XCTAssertEqual(sessionRows.first?["session_id"] as? String, durableSessionID) + + let claudeFiltered = runProcess( + executablePath: cliPath, + arguments: ["agents", "list", "--agent", "claude", "--json", "--state-dir", root.path], + environment: environment, + timeout: 5 + ) + XCTAssertEqual(claudeFiltered.status, 0, claudeFiltered.stderr) + let claudePayload = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(claudeFiltered.stdout.utf8)) as? [String: Any] + ) + let claudeRows = try XCTUnwrap(claudePayload["sessions"] as? [[String: Any]]) + XCTAssertEqual(claudeRows.count, 1) + XCTAssertEqual(claudeRows.first?["pid"] as? Int, 303) + + let devinFiltered = runProcess( + executablePath: cliPath, + arguments: ["agents", "list", "--agent", "devin", "--json", "--state-dir", root.path], + environment: environment, + timeout: 5 + ) + XCTAssertEqual(devinFiltered.status, 0, devinFiltered.stderr) + let devinPayload = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(devinFiltered.stdout.utf8)) as? [String: Any] + ) + let devinRows = try XCTUnwrap(devinPayload["sessions"] as? [[String: Any]]) + XCTAssertEqual(devinRows.count, 1) + XCTAssertEqual(devinRows.first?["pid"] as? Int, 505) + + let kimiText = runProcess( + executablePath: cliPath, + arguments: ["agents", "list", "--agent", "kimi", "--state-dir", root.path], + environment: environment, + timeout: 5 + ) + XCTAssertEqual(kimiText.status, 0, kimiText.stderr) + XCTAssertTrue(kimiText.stdout.contains("kimi pid 404"), kimiText.stdout) + XCTAssertFalse(kimiText.stdout.contains("kimi unknown"), kimiText.stdout) + + wait(for: [serverHandled], timeout: 1) + + var offlineEnvironment = environment + offlineEnvironment.removeValue(forKey: "CMUX_SOCKET_PATH") + let offline = runProcess( + executablePath: cliPath, + arguments: ["agents", "list", "--json", "--state-dir", root.path], + environment: offlineEnvironment, + timeout: 5 + ) + XCTAssertEqual(offline.status, 0, offline.stderr) + let offlinePayload = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(offline.stdout.utf8)) as? [String: Any] + ) + let offlineRows = try XCTUnwrap(offlinePayload["sessions"] as? [[String: Any]]) + XCTAssertEqual(offlineRows.count, 1) + XCTAssertEqual(offlineRows.first?["session_id"] as? String, durableSessionID) + } + + func testSessionFilterDoesNotApplyActiveSiblingObservationToInactiveSession() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-filtered-shared-process-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let socketPath = makeSocketPath("agent-filtered-shared-process") + let listenerFD = try bindUnixSocket(at: socketPath) + defer { + Darwin.shutdown(listenerFD, SHUT_RDWR) + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let runtimeID = "target-runtime" + let workspaceID = UUID() + let surfaceID = UUID() + let inactiveSessionID = "inactive-session" + let activeSessionID = "active-session" + let filteredCWD = "/tmp/shared-process" + let pid = getpid() + var processInfo = kinfo_proc() + var processInfoSize = MemoryLayout.stride + var processMIB: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + XCTAssertEqual( + sysctl( + &processMIB, + u_int(processMIB.count), + &processInfo, + &processInfoSize, + nil, + 0 + ), + 0 + ) + let startSeconds = Int64(processInfo.kp_proc.p_un.__p_starttime.tv_sec) + let startMicroseconds = Int64(processInfo.kp_proc.p_un.__p_starttime.tv_usec) + let processStartedAt = TimeInterval(startSeconds) + + TimeInterval(startMicroseconds) / 1_000_000 + + func record( + sessionID: String, + runID: String, + cwd: String, + updatedAt: TimeInterval + ) -> [String: Any] { + [ + "sessionId": sessionID, + "workspaceId": workspaceID.uuidString, + "surfaceId": surfaceID.uuidString, + "cwd": cwd, + "runId": runID, + "activeRunId": runID, + "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeID], + "foregroundState": "idle", + "attentionState": "none", + "sessionState": "active", + "runs": [[ + "runId": runID, + "pid": pid, + "processStartedAt": processStartedAt, + "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeID], + "startedAt": 100.0, + "updatedAt": updatedAt, + ]], + "startedAt": 100.0, + "updatedAt": updatedAt, + ] + } + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + inactiveSessionID: record( + sessionID: inactiveSessionID, + runID: "inactive-run", + cwd: filteredCWD, + updatedAt: 200 + ), + activeSessionID: record( + sessionID: activeSessionID, + runID: "active-run", + cwd: "/tmp/active-persisted-cwd", + updatedAt: 300 + ), + ], + "activeSessionsByWorkspace": [workspaceID.uuidString: [ + "sessionId": activeSessionID, + "updatedAt": 300.0, + ]], + "activeSessionsBySurface": [surfaceID.uuidString: [ + "sessionId": activeSessionID, + "updatedAt": 300.0, + ]], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + + let observation = CmuxAgentTerminalObservation( + runtimeID: runtimeID, + workspaceID: workspaceID, + surfaceID: surfaceID, + surfaceGeneration: 1, + revision: 1, + familyID: "codex", + sessionProviderID: "codex", + lifecycleAuthoritative: false, + state: .blocked, + pid: pid, + processStartSeconds: startSeconds, + processStartMicroseconds: startMicroseconds, + cwd: filteredCWD, + publishedAt: 400 + ) + let observationObjects = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode([observation])) as? [Any] + ) + let state = MockSocketServerState() + let serverHandled = startMockServer( + listenerFD: listenerFD, + state: state, + connectionCount: 2 + ) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + switch method { + case "system.capabilities": + return self.v2Response(id: id, ok: true, result: [ + "runtime_id": runtimeID, + "socket_path": socketPath, + "bundle_identifier": "com.cmuxterm.app.debug.target", + "methods": ["agents.observations"], + ]) + case "agents.observations": + return self.v2Response(id: id, ok: true, result: [ + "runtime_id": runtimeID, + "observations": observationObjects, + ]) + default: + return self.v2Response( + id: id, + ok: false, + error: ["code": "unknown_method", "message": method] + ) + } + } + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + + for (subcommand, resultKey) in [("list", "sessions"), ("tree", "nodes")] { + var arguments = [ + "agents", subcommand, "--session", inactiveSessionID, + "--json", "--state-dir", root.path, + ] + if subcommand == "list" { + arguments.append(contentsOf: ["--cwd", filteredCWD]) + } + let result = runProcess( + executablePath: cliPath, + arguments: arguments, + environment: environment, + timeout: 5 + ) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let rows = try XCTUnwrap(payload[resultKey] as? [[String: Any]]) + XCTAssertEqual(rows.count, 1, result.stdout) + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(row["session_id"] as? String, inactiveSessionID) + XCTAssertEqual(row["effective_state"] as? String, "idle") + XCTAssertEqual(row["state_source"] as? String, "lifecycle") + } + wait(for: [serverHandled], timeout: 1) + } + + func testWorkspaceFilterUsesLiveMovedSurfaceWorkspaceInListAndTree() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-live-moved-workspace-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let socketPath = makeSocketPath("agent-live-moved-workspace") + let listenerFD = try bindUnixSocket(at: socketPath) + defer { + Darwin.shutdown(listenerFD, SHUT_RDWR) + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let runtimeID = "target-runtime" + let savedWorkspaceID = UUID() + let liveWorkspaceID = UUID() + let surfaceID = UUID() + let sessionID = "moved-workspace-session" + let pid = getpid() + var processInfo = kinfo_proc() + var processInfoSize = MemoryLayout.stride + var processMIB: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + XCTAssertEqual( + sysctl( + &processMIB, + u_int(processMIB.count), + &processInfo, + &processInfoSize, + nil, + 0 + ), + 0 + ) + let startSeconds = Int64(processInfo.kp_proc.p_un.__p_starttime.tv_sec) + let startMicroseconds = Int64(processInfo.kp_proc.p_un.__p_starttime.tv_usec) + let processStartedAt = TimeInterval(startSeconds) + + TimeInterval(startMicroseconds) / 1_000_000 + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: [ + "sessionId": sessionID, + "workspaceId": savedWorkspaceID.uuidString, + "surfaceId": surfaceID.uuidString, + "runId": "moved-workspace-run", + "activeRunId": "moved-workspace-run", + "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeID], + "foregroundState": "idle", + "attentionState": "none", + "sessionState": "active", + "runs": [[ + "runId": "moved-workspace-run", + "pid": pid, + "processStartedAt": processStartedAt, + "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeID], + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + "activeSessionsByWorkspace": [savedWorkspaceID.uuidString: [ + "sessionId": sessionID, + "updatedAt": 200.0, + ]], + "activeSessionsBySurface": [surfaceID.uuidString: [ + "sessionId": sessionID, + "updatedAt": 200.0, + ]], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + + let observation = CmuxAgentTerminalObservation( + runtimeID: runtimeID, + workspaceID: liveWorkspaceID, + surfaceID: surfaceID, + surfaceGeneration: 1, + revision: 1, + familyID: "codex", + sessionProviderID: "codex", + lifecycleAuthoritative: false, + state: .working, + pid: pid, + processStartSeconds: startSeconds, + processStartMicroseconds: startMicroseconds, + cwd: "/tmp/moved-workspace", + publishedAt: 400 + ) + let observationObjects = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode([observation])) as? [Any] + ) + let state = MockSocketServerState() + let serverHandled = startMockServer( + listenerFD: listenerFD, + state: state, + connectionCount: 16 + ) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + switch method { + case "system.capabilities": + return self.v2Response(id: id, ok: true, result: [ + "runtime_id": runtimeID, + "socket_path": socketPath, + "bundle_identifier": "com.cmuxterm.app.debug.target", + "methods": ["agents.observations"], + ]) + case "agents.observations": + return self.v2Response(id: id, ok: true, result: [ + "runtime_id": runtimeID, + "observations": observationObjects, + ]) + default: + return self.v2Response( + id: id, + ok: false, + error: ["code": "unknown_method", "message": method] + ) + } + } + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + + for subcommand in ["list", "tree"] { + let rowsKey = subcommand == "list" ? "sessions" : "nodes" + for includesSessionFilter in [false, true] { + for jsonOutput in [false, true] { + for (workspaceID, shouldMatch) in [ + (savedWorkspaceID, false), + (liveWorkspaceID, true), + ] { + var arguments = [ + "agents", subcommand, + "--agent", "codex", + "--workspace", workspaceID.uuidString, + "--state-dir", root.path, + ] + if includesSessionFilter { + arguments.append(contentsOf: ["--session", sessionID]) + } + if jsonOutput { arguments.append("--json") } + let result = runProcess( + executablePath: cliPath, + arguments: arguments, + environment: environment, + timeout: 5 + ) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + if jsonOutput { + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) + as? [String: Any] + ) + let rows = try XCTUnwrap(payload[rowsKey] as? [[String: Any]]) + XCTAssertEqual(rows.count, shouldMatch ? 1 : 0, result.stdout) + if shouldMatch { + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(row["session_id"] as? String, sessionID) + XCTAssertEqual(row["workspace_id"] as? String, liveWorkspaceID.uuidString) + XCTAssertEqual(row["identity_source"] as? String, "hook_session") + XCTAssertEqual(row["state_source"] as? String, "terminal") + } + } else if shouldMatch { + XCTAssertTrue(result.stdout.contains(sessionID), result.stdout) + let workspaceLabel = subcommand == "list" ? "workspace=" : "workspace:" + XCTAssertTrue( + result.stdout.contains("\(workspaceLabel)\(liveWorkspaceID.uuidString)"), + result.stdout + ) + } else { + XCTAssertFalse(result.stdout.contains(sessionID), result.stdout) + } + } + } + } + } + wait(for: [serverHandled], timeout: 2) + } + + func testAgentFamilyAliasesRetainDurableSessionsInListAndTree() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-family-aliases-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let socketPath = makeSocketPath("agent-family-aliases") + let listenerFD = try bindUnixSocket(at: socketPath) + defer { + Darwin.shutdown(listenerFD, SHUT_RDWR) + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let runtimeID = "target-runtime" + let pid = getpid() + var processInfo = kinfo_proc() + var processInfoSize = MemoryLayout.stride + var processMIB: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + let processInfoResult = sysctl( + &processMIB, + u_int(processMIB.count), + &processInfo, + &processInfoSize, + nil, + 0 + ) + XCTAssertEqual(processInfoResult, 0) + let startSeconds = Int64(processInfo.kp_proc.p_un.__p_starttime.tv_sec) + let startMicroseconds = Int64(processInfo.kp_proc.p_un.__p_starttime.tv_usec) + let processStartedAt = TimeInterval(startSeconds) + + TimeInterval(startMicroseconds) / 1_000_000 + + func writeStore( + provider: String, + sessionID: String, + workspaceID: UUID, + surfaceID: UUID + ) throws { + let runID = "\(provider)-run" + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: [ + "sessionId": sessionID, + "workspaceId": workspaceID.uuidString, + "surfaceId": surfaceID.uuidString, + "runId": runID, + "activeRunId": runID, + "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeID], + "runs": [[ + "runId": runID, + "pid": pid, + "processStartedAt": processStartedAt, + "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeID], + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + "activeSessionsByWorkspace": [workspaceID.uuidString: [ + "sessionId": sessionID, + "updatedAt": 200.0, + ]], + "activeSessionsBySurface": [surfaceID.uuidString: [ + "sessionId": sessionID, + "updatedAt": 200.0, + ]], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("\(provider)-hook-sessions.json"), + options: .atomic + ) + } + + let cursorWorkspaceID = UUID() + let cursorSurfaceID = UUID() + let cursorSessionID = "durable-cursor-session" + let factoryWorkspaceID = UUID() + let factorySurfaceID = UUID() + let factorySessionID = "durable-factory-session" + try writeStore( + provider: "cursor", + sessionID: cursorSessionID, + workspaceID: cursorWorkspaceID, + surfaceID: cursorSurfaceID + ) + try writeStore( + provider: "factory", + sessionID: factorySessionID, + workspaceID: factoryWorkspaceID, + surfaceID: factorySurfaceID + ) + + func observation( + familyID: String, + provider: String, + workspaceID: UUID, + surfaceID: UUID + ) -> CmuxAgentTerminalObservation { + CmuxAgentTerminalObservation( + runtimeID: runtimeID, + workspaceID: workspaceID, + surfaceID: surfaceID, + surfaceGeneration: 1, + revision: 1, + familyID: familyID, + sessionProviderID: provider, + lifecycleAuthoritative: false, + state: .working, + pid: pid, + processStartSeconds: startSeconds, + processStartMicroseconds: startMicroseconds, + cwd: "/tmp/\(provider)", + publishedAt: 300.0 + ) + } + let observations = [ + observation( + familyID: "cursor-agent", + provider: "cursor", + workspaceID: cursorWorkspaceID, + surfaceID: cursorSurfaceID + ), + observation( + familyID: "droid", + provider: "factory", + workspaceID: factoryWorkspaceID, + surfaceID: factorySurfaceID + ), + ] + let observationsData = try JSONEncoder().encode(observations) + + let state = MockSocketServerState() + let serverHandled = startMockServer( + listenerFD: listenerFD, + state: state, + connectionCount: 4 + ) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + switch method { + case "system.capabilities": + return self.v2Response(id: id, ok: true, result: [ + "runtime_id": runtimeID, + "socket_path": socketPath, + "bundle_identifier": "com.cmuxterm.app.debug.target", + "methods": ["agents.observations"], + ]) + case "agents.observations": + let objects = (try? JSONSerialization.jsonObject(with: observationsData)) as? [Any] ?? [] + return self.v2Response(id: id, ok: true, result: [ + "runtime_id": runtimeID, + "observations": objects, + ]) + default: + return self.v2Response( + id: id, + ok: false, + error: ["code": "unknown_method", "message": method] + ) + } + } + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + + func rows( + command: String, + agent: String, + key: String, + processEnvironment: [String: String] + ) throws -> [[String: Any]] { + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", command, "--agent", agent, "--json", "--state-dir", root.path], + environment: processEnvironment, + timeout: 5 + ) + XCTAssertEqual(result.status, 0, result.stderr) + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + return try XCTUnwrap(payload[key] as? [[String: Any]]) + } + + let cursorListRows = try rows( + command: "list", + agent: "cursor-agent", + key: "sessions", + processEnvironment: environment + ) + XCTAssertEqual(cursorListRows.count, 1) + XCTAssertEqual(cursorListRows.first?["agent"] as? String, "cursor") + XCTAssertEqual(cursorListRows.first?["session_id"] as? String, cursorSessionID) + XCTAssertEqual(cursorListRows.first?["identity_source"] as? String, "hook_session") + + let droidListRows = try rows( + command: "list", + agent: "droid", + key: "sessions", + processEnvironment: environment + ) + XCTAssertEqual(droidListRows.count, 1) + XCTAssertEqual(droidListRows.first?["agent"] as? String, "factory") + XCTAssertEqual(droidListRows.first?["session_id"] as? String, factorySessionID) + XCTAssertEqual(droidListRows.first?["identity_source"] as? String, "hook_session") + + let cursorTreeRows = try rows( + command: "tree", + agent: "cursor-agent", + key: "nodes", + processEnvironment: environment + ) + XCTAssertEqual(cursorTreeRows.count, 1) + XCTAssertEqual(cursorTreeRows.first?["provider"] as? String, "cursor") + XCTAssertEqual(cursorTreeRows.first?["session_id"] as? String, cursorSessionID) + XCTAssertEqual(cursorTreeRows.first?["identity_source"] as? String, "hook_session") + + let droidTreeRows = try rows( + command: "tree", + agent: "droid", + key: "nodes", + processEnvironment: environment + ) + XCTAssertEqual(droidTreeRows.count, 1) + XCTAssertEqual(droidTreeRows.first?["provider"] as? String, "factory") + XCTAssertEqual(droidTreeRows.first?["session_id"] as? String, factorySessionID) + XCTAssertEqual(droidTreeRows.first?["identity_source"] as? String, "hook_session") + + wait(for: [serverHandled], timeout: 1) + + var offlineEnvironment = environment + offlineEnvironment.removeValue(forKey: "CMUX_SOCKET_PATH") + let offlineCursorListRows = try rows( + command: "list", + agent: "cursor-agent", + key: "sessions", + processEnvironment: offlineEnvironment + ) + XCTAssertEqual(offlineCursorListRows.compactMap { $0["session_id"] as? String }, [cursorSessionID]) + + let offlineDroidListRows = try rows( + command: "list", + agent: "droid", + key: "sessions", + processEnvironment: offlineEnvironment + ) + XCTAssertEqual(offlineDroidListRows.compactMap { $0["session_id"] as? String }, [factorySessionID]) + + let offlineCursorTreeRows = try rows( + command: "tree", + agent: "cursor-agent", + key: "nodes", + processEnvironment: offlineEnvironment + ) + XCTAssertEqual(offlineCursorTreeRows.compactMap { $0["session_id"] as? String }, [cursorSessionID]) + + let offlineDroidTreeRows = try rows( + command: "tree", + agent: "droid", + key: "nodes", + processEnvironment: offlineEnvironment + ) + XCTAssertEqual(offlineDroidTreeRows.compactMap { $0["session_id"] as? String }, [factorySessionID]) + } + + func testAmbientSocketScopesAgentsTreeToTheConnectedRuntime() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-ambient-socket-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let socketPath = makeSocketPath("agent-ambient-runtime") + let listenerFD = try bindUnixSocket(at: socketPath) + defer { + Darwin.shutdown(listenerFD, SHUT_RDWR) + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + func record(_ sessionID: String, _ runtimeID: String) -> [String: Any] { + [ + "sessionId": sessionID, "workspaceId": "workspace-\(runtimeID)", + "surfaceId": "surface-\(runtimeID)", "runId": "run-\(runtimeID)", + "activeRunId": "run-\(runtimeID)", "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeID], + "runs": [[ + "runId": "run-\(runtimeID)", "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeID], "startedAt": 100.0, "updatedAt": 200.0, + ]], + "startedAt": 100.0, "updatedAt": 200.0, + ] + } + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + "current-session": record("current-session", "target-runtime"), + "other-session": record("other-session", "other-runtime"), + ], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("codex-hook-sessions.json"), options: .atomic + ) + let state = MockSocketServerState() + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + payload["method"] as? String == "system.capabilities" else { + return self.malformedRequestResponse(raw: line) + } + return self.v2Response(id: id, ok: true, result: [ + "runtime_id": "target-runtime", + "socket_path": socketPath, + "bundle_identifier": "com.cmuxterm.app.debug.target", + ]) + } + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + let result = runProcess( + executablePath: cliPath, + arguments: ["agents", "tree", "--json", "--state-dir", root.path], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 1) + XCTAssertEqual(result.status, 0, result.stderr) + let output = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let nodes = try XCTUnwrap(output["nodes"] as? [[String: Any]]) + XCTAssertEqual(nodes.compactMap { $0["session_id"] as? String }, ["current-session"]) + } + + func testAgentsInspectionSocketFallbackIsBoundedAndExplicitSocketStaysAuthoritative() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-socket-timeout-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let socketPath = makeSocketPath("agent-timeout") + let listenerFD = try bindUnixSocket(at: socketPath) + defer { + Darwin.shutdown(listenerFD, SHUT_RDWR) + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [ + "saved-session": [ + "sessionId": "saved-session", + "workspaceId": "workspace-saved", + "surfaceId": "surface-saved", + "runId": "run-saved", + "restoreAuthority": true, + "startedAt": 100.0, + "updatedAt": 200.0, + ], + ], + ], options: [.sortedKeys]).write( + to: root.appendingPathComponent("opencode-hook-sessions.json"), + options: .atomic + ) + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["HOME"] = root.path + + var deadSocketEnvironment = environment + deadSocketEnvironment["CMUX_SOCKET_PATH"] = makeSocketPath("agent-dead") + for subcommand in ["list", "tree"] { + let result = runProcess( + executablePath: cliPath, + arguments: [ + "agents", subcommand, "--agent", "opencode", "--all", + "--json", "--state-dir", root.path, + ], + environment: deadSocketEnvironment, + timeout: 3 + ) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + } + + let state = MockSocketServerState() + let serverObservedRequest = startMockServerAllowingNoResponse( + listenerFD: listenerFD, + state: state, + connectionCount: 4, + fulfillWhen: { line in + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return false + } + return payload["method"] as? String == "system.capabilities" + }, + handler: { _ in nil } + ) + var inheritedSocketEnvironment = environment + inheritedSocketEnvironment["CMUX_SOCKET_PATH"] = socketPath + for subcommand in ["list", "tree"] { + let inheritedResult = runProcess( + executablePath: cliPath, + arguments: [ + "agents", subcommand, "--agent", "opencode", "--all", + "--json", "--state-dir", root.path, + ], + environment: inheritedSocketEnvironment, + timeout: 3 + ) + XCTAssertFalse(inheritedResult.timedOut, inheritedResult.stderr) + XCTAssertEqual(inheritedResult.status, 0, inheritedResult.stderr) + let inheritedPayload = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(inheritedResult.stdout.utf8)) as? [String: Any] + ) + let rowKey = subcommand == "list" ? "sessions" : "nodes" + let rows = try XCTUnwrap(inheritedPayload[rowKey] as? [[String: Any]]) + XCTAssertEqual(rows.compactMap { $0["session_id"] as? String }, ["saved-session"]) + + let explicitResult = runProcess( + executablePath: cliPath, + arguments: [ + "--socket", socketPath, + "agents", subcommand, "--agent", "opencode", "--all", + "--json", "--state-dir", root.path, + ], + environment: environment, + timeout: 3 + ) + XCTAssertFalse(explicitResult.timedOut, explicitResult.stderr) + XCTAssertNotEqual(explicitResult.status, 0) + } + wait(for: [serverObservedRequest], timeout: 1) + let capabilityRequests = state.snapshot().filter { line in + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return false + } + return payload["method"] as? String == "system.capabilities" + } + XCTAssertEqual(capabilityRequests.count, 4) + } + + func testExplicitSocketScopesAgentsTreeToTheTargetRuntime() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agents-explicit-socket-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let socketPath = makeSocketPath("agent-runtime") + let listenerFD = try bindUnixSocket(at: socketPath) + defer { + Darwin.shutdown(listenerFD, SHUT_RDWR) + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + func record(sessionId: String, runtimeId: String) -> [String: Any] { + [ + "sessionId": sessionId, + "workspaceId": "workspace-\(runtimeId)", + "surfaceId": "surface-\(runtimeId)", + "runId": "run-\(runtimeId)", + "activeRunId": "run-\(runtimeId)", + "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeId], + "runs": [[ + "runId": "run-\(runtimeId)", + "restoreAuthority": true, + "cmuxRuntime": ["id": runtimeId], + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + "startedAt": 100.0, + "updatedAt": 200.0, + ] + } + let store: [String: Any] = [ + "version": 2, + "sessions": [ + "current-session": record(sessionId: "current-session", runtimeId: "target-runtime"), + "other-session": record(sessionId: "other-session", runtimeId: "other-runtime"), + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.sortedKeys]) + .write(to: root.appendingPathComponent("codex-hook-sessions.json"), options: .atomic) + + let state = MockSocketServerState() + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + payload["method"] as? String == "system.capabilities" else { + return self.malformedRequestResponse(raw: line) + } + return self.v2Response( + id: id, + ok: true, + result: [ + "runtime_id": "target-runtime", + "socket_path": socketPath, + "bundle_identifier": "com.cmuxterm.app.debug.target", + ] + ) + } + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + let result = runProcess( + executablePath: cliPath, + arguments: [ + "--socket", socketPath, + "agents", "tree", "--json", "--state-dir", root.path, + ], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 1) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let output = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let nodes = try XCTUnwrap(output["nodes"] as? [[String: Any]]) + XCTAssertEqual(nodes.compactMap { $0["session_id"] as? String }, ["current-session"]) + } +} diff --git a/cmuxTests/AgentSessionWriterGenerationRegressionTests.swift b/cmuxTests/AgentSessionWriterGenerationRegressionTests.swift new file mode 100644 index 000000000000..b786247a7695 --- /dev/null +++ b/cmuxTests/AgentSessionWriterGenerationRegressionTests.swift @@ -0,0 +1,298 @@ +import CmuxFoundation +import Darwin +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension CMUXCLIErrorOutputRegressionTests { + @Test func committedHookMutationSurvivesLegacyProjectionLockContention() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-projection-contention-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let descriptor = open( + stateURL.path + ".lock", + O_CREAT | O_RDWR, + mode_t(S_IRUSR | S_IWUSR) + ) + #expect(descriptor >= 0) + guard descriptor >= 0 else { return } + defer { Darwin.close(descriptor) } + #expect(flock(descriptor, LOCK_EX | LOCK_NB) == 0) + defer { _ = flock(descriptor, LOCK_UN) } + + let store = ClaudeHookSessionStore( + processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + ], + agentName: "codex" + ) + + #expect(try store.upsert( + sessionId: "committed-session", + workspaceId: "workspace", + surfaceId: "surface", + cwd: root.path + )) + let registry = CmuxAgentSessionRegistry(url: registryURL) + #expect( + try registry.hookRecord(provider: "codex", sessionID: "committed-session") != nil + ) + } + + @Test func corruptLegacyReadFallsBackToLastCompleteRegistrySnapshot() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-corrupt-legacy-read-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let sessionID = "last-complete" + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: [ + "sessionId": sessionID, + "workspaceId": "workspace", + "surfaceId": "surface", + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + ]).write(to: stateURL, options: .atomic) + let bridge = AgentHookSessionRegistryBridge( + provider: "codex", + statePath: stateURL.path, + environment: ["CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path], + fileManager: .default + ) + #expect(bridge.load().sessions[sessionID] != nil) + + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: "partial-record"], + ]).write(to: stateURL, options: .atomic) + + #expect(bridge.load().sessions[sessionID] != nil) + } + + @Test func permissionModeOnlyMutationAdvancesRegistryProjection() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-permission-mode-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("claude-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let sessionID = "permission-session" + try JSONSerialization.data(withJSONObject: [ + "version": 2, + "sessions": [sessionID: [ + "sessionId": sessionID, + "workspaceId": "workspace", + "surfaceId": "surface", + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + ], options: [.sortedKeys]).write(to: stateURL, options: .atomic) + let store = ClaudeHookSessionStore( + processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + ] + ) + + try store.updateLastPermissionMode(sessionId: sessionID, permissionMode: "plan") + + let saved = try #require(try store.lookup(sessionId: sessionID)) + #expect(saved.lastPermissionMode == "plan") + #expect(saved.updatedAt > 200) + let registry = CmuxAgentSessionRegistry(url: registryURL) + let projected = try #require(registry.snapshot(provider: "claude").records.first) + let projectedRecord = try JSONDecoder().decode(ClaudeHookSessionRecord.self, from: projected.json) + #expect(projectedRecord.lastPermissionMode == "plan") + } + + @Test func futureGenerationRegistryRowRejectsOlderBridgeMutation() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-future-writer-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let sessionID = "future-codex" + let record: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": "workspace-a", + "surfaceId": "surface-a", + "startedAt": 100.0, + "updatedAt": 200.0, + ] + let recordJSON = try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + let registry = CmuxAgentSessionRegistry(url: registryURL) + try registry.apply(provider: "codex", records: [ + CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: sessionID, + updatedAt: 200, + writerGeneration: CmuxAgentSessionRegistry.currentWriterGeneration + 1, + json: recordJSON + ), + ]) + let bridge = AgentHookSessionRegistryBridge( + provider: "codex", + statePath: stateURL.path, + environment: ["CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path], + fileManager: .default + ) + + #expect(throws: (any Error).self) { + try bridge.mutate { state in + state.sessions[sessionID]?.updatedAt = 300 + return true + } + } + + let stored = try #require(registry.snapshot(provider: "codex").records.first) + #expect(stored.updatedAt == 200) + #expect(stored.writerGeneration == CmuxAgentSessionRegistry.currentWriterGeneration + 1) + #expect(stored.json == recordJSON) + } + + @Test func hookMutationPrunesTheInactiveRecordAddedBeyondTheRetentionCap() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-retention-cap-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let registry = CmuxAgentSessionRegistry(url: registryURL) + let now = Date().timeIntervalSince1970 + let records = try (0..<10_000).map { index in + let sessionID = "retained-\(index)" + let updatedAt = now + Double(index) / 100_000 + return CmuxAgentSessionRegistry.Record( + provider: "codex", + sessionID: sessionID, + updatedAt: updatedAt, + json: try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "workspaceId": "workspace-\(index)", + "surfaceId": "surface-\(index)", + "startedAt": now, + "updatedAt": updatedAt, + ], options: [.sortedKeys]) + ) + } + try registry.apply(provider: "codex", records: records) + let store = ClaudeHookSessionStore( + processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + ], + agentName: "codex" + ) + + #expect(try store.upsert( + sessionId: "newest", + workspaceId: "workspace-new", + surfaceId: "surface-new", + cwd: root.path + )) + + let snapshot = try registry.snapshot(provider: "codex") + #expect(snapshot.records.count == 10_000) + #expect(snapshot.records.contains { $0.sessionID == "newest" }) + #expect(!snapshot.records.contains { $0.sessionID == "retained-0" }) + } + + @Test func acceptedResumeHookClearsHibernationAttemptsAndPreservesFutureFields() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-resume-hook-cleanup-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.copyItem(atPath: "/usr/bin/yes", toPath: executable.path) + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = executable + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let registryURL = root.appendingPathComponent(CmuxAgentSessionRegistry.filename) + let sessionID = "resumed-session" + let hibernationAttemptID = UUID().uuidString + let resumeAttemptID = UUID().uuidString + let restoreAdoptionID = UUID().uuidString + let original: [String: Any] = [ + "version": 2, + "sessions": [sessionID: [ + "sessionId": sessionID, + "workspaceId": "workspace-old", + "surfaceId": "surface-old", + "sessionState": "restoring", + "restoreAuthority": true, + "cmuxHibernationAttemptId": hibernationAttemptID, + "cmuxHibernatedAt": 100.0, + "cmuxHibernationDetached": true, + "cmuxHibernationResumeAttemptId": resumeAttemptID, + "cmuxHibernationResumeStartedAt": 200.0, + "cmuxHibernationResumeFromAttemptId": hibernationAttemptID, + "cmuxRestoreAdoptionId": restoreAdoptionID, + "futureWriterField": "preserve-me", + "startedAt": 100.0, + "updatedAt": 200.0, + ]], + ] + try JSONSerialization.data(withJSONObject: original, options: [.sortedKeys]) + .write(to: stateURL, options: .atomic) + + let store = ClaudeHookSessionStore( + processEnv: [ + "CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path, + "CMUX_AGENT_SESSION_REGISTRY_PATH": registryURL.path, + "CMUX_RUNTIME_ID": "resumed-runtime", + ], + agentName: "codex" + ) + #expect(try store.upsert( + sessionId: sessionID, + workspaceId: "workspace-new", + surfaceId: "surface-new", + cwd: root.path, + pid: Int(process.processIdentifier), + markActive: true + )) + + let stored = try #require( + try CmuxAgentSessionRegistry(url: registryURL) + .snapshot(provider: "codex") + .records + .first { $0.sessionID == sessionID } + ) + let object = try #require( + JSONSerialization.jsonObject(with: stored.json) as? [String: Any] + ) + #expect(object["sessionState"] as? String == "active") + #expect(object["cmuxHibernationAttemptId"] == nil) + #expect(object["cmuxHibernatedAt"] == nil) + #expect(object["cmuxHibernationDetached"] == nil) + #expect(object["cmuxHibernationResumeAttemptId"] == nil) + #expect(object["cmuxHibernationResumeStartedAt"] == nil) + #expect(object["cmuxHibernationResumeFromAttemptId"] == nil) + #expect(object["cmuxRestoreAdoptionId"] == nil) + #expect(object["futureWriterField"] as? String == "preserve-me") + } +} diff --git a/cmuxTests/AgentTerminalStateRuntimeLifecycleTests.swift b/cmuxTests/AgentTerminalStateRuntimeLifecycleTests.swift new file mode 100644 index 000000000000..8f1d2b7315f9 --- /dev/null +++ b/cmuxTests/AgentTerminalStateRuntimeLifecycleTests.swift @@ -0,0 +1,123 @@ +import CmuxFoundation +import Foundation +import Testing + +#if DEBUG +@testable import cmux_DEV +#else +@testable import cmux +#endif + +@Suite(.serialized) +@MainActor +struct AgentTerminalStateRuntimeLifecycleTests { + @Test + func observationCacheCopiesLatestMetadataWithoutMainActorWork() async throws { + let cache = AgentTerminalObservationCache() + let workspaceID = UUID() + let surfaceID = UUID() + let observation = CmuxAgentTerminalObservation( + runtimeID: "runtime", + workspaceID: workspaceID, + surfaceID: surfaceID, + surfaceGeneration: 3, + revision: 9, + familyID: "codex", + sessionProviderID: "codex", + lifecycleAuthoritative: true, + state: .working, + pid: 42, + processStartSeconds: 100, + processStartMicroseconds: 200, + cwd: "/tmp/project", + publishedAt: 300 + ) + + await Task.detached { + cache.replace(surfaceID: surfaceID, with: observation) + }.value + #expect(cache.snapshot() == [observation]) + + await Task.detached { + cache.replace(surfaceID: surfaceID, with: nil) + }.value + #expect(cache.snapshot().isEmpty) + } + + @Test + func reinstallWaitsForPriorTeardownAndRepeatedDropIsIdempotent() async { + let sequencer = AgentTerminalSurfaceTaskSequencer() + let surfaceID = UUID() + let events = EventRecorder() + let teardownGate = AsyncGate() + + sequencer.install(surfaceID: surfaceID) { + await events.append("first-start") + } + await events.waitForCount(1) + + sequencer.drop(surfaceID: surfaceID) { + await events.append("teardown-start") + await teardownGate.wait() + await events.append("teardown-finish") + } + sequencer.drop(surfaceID: surfaceID) { + await events.append("duplicate-teardown") + } + await events.waitForCount(2) + + sequencer.install(surfaceID: surfaceID) { + await events.append("second-start") + } + await Task.yield() + #expect(await events.snapshot() == ["first-start", "teardown-start"]) + + await teardownGate.open() + await events.waitForCount(4) + #expect(await events.snapshot() == [ + "first-start", "teardown-start", "teardown-finish", "second-start", + ]) + } +} + +private actor EventRecorder { + private var events: [String] = [] + private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func append(_ event: String) { + events.append(event) + let ready = waiters.filter { events.count >= $0.count } + waiters.removeAll { events.count >= $0.count } + ready.forEach { $0.continuation.resume() } + } + + func snapshot() -> [String] { + events + } + + func waitForCount(_ count: Int) async { + guard events.count < count else { return } + await withCheckedContinuation { continuation in + waiters.append((count, continuation)) + } + } +} + +private actor AsyncGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func open() { + isOpen = true + let pending = waiters + waiters.removeAll() + pending.forEach { $0.resume() } + } +} diff --git a/cmuxTests/CLICodexHookSessionStartRegressionTests.swift b/cmuxTests/CLICodexHookSessionStartRegressionTests.swift new file mode 100644 index 000000000000..99cef0ee973a --- /dev/null +++ b/cmuxTests/CLICodexHookSessionStartRegressionTests.swift @@ -0,0 +1,166 @@ +import Foundation +import Testing + +extension CLICodexHookTimeoutRegressionTests { + @Test func codexSessionStartFromNewProcessReplacesInterruptedTurnState() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-restarted-active-turn-\(UUID().uuidString)", isDirectory: true) + let socketPath = makeCodexHookSocketPath("codex-restarted-active") + let listenerFD = try bindCodexHookUnixSocket(at: socketPath) + let commands = CodexHookCapturedSocketCommands() + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "codex-restarted-active-session" + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": workspaceId, + "surfaceId": surfaceId, + "cwd": root.path, + "pid": 4242, + "agentLifecycle": "running", + "runtimeStatus": "running", + "activePromptDepth": 1, + "activePromptTurnId": "interrupted-turn", + "activePromptTurnIds": ["interrupted-turn"], + "lastPromptTurnId": "interrupted-turn", + "startedAt": now, + "updatedAt": now, + ], + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted, .sortedKeys]) + .write(to: stateURL, options: .atomic) + startCodexHookMockSocketServerAccepting( + listenerFD: listenerFD, + commands: commands, + surfaceId: surfaceId, + connectionLimit: 8 + ) + + let result = runCodexHookProcess( + executablePath: cliPath, + arguments: ["hooks", "codex", "session-start"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + "CMUX_CODEX_PID": "4343", + ], + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"#, + timeout: 5 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "{}\n") + let sentCommands = commands.snapshot() + #expect(sentCommands.contains { $0.hasPrefix("set_agent_lifecycle codex unknown ") }) + #expect(sentCommands.contains { codexHookJSONObject($0)?["method"] as? String == "surface.resume.set" }) + + let saved = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any] + ) + let sessions = try #require(saved["sessions"] as? [String: Any]) + let session = try #require(sessions[sessionId] as? [String: Any]) + #expect(session["pid"] as? Int == 4343) + #expect(session["agentLifecycle"] as? String == "unknown") + #expect(session["runtimeStatus"] as? String == "running") + #expect(session["activePromptDepth"] == nil) + #expect(session["activePromptTurnId"] == nil) + #expect(session["activePromptTurnIds"] == nil) + } + + @Test func staleCodexSessionStartDoesNotWaitForSessionStoreWriter() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-lock-free-stale-start-\(UUID().uuidString)", isDirectory: true) + let socketPath = makeCodexHookSocketPath("codex-lock-free-stale") + let listenerFD = try bindCodexHookUnixSocket(at: socketPath) + let commands = CodexHookCapturedSocketCommands() + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "codex-lock-free-stale-session" + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let lockURL = URL(fileURLWithPath: stateURL.path + ".lock") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 2, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": workspaceId, + "surfaceId": surfaceId, + "cwd": root.path, + "pid": 4242, + "activePromptDepth": 1, + "activePromptTurnId": "active-turn", + "activePromptTurnIds": ["active-turn"], + "startedAt": now, + "updatedAt": now, + ], + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted, .sortedKeys]) + .write(to: stateURL, options: .atomic) + FileManager.default.createFile(atPath: lockURL.path, contents: nil) + let lockFD = open(lockURL.path, O_RDWR) + #expect(lockFD >= 0) + defer { Darwin.close(lockFD) } + #expect(flock(lockFD, LOCK_EX) == 0) + defer { _ = flock(lockFD, LOCK_UN) } + + startCodexHookMockSocketServerAccepting( + listenerFD: listenerFD, + commands: commands, + surfaceId: surfaceId, + connectionLimit: 8 + ) + let result = runCodexHookProcess( + executablePath: cliPath, + arguments: ["hooks", "codex", "session-start"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + "CMUX_CODEX_PID": "4242", + ], + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"#, + timeout: 1 + ) + + #expect(!result.timedOut, "Read-only stale-hook checks must not wait for the writer lock") + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "{}\n") + #expect(commands.snapshot().isEmpty) + } +} diff --git a/cmuxTests/CLICodexHookTimeoutRegressionTestSupport.swift b/cmuxTests/CLICodexHookTimeoutRegressionTestSupport.swift index e244962d3fc8..23950a5cdb80 100644 --- a/cmuxTests/CLICodexHookTimeoutRegressionTestSupport.swift +++ b/cmuxTests/CLICodexHookTimeoutRegressionTestSupport.swift @@ -207,6 +207,7 @@ func runCodexHookProcess( arguments: [String], environment: [String: String], standardInput: String? = nil, + currentDirectoryURL: URL? = nil, timeout: TimeInterval ) -> CodexHookProcessRunResult { let process = Process() @@ -216,6 +217,7 @@ func runCodexHookProcess( process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = arguments process.environment = environment + process.currentDirectoryURL = currentDirectoryURL process.standardInput = stdinPipe ?? FileHandle.nullDevice process.standardOutput = stdoutPipe process.standardError = stderrPipe diff --git a/cmuxTests/CLICodexHookTimeoutRegressionTests.swift b/cmuxTests/CLICodexHookTimeoutRegressionTests.swift index 1f0fb823ac9c..b8e9b2a238b3 100644 --- a/cmuxTests/CLICodexHookTimeoutRegressionTests.swift +++ b/cmuxTests/CLICodexHookTimeoutRegressionTests.swift @@ -39,15 +39,21 @@ struct CLICodexHookTimeoutRegressionTests { #expect(sessionStartHooks.count == 1, "Installer should install one session-start hook") #expect(sessionStartHooks.allSatisfy { $0.body.contains("hooks codex session-start") }) #expect(sessionStartHooks.allSatisfy { $0.body.contains("nohup sh -c") && $0.body.contains("cat >\"$payload\"") }) - #expect(sessionStartHooks.allSatisfy { $0.body.contains("agent_pid=") && $0.body.contains("CMUX_CODEX_PID=") }) + #expect(sessionStartHooks.allSatisfy { + $0.body.contains(#"agent_pid="${CMUX_CODEX_PID:-${PPID:-}}""#) + }) #expect(promptHooks.count == 1, "Installer should collapse duplicate prompt hooks") #expect(promptHooks.allSatisfy { $0.body.contains("hooks codex prompt-submit") }) #expect(promptHooks.allSatisfy { $0.body.contains("nohup sh -c") && $0.body.contains("cat >\"$payload\"") }) - #expect(promptHooks.allSatisfy { $0.body.contains("agent_pid=") && $0.body.contains("CMUX_CODEX_PID=") }) + #expect(promptHooks.allSatisfy { + $0.body.contains(#"agent_pid="${CMUX_CODEX_PID:-${PPID:-}}""#) + }) #expect(stopHooks.count == 1, "Installer should install one stop hook") #expect(stopHooks.allSatisfy { $0.body.contains("hooks codex stop") }) #expect(stopHooks.allSatisfy { $0.body.contains("nohup sh -c") && $0.body.contains("cat >\"$payload\"") }) - #expect(stopHooks.allSatisfy { $0.body.contains("agent_pid=") && $0.body.contains("CMUX_CODEX_PID=") }) + #expect(stopHooks.allSatisfy { + $0.body.contains(#"agent_pid="${CMUX_CODEX_PID:-${PPID:-}}""#) + }) let expectedFeedEvents: Set = [ "PreToolUse", "PermissionRequest", @@ -90,7 +96,10 @@ struct CLICodexHookTimeoutRegressionTests { let install = runCodexHookProcess( executablePath: cliPath, arguments: ["hooks", "codex", "install", "--yes"], - environment: codexHookTestEnvironment(root: root, codexHome: codexHome), + environment: codexHookTestEnvironment(root: root, codexHome: codexHome).merging([ + "CMUX_BUNDLED_CLI_PATH": fakeCLI.path, + "CMUX_SOCKET_PATH": "/tmp/cmux-test.sock", + ], uniquingKeysWith: { _, new in new }), timeout: 5 ) #expect(!install.timedOut, Comment(rawValue: install.stderr)) @@ -108,8 +117,6 @@ struct CLICodexHookTimeoutRegressionTests { "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "TMPDIR": root.path, "CMUX_SURFACE_ID": "surface-123", - "CMUX_SOCKET_PATH": "/tmp/cmux-test.sock", - "CMUX_BUNDLED_CLI_PATH": fakeCLI.path, "CMUX_CODEX_PID": "4242", "CMUX_TEST_STDIN": capturedStdin.path, "CMUX_TEST_ARGS": capturedArgs.path, @@ -154,7 +161,10 @@ struct CLICodexHookTimeoutRegressionTests { let install = runCodexHookProcess( executablePath: cliPath, arguments: ["hooks", "codex", "install", "--yes"], - environment: codexHookTestEnvironment(root: root, codexHome: codexHome), + environment: codexHookTestEnvironment(root: root, codexHome: codexHome).merging([ + "CMUX_BUNDLED_CLI_PATH": fakeCLI.path, + "CMUX_SOCKET_PATH": "/tmp/cmux-test.sock", + ], uniquingKeysWith: { _, new in new }), timeout: 5 ) #expect(!install.timedOut, Comment(rawValue: install.stderr)) @@ -172,8 +182,6 @@ struct CLICodexHookTimeoutRegressionTests { "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "TMPDIR": root.path, "CMUX_SURFACE_ID": "surface-123", - "CMUX_SOCKET_PATH": "/tmp/cmux-test.sock", - "CMUX_BUNDLED_CLI_PATH": fakeCLI.path, "CMUX_CODEX_PID": "4242", "CMUX_TEST_STDIN": capturedStdin.path, "CMUX_TEST_ARGS": capturedArgs.path, @@ -659,7 +667,7 @@ struct CLICodexHookTimeoutRegressionTests { #expect(session["terminalPromptTurnIds"] as? [String] == ["turn-done"]) } - private func bundledCLIPath() throws -> String { + func bundledCLIPath() throws -> String { try BundledCLITestSupport.bundledCLIPath(for: BundledCLILinkageTests.self) } } diff --git a/cmuxTests/CLICodexWeakEnvironmentRestoreBindingTests.swift b/cmuxTests/CLICodexWeakEnvironmentRestoreBindingTests.swift index 7655c0d805ed..d2c0123b7dfc 100644 --- a/cmuxTests/CLICodexWeakEnvironmentRestoreBindingTests.swift +++ b/cmuxTests/CLICodexWeakEnvironmentRestoreBindingTests.swift @@ -76,6 +76,10 @@ extension CLINotifyProcessIntegrationRegressionTests { commands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "weak env-only Codex captures must not become durable restore bindings: \(commands)" ) + XCTAssertTrue( + commands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.clear" }, + "a non-restorable top-level hook must clear the surface's stale agent binding: \(commands)" + ) } func testCodexWeakCurrentCapturePreservesDurableMappedResumeBinding() throws { @@ -239,6 +243,14 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(resume["checkpoint_id"] as? String, sessionId) XCTAssertEqual(resume["cwd"] as? String, repo.path) XCTAssertTrue((resume["command"] as? String)?.contains("codex") == true) + XCTAssertTrue( + (resume["command"] as? String)?.contains("CMUX_CODEX_WRAPPER_SHIM") == true, + "a captured Codex executable must still route through cmux's hook wrapper: \(resume)" + ) + XCTAssertTrue( + (resume["command"] as? String)?.contains("CMUX_CUSTOM_CODEX_PATH=/usr/local/bin/codex") == true, + "wrapper routing must retain the trusted Codex binary selection: \(resume)" + ) XCTAssertTrue( (resume["command"] as? String)?.contains(expectedFlag) == true, "a transcript-backed Codex resume must preserve safe launch flags: \(resume)" @@ -331,8 +343,17 @@ extension CLINotifyProcessIntegrationRegressionTests { let resume = try XCTUnwrap(resumeRequests.last, "expected default resume binding, saw \(commands)") XCTAssertEqual(resume["checkpoint_id"] as? String, sessionId) XCTAssertEqual(resume["cwd"] as? String, repo.path) - XCTAssertTrue((resume["command"] as? String)?.contains("codex") == true) - XCTAssertTrue((resume["command"] as? String)?.contains("resume") == true) + let resumeCommand = try XCTUnwrap(resume["command"] as? String) + XCTAssertTrue(resumeCommand.contains("codex")) + XCTAssertTrue(resumeCommand.contains("resume")) + XCTAssertTrue( + resumeCommand.contains("CMUX_CODEX_WRAPPER_SHIM"), + "default Codex restore must route through cmux's wrapper so restored sessions keep publishing hooks: \(resumeCommand)" + ) + XCTAssertTrue( + resumeCommand.hasPrefix("/bin/sh -c "), + "the Codex wrapper token must be portable across fish, csh, and POSIX login shells: \(resumeCommand)" + ) let storeJSON = try XCTUnwrap(JSONSerialization.jsonObject( with: Data(contentsOf: root.appendingPathComponent("codex-hook-sessions.json")) ) as? [String: Any]) diff --git a/cmuxTests/CLIGenericHookPersistenceTests.swift b/cmuxTests/CLIGenericHookPersistenceTests.swift index 7aa3f71a830c..b2d87f093650 100644 --- a/cmuxTests/CLIGenericHookPersistenceTests.swift +++ b/cmuxTests/CLIGenericHookPersistenceTests.swift @@ -298,6 +298,7 @@ extension CLINotifyProcessIntegrationRegressionTests { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { + Darwin.shutdown(listenerFD, SHUT_RDWR) Darwin.close(listenerFD) unlink(socketPath) try? FileManager.default.removeItem(at: root) @@ -565,6 +566,7 @@ extension CLINotifyProcessIntegrationRegressionTests { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { + Darwin.shutdown(listenerFD, SHUT_RDWR) Darwin.close(listenerFD) unlink(socketPath) try? FileManager.default.removeItem(at: root) @@ -1342,6 +1344,56 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(events.compactMap { $0["_ppid"] as? Int }, [424242, 424242, 424242]) } + func testOversizedHookInputsNoOpAcrossFeedAndLifecycleEntrypoints() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hook-input-bound-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environment: [String: String] = [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": root.appendingPathComponent("absent.sock").path, + "CMUX_WORKSPACE_ID": "55555555-5555-5555-5555-555555555555", + "CMUX_SURFACE_ID": "66666666-6666-6666-6666-666666666666", + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + let padding = String(repeating: "x", count: 1_048_576) + let cases: [(arguments: [String], input: String)] = [ + ( + ["hooks", "feed", "--source", "gemini", "--event", "PreToolUse"], + #"{"hook_event_name":"PreToolUse","tool_name":"write","padding":"\#(padding)"}"# + ), + ( + ["hooks", "feed", "--source", "kimi", "--event", "postToolUse"], + #"{"hook_event_name":"postToolUse","tool_name":"shell","padding":"\#(padding)"}"# + ), + ( + ["hooks", "claude", "session-start"], + #"{"hook_event_name":"SessionStart","session_id":"claude-session","padding":"\#(padding)"}"# + ), + ( + ["hooks", "kimi", "session-start"], + #"{"hookEventName":"sessionStart","sessionId":"kimi-session","padding":"\#(padding)"}"# + ), + ] + + for testCase in cases { + let result = runProcess( + executablePath: cliPath, + arguments: testCase.arguments, + environment: environment, + standardInput: testCase.input, + timeout: 5 + ) + XCTAssertFalse(result.timedOut, "\(testCase.arguments): \(result.stderr)") + XCTAssertEqual(result.status, 0, "\(testCase.arguments): \(result.stderr)") + XCTAssertEqual(result.stdout, "{}\n", "\(testCase.arguments)") + } + } + func testGrokNotificationHookUsesPayloadMessageAndStopDoesNotSendGenericNotification() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("grok-notification") @@ -1356,6 +1408,7 @@ extension CLINotifyProcessIntegrationRegressionTests { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { + Darwin.shutdown(listenerFD, SHUT_RDWR) Darwin.close(listenerFD) unlink(socketPath) try? FileManager.default.removeItem(at: root) @@ -3484,6 +3537,16 @@ extension CLINotifyProcessIntegrationRegressionTests { params["surface_id"] as? String, ttySurfaceId, "PID/TTY ground truth must override the leaked env CMUX_SURFACE_ID; params=\(params)" ) + let surfaceListRequestCount = state.snapshot().reduce(into: 0) { count, command in + guard let payload = self.jsonObject(command), + payload["method"] as? String == "surface.list" else { return } + count += 1 + } + XCTAssertEqual( + surfaceListRequestCount, + 1, + "one hook invocation must reuse its validated surface list; saw \(state.snapshot())" + ) } /// G3 stale-env variant (https://github.com/manaflow-ai/cmux/issues/5333): when the ambient @@ -3665,4 +3728,108 @@ extension CLINotifyProcessIntegrationRegressionTests { ) } } + + func testCopilotHookInstallUsesHooksDirectoryNativeSchemaAndMigratesLegacyConfig() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-copilot-hook-install-\(UUID().uuidString)", isDirectory: true) + let hooksDirectory = root.appendingPathComponent("hooks", isDirectory: true) + let hooksURL = hooksDirectory.appendingPathComponent("cmux.json", isDirectory: false) + let legacyURL = root.appendingPathComponent("config.json", isDirectory: false) + try FileManager.default.createDirectory(at: hooksDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + try Data(#""" + { + "version":1, + "hooks":{"sessionStart":[{"type":"command","command":"user-native-hook","timeoutSec":9}]} + } + """#.utf8).write(to: hooksURL, options: .atomic) + try Data(#""" + { + "theme":"dark", + "hooks":{ + "SessionStart":[{"hooks":[ + {"type":"command","command":"cmux hooks copilot session-start","timeout":5000}, + {"type":"command","command":"user-legacy-hook"} + ]}], + "PreToolUse":[{"hooks":[ + {"type":"command","command":"cmux hooks feed --source copilot --event PreToolUse"} + ]}] + } + } + """#.utf8).write(to: legacyURL, options: .atomic) + + var environment = ProcessInfo.processInfo.environment + environment["COPILOT_HOME"] = root.path + environment["HOME"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let install = runProcess( + executablePath: cliPath, + arguments: ["hooks", "copilot", "install", "--yes"], + environment: environment, + timeout: 5 + ) + XCTAssertFalse(install.timedOut, install.stderr) + XCTAssertEqual(install.status, 0, install.stderr) + XCTAssertTrue(FileManager.default.fileExists(atPath: hooksURL.path)) + + let installedData = try Data(contentsOf: hooksURL) + let installed = try XCTUnwrap( + JSONSerialization.jsonObject(with: installedData) as? [String: Any] + ) + XCTAssertEqual(installed["version"] as? Int, 1) + let hooks = try XCTUnwrap(installed["hooks"] as? [String: Any]) + XCTAssertEqual(Set(hooks.keys), Set([ + "sessionStart", "agentStop", "notification", "sessionEnd", "preToolUse", + ])) + let starts = try XCTUnwrap(hooks["sessionStart"] as? [[String: Any]]) + XCTAssertEqual(starts.first?["command"] as? String, "user-native-hook") + XCTAssertEqual(starts.first?["timeoutSec"] as? Int, 9) + let installedStart = try XCTUnwrap(starts.last) + XCTAssertTrue((installedStart["command"] as? String)?.contains("hooks copilot session-start") == true) + XCTAssertEqual(installedStart["timeoutSec"] as? Int, 5) + XCTAssertNil(installedStart["hooks"]) + let feed = try XCTUnwrap((hooks["preToolUse"] as? [[String: Any]])?.first) + XCTAssertTrue((feed["command"] as? String)?.contains("hooks feed --source copilot --event preToolUse") == true) + XCTAssertEqual(feed["timeoutSec"] as? Int, 120) + + let migrated = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(contentsOf: legacyURL)) as? [String: Any] + ) + XCTAssertEqual(migrated["theme"] as? String, "dark") + let legacyHooks = try XCTUnwrap(migrated["hooks"] as? [String: Any]) + XCTAssertNil(legacyHooks["PreToolUse"]) + let legacyGroups = try XCTUnwrap(legacyHooks["SessionStart"] as? [[String: Any]]) + let legacyCommands = try XCTUnwrap(legacyGroups.first?["hooks"] as? [[String: Any]]) + XCTAssertEqual(legacyCommands.count, 1) + XCTAssertEqual(legacyCommands.first?["command"] as? String, "user-legacy-hook") + + let reinstall = runProcess( + executablePath: cliPath, + arguments: ["hooks", "copilot", "install", "--yes"], + environment: environment, + timeout: 5 + ) + XCTAssertFalse(reinstall.timedOut, reinstall.stderr) + XCTAssertEqual(reinstall.status, 0, reinstall.stderr) + XCTAssertEqual(try Data(contentsOf: hooksURL), installedData) + + let uninstall = runProcess( + executablePath: cliPath, + arguments: ["hooks", "copilot", "uninstall"], + environment: environment, + timeout: 5 + ) + XCTAssertFalse(uninstall.timedOut, uninstall.stderr) + XCTAssertEqual(uninstall.status, 0, uninstall.stderr) + let uninstalled = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(contentsOf: hooksURL)) as? [String: Any] + ) + let remainingHooks = try XCTUnwrap(uninstalled["hooks"] as? [String: Any]) + let remainingStarts = try XCTUnwrap(remainingHooks["sessionStart"] as? [[String: Any]]) + XCTAssertEqual(remainingStarts.count, 1) + XCTAssertEqual(remainingStarts.first?["command"] as? String, "user-native-hook") + } } diff --git a/cmuxTests/CLIGrokNotificationNoiseTests.swift b/cmuxTests/CLIGrokNotificationNoiseTests.swift index 668f06eacfbf..71545ca42538 100644 --- a/cmuxTests/CLIGrokNotificationNoiseTests.swift +++ b/cmuxTests/CLIGrokNotificationNoiseTests.swift @@ -117,8 +117,13 @@ extension CLINotifyProcessIntegrationRegressionTests { let start = context.state.snapshot().count try runGrokNoiseHook(context, "notification", payload: antigravityNoisePayload(context, event: "Notification", message: "Build failed: exit 1")) - let notifications = notifyCommands(in: Array(context.state.snapshot().dropFirst(start))) - XCTAssertEqual(notifications.count, 1, "Expected one Antigravity error notification, saw \(notifications)") + let commands = Array(context.state.snapshot().dropFirst(start)) + let notifications = notifyCommands(in: commands) + XCTAssertEqual( + notifications.count, + 1, + "Expected one Antigravity error notification, saw \(notifications); commands: \(commands)" + ) XCTAssertFalse( notifications.first?.contains("|c=") == true, "Error notifications should remain untagged, saw \(notifications)" @@ -135,9 +140,15 @@ extension CLINotifyProcessIntegrationRegressionTests { let surfaceId: String let sessionId: String let agent: String + let agentProcess: Process let environment: [String: String] func cleanup() { + if agentProcess.isRunning { + agentProcess.terminate() + } + agentProcess.waitUntilExit() + Darwin.shutdown(listenerFD, SHUT_RDWR) Darwin.close(listenerFD) unlink(socketPath) try? FileManager.default.removeItem(at: root) @@ -161,7 +172,19 @@ extension CLINotifyProcessIntegrationRegressionTests { let grokHome = root.appendingPathComponent("grok-home", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - let environment: [String: String] = [ + let agentExecutable = root.appendingPathComponent(agent, isDirectory: false) + try FileManager.default.createSymbolicLink( + at: agentExecutable, + withDestinationURL: URL(fileURLWithPath: "/bin/sleep", isDirectory: false) + ) + let agentProcess = Process() + agentProcess.executableURL = agentExecutable + agentProcess.arguments = ["30"] + agentProcess.standardOutput = FileHandle.nullDevice + agentProcess.standardError = FileHandle.nullDevice + try agentProcess.run() + + var environment: [String: String] = [ "HOME": root.path, "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "PWD": root.path, @@ -172,6 +195,9 @@ extension CLINotifyProcessIntegrationRegressionTests { "CMUX_CLI_SENTRY_DISABLED": "1", "GROK_HOME": grokHome.path, ] + environment[agent == "grok" ? "CMUX_GROK_PID" : "CMUX_ANTIGRAVITY_PID"] = String( + agentProcess.processIdentifier + ) startDetachedAgentHookMockServer(listenerFD: listenerFD, state: state, surfaceId: surfaceId, connectionCount: 128) return GrokNoiseContext( @@ -184,6 +210,7 @@ extension CLINotifyProcessIntegrationRegressionTests { surfaceId: surfaceId, sessionId: sessionId, agent: agent, + agentProcess: agentProcess, environment: environment ) } diff --git a/cmuxTests/CLIMockSocketServerSupport.swift b/cmuxTests/CLIMockSocketServerSupport.swift index 37bbeebed711..cd565efc8674 100644 --- a/cmuxTests/CLIMockSocketServerSupport.swift +++ b/cmuxTests/CLIMockSocketServerSupport.swift @@ -127,12 +127,8 @@ extension CLINotifyProcessIntegrationRegressionTests { ) -> XCTestExpectation { let handled = expectation(description: "cli mock socket handled") let fulfillmentGate = MockSocketFulfillmentGate() - for _ in 0...size) let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in @@ -141,37 +137,44 @@ extension CLINotifyProcessIntegrationRegressionTests { } } guard clientFD >= 0 else { - fulfillOnce() + fulfillmentGate.fulfill(handled) return } - defer { - Darwin.close(clientFD) - fulfillOnce() - } - var pending = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - while true { - let count = Darwin.read(clientFD, &buffer, buffer.count) - if count < 0 { - if errno == EINTR { continue } - return + DispatchQueue.global(qos: .userInitiated).async { + func fulfillOnce() { + fulfillmentGate.fulfill(handled) + } + + defer { + Darwin.close(clientFD) + fulfillOnce() } - if count == 0 { return } - pending.append(buffer, count: count) - while let newlineRange = pending.firstRange(of: Data([0x0A])) { - let lineData = pending.subdata(in: 0.. String ) { - for _ in 0...size) let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in @@ -198,29 +201,31 @@ extension CLINotifyProcessIntegrationRegressionTests { guard clientFD >= 0 else { return } - defer { - Darwin.close(clientFD) - } - - var pending = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - while true { - let count = Darwin.read(clientFD, &buffer, buffer.count) - if count < 0 { - if errno == EINTR { continue } - return + DispatchQueue.global(qos: .userInitiated).async { + defer { + Darwin.close(clientFD) } - if count == 0 { return } - pending.append(buffer, count: count) - while let newlineRange = pending.firstRange(of: Data([0x0A])) { - let lineData = pending.subdata(in: 0.. String { + arguments.reduce(into: Data()) { data, argument in + data.append(contentsOf: argument.utf8) + data.append(0) + }.base64EncodedString() + } + + private func jsonObject(_ line: String) -> [String: Any]? { + guard let data = line.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } +} diff --git a/cmuxTests/CLINotifyClaudeForkOfForkRegressionTests.swift b/cmuxTests/CLINotifyClaudeForkOfForkRegressionTests.swift index 9553cc9cf0fc..dc2edabc50d6 100644 --- a/cmuxTests/CLINotifyClaudeForkOfForkRegressionTests.swift +++ b/cmuxTests/CLINotifyClaudeForkOfForkRegressionTests.swift @@ -84,6 +84,7 @@ extension CLINotifyProcessIntegrationRegressionTests { let surfaceId: String func cleanup() { + Darwin.shutdown(listenerFD, SHUT_RDWR) Darwin.close(listenerFD) unlink(socketPath) try? FileManager.default.removeItem(at: root) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 2905962f55aa..8e5c4cacb103 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -3278,18 +3278,16 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(result.timedOut, result.stderr) XCTAssertEqual(result.status, 0, result.stderr) XCTAssertEqual(result.stdout, "{}\n") - XCTAssertTrue( - context.state.commands.contains { $0.contains(#""method":"feed.push""#) && $0.contains(#""hook_event_name":"Stop""#) }, - "Managed subagent Stop should remain Feed telemetry, saw \(context.state.commands)" - ) - XCTAssertFalse( - context.state.commands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, - "Managed subagent Stop should not publish a child resume binding, saw \(context.state.commands)" - ) - XCTAssertFalse( - context.state.commands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, - "Managed subagent Stop should not notify or clobber visible status, saw \(context.state.commands)" - ) + XCTAssertTrue(context.state.commands.contains { $0.contains(#""method":"feed.push""#) && $0.contains(#""hook_event_name":"Stop""#) }, "Managed subagent Stop should remain Feed telemetry, saw \(context.state.commands)") + XCTAssertFalse(context.state.commands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "Managed subagent Stop should not publish a child resume binding, saw \(context.state.commands)") + XCTAssertFalse(context.state.commands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "Managed subagent Stop should not notify or clobber visible status, saw \(context.state.commands)") + let stateURL = context.root.appendingPathComponent("codex-hook-sessions.json") + let state = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let sessions = try XCTUnwrap(state["sessions"] as? [String: Any]) + let child = try XCTUnwrap(sessions[sessionId] as? [String: Any]) + XCTAssertEqual([child["runId"] as? String, child["parentRunId"] as? String, child["relationship"] as? String], ["child-thread", "root-thread", "spawned"]) + XCTAssertEqual(child["restoreAuthority"] as? Bool, false) + XCTAssertEqual(child["foregroundState"] as? String, "completed") } func testCodexStopIgnoresStaleSubagentRelayFromCompletedTurnWithoutTurnId() throws { diff --git a/cmuxTests/CLINotifyProcessTestSupport.swift b/cmuxTests/CLINotifyProcessTestSupport.swift index 128820993c9a..60a17da90571 100644 --- a/cmuxTests/CLINotifyProcessTestSupport.swift +++ b/cmuxTests/CLINotifyProcessTestSupport.swift @@ -374,6 +374,10 @@ extension CLINotifyProcessIntegrationRegressionTests { process.standardInput = stdinPipe ?? FileHandle.nullDevice process.standardOutput = stdoutPipe process.standardError = stderrPipe + let exitSignal = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + exitSignal.signal() + } do { try process.run() @@ -408,12 +412,6 @@ extension CLINotifyProcessIntegrationRegressionTests { outputGroup.leave() } - let exitSignal = DispatchSemaphore(value: 0) - DispatchQueue.global(qos: .userInitiated).async { - process.waitUntilExit() - exitSignal.signal() - } - let timedOut = exitSignal.wait(timeout: .now() + processTimeout(timeout)) == .timedOut if timedOut { process.terminate() diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index 17406f08aa7f..7068c7b49da4 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -1392,6 +1392,10 @@ import Testing process.arguments = ["-c", command] process.standardInput = FileHandle.nullDevice process.standardOutput = stdoutPipe + let exitSignal = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + exitSignal.signal() + } do { try process.run() @@ -1399,12 +1403,6 @@ import Testing return ProcessRunResult(status: -1, stdout: String(describing: error), timedOut: false) } - let exitSignal = DispatchSemaphore(value: 0) - DispatchQueue.global(qos: .userInitiated).async { - process.waitUntilExit() - exitSignal.signal() - } - let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut if timedOut { process.terminate() @@ -1438,6 +1436,10 @@ import Testing process.standardInput = FileHandle.nullDevice process.standardOutput = outputPipe process.standardError = outputPipe + let exitSignal = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + exitSignal.signal() + } do { try process.run() @@ -1445,12 +1447,6 @@ import Testing return ProcessRunResult(status: -1, stdout: String(describing: error), timedOut: false) } - let exitSignal = DispatchSemaphore(value: 0) - DispatchQueue.global(qos: .userInitiated).async { - process.waitUntilExit() - exitSignal.signal() - } - let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut if timedOut { process.terminate() diff --git a/cmuxTests/CMUXCLISessionsListForkDiagnosticsTests.swift b/cmuxTests/CMUXCLISessionsListForkDiagnosticsTests.swift index 1877bf272210..56b2e5ae8eb7 100644 --- a/cmuxTests/CMUXCLISessionsListForkDiagnosticsTests.swift +++ b/cmuxTests/CMUXCLISessionsListForkDiagnosticsTests.swift @@ -244,7 +244,7 @@ extension CMUXCLIErrorOutputRegressionTests { func sessionsListDiagnosticSession( agent: String = "codex", launcher: String, executablePath: String, arguments: [String], environment: [String: String] = [:], workingDirectory: String = "/tmp/cmux/debug", pid: Int? = nil, - transcriptPath: String? = nil + transcriptPath: String? = nil, restoreAuthority: Bool? = nil ) throws -> [String: Any] { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory @@ -271,6 +271,7 @@ extension CMUXCLIErrorOutputRegressionTests { ] if let pid { record["pid"] = pid } if let transcriptPath { record["transcriptPath"] = transcriptPath } + if let restoreAuthority { record["restoreAuthority"] = restoreAuthority } let store: [String: Any] = [ "version": 1, "sessions": [sessionId: record], @@ -289,22 +290,151 @@ extension CMUXCLIErrorOutputRegressionTests { return try #require(sessions.first) } - @Test func testSessionsListFailsClosedForUnverifiedPiFamilyVersions() throws { - for agent in ["pi", "omp"] { - let session = try sessionsListDiagnosticSession( - agent: agent, - launcher: agent, - executablePath: agent, - arguments: [agent, "--session", "session-id"] - ) - #expect(session["fork_command_available"] as? Bool == true) - #expect(session["fork_supported"] as? Bool == false) - #expect(session["fork_unavailable_reason"] as? String == "\(agent)_version_unverified") - #expect(session["fork_startup_input_available"] as? Bool == true) + @Test func testSessionsListNeverForksNestedRunsWithoutRestoreAuthority() throws { + let session = try sessionsListDiagnosticSession( + launcher: "codexTeams", + executablePath: "/usr/local/bin/cmux", + arguments: ["/usr/local/bin/cmux", "codex-teams"], + restoreAuthority: false + ) + + #expect(session["restore_authority"] as? Bool == false) + #expect(session["hook_record_restorable"] as? Bool == false) + #expect(session["fork_command_available"] as? Bool == false) + #expect(session["fork_supported"] as? Bool == false) + #expect(session["fork_unavailable_reason"] as? String == "record_marked_non_restorable") + } + + @Test func testSessionsListForkDiagnosticsFollowCanonicalRunAuthority() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-sessions-list-run-authority-\(UUID().uuidString)", isDirectory: true) + let stateDir = root.appendingPathComponent("state", isDirectory: true) + try FileManager.default.createDirectory(at: stateDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let workspaceID = "33B0D372-292E-42BF-97B6-E37CCA79AB84" + let sessionIDs = [ + "019ef275-74e3-7777-9773-9dcb118ed5a1", + "019ef275-74e3-7777-9773-9dcb118ed5a2", + "019ef275-74e3-7777-9773-9dcb118ed5a3", + "019ef275-74e3-7777-9773-9dcb118ed5a4", + ] + let activeRunIDs: [Any] = ["child-run-1", "missing-run", NSNull(), "root-run-4"] + var sessions: [String: Any] = [:] + for (index, sessionID) in sessionIDs.enumerated() { + let ordinal = index + 1 + let rootRunID = "root-run-\(ordinal)" + let childRunID = "child-run-\(ordinal)" + let canonicalRunIsRoot = index == 3 + let childRunTimestamp = 1_781_996_900.0 + Double(index) + let rootRunEndedAt: Any + let childRunEndedAt: Any + if canonicalRunIsRoot { + rootRunEndedAt = NSNull() + childRunEndedAt = childRunTimestamp + 1 + } else { + rootRunEndedAt = 1_781_996_850.0 + childRunEndedAt = NSNull() + } + let launchCommand: [String: Any] = [ + "launcher": "codex", + "executablePath": "/usr/local/bin/codex", + "arguments": ["/usr/local/bin/codex"], + "workingDirectory": "/tmp/cmux/debug", + "environment": [:], + "source": "environment", + ] + let rootRun: [String: Any] = [ + "runId": rootRunID, + "restoreAuthority": true, + "startedAt": 1_781_996_800.0, + "updatedAt": 1_781_996_850.0, + "endedAt": rootRunEndedAt, + ] + let childRun: [String: Any] = [ + "runId": childRunID, + "parentRunId": rootRunID, + "relationship": "spawned", + "restoreAuthority": false, + "startedAt": childRunTimestamp, + "updatedAt": childRunTimestamp, + "endedAt": childRunEndedAt, + ] + var record: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": workspaceID, + "surfaceId": "A2AECAA9-EE1C-4999-B7A9-EE4BB4CDA5D\(ordinal)", + "cwd": "/tmp/cmux/debug", + "startedAt": 1_781_996_800.0, + "updatedAt": childRunTimestamp, + // Simulate a stale compatibility field that still describes the + // previous root while run history says the projected run is a child. + "restoreAuthority": !canonicalRunIsRoot, + "launchCommand": launchCommand, + "runs": [rootRun, childRun], + ] + if let activeRunID = activeRunIDs[index] as? String { + record["activeRunId"] = activeRunID + } + sessions[sessionID] = record + } + let store: [String: Any] = ["version": 1, "sessions": sessions] + let data = try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: stateDir.appendingPathComponent("codex-hook-sessions.json"), options: .atomic) + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_AGENT_HOOK_STATE_DIR"] = stateDir.path + + let result = runProcess( + executablePath: cliPath, + arguments: ["sessions", "list", "--agent", "codex", "--workspace", workspaceID, "--json"], + environment: environment, + timeout: 5 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stdout)) + #expect(result.status == 0, Comment(rawValue: result.stdout)) + let outputData = try #require(result.stdout.data(using: .utf8)) + let object = try #require(JSONSerialization.jsonObject(with: outputData) as? [String: Any]) + let outputSessions = try #require(object["sessions"] as? [[String: Any]]) + #expect(Set(outputSessions.compactMap { $0["session_id"] as? String }) == Set(sessionIDs)) + for session in outputSessions { + if session["session_id"] as? String == sessionIDs[3] { + #expect(session["run_id"] as? String == "root-run-4") + #expect(session["restore_authority"] as? Bool == true) + #expect(session["hook_record_restorable"] as? Bool == true) + #expect(session["fork_command_available"] as? Bool == true) + #expect(session["fork_supported"] as? Bool == true) + #expect(session["fork_unavailable_reason"] as? String == "available") + } else { + #expect(session["restore_authority"] as? Bool == false) + #expect(session["hook_record_restorable"] as? Bool == false) + #expect(session["fork_command_available"] as? Bool == false) + #expect(session["fork_supported"] as? Bool == false) + #expect(session["fork_unavailable_reason"] as? String == "run_marked_non_restorable") + } } } - @Test func testSessionsListUsesRequestedPiFamilyAgentBeforeExecutableBasename() throws { + @Test func testSessionsListFailsClosedForUnverifiedPiVersion() throws { + let session = try sessionsListDiagnosticSession( + agent: "pi", + launcher: "pi", + executablePath: "pi", + arguments: ["pi", "--session", "session-id"] + ) + #expect(session["fork_command_available"] as? Bool == true) + #expect(session["fork_supported"] as? Bool == false) + #expect(session["fork_unavailable_reason"] as? String == "pi_version_unverified") + #expect(session["fork_startup_input_available"] as? Bool == true) + } + + @Test func testSessionsListReportsNoForkCommandForOmp() throws { let session = try sessionsListDiagnosticSession( agent: "omp", launcher: "omp", @@ -312,9 +442,10 @@ extension CMUXCLIErrorOutputRegressionTests { arguments: ["/tmp/pi", "--session", "session-id"] ) - #expect(session["fork_command_available"] as? Bool == true) + #expect(session["fork_command_available"] as? Bool == false) #expect(session["fork_supported"] as? Bool == false) - #expect(session["fork_unavailable_reason"] as? String == "omp_version_unverified") + #expect(session["fork_unavailable_reason"] as? String == "agent_has_no_fork_command") + #expect(session["fork_startup_input_available"] as? Bool == false) } @Test func testSessionsListDoesNotInferPiFamilyFromBasenameWhenStructuredIdentityDisagrees() throws { diff --git a/cmuxTests/CMUXCLISessionsListTranscriptPidTests.swift b/cmuxTests/CMUXCLISessionsListTranscriptPidTests.swift index 58a293360666..0bdad315c517 100644 --- a/cmuxTests/CMUXCLISessionsListTranscriptPidTests.swift +++ b/cmuxTests/CMUXCLISessionsListTranscriptPidTests.swift @@ -2,19 +2,23 @@ import Foundation import Testing extension CMUXCLIErrorOutputRegressionTests { - @Test func testSessionsListTreatsTranscriptBackedClaudeRecordAsRestorable() throws { + @Test(arguments: [true, false]) + func testSessionsListRequiresClaudeTranscriptPathToMatchSession( + transcriptMatchesSession: Bool + ) throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-sessions-list-claude-transcript-\(UUID().uuidString)", isDirectory: true) let stateDir = root.appendingPathComponent("state", isDirectory: true) let repoDir = root.appendingPathComponent("repo", isDirectory: true) - let transcriptURL = root.appendingPathComponent("claude-session.jsonl", isDirectory: false) + let sessionId = "claude-transcript-backed-session" + let transcriptFilename = transcriptMatchesSession ? "\(sessionId).jsonl" : "another-session.jsonl" + let transcriptURL = root.appendingPathComponent(transcriptFilename, isDirectory: false) try FileManager.default.createDirectory(at: stateDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: repoDir, withIntermediateDirectories: true) try "{}\n".write(to: transcriptURL, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: root) } - let sessionId = "claude-transcript-backed-session" let workspaceId = "33B0D372-292E-42BF-97B6-E37CCA79AB84" let surfaceId = "A2AECAA9-EE1C-4999-B7A9-EE4BB4CDA5D8" let store: [String: Any] = [ @@ -64,10 +68,14 @@ extension CMUXCLIErrorOutputRegressionTests { let sessions = try #require(object["sessions"] as? [[String: Any]]) let session = try #require(sessions.first) #expect(session["session_id"] as? String == sessionId) - #expect(session["hook_record_restorable"] as? Bool == true) - #expect(session["fork_command_available"] as? Bool == true) - #expect(session["fork_supported"] as? Bool == true) - #expect(session["fork_unavailable_reason"] as? String == "available") + #expect(session["transcript_backed"] as? Bool == transcriptMatchesSession) + #expect(session["hook_record_restorable"] as? Bool == transcriptMatchesSession) + #expect(session["fork_command_available"] as? Bool == transcriptMatchesSession) + #expect(session["fork_supported"] as? Bool == transcriptMatchesSession) + #expect( + session["fork_unavailable_reason"] as? String + == (transcriptMatchesSession ? "available" : "record_marked_non_restorable") + ) } @Test func testSessionsListDoesNotTrustClaudeRestorableFlagWithoutTranscript() throws { @@ -213,7 +221,10 @@ extension CMUXCLIErrorOutputRegressionTests { #expect(session["fork_unavailable_reason"] as? String == "available") } - @Test func testSessionsListRejectsAmbiguousClaudeWorkflowContainerTranscripts() throws { + @Test(arguments: [1, 2]) + func testSessionsListRejectsUnrelatedClaudeWorkflowContainerTranscripts( + siblingCount: Int + ) throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-sessions-list-claude-workflow-\(UUID().uuidString)", isDirectory: true) @@ -225,7 +236,10 @@ extension CMUXCLIErrorOutputRegressionTests { defer { try? FileManager.default.removeItem(at: root) } let containerSessionId = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa" - let siblingSessionIds = ["bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb", "cccccccc-3333-3333-3333-cccccccccccc"] + let siblingSessionIds = Array([ + "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb", + "cccccccc-3333-3333-3333-cccccccccccc", + ].prefix(siblingCount)) let projectDirName = repoDir.path .replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: ".", with: "-") @@ -295,6 +309,61 @@ extension CMUXCLIErrorOutputRegressionTests { #expect(session["fork_unavailable_reason"] as? String == "record_marked_non_restorable") } + @Test func testClaudeTranscriptLookupDoesNotScanUnrelatedProjectDirectories() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-sessions-list-claude-scale-\(UUID().uuidString)", isDirectory: true) + let configRoot = root.appendingPathComponent("claude-config", isDirectory: true) + let projectsRoot = configRoot.appendingPathComponent("projects", isDirectory: true) + try FileManager.default.createDirectory(at: projectsRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let count = 128 + for index in 0.. [String] { + directoryReadCount += 1 + return try super.contentsOfDirectory(atPath: path) + } + + override func fileExists(atPath path: String, isDirectory: UnsafeMutablePointer?) -> Bool { + existenceCheckCount += 1 + return super.fileExists(atPath: path, isDirectory: isDirectory) + } } diff --git a/cmuxTests/CampfireSupportProcessBindingTests.swift b/cmuxTests/CampfireSupportProcessBindingTests.swift index 581702eee61e..77843808aa1e 100644 --- a/cmuxTests/CampfireSupportProcessBindingTests.swift +++ b/cmuxTests/CampfireSupportProcessBindingTests.swift @@ -163,6 +163,7 @@ extension CampfireSupportTests { let registration = CmuxVaultAgentRegistration.builtInCampfire #expect(registration.id == "campfire") #expect(registration.resumeCommand == "{{executable}} --session {{sessionId}}") + #expect(registration.forkCommand == "{{executable}} --fork {{sessionId}}") #expect(registration.sessionIdSource == .argvOption("--session")) #expect(registration.sessionDirectory == "~/.campfire/agent/sessions") } diff --git a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift index 45c494461d96..0d527e4f2b54 100644 --- a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift +++ b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift @@ -25,7 +25,7 @@ struct ClaudeBackgroundWorkNotifyTests { name: String, sessionId: String, stdin: String - ) throws -> (snapshot: [String], cachedPending: Bool?) { + ) throws -> (snapshot: [String], cachedPending: Bool?, workloads: [[String: Any]]) { let harness = ClaudeHookSurfaceResolutionSwiftTests() let context = try harness.makeClaudeHookContext(name: name) let storeURL = context.root.appendingPathComponent("claude-hook-sessions.json") @@ -53,8 +53,9 @@ struct ClaudeBackgroundWorkNotifyTests { let snapshot = context.state.snapshot() // Read the cached flag from the store BEFORE cleanup deletes the temp dir. let cached = cachedPending(storeURL, sessionId: sessionId) + let workloads = storedWorkloads(storeURL, sessionId: sessionId) context.cleanup() - return (snapshot, cached) + return (snapshot, cached, workloads) } private func cachedPending(_ storeURL: URL, sessionId: String) -> Bool? { @@ -65,17 +66,30 @@ struct ClaudeBackgroundWorkNotifyTests { return record["hadPendingBackgroundWorkAtStop"] as? Bool } + private func storedWorkloads(_ storeURL: URL, sessionId: String) -> [[String: Any]] { + guard let data = try? Data(contentsOf: storeURL), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let sessions = obj["sessions"] as? [String: Any], + let record = sessions[sessionId] as? [String: Any] else { return [] } + return record["workloads"] as? [[String: Any]] ?? [] + } + @Test func stopWithRunningBackgroundTaskTagsPendingAndCaches() throws { let session = "bg-running-session" let stdin = #""" {"session_id":"\#(session)","cwd":"/tmp/x","hook_event_name":"Stop","last_assistant_message":"ok","background_tasks":[{"id":"t1","type":"shell","status":"running","description":"build","command":"sleep 1"}],"session_crons":[]} """# - let (snapshot, cached) = try runStopHook(name: "bg-run", sessionId: session, stdin: stdin) + let (snapshot, cached, workloads) = try runStopHook(name: "bg-run", sessionId: session, stdin: stdin) #expect( notifyLine(snapshot, containing: "c=turn-complete;p=1") != nil, "Stop with a running background task must tag the done-ping pending; saw \(snapshot)" ) #expect(cached == true) + #expect(workloads.count == 1) + #expect(workloads[0]["id"] as? String == "t1") + #expect(workloads[0]["kind"] as? String == "background_terminal") + #expect(workloads[0]["phase"] as? String == "running") + #expect(workloads[0]["keepsSessionBusy"] as? Bool == true) // Sidebar pill must not say "Idle" while background work is live. #expect(statusLine(snapshot, value: "Running") != nil, "Pending stop must show a Running pill, not Idle; saw \(snapshot)") @@ -92,10 +106,11 @@ struct ClaudeBackgroundWorkNotifyTests { let stdin = #""" {"session_id":"\#(session)","cwd":"/tmp/x","hook_event_name":"Stop","last_assistant_message":"ok","background_tasks":[],"session_crons":[]} """# - let (snapshot, cached) = try runStopHook(name: "bg-empty", sessionId: session, stdin: stdin) + let (snapshot, cached, workloads) = try runStopHook(name: "bg-empty", sessionId: session, stdin: stdin) #expect(notifyLine(snapshot, containing: "c=turn-complete;p=0") != nil, "Truly-idle stop must tag pending=0; saw \(snapshot)") #expect(cached == false) + #expect(workloads.isEmpty) // Truly-idle turn end keeps the "Idle" pill and the hibernatable lifecycle. #expect(statusLine(snapshot, value: "Idle") != nil, "Truly-idle stop must show the Idle pill; saw \(snapshot)") @@ -108,9 +123,13 @@ struct ClaudeBackgroundWorkNotifyTests { let stdin = #""" {"session_id":"\#(session)","cwd":"/tmp/x","hook_event_name":"Stop","last_assistant_message":"ok","background_tasks":[],"session_crons":[{"id":"c1"}]} """# - let (snapshot, _) = try runStopHook(name: "bg-cron", sessionId: session, stdin: stdin) + let (snapshot, _, workloads) = try runStopHook(name: "bg-cron", sessionId: session, stdin: stdin) #expect(notifyLine(snapshot, containing: "c=turn-complete;p=1") != nil, "A pending scheduled wakeup must tag pending=1; saw \(snapshot)") + #expect(workloads.count == 1) + #expect(workloads[0]["kind"] as? String == "scheduled") + #expect(workloads[0]["phase"] as? String == "queued") + #expect(workloads[0]["keepsSessionBusy"] as? Bool == true) } @Test func stopWithoutBackgroundKeysOldClientTagsNotPending() throws { @@ -119,10 +138,11 @@ struct ClaudeBackgroundWorkNotifyTests { let stdin = #""" {"session_id":"\#(session)","cwd":"/tmp/x","hook_event_name":"Stop","last_assistant_message":"ok"} """# - let (snapshot, cached) = try runStopHook(name: "bg-old", sessionId: session, stdin: stdin) + let (snapshot, cached, workloads) = try runStopHook(name: "bg-old", sessionId: session, stdin: stdin) #expect(notifyLine(snapshot, containing: "c=turn-complete;p=0") != nil, "Absent arrays (old client) must behave as not-pending; saw \(snapshot)") #expect(cached == false) + #expect(workloads.isEmpty) } @Test func notificationPermissionPromptTagsNeedsPermission() throws { @@ -185,6 +205,42 @@ struct ClaudeBackgroundWorkNotifyTests { "Permission-cue notification without notification_type must tag needs-permission; saw \(context.state.snapshot())") } + @Test func nestedNotificationPreferenceCanAllowDeliveryWithoutVisibleOwnership() throws { + let harness = ClaudeHookSurfaceResolutionSwiftTests() + let context = try harness.makeClaudeHookContext(name: "notif-child-opt-in") + defer { context.cleanup() } + let handled = harness.startClaudeSurfaceResolutionServer( + context: context, + surfaces: [(context.surfaceId, "surface:1", true)], + ttyName: "ttys-notif-child-opt-in", + ttySurfaceId: context.surfaceId + ) + var environment = harness.claudeHookEnvironment( + context: context, + surfaceId: context.surfaceId, + ttyName: "ttys-notif-child-opt-in", + storeURL: context.root.appendingPathComponent("claude-hook-sessions.json") + ) + environment["CMUX_AGENT_MANAGED_SUBAGENT"] = "1" + environment["CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS"] = "0" + let result = harness.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "notification"], + environment: environment, + standardInput: #"{"session_id":"notif-child-opt-in-session","cwd":"/tmp/x","hook_event_name":"Notification","message":"Child needs your permission","notification_type":"permission_prompt"}"#, + timeout: 5 + ) + #expect(handled.wait(timeout: .now() + 5) == .success) + harness.assertSuccessfulHook(result) + let snapshot = context.state.snapshot() + #expect(notifyLine(snapshot, containing: "c=needs-permission;p=0") != nil, + "Explicit opt-in must deliver nested notifications; saw \(snapshot)") + #expect(!snapshot.contains { $0.hasPrefix("set_status claude_code ") }, + "Nested notification delivery must not claim the root status; saw \(snapshot)") + #expect(!snapshot.contains { $0.hasPrefix("set_agent_lifecycle claude_code ") }, + "Nested notification delivery must not claim the root lifecycle; saw \(snapshot)") + } + @Test func idlePromptAfterPendingStopReadsCachedPending() throws { // Stop (pending) then idle_prompt on the SAME session: the idle nag must // inherit the cached pending flag because its payload lacks background_tasks. diff --git a/cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift b/cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift index c723fe10717d..bbe68b34d52d 100644 --- a/cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift +++ b/cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift @@ -439,6 +439,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests { let surfaceId: String func cleanup() { + Darwin.shutdown(listenerFD, SHUT_RDWR) Darwin.close(listenerFD) unlink(socketPath) try? FileManager.default.removeItem(at: root) @@ -544,52 +545,50 @@ struct ClaudeHookSurfaceResolutionSwiftTests { return } - DispatchQueue.global(qos: .userInitiated).async { - var authenticated = requiredSocketPassword == nil + var authenticated = requiredSocketPassword == nil - defer { - Darwin.close(clientFD) - handled.signal() - } + defer { + Darwin.close(clientFD) + handled.signal() + } - func writeResponse(_ response: String) { - let line = response + "\n" - _ = line.withCString { ptr in - Darwin.write(clientFD, ptr, strlen(ptr)) - } + func writeResponse(_ response: String) { + let line = response + "\n" + _ = line.withCString { ptr in + Darwin.write(clientFD, ptr, strlen(ptr)) } + } - var pending = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - while true { - let count = Darwin.read(clientFD, &buffer, buffer.count) - if count < 0 { - if errno == EINTR { continue } - return - } - if count == 0 { return } - pending.append(buffer, count: count) - - while let newlineRange = pending.firstRange(of: Data([0x0A])) { - let lineData = pending.subdata(in: 0.. \(shellQuotedForTest(recordURL.path)) + """ + ) + let fakeCmuxURL = binDir.appendingPathComponent("cmux", isDirectory: false) + try writeExecutable( + fakeCmuxURL, + """ + #!/usr/bin/env bash + if [[ "${1:-}" == "--socket" && "${3:-}" == "ping" ]]; then + exit 0 + fi + exit 1 + """ + ) + + let utilityCases = [ + ["--append-system-prompt-file", "/tmp/prompt file", "--teammate-mode", "auto", "gateway"], + ["--system-prompt-file", "/tmp/system prompt", "project"], + ["--debug", "api", "gateway"], + ["--prompt-suggestions", "false", "gateway"], + ["--prompt-suggestions", "true", "ultrareview"], + ["--print", "--help"], + ] + let sessionCases = [ + ["--append-system-prompt-file", "/tmp/prompt file", "--teammate-mode", "auto", "--resume", "session-1"], + ["--from-pr", "--model", "sonnet"], + ["--prompt-suggestions", "true", "--resume", "session-2"], + ["--prompt-suggestions", "--resume", "session-3"], + ["--remote-control", "--model", "sonnet"], + ["--resume", "--model", "sonnet"], + ["--tmux"], + ["--tmux", "--model", "sonnet"], + ["--tmux=classic", "--model", "sonnet"], + ["--worktree", "--model", "sonnet"], + ] + + for (index, arguments) in (utilityCases + sessionCases).enumerated() { + try? fileManager.removeItem(at: recordURL) + let process = Process() + process.executableURL = wrapperURL + process.arguments = arguments + process.environment = [ + "PATH": "\(binDir.path):/usr/bin:/bin", + "HOME": homeDir.path, + "TMPDIR": sandbox.path, + "CMUX_SURFACE_ID": UUID().uuidString, + "CMUX_SOCKET_PATH": socketURL.path, + "CMUX_BUNDLED_CLI_PATH": fakeCmuxURL.path, + ] + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try runWithBoundedWait(process, shellDescription: "cmux-claude-wrapper current widths") + + let recorded = try String(contentsOf: recordURL, encoding: .utf8) + if index < utilityCases.count { + #expect(!recorded.contains("--settings"), Comment(rawValue: "\(arguments): \(recorded)")) + #expect(!recorded.contains("--session-id"), Comment(rawValue: "\(arguments): \(recorded)")) + } else { + #expect(recorded.contains("--settings"), Comment(rawValue: "\(arguments): \(recorded)")) + } + for argument in arguments { + #expect(recorded.contains(argument), Comment(rawValue: "\(arguments): \(recorded)")) + } + } + } + @Test func bundledClaudeWrapperScrubsSessionIdentityAndPreservesTrustBypassOnResume() throws { let fileManager = FileManager.default let repoRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() diff --git a/cmuxTests/ClosedPanelAgentHistoryRegressionTests.swift b/cmuxTests/ClosedPanelAgentHistoryRegressionTests.swift new file mode 100644 index 000000000000..59de51530a38 --- /dev/null +++ b/cmuxTests/ClosedPanelAgentHistoryRegressionTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension CMUXCLIErrorOutputRegressionTests { + @MainActor + @Test func completedRootExitCannotBeReattachedByColdIndexEnrichment() { + #expect(!Workspace.closedPanelAgentEnrichmentAllowed(resumeState: .completedAgentExit)) + #expect(Workspace.closedPanelAgentEnrichmentAllowed(resumeState: nil)) + #expect(Workspace.closedPanelAgentEnrichmentAllowed(resumeState: .manualResumeAvailable)) + } + + @MainActor + @Test func coldAgentIndexCanEnrichAnAlreadyClosedPanelWithoutBlockingClose() throws { + let panelId = UUID() + let panelData = try JSONSerialization.data(withJSONObject: [ + "id": panelId.uuidString, + "type": "terminal", + "isPinned": false, + "isManuallyUnread": false, + "listeningPorts": [], + "terminal": ["workingDirectory": "/tmp/project"], + ]) + let panel = try JSONDecoder().decode(SessionPanelSnapshot.self, from: panelData) + let store = ClosedItemHistoryStore(capacity: 10) + let recordId = store.push(.panel(ClosedPanelHistoryEntry( + workspaceId: UUID(), paneId: UUID(), tabIndex: 0, snapshot: panel + ))) + let agent = SessionRestorableAgentSnapshot( + kind: .codex, sessionId: "codex-session", workingDirectory: "/tmp/project", launchCommand: nil + ) + + #expect(store.enrichClosedPanelAgent(recordId: recordId, agent: agent)) + + var restored: ClosedItemHistoryEntry? + #expect(!store.restoreFirstRestorable { entry in + restored = entry + return false + }) + guard case .panel(let entry) = try #require(restored) else { + Issue.record("Expected a panel history entry") + return + } + #expect(entry.snapshot.terminal?.agent?.sessionId == "codex-session") + } + + @MainActor + @Test func coldAgentIndexCanEnrichAnAlreadyClosedWorkspaceWithoutBlockingClose() throws { + let workspaceId = UUID() + let panelId = UUID() + let panelData = try JSONSerialization.data(withJSONObject: [ + "id": panelId.uuidString, + "type": "terminal", + "isPinned": false, + "isManuallyUnread": false, + "listeningPorts": [], + "terminal": ["workingDirectory": "/tmp/project"], + ]) + let panel = try JSONDecoder().decode(SessionPanelSnapshot.self, from: panelData) + let snapshot = SessionWorkspaceSnapshot( + workspaceId: workspaceId, + processTitle: "Agent workspace", + customTitle: nil, + customDescription: nil, + customColor: nil, + isPinned: false, + terminalScrollBarHidden: nil, + currentDirectory: "/tmp/project", + focusedPanelId: panelId, + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [panelId], selectedPanelId: panelId)), + panels: [panel], + statusEntries: [], + logEntries: [], + progress: nil, + gitBranch: nil, + remote: nil + ) + let store = ClosedItemHistoryStore(capacity: 10) + let recordId = store.push(.workspace(ClosedWorkspaceHistoryEntry( + workspaceId: workspaceId, windowId: nil, workspaceIndex: 0, snapshot: snapshot + ))) + let agent = SessionRestorableAgentSnapshot( + kind: .codex, sessionId: "workspace-codex", workingDirectory: "/tmp/project", launchCommand: nil + ) + + #expect(store.enrichClosedWorkspaceAgents(recordId: recordId, agentsByPanelId: [panelId: agent])) + + var restored: ClosedItemHistoryEntry? + #expect(!store.restoreFirstRestorable { entry in restored = entry; return false }) + guard case .workspace(let entry) = try #require(restored) else { + Issue.record("Expected a workspace history entry") + return + } + #expect(entry.snapshot.panels.first?.terminal?.agent?.sessionId == "workspace-codex") + } +} diff --git a/cmuxTests/CodexHookWriterOwnershipRegressionTests.swift b/cmuxTests/CodexHookWriterOwnershipRegressionTests.swift new file mode 100644 index 000000000000..1d0a446adf00 --- /dev/null +++ b/cmuxTests/CodexHookWriterOwnershipRegressionTests.swift @@ -0,0 +1,649 @@ +import Darwin +import Foundation +import Testing + +@Suite +struct CodexHookWriterOwnershipRegressionTests { + struct WrapperClassificationCase: Sendable, CustomTestStringConvertible { + let arguments: [String] + let expectsHookInstallation: Bool + + var testDescription: String { + arguments.joined(separator: " ") + } + } + + struct WrapperReplayCase: Sendable, CustomTestStringConvertible { + let command: String + let expectsSyntheticSessionStart: Bool + + var testDescription: String { command } + } + + enum InvalidCustomCodexCase: String, CaseIterable, Sendable, CustomTestStringConvertible { + case stale + case nonExecutable + + var testDescription: String { rawValue } + } + + static let wrapperClassificationCases: [WrapperClassificationCase] = [ + WrapperClassificationCase( + arguments: ["--remote-auth-token-env", "CODEX_TOKEN", "plugin", "list"], + expectsHookInstallation: false + ), + WrapperClassificationCase( + arguments: ["--local-provider", "ollama", "plugin", "list"], + expectsHookInstallation: false + ), + WrapperClassificationCase( + arguments: ["--add-dir", "/tmp/source tree", "plugin", "list"], + expectsHookInstallation: false + ), + WrapperClassificationCase( + arguments: ["--image=/tmp/a.png", "plugin", "list"], + expectsHookInstallation: false + ), + WrapperClassificationCase(arguments: ["initial prompt", "--help"], expectsHookInstallation: false), + WrapperClassificationCase( + arguments: ["-i", "/tmp/a.png", "/tmp/b.png", "--help"], + expectsHookInstallation: false + ), + WrapperClassificationCase( + arguments: ["resume", "019dad34-d218-7943-b81a-eddac5c87951", "--version"], + expectsHookInstallation: false + ), + WrapperClassificationCase( + arguments: ["--add-dir", "/tmp/source tree", "fork", "019dad34-d218-7943-b81a-eddac5c87951"], + expectsHookInstallation: true + ), + WrapperClassificationCase( + arguments: ["--local-provider", "ollama", "resume", "019dad34-d218-7943-b81a-eddac5c87951"], + expectsHookInstallation: true + ), + WrapperClassificationCase( + arguments: ["--remote-auth-token-env", "CODEX_TOKEN", "exec", "echo", "ok"], + expectsHookInstallation: true + ), + WrapperClassificationCase( + arguments: ["--image=/tmp/a.png", "resume", "019dad34-d218-7943-b81a-eddac5c87951"], + expectsHookInstallation: true + ), + // Codex 0.144.3 parses --image/-i as ..., so every following + // bare token belongs to the root invocation. `plugin` and `exec` + // here are image values, not subcommands, and the wrapper must keep + // treating the resulting root launch as a session. + WrapperClassificationCase( + arguments: ["--image", "/tmp/a.png", "plugin", "list"], + expectsHookInstallation: true + ), + WrapperClassificationCase( + arguments: ["-i", "/tmp/a.png", "plugin", "list"], + expectsHookInstallation: true + ), + WrapperClassificationCase( + arguments: ["--image", "/tmp/a.png", "/tmp/b.png", "plugin", "list"], + expectsHookInstallation: true + ), + WrapperClassificationCase( + arguments: ["--image", "/tmp/a.png", "/tmp/b.png", "exec", "echo", "ok"], + expectsHookInstallation: true + ), + ] + + static let wrapperReplayCases: [WrapperReplayCase] = [ + WrapperReplayCase(command: "resume", expectsSyntheticSessionStart: true), + WrapperReplayCase(command: "fork", expectsSyntheticSessionStart: false), + ] + + @Test(arguments: wrapperClassificationCases) + func `Wrapper classifies current global option widths before utilities and sessions`( + classification: WrapperClassificationCase + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-wrapper-widths-\(UUID().uuidString)", isDirectory: true) + let fakeCLI = root.appendingPathComponent("cmux", isDirectory: false) + let fakeCodex = root.appendingPathComponent("codex-real", isDirectory: false) + let socketPath = makeCodexHookSocketPath("widths") + let listenerFD = try bindCodexHookUnixSocket(at: socketPath) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + try makeCodexHookExecutableShellFile(at: fakeCLI, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" >> \"$TEST_CLI_CAPTURE\"", + "exit 0", + ]) + try makeCodexHookExecutableShellFile(at: fakeCodex, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" > \"$TEST_CODEX_CAPTURE\"", + ]) + let wrapper = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/bin/cmux-codex-wrapper", isDirectory: false) + let cliCapture = root.appendingPathComponent("cli.txt", isDirectory: false) + let codexCapture = root.appendingPathComponent("codex.txt", isDirectory: false) + let result = runCodexHookProcess( + executablePath: wrapper.path, + arguments: classification.arguments, + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SURFACE_ID": "surface-widths", + "CMUX_SOCKET_PATH": socketPath, + "CMUX_BUNDLED_CLI_PATH": fakeCLI.path, + "CMUX_CUSTOM_CODEX_PATH": fakeCodex.path, + "TEST_CLI_CAPTURE": cliCapture.path, + "TEST_CODEX_CAPTURE": codexCapture.path, + ], + timeout: 3 + ) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect( + try String(contentsOf: codexCapture, encoding: .utf8) + .trimmingCharacters(in: .newlines) == classification.arguments.joined(separator: " ") + ) + if classification.expectsHookInstallation { + #expect( + waitForFile(cliCapture, containing: "hooks codex install --yes", timeout: 1), + "\(classification.arguments)" + ) + } else { + let cliInvocations = (try? String(contentsOf: cliCapture, encoding: .utf8)) ?? "" + #expect( + !cliInvocations.contains("hooks codex install --yes"), + "\(classification.arguments): \(cliInvocations)" + ) + } + } + + @Test(arguments: wrapperReplayCases) + func `Wrapper routes resume and fork through custom Codex with hook ownership`( + replay: WrapperReplayCase + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux codex custom path \(UUID().uuidString)", isDirectory: true) + let fakeCLI = root.appendingPathComponent("cmux test cli", isDirectory: false) + let fakeCodex = root.appendingPathComponent("Custom Codex Builds/codex", isDirectory: false) + let cliCapture = root.appendingPathComponent("cli-invocations.txt", isDirectory: false) + let socketPath = makeCodexHookSocketPath("replay") + let listenerFD = try bindCodexHookUnixSocket(at: socketPath) + try FileManager.default.createDirectory( + at: fakeCodex.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + try makeCodexHookExecutableShellFile(at: fakeCLI, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" >> \"$TEST_CLI_CAPTURE\"", + "exit 0", + ]) + try makeCodexHookExecutableShellFile(at: fakeCodex, lines: [ + "#!/bin/sh", + "printf 'owner=%s\\nlaunch=%s\\nargs=%s\\n' \"${CMUX_CODEX_WRAPPER_HOOK_OWNER:-unset}\" \"${CMUX_AGENT_LAUNCH_EXECUTABLE:-unset}\" \"$*\" > \"$TEST_CAPTURE\"", + ]) + + let wrapper = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/bin/cmux-codex-wrapper", isDirectory: false) + let sessionID = "019dad34-d218-7943-b81a-eddac5c87951" + let capture = root.appendingPathComponent("\(replay.command)-capture.txt", isDirectory: false) + let result = runCodexHookProcess( + executablePath: wrapper.path, + arguments: [replay.command, sessionID, "--model", "gpt-5.4"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SURFACE_ID": "surface-replay", + "CMUX_SOCKET_PATH": socketPath, + "CMUX_BUNDLED_CLI_PATH": fakeCLI.path, + "CMUX_CUSTOM_CODEX_PATH": fakeCodex.path, + "TEST_CAPTURE": capture.path, + "TEST_CLI_CAPTURE": cliCapture.path, + ], + timeout: 3 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let captured = try String(contentsOf: capture, encoding: .utf8) + #expect(captured.contains("owner=1"), "\(replay.command): \(captured)") + #expect(captured.contains("launch=\(fakeCodex.path)"), "\(replay.command): \(captured)") + #expect( + captured.contains("args=\(replay.command) \(sessionID) --model gpt-5.4"), + "\(replay.command): \(captured)" + ) + #expect(waitForFile(cliCapture, containing: "hooks codex install --yes", timeout: 1)) + if replay.expectsSyntheticSessionStart { + #expect(waitForFile(cliCapture, containing: "hooks codex session-start", timeout: 1)) + } else { + let cliInvocations = try String(contentsOf: cliCapture, encoding: .utf8) + #expect(!cliInvocations.contains("hooks codex session-start"), Comment(rawValue: cliInvocations)) + } + } + + @Test(arguments: InvalidCustomCodexCase.allCases) + func `Wrapper falls back safely from invalid custom Codex paths`( + invalidCase: InvalidCustomCodexCase + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-custom-fallback-\(UUID().uuidString)", isDirectory: true) + let bin = root.appendingPathComponent("bin", isDirectory: true) + let fallbackCodex = bin.appendingPathComponent("codex", isDirectory: false) + let nonExecutableCodex = root.appendingPathComponent("not executable/codex", isDirectory: false) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: nonExecutableCodex.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + + try makeCodexHookExecutableShellFile(at: fallbackCodex, lines: [ + "#!/bin/sh", + "printf 'fallback=%s\\nargs=%s\\n' \"$0\" \"$*\" > \"$TEST_CAPTURE\"", + ]) + try "#!/bin/sh\nexit 99\n".write( + to: nonExecutableCodex, + atomically: true, + encoding: .utf8 + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: nonExecutableCodex.path + ) + + let wrapper = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/bin/cmux-codex-wrapper", isDirectory: false) + let customPath = switch invalidCase { + case .stale: + root.appendingPathComponent("moved Codex/codex", isDirectory: false).path + case .nonExecutable: + nonExecutableCodex.path + } + let capture = root.appendingPathComponent("fallback.txt", isDirectory: false) + let result = runCodexHookProcess( + executablePath: wrapper.path, + arguments: ["review", "--help"], + environment: [ + "HOME": root.path, + "PATH": "\(bin.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_CUSTOM_CODEX_PATH": customPath, + "TEST_CAPTURE": capture.path, + ], + timeout: 3 + ) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let captured = try String(contentsOf: capture, encoding: .utf8) + #expect(captured.contains("fallback=\(fallbackCodex.path)"), Comment(rawValue: captured)) + #expect(captured.contains("args=review --help"), Comment(rawValue: captured)) + } + + @Test + func `Explicit disable wins over inherited wrapper ownership`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-hook-opt-out-\(UUID().uuidString)", isDirectory: true) + let fakeCLI = root.appendingPathComponent("cmux", isDirectory: false) + let fakeCodex = root.appendingPathComponent("codex-real", isDirectory: false) + let capturedEnvironment = root.appendingPathComponent("codex-environment.txt", isDirectory: false) + let capturedCLIInvocations = root.appendingPathComponent("cmux-invocations.txt", isDirectory: false) + let socketPath = makeCodexHookSocketPath("opt-out") + let listenerFD = try bindCodexHookUnixSocket(at: socketPath) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + try makeCodexHookExecutableShellFile(at: fakeCLI, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" >> \"$TEST_CLI_CAPTURE\"", + ]) + try makeCodexHookExecutableShellFile(at: fakeCodex, lines: [ + "#!/bin/sh", + "printf 'disabled=%s\\nowner=%s\\nargs=%s\\n' \"${CMUX_CODEX_HOOKS_DISABLED:-unset}\" \"${CMUX_CODEX_WRAPPER_HOOK_OWNER:-unset}\" \"$*\" > \"$TEST_CAPTURE\"", + ]) + + let wrapper = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/bin/cmux-codex-wrapper", isDirectory: false) + let result = runCodexHookProcess( + executablePath: wrapper.path, + arguments: [], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SURFACE_ID": "surface-opt-out", + "CMUX_SOCKET_PATH": socketPath, + "CMUX_BUNDLED_CLI_PATH": fakeCLI.path, + "CMUX_CUSTOM_CODEX_PATH": fakeCodex.path, + "CMUX_CODEX_HOOKS_DISABLED": "1", + "CMUX_CODEX_WRAPPER_HOOK_OWNER": "1", + "TEST_CAPTURE": capturedEnvironment.path, + "TEST_CLI_CAPTURE": capturedCLIInvocations.path, + ], + timeout: 3 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let captured = try String(contentsOf: capturedEnvironment, encoding: .utf8) + #expect(captured.contains("disabled=unset")) + #expect(captured.contains("owner=unset")) + #expect(captured.contains("args=\n")) + #expect(!FileManager.default.fileExists(atPath: capturedCLIInvocations.path)) + } + + @Test + func `Wrapper prefers trusted persistent hooks over untrusted session flags`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-hook-owner-\(UUID().uuidString)", isDirectory: true) + let fakeCLI = root.appendingPathComponent("cmux", isDirectory: false) + let fakeCodex = root.appendingPathComponent("codex-real", isDirectory: false) + let capturedEnvironment = root.appendingPathComponent("codex-environment.txt", isDirectory: false) + let capturedCLIInvocations = root.appendingPathComponent("cmux-invocations.txt", isDirectory: false) + let socketPath = makeCodexHookSocketPath("owner") + let listenerFD = try bindCodexHookUnixSocket(at: socketPath) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + try makeCodexHookExecutableShellFile(at: fakeCLI, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" >> \"$CMUX_TEST_CLI_CAPTURE\"", + "case \" $* \" in", + " *\" hooks codex install --yes \"*) exit 0 ;;", + " *\" hooks codex inject-args \"*) printf '%s\\0' --yolo ;;", + "esac", + "exit 0", + ]) + try makeCodexHookExecutableShellFile(at: fakeCodex, lines: [ + "#!/bin/sh", + "printf 'disabled=%s\\nowner=%s\\nargs=%s\\n' \"${CMUX_CODEX_HOOKS_DISABLED:-unset}\" \"${CMUX_CODEX_WRAPPER_HOOK_OWNER:-unset}\" \"$*\" > \"$CMUX_TEST_CAPTURE\"", + ]) + + let wrapper = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/bin/cmux-codex-wrapper", isDirectory: false) + let result = runCodexHookProcess( + executablePath: wrapper.path, + arguments: [], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SURFACE_ID": "surface-owner", + "CMUX_SOCKET_PATH": socketPath, + "CMUX_BUNDLED_CLI_PATH": fakeCLI.path, + "CMUX_CUSTOM_CODEX_PATH": fakeCodex.path, + "CMUX_TEST_CAPTURE": capturedEnvironment.path, + "CMUX_TEST_CLI_CAPTURE": capturedCLIInvocations.path, + ], + timeout: 3 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let captured = try String(contentsOf: capturedEnvironment, encoding: .utf8) + #expect(captured.contains("disabled=0")) + #expect(captured.contains("owner=1")) + #expect(captured.contains("args=\n")) + let invocations = try String(contentsOf: capturedCLIInvocations, encoding: .utf8) + #expect(invocations.contains("hooks codex install --yes")) + #expect(!invocations.contains("hooks codex inject-args")) + } + + @Test + func `Injected hooks require wrapper ownership while persistent hooks respect disable`() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-injected-hooks-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let result = runCodexInjectArgsProcess(executablePath: cliPath, homeDirectory: root.path) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + + let arguments = result.stdout.split(separator: 0).map { String(decoding: $0, as: UTF8.self) } + let hookConfigurations = arguments.filter { $0.hasPrefix("hooks.") } + try #require(!hookConfigurations.isEmpty) + for configuration in hookConfigurations { + let marker = "command='''" + let commandStart = try #require(configuration.range(of: marker)?.upperBound) + let commandEnd = try #require( + configuration.range(of: "'''", range: commandStart.. [InstalledHookEntry] { + var environment = codexHookTestEnvironment(root: root, codexHome: codexHome) + environment["CMUX_BUNDLED_CLI_PATH"] = cli.path + environment["CMUX_SOCKET_PATH"] = socket + let result = runCodexHookProcess( + executablePath: cliPath, + arguments: ["hooks", "codex", "install", "--yes"], + environment: environment, + timeout: 10 + ) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + return try codexHookEntries(in: codexHome) + } + + let firstHooks = try install(cli: firstCLI, socket: "/tmp/cmux-debug-first.sock") + let secondHooks = try install(cli: secondCLI, socket: "/tmp/cmux-debug-second.sock") + #expect(firstHooks.map(\.command).sorted() == secondHooks.map(\.command).sorted()) + for hook in secondHooks where hook.body.contains("hooks codex") { + #expect(hook.body.contains("CMUX_CODEX_HOOK_CMUX_BIN")) + #expect(hook.body.contains("CMUX_SOCKET_PATH")) + #expect(!hook.body.contains(firstCLI.path)) + #expect(!hook.body.contains(secondCLI.path)) + #expect(!hook.body.contains("/tmp/cmux-debug-first.sock")) + #expect(!hook.body.contains("/tmp/cmux-debug-second.sock")) + } + } + + @Test + func `Hook scripts are immutable across concurrent cmux versions`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-hook-content-address-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let result = runCodexInjectArgsProcess( + executablePath: try bundledCLIPath(), + homeDirectory: root.path + ) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let arguments = result.stdout.split(separator: 0).map { String(decoding: $0, as: UTF8.self) } + let sessionStart = try #require(arguments.first { $0.hasPrefix("hooks.SessionStart=") }) + let marker = "command='''" + let commandStart = try #require(sessionStart.range(of: marker)?.upperBound) + let commandEnd = try #require(sessionStart.range( + of: "'''", + range: commandStart.. (status: Int32, stdout: Data, stderr: String) { + let process = Process() + let stdout = Pipe() + let stderr = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = ["hooks", "codex", "inject-args"] + process.environment = [ + "HOME": homeDirectory, + "CFFIXED_USER_HOME": homeDirectory, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + process.standardOutput = stdout + process.standardError = stderr + do { + try process.run() + process.waitUntilExit() + } catch { + return (-1, Data(), String(describing: error)) + } + return ( + process.terminationStatus, + stdout.fileHandleForReading.readDataToEndOfFile(), + String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + ) + } + + private func bundledCLIPath() throws -> String { + try BundledCLITestSupport.bundledCLIPath(for: BundledCLILinkageTests.self) + } +} diff --git a/cmuxTests/CodexWrapperResumeRegressionTests.swift b/cmuxTests/CodexWrapperResumeRegressionTests.swift new file mode 100644 index 000000000000..f49fceecd56c --- /dev/null +++ b/cmuxTests/CodexWrapperResumeRegressionTests.swift @@ -0,0 +1,237 @@ +import Darwin +import Foundation +import Testing + +@Suite +struct CodexWrapperResumeRegressionTests { + struct ResumeInvocationCase: Sendable, CustomTestStringConvertible { + let arguments: [String] + let expectedSessionID: String? + + var testDescription: String { + arguments.joined(separator: " ") + } + } + + static let resumeInvocationCases: [ResumeInvocationCase] = { + let optionUUID = "019dad34-d218-7943-b81a-eddac5c87951" + let directoryUUID = "019dad34-d218-7943-b81a-eddac5c87952" + let sessionID = "019dad34-d218-7943-b81a-eddac5c87953" + let rejectedRootBooleanOptions = [ + "--strict-config", + "--oss", + "--yolo", + "--dangerously-bypass-approvals-and-sandbox", + "--dangerously-bypass-hook-trust", + "--search", + "--no-alt-screen", + ] + let rejectedResumeBooleanOptions = [ + "--all", + "--include-non-interactive", + "--last", + ] + let rejectedBooleanAssignments = rejectedRootBooleanOptions.map { + ResumeInvocationCase( + arguments: ["\($0)=false", "resume", sessionID], + expectedSessionID: nil + ) + } + rejectedResumeBooleanOptions.map { + ResumeInvocationCase( + arguments: ["resume", "\($0)=false", sessionID], + expectedSessionID: nil + ) + } + return [ + ResumeInvocationCase( + arguments: ["resume", "-c", "feature=\(optionUUID)", "--add-dir", directoryUUID, sessionID], + expectedSessionID: sessionID + ), + ResumeInvocationCase( + arguments: ["--model=gpt-5.4", "-c=feature=\(optionUUID)", "resume", sessionID], + expectedSessionID: sessionID + ), + ResumeInvocationCase( + arguments: ["-mgpt-5.4", "-cfeature=true", "resume", sessionID], + expectedSessionID: sessionID + ), + ResumeInvocationCase( + arguments: ["resume", "--model=gpt-5.4", "--add-dir=\(directoryUUID)", sessionID], + expectedSessionID: sessionID + ), + ResumeInvocationCase( + arguments: ["resume", "-mgpt-5.4", "-cfeature=true", "-i/tmp/a.png", sessionID], + expectedSessionID: sessionID + ), + ResumeInvocationCase(arguments: ["resume", "--yolo", sessionID], expectedSessionID: sessionID), + ResumeInvocationCase( + arguments: ["resume", "-i", "/tmp/a.png", optionUUID, "--model", "gpt-5.4", sessionID], + expectedSessionID: sessionID + ), + ResumeInvocationCase( + arguments: ["resume", "--image=/tmp/a.png", sessionID], + expectedSessionID: sessionID + ), + ResumeInvocationCase( + arguments: ["resume", sessionID, "--image", "/tmp/a.png"], + expectedSessionID: sessionID + ), + ResumeInvocationCase(arguments: ["resume", sessionID, "--image"], expectedSessionID: nil), + ResumeInvocationCase(arguments: ["resume", sessionID, "--model"], expectedSessionID: nil), + ResumeInvocationCase(arguments: ["--all", "resume", sessionID], expectedSessionID: nil), + ResumeInvocationCase(arguments: ["--last", "resume", sessionID], expectedSessionID: nil), + // Unknown options still reach Codex unchanged, but the wrapper + // cannot infer their width and therefore must not synthesize a rebind. + ResumeInvocationCase( + arguments: ["--future-mode=fast", "resume", sessionID], + expectedSessionID: nil + ), + ResumeInvocationCase( + arguments: ["resume", "--future-mode=fast", sessionID], + expectedSessionID: nil + ), + ] + rejectedBooleanAssignments + [ + ResumeInvocationCase(arguments: ["resume", "--", sessionID], expectedSessionID: sessionID), + ResumeInvocationCase(arguments: ["resume", "--last"], expectedSessionID: nil), + ResumeInvocationCase(arguments: ["resume", "--all"], expectedSessionID: nil), + ResumeInvocationCase(arguments: ["resume", "named-session", sessionID], expectedSessionID: nil), + ] + }() + + @Test(arguments: resumeInvocationCases) + func `Resume SessionStart parses selectors and uses environment for complex cwd`( + invocation: ResumeInvocationCase + ) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-resume-parser-\(UUID().uuidString)", isDirectory: true) + let project = root.appendingPathComponent("quote\" slash\\ newline\nproject", isDirectory: true) + let fakeCLI = root.appendingPathComponent("cmux", isDirectory: false) + let fakeCodex = root.appendingPathComponent("codex-real", isDirectory: false) + let payload = root.appendingPathComponent("payload.json", isDirectory: false) + let cwdCapture = root.appendingPathComponent("cwd.txt", isDirectory: false) + let cliCapture = root.appendingPathComponent("cli.txt", isDirectory: false) + let codexCapture = root.appendingPathComponent("codex.txt", isDirectory: false) + let socketPath = makeCodexHookSocketPath("resume") + let listenerFD = try bindCodexHookUnixSocket(at: socketPath) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + try makeCodexHookExecutableShellFile(at: fakeCLI, lines: [ + "#!/bin/sh", + "case \" $* \" in", + " *\" hooks codex session-start \"*)", + " printf '%s' \"${CMUX_AGENT_LAUNCH_CWD:-${PWD:-}}\" > \"$TEST_SESSION_CWD\"", + " cat > \"$TEST_SESSION_PAYLOAD\"", + " ;;", + "esac", + "printf '%s\\n' \"$*\" >> \"$TEST_CLI_CAPTURE\"", + "exit 0", + ]) + try makeCodexHookExecutableShellFile(at: fakeCodex, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" > \"$TEST_CODEX_CAPTURE\"", + ]) + + let wrapper = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/bin/cmux-codex-wrapper", isDirectory: false) + let result = runCodexHookProcess( + executablePath: wrapper.path, + arguments: invocation.arguments, + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SURFACE_ID": "surface-resume-parser", + "CMUX_SOCKET_PATH": socketPath, + "CMUX_BUNDLED_CLI_PATH": fakeCLI.path, + "CMUX_CUSTOM_CODEX_PATH": fakeCodex.path, + "TEST_SESSION_PAYLOAD": payload.path, + "TEST_SESSION_CWD": cwdCapture.path, + "TEST_CLI_CAPTURE": cliCapture.path, + "TEST_CODEX_CAPTURE": codexCapture.path, + ], + currentDirectoryURL: project, + timeout: 3 + ) + #expect(!result.timedOut, "\(invocation.arguments): \(result.stderr)") + #expect(result.status == 0, "\(invocation.arguments): \(result.stderr)") + #expect( + try String(contentsOf: codexCapture, encoding: .utf8) + .trimmingCharacters(in: .newlines) == invocation.arguments.joined(separator: " ") + ) + + if let expectedSessionID = invocation.expectedSessionID { + #expect(waitForFile(payload, containing: expectedSessionID, timeout: 1)) + let payloadObject = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: payload)) as? [String: Any] + ) + #expect(payloadObject["session_id"] as? String == expectedSessionID) + #expect(payloadObject["cwd"] == nil, "cwd is inherited from the wrapper environment") + #expect(waitForFile(cwdCapture, containing: project.path, timeout: 1)) + #expect(try String(contentsOf: cwdCapture, encoding: .utf8) == project.path) + } else { + #expect(!FileManager.default.fileExists(atPath: payload.path), "\(invocation.arguments)") + let cliInvocations = (try? String(contentsOf: cliCapture, encoding: .utf8)) ?? "" + #expect( + !cliInvocations.contains("hooks codex session-start"), + "\(invocation.arguments): \(cliInvocations)" + ) + } + } + + @Test + func `Fork relies on installed persistent SessionStart without rebinding parent`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-fork-session-start-\(UUID().uuidString)", isDirectory: true) + let fakeCLI = root.appendingPathComponent("cmux", isDirectory: false) + let fakeCodex = root.appendingPathComponent("codex-real", isDirectory: false) + let cliCapture = root.appendingPathComponent("cli.txt", isDirectory: false) + let syntheticPayload = root.appendingPathComponent("synthetic.json", isDirectory: false) + let socketPath = makeCodexHookSocketPath("fork") + let listenerFD = try bindCodexHookUnixSocket(at: socketPath) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + try makeCodexHookExecutableShellFile(at: fakeCLI, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" >> \"$TEST_CLI_CAPTURE\"", + "case \" $* \" in", + " *\" hooks codex session-start \"*) cat > \"$TEST_SESSION_PAYLOAD\" ;;", + "esac", + "exit 0", + ]) + try makeCodexHookExecutableShellFile(at: fakeCodex, lines: ["#!/bin/sh", "exit 0"]) + let wrapper = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/bin/cmux-codex-wrapper", isDirectory: false) + let result = runCodexHookProcess( + executablePath: wrapper.path, + arguments: ["fork", "019dad34-d218-7943-b81a-eddac5c87951"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SURFACE_ID": "surface-fork", + "CMUX_SOCKET_PATH": socketPath, + "CMUX_BUNDLED_CLI_PATH": fakeCLI.path, + "CMUX_CUSTOM_CODEX_PATH": fakeCodex.path, + "TEST_CLI_CAPTURE": cliCapture.path, + "TEST_SESSION_PAYLOAD": syntheticPayload.path, + ], + timeout: 3 + ) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(waitForFile(cliCapture, containing: "hooks codex install --yes", timeout: 1)) + #expect(!FileManager.default.fileExists(atPath: syntheticPayload.path)) + } +} diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index c18dd8744133..be3b1fb096fb 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import CMUXAgentLaunch +import CmuxFoundation #if canImport(cmux_DEV) @testable import cmux_DEV @@ -10,6 +11,264 @@ import CMUXAgentLaunch @Suite("Feed coordinator", .serialized) struct FeedCoordinatorTests { + @Test func feedJumpResolverFindsCanonicalSessionBeyondLegacyProjection() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-feed-canonical-\(UUID().uuidString)", isDirectory: true) + let stateDirectory = home.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + + let targetSessionID = "session-older-than-projection" + let targetWorkspaceID = UUID().uuidString + let targetSurfaceID = UUID().uuidString + let registry = CmuxAgentSessionRegistry( + url: stateDirectory.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + var records = try (0..<300).map { index in + try canonicalRecord( + provider: "codex", + sessionID: String(format: "recent-%03d", index), + workspaceID: UUID().uuidString, + surfaceID: UUID().uuidString, + updatedAt: TimeInterval(1_000 + index) + ) + } + records.append(try canonicalRecord( + provider: "codex", + sessionID: targetSessionID, + workspaceID: targetWorkspaceID, + surfaceID: targetSurfaceID, + updatedAt: 1 + )) + try registry.apply(provider: "codex", records: records) + try writeLegacyProjection( + records: Array(records.prefix(256)), + to: stateDirectory.appendingPathComponent("codex-hook-sessions.json") + ) + + let target = FeedJumpResolver.lookup( + agent: "codex", + sessionId: targetSessionID, + homeDirectory: home, + environment: [:] + ) + #expect(target?.workspaceId == targetWorkspaceID) + #expect(target?.surfaceId == targetSurfaceID) + } + + @Test func feedJumpResolverRefreshesChangedGenerationZeroLegacyRecord() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-feed-legacy-refresh-\(UUID().uuidString)", isDirectory: true) + let stateDirectory = home.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + + let sessionID = "legacy-session" + let firstWorkspaceID = UUID().uuidString + let secondWorkspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let legacyURL = stateDirectory.appendingPathComponent("codex-hook-sessions.json") + func writeLegacy(workspaceID: String, updatedAt: TimeInterval, padding: String) throws { + let data = try JSONSerialization.data(withJSONObject: [ + "version": 1, + "sessions": [ + sessionID: [ + "sessionId": sessionID, + "workspaceId": workspaceID, + "surfaceId": surfaceID, + "updatedAt": updatedAt, + "padding": padding, + ], + ], + ], options: [.sortedKeys]) + try data.write(to: legacyURL, options: .atomic) + } + + try writeLegacy(workspaceID: firstWorkspaceID, updatedAt: 1, padding: "first") + #expect( + FeedJumpResolver.lookup( + agent: "codex", + sessionId: sessionID, + homeDirectory: home, + environment: [:] + )?.workspaceId == firstWorkspaceID + ) + + try writeLegacy( + workspaceID: secondWorkspaceID, + updatedAt: 2, + padding: String(repeating: "changed", count: 4) + ) + #expect( + FeedJumpResolver.lookup( + agent: "codex", + sessionId: sessionID, + homeDirectory: home, + environment: [:] + )?.workspaceId == secondWorkspaceID + ) + } + + @Test func feedJumpResolverDropsRemovedGenerationZeroLegacyRecord() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-feed-legacy-delete-\(UUID().uuidString)", isDirectory: true) + let stateDirectory = home.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + + let sessionID = "removed-legacy-session" + let legacyURL = stateDirectory.appendingPathComponent("codex-hook-sessions.json") + let initial = try JSONSerialization.data(withJSONObject: [ + "version": 1, + "sessions": [ + sessionID: [ + "sessionId": sessionID, + "workspaceId": UUID().uuidString, + "surfaceId": UUID().uuidString, + "updatedAt": 1.0, + ], + ], + ], options: [.sortedKeys]) + try initial.write(to: legacyURL, options: .atomic) + #expect( + FeedJumpResolver.lookup( + agent: "codex", + sessionId: sessionID, + homeDirectory: home, + environment: [:] + ) != nil + ) + + let removed = try JSONSerialization.data(withJSONObject: [ + "version": 1, + "sessions": [:], + "padding": "force a distinct compatibility-file stamp", + ], options: [.sortedKeys]) + try removed.write(to: legacyURL, options: .atomic) + + #expect( + FeedJumpResolver.lookup( + agent: "codex", + sessionId: sessionID, + homeDirectory: home, + environment: [:] + ) == nil + ) + } + + @Test func feedJumpResolverRetainsBoundedFlatLegacyFallback() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-feed-flat-legacy-\(UUID().uuidString)", isDirectory: true) + let stateDirectory = home.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + + let sessionID = "flat-session" + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let data = try JSONSerialization.data(withJSONObject: [ + sessionID: [ + "workspaceId": workspaceID, + "surfaceId": surfaceID, + "updatedAt": 1.0, + ], + ], options: [.sortedKeys]) + try data.write( + to: stateDirectory.appendingPathComponent("codex-hook-sessions.json"), + options: .atomic + ) + + let target = FeedJumpResolver.lookup( + agent: "codex", + sessionId: sessionID, + homeDirectory: home, + environment: [:] + ) + #expect(target?.workspaceId == workspaceID) + #expect(target?.surfaceId == surfaceID) + } + + @Test func feedJumpResolverKeepsHyphenatedProviderIdentity() { + #expect( + FeedJumpResolver.parse( + "hermes-agent-session-123", + source: "hermes-agent" + )?.agent == "hermes-agent" + ) + #expect( + FeedJumpResolver.parse( + "hermes-agent-session-123", + source: "hermes-agent" + )?.sessionId == "session-123" + ) + + // Socket callers carry only the composite workstream id. They must + // still prefer the longest registered provider prefix rather than + // splitting `hermes-agent` at its first dash. + #expect( + FeedJumpResolver.parse("hermes-agent-session-123")?.agent == "hermes-agent" + ) + #expect( + FeedJumpResolver.parse("hermes-agent-session-123")?.sessionId == "session-123" + ) + } + + @Test func feedJumpResolverRejectsMismatchedExplicitProvider() { + #expect( + FeedJumpResolver.parse( + "codex-session-123", + source: "hermes-agent" + )?.agent == nil + ) + #expect(FeedJumpResolver.parse("hermes-agent-")?.agent == nil) + } + + private func canonicalRecord( + provider: String, + sessionID: String, + workspaceID: String, + surfaceID: String, + updatedAt: TimeInterval + ) throws -> CmuxAgentSessionRegistry.Record { + let json = try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "workspaceId": workspaceID, + "surfaceId": surfaceID, + "updatedAt": updatedAt, + ], options: [.sortedKeys]) + return CmuxAgentSessionRegistry.Record( + provider: provider, + sessionID: sessionID, + updatedAt: updatedAt, + json: json + ) + } + + private func writeLegacyProjection( + records: [CmuxAgentSessionRegistry.Record], + to url: URL + ) throws { + var sessions: [String: Any] = [:] + for record in records { + sessions[record.sessionID] = try JSONSerialization.jsonObject(with: record.json) + } + let data = try JSONSerialization.data(withJSONObject: [ + "version": 1, + "sessions": sessions, + ], options: [.sortedKeys]) + try data.write(to: url, options: .atomic) + } + + @Test func feedJumpResolverRejectsProviderPathTraversal() { + #expect(FeedJumpResolver.parse("../outside-session")?.agent == nil) + #expect( + FeedJumpResolver.parse( + "../outside-session", + source: "../outside" + )?.agent == nil + ) + } + @Test func codexTeamsResolvesExplicitWorkingDirectoryFlags() { let base = "/tmp/cmux-base" diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index 281bd887cfb7..90986c9de4be 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -186,4 +186,15 @@ struct FeedEventClassificationTests { #expect(classify("gemini", "PreToolUse", tool: "write").actionable == false) #expect(classify("gemini", "PreToolUse", tool: "execute_bash").actionable == false) } + + @Test func copilotNativeCamelCaseEventsClassifyCorrectly() { + #expect(classify("copilot", "preToolUse", tool: "Bash").name == "PermissionRequest") + #expect(classify("copilot", "preToolUse", tool: "Bash").actionable == true) + #expect(classify("copilot", "preToolUse", tool: "Read").name == "PreToolUse") + #expect(classify("copilot", "preToolUse", tool: "Read").actionable == false) + #expect(classify("copilot", "sessionStart").name == "SessionStart") + #expect(classify("copilot", "sessionEnd").name == "SessionEnd") + #expect(classify("copilot", "agentStop").name == "Stop") + #expect(classify("copilot", "notification").name == "Notification") + } } diff --git a/cmuxTests/ForkParentFallbackGeneralizationTests.swift b/cmuxTests/ForkParentFallbackGeneralizationTests.swift index a5118b8ed401..97514162855d 100644 --- a/cmuxTests/ForkParentFallbackGeneralizationTests.swift +++ b/cmuxTests/ForkParentFallbackGeneralizationTests.swift @@ -217,14 +217,17 @@ struct ForkParentFallbackGeneralizationTests { #expect(detected[fixture.forkKey] == nil) } - @Test func ompForkFlagDoesNotInferSingleChildThatNamesParentSession() throws { + @Test func unsupportedOmpForkFlagWithoutExplicitIdentityFailsClosed() throws { let fixture = try Fixture.make() defer { fixture.cleanup() } let sessionDirectory = try piSessionDirectory(fixture: fixture) let parentPath = sessionDirectory.appendingPathComponent("parent-omp.jsonl").path - let childPath = sessionDirectory.appendingPathComponent("child-omp.jsonl").path try writeSessionFile(URL(fileURLWithPath: parentPath), modifiedAt: 20) - try writeSessionFile(URL(fileURLWithPath: childPath), modifiedAt: 30, parentSessionId: parentPath) + try writeSessionFile( + sessionDirectory.appendingPathComponent("child-omp.jsonl"), + modifiedAt: 30, + parentSessionId: parentPath + ) let registry = CmuxVaultAgentRegistry(registrations: [.builtInOmp]) let detected = detectedSnapshots( @@ -239,7 +242,7 @@ struct ForkParentFallbackGeneralizationTests { #expect(detected[fixture.forkKey] == nil) } - @Test func legacyOmpSessionForkFlagStaysParentFallback() throws { + @Test func unsupportedOmpForkFlagDoesNotChangeExplicitSessionAuthority() throws { let fixture = try Fixture.make() defer { fixture.cleanup() } @@ -253,7 +256,7 @@ struct ForkParentFallbackGeneralizationTests { processPath: "/usr/local/bin/omp" ) - #expect(detected[fixture.forkKey]?.sessionIDSource == .forkParentFallback) + #expect(detected[fixture.forkKey]?.sessionIDSource == .explicit) } @Test func piPaneHookIdentityWinsAfterForkMintsOwnSession() throws { diff --git a/cmuxTests/ForkParentFallbackResidualTests.swift b/cmuxTests/ForkParentFallbackResidualTests.swift index 402738ccd242..fc9b02859121 100644 --- a/cmuxTests/ForkParentFallbackResidualTests.swift +++ b/cmuxTests/ForkParentFallbackResidualTests.swift @@ -179,6 +179,50 @@ struct ForkParentFallbackResidualTests { #expect(cwdless.forkStartupInput()?.contains("cd --") == false) } + @Test func codexThreadCwdLookupReusesOpenDatabaseAfterPathBecomesUnavailable() throws { + let fixture = try Fixture.make() + defer { fixture.cleanup() } + + let codexHome = fixture.root.appendingPathComponent("codex-home", isDirectory: true) + try fixture.fileManager.createDirectory(at: codexHome, withIntermediateDirectories: true) + let rows = (0..<10_000).map { index in + (sessionId: "session-\(index)", cwd: "/tmp/project-\(index)") + } + try writeCodexStateDB(codexHome: codexHome, rows: rows) + let launchCommand = AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex"], + workingDirectory: nil, + environment: ["CODEX_HOME": codexHome.path], + capturedAt: nil, + source: "test" + ) + let cache = CodexSessionCwdLookupCache(fileManager: fixture.fileManager) + + let first = try #require(rows.first) + #expect(cache.workingDirectory( + kind: .codex, + sessionId: first.sessionId, + launchCommand: launchCommand + ) == first.cwd) + try fixture.fileManager.setAttributes( + [.posixPermissions: 0o000], + ofItemAtPath: codexHome.appendingPathComponent("state_5.sqlite", isDirectory: false).path + ) + + var mismatchCount = 0 + for row in rows.dropFirst() where cache.workingDirectory( + kind: .codex, + sessionId: row.sessionId, + launchCommand: launchCommand + ) != row.cwd { + mismatchCount += 1 + } + + #expect(mismatchCount == 0) + } + @Test func codexTeamsWrapperForkFallbackRoundTripsAndParentHookSurvives() throws { let fixture = try Fixture.make() defer { fixture.cleanup() } @@ -422,6 +466,13 @@ struct ForkParentFallbackResidualTests { } private func writeCodexStateDB(codexHome: URL, sessionId: String, cwd: String) throws { + try writeCodexStateDB(codexHome: codexHome, rows: [(sessionId: sessionId, cwd: cwd)]) + } + + private func writeCodexStateDB( + codexHome: URL, + rows: [(sessionId: String, cwd: String)] + ) throws { let dbPath = codexHome.appendingPathComponent("state_5.sqlite", isDirectory: false).path var db: OpaquePointer? guard sqlite3_open(dbPath, &db) == SQLITE_OK, let db else { @@ -438,9 +489,19 @@ struct ForkParentFallbackResidualTests { } defer { sqlite3_finalize(stmt) } let SQLITE_TRANSIENT_FN = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) - sqlite3_bind_text(stmt, 1, sessionId, -1, SQLITE_TRANSIENT_FN) - sqlite3_bind_text(stmt, 2, cwd, -1, SQLITE_TRANSIENT_FN) - guard sqlite3_step(stmt) == SQLITE_DONE else { + guard sqlite3_exec(db, "BEGIN", nil, nil, nil) == SQLITE_OK else { + throw testFailure() + } + for row in rows { + sqlite3_reset(stmt) + sqlite3_clear_bindings(stmt) + sqlite3_bind_text(stmt, 1, row.sessionId, -1, SQLITE_TRANSIENT_FN) + sqlite3_bind_text(stmt, 2, row.cwd, -1, SQLITE_TRANSIENT_FN) + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw testFailure() + } + } + guard sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK else { throw testFailure() } } diff --git a/cmuxTests/OmpSupportTests.swift b/cmuxTests/OmpSupportTests.swift index 46af2843a35d..d89c098b4cde 100644 --- a/cmuxTests/OmpSupportTests.swift +++ b/cmuxTests/OmpSupportTests.swift @@ -46,9 +46,100 @@ struct OmpSupportTests { ) #expect(taskManagerDefinition.assetName == "AgentIcons/Pi") #expect(CmuxVaultAgentRegistration.builtInOmp.iconAssetName == "AgentIcons/Pi") + #expect( + CmuxVaultAgentRegistration.builtInOmp.resumeCommand + == "{{executable}} --resume {{sessionId}}" + ) + #expect(CmuxVaultAgentRegistration.builtInOmp.forkCommand == nil) } - @Test func directProcessDetectionUsesExplicitSessionSelectorsBeforeLatestFallback() throws { + @Test func plainPiCompatibleProcessesDoNotBindToNewestSameDirectoryTranscript() throws { + let fileManager = FileManager.default + let root = try Self.makeTemporaryDirectory(prefix: "cmux-pi-process-identity-") + defer { try? fileManager.removeItem(at: root) } + + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectoryName = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectoryName, isDirectory: true) + try fileManager.createDirectory(at: workspace, withIntermediateDirectories: true) + try fileManager.createDirectory(at: projectSessions, withIntermediateDirectories: true) + + for (index, sessionID) in ["same-cwd-session-a", "same-cwd-session-b"].enumerated() { + _ = try Self.writeSessionFile( + id: sessionID, + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: TimeInterval(index + 1)) + ) + } + + var piCompatibleCampfire = CmuxVaultAgentRegistration.builtInCampfire + piCompatibleCampfire.sessionIdSource = .piSessionFile + let agents: [(registration: CmuxVaultAgentRegistration, executable: String)] = [ + (.builtInPi, "pi"), + (.builtInOmp, "omp"), + (piCompatibleCampfire, "campfire"), + ] + + for (index, agent) in agents.enumerated() { + let workspaceID = UUID() + let panelID = UUID() + let processID = 5_000 + index + let key = RestorableAgentSessionIndex.PanelKey( + workspaceId: workspaceID, + panelId: panelID + ) + let processSnapshot = CmuxTopProcessSnapshot( + processes: [ + CmuxTopProcessInfo( + pid: processID, + parentPID: 1, + name: agent.executable, + path: "/usr/local/bin/\(agent.executable)", + ttyDevice: nil, + cmuxWorkspaceID: workspaceID, + cmuxSurfaceID: panelID, + cmuxAttributionReason: "cmux-test", + processGroupID: nil, + terminalProcessGroupID: nil, + cpuPercent: 0, + residentBytes: 0, + virtualBytes: 0, + threadCount: 1 + ), + ], + sampledAt: Date(timeIntervalSince1970: 0), + includesProcessDetails: true + ) + let detected = RestorableAgentSessionIndex.processDetectedSnapshots( + registry: CmuxVaultAgentRegistry(registrations: [agent.registration]), + fileManager: fileManager, + processSnapshot: processSnapshot, + capturedAt: 42, + processArgumentsProvider: { requestedProcessID in + guard requestedProcessID == processID else { return nil } + return CmuxTopProcessArguments( + arguments: [ + "/usr/local/bin/\(agent.executable)", + "--session-dir", + sessionsRoot.path, + ], + environment: [ + "PWD": workspace.path, + "CAMPFIRE_SESSION_ROLE": "host", + ] + ) + } + ) + + #expect( + detected[key] == nil, + Comment(rawValue: "\(agent.registration.id) needs explicit or hook-backed session identity") + ) + } + } + + @Test func directProcessDetectionUsesExplicitSessionSelectors() throws { struct Selector { let name: String let arguments: [String] @@ -106,7 +197,7 @@ struct OmpSupportTests { } } - @Test func directProcessDetectionFallsBackToPartialSessionFileMatch() throws { + @Test func directProcessDetectionResolvesPiUUIDPrefixFromTimestampedSessionFile() throws { let root = try Self.makeTemporaryDirectory(prefix: "cmux-omp-partial-session-") defer { try? FileManager.default.removeItem(at: root) } let workspace = root.appendingPathComponent("repo", isDirectory: true) @@ -116,14 +207,15 @@ struct OmpSupportTests { try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + let sessionID = "019e1c86-def0-72c9-90d4-8543db20f981" let partial = try Self.writeSessionFile( - id: "prefix-partial-omp-session-suffix", + id: "2026-07-18T12-00-00-000Z_\(sessionID)", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 2_000) ) let detected = try #require(Self.detectedOmpSnapshot( - arguments: ["/Users/example/.bun/bin/omp", "--session", "partial-omp-session"], + arguments: ["/Users/example/.bun/bin/omp", "--session", String(sessionID.prefix(12))], environment: [ "PWD": workspace.path, "PI_CODING_AGENT_SESSION_DIR": sessionsRoot.path, @@ -147,14 +239,14 @@ struct OmpSupportTests { try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) - let latest = try Self.writeSessionFile( + let session = try Self.writeSessionFile( id: "omp-agent-dir-session", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 2_000) ) let detected = try #require(Self.detectedOmpSnapshot( - arguments: ["/Users/example/.bun/bin/omp"], + arguments: ["/Users/example/.bun/bin/omp", "--session", "omp-agent-dir-session"], environment: [ "PWD": workspace.path, "PI_CODING_AGENT_DIR": agentRoot.path, @@ -162,7 +254,7 @@ struct OmpSupportTests { )) #expect(detected.kind == RestorableAgentKind.custom("omp")) - #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(session.path)) #expect(detected.workingDirectory == workspace.path) } @@ -180,14 +272,14 @@ struct OmpSupportTests { try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) - let latest = try Self.writeSessionFile( + let session = try Self.writeSessionFile( id: "omp-config-dir-session", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 2_000) ) let detected = try #require(Self.detectedOmpSnapshot( - arguments: ["/Users/example/.bun/bin/omp"], + arguments: ["/Users/example/.bun/bin/omp", "--session", "omp-config-dir-session"], environment: [ "HOME": home.path, "PWD": workspace.path, @@ -196,7 +288,7 @@ struct OmpSupportTests { )) #expect(detected.kind == RestorableAgentKind.custom("omp")) - #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(session.path)) #expect(detected.workingDirectory == workspace.path) } @@ -235,7 +327,7 @@ struct OmpSupportTests { sessionDirectory: customRoot.path ) let detected = try #require(Self.detectedOmpSnapshot( - arguments: ["/Users/example/.bun/bin/omp"], + arguments: ["/Users/example/.bun/bin/omp", "--session", "omp-custom-session-dir-session"], environment: [ "PWD": workspace.path, "PI_CODING_AGENT_DIR": environmentRoot.path, @@ -259,7 +351,7 @@ struct OmpSupportTests { try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) - let latest = try Self.writeSessionFile( + let session = try Self.writeSessionFile( id: "omp-bun-session", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 2_000) @@ -271,6 +363,8 @@ struct OmpSupportTests { arguments: [ "/opt/homebrew/bin/bun", "/Users/example/.bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/src/main.ts", + "--session", + "omp-bun-session", "--model", "anthropic/claude-sonnet-4-5", ], @@ -281,17 +375,19 @@ struct OmpSupportTests { )) #expect(detected.kind == RestorableAgentKind.custom("omp")) - #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(session.path)) #expect(detected.workingDirectory == workspace.path) #expect(detected.launchCommand?.executablePath == "omp") #expect(detected.launchCommand?.arguments == [ "omp", + "--session", + "omp-bun-session", "--model", "anthropic/claude-sonnet-4-5", ]) } - @Test func hostedOmpIgnoresRuntimePreloadFlagsBeforeAgentScript() throws { + @Test func hostedOmpRuntimePreloadIsNotSessionIdentity() throws { let root = try Self.makeTemporaryDirectory(prefix: "cmux-omp-hosted-runtime-preload-") defer { try? FileManager.default.removeItem(at: root) } let workspace = root.appendingPathComponent("repo", isDirectory: true) @@ -301,13 +397,13 @@ struct OmpSupportTests { try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) - let latest = try Self.writeSessionFile( + _ = try Self.writeSessionFile( id: "omp-hosted-latest-session", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 2_000) ) - let detected = try #require(Self.detectedOmpSnapshot( + let detected = Self.detectedOmpSnapshot( processName: "node", processPath: "/opt/homebrew/bin/node", arguments: [ @@ -320,14 +416,9 @@ struct OmpSupportTests { "PWD": workspace.path, "PI_CODING_AGENT_SESSION_DIR": sessionsRoot.path, ] - )) + ) - #expect(detected.kind == RestorableAgentKind.custom("omp")) - #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) - #expect(detected.sessionId != "/tmp/preload-session-module.js") - #expect(detected.workingDirectory == workspace.path) - #expect(detected.launchCommand?.executablePath == "omp") - #expect(detected.launchCommand?.arguments == ["omp"]) + #expect(detected == nil) } @Test func hostedOmpParsesSessionSelectorsAfterAgentScript() throws { @@ -416,6 +507,87 @@ struct OmpSupportTests { #expect(legacyPi.id == "pi") } + @Test func piSessionDirectoryIndexResolvesThousandUUIDPrefixesWithoutCandidateScans() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-pi-session-index-") + defer { try? FileManager.default.removeItem(at: root) } + + var sessionFiles: [(id: String, url: URL)] = [] + for index in 0..<1_000 { + let sessionID = String(format: "%08x-0000-4000-8000-000000000000", index) + let url = root.appendingPathComponent( + "2026-07-18T12-00-00-000Z_\(sessionID).jsonl", + isDirectory: false + ) + try "{}\n".write(to: url, atomically: false, encoding: .utf8) + sessionFiles.append((id: sessionID, url: url)) + } + + var index = PiSessionDirectoryIndex(fileManager: .default) + for session in sessionFiles { + let candidate = index.resolvedSessionPath(String(session.id.prefix(8)), in: root.path) + let resolved = try #require(candidate) + #expect(Self.normalizedPath(resolved) == Self.normalizedPath(session.url.path)) + } + let exactBasename = sessionFiles[500].url.deletingPathExtension().lastPathComponent + let exactCandidate = index.resolvedSessionPath(exactBasename, in: root.path) + let exact = try #require(exactCandidate) + #expect(Self.normalizedPath(exact) == Self.normalizedPath(sessionFiles[500].url.path)) + #expect(index.directoryEnumerationCount == 1) + #expect(index.candidateQueryVisitCount <= 2_000) + } + + @Test func piSessionDirectoryIndexRejectsAmbiguousUUIDPrefix() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-pi-session-prefix-tie-") + defer { try? FileManager.default.removeItem(at: root) } + let older = try Self.writeSessionFile( + id: "2026-07-18T12-00-00-000Z_019e1c86-def0-72c9-90d4-8543db20f981", + in: root, + modifiedAt: Date(timeIntervalSince1970: 1_000) + ) + let newer = try Self.writeSessionFile( + id: "2026-07-18T12-00-01-000Z_019e1c86-def0-72c9-90d4-8543db20f982", + in: root, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + + var index = PiSessionDirectoryIndex(fileManager: .default) + #expect(index.resolvedSessionPath("019e1c86-def0", in: root.path) == nil) + #expect( + Self.normalizedPath(index.resolvedSessionPath("019e1c86-def0-72c9-90d4-8543db20f981", in: root.path) ?? "") + == Self.normalizedPath(older.path) + ) + #expect( + Self.normalizedPath(index.resolvedSessionPath("019e1c86-def0-72c9-90d4-8543db20f982", in: root.path) ?? "") + == Self.normalizedPath(newer.path) + ) + #expect(index.resolvedSessionPath("def0-72c9", in: root.path) == nil) + } + + @Test func piSessionDirectoryIndexRejectsDuplicateExactBasename() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-pi-session-tie-") + defer { try? FileManager.default.removeItem(at: root) } + let firstDirectory = root.appendingPathComponent("a", isDirectory: true) + let secondDirectory = root.appendingPathComponent("b", isDirectory: true) + try FileManager.default.createDirectory(at: firstDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondDirectory, withIntermediateDirectories: true) + let modifiedAt = Date(timeIntervalSince1970: 1_000) + _ = try Self.writeSessionFile( + id: "same-session", + in: firstDirectory, + modifiedAt: modifiedAt + ) + _ = try Self.writeSessionFile( + id: "same-session", + in: secondDirectory, + modifiedAt: modifiedAt + ) + + var index = PiSessionDirectoryIndex(fileManager: .default) + #expect(index.resolvedSessionPath("same-session", in: root.path) == nil) + #expect(index.directoryEnumerationCount == 1) + #expect(index.candidateQueryVisitCount == 0) + } + private static func detectedOmpSnapshot( processName: String = "omp", processPath: String? = "/Users/example/.bun/bin/omp", diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index 430c8770f112..8c417ac5761f 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -1,5 +1,12 @@ -import XCTest +import CmuxFoundation import Darwin +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif final class OpenCodeHookRegressionTests: XCTestCase { private struct ProcessRunResult { @@ -9,6 +16,859 @@ final class OpenCodeHookRegressionTests: XCTestCase { let timedOut: Bool } + func testOpenCodeLifecycleHookDeliveryDoesNotBlockOnCmuxProcessExit() throws { + let fixture = try makeOpenCodePluginFixture(fakeCmuxLines: [ + "cat >/dev/null", + "/usr/bin/nc -U \"$TEST_HOOK_RELEASE_SOCKET\" >/dev/null", + ]) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let releaseSocket = fixture.root.appendingPathComponent("release.sock", isDirectory: false) + var environment = fixture.environment + environment["TEST_HOOK_RELEASE_SOCKET"] = releaseSocket.path + let harness = fixture.root.appendingPathComponent("nonblocking.mjs", isDirectory: false) + try """ + import net from "node:net"; + import plugin from \(javaScriptString(fixture.pluginURL.absoluteString)); + let releaseHook; + const hookConnected = new Promise((resolve) => { releaseHook = resolve; }); + const releaseServer = net.createServer((socket) => releaseHook(socket)); + await new Promise((resolve, reject) => { + releaseServer.once("error", reject); + releaseServer.listen(process.env.TEST_HOOK_RELEASE_SOCKET, resolve); + }); + const hooks = await plugin({ directory: process.cwd() }); + let eventReturned = false; + const eventDelivery = hooks.event({ event: { + type: "session.created", + properties: { info: { id: "session-nonblocking", directory: process.cwd() } }, + } }).then(() => { eventReturned = true; }); + const heldHook = await hookConnected; + const returnedBeforeRelease = eventReturned; + heldHook.end("release\\n"); + await eventDelivery; + await new Promise((resolve) => releaseServer.close(resolve)); + console.log(JSON.stringify({ returnedBeforeRelease })); + """.write(to: harness, atomically: true, encoding: .utf8) + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: ["node", harness.path], + environment: environment, + timeout: 3 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let output = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Bool] + ) + XCTAssertEqual(output["returnedBeforeRelease"], true) + } + + func testOpenCodeSessionUpdatedDoesNotRepeatSessionStartHook() throws { + let fixture = try makeOpenCodePluginFixture(fakeCmuxLines: [ + "printf '%s\\n' \"$*\" >> \"$TEST_HOOK_CAPTURE\"", + "cat >/dev/null", + ]) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let capture = fixture.root.appendingPathComponent("hooks.txt", isDirectory: false) + var environment = fixture.environment + environment["TEST_HOOK_CAPTURE"] = capture.path + + let harness = fixture.root.appendingPathComponent("dedupe.mjs", isDirectory: false) + try """ + import fs from "node:fs"; + import plugin from \(javaScriptString(fixture.pluginURL.absoluteString)); + const hooks = await plugin({ directory: process.cwd() }); + const info = { id: "session-dedupe", directory: process.cwd() }; + await hooks.event({ event: { type: "session.created", properties: { info } } }); + await hooks.event({ event: { type: "session.updated", properties: { info } } }); + await hooks.event({ event: { type: "session.updated", properties: { info } } }); + await hooks.event({ event: { type: "session.updated", properties: { info } } }); + await new Promise((resolve, reject) => { + if (fs.existsSync(process.env.TEST_HOOK_CAPTURE)) return resolve(); + const watcher = fs.watch(\(javaScriptString(fixture.root.path)), () => { + if (!fs.existsSync(process.env.TEST_HOOK_CAPTURE)) return; + watcher.close(); + clearTimeout(timeout); + resolve(); + }); + const timeout = setTimeout(() => { + watcher.close(); + reject(new Error("hook capture was not created")); + }, 2000); + }); + """.write(to: harness, atomically: true, encoding: .utf8) + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: ["node", harness.path], + environment: environment, + timeout: 3 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let invocations = try String(contentsOf: capture, encoding: .utf8) + .split(separator: "\n") + .filter { $0.contains("hooks opencode session-start") } + XCTAssertEqual(invocations.count, 1, "session.updated repeated session-start: \(invocations)") + } + + func testOpenCodeLifecycleHooksStayOrderedPerSessionWhileOtherSessionsDispatch() throws { + let fixture = try makeOpenCodePluginFixture(fakeCmuxLines: [ + "payload=\"$(cat)\"", + "printf '%s|%s\\n' \"$3\" \"$payload\" >> \"$TEST_HOOK_CAPTURE\"", + "case \"$payload\" in", + " *session-ordered*)", + " cat \"$TEST_HOOK_RELEASE_FIFO\" >/dev/null", + " ;;", + "esac", + ]) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let capture = fixture.root.appendingPathComponent("hooks.txt", isDirectory: false) + let releaseFIFO = fixture.root.appendingPathComponent("release.fifo", isDirectory: false) + XCTAssertEqual(mkfifo(releaseFIFO.path, S_IRUSR | S_IWUSR), 0) + var environment = fixture.environment + environment["TEST_HOOK_CAPTURE"] = capture.path + environment["TEST_HOOK_RELEASE_FIFO"] = releaseFIFO.path + + let harness = fixture.root.appendingPathComponent("ordered.mjs", isDirectory: false) + try """ + import fs from "node:fs"; + import plugin from \(javaScriptString(fixture.pluginURL.absoluteString)); + + const hooks = await plugin({ directory: process.cwd() }); + const orderedInfo = { id: "session-ordered", directory: process.cwd() }; + const otherInfo = { id: "session-other", directory: process.cwd() }; + const captureLines = () => fs.existsSync(process.env.TEST_HOOK_CAPTURE) + ? fs.readFileSync(process.env.TEST_HOOK_CAPTURE, "utf8").trim().split("\\n").filter(Boolean) + : []; + const waitForLineCount = (count) => new Promise((resolve, reject) => { + const ready = () => captureLines().length >= count; + if (ready()) return resolve(); + const watcher = fs.watch(\(javaScriptString(fixture.root.path)), () => { + if (!ready()) return; + watcher.close(); + clearTimeout(timeout); + resolve(); + }); + const timeout = setTimeout(() => { + watcher.close(); + reject(new Error(`hook capture did not reach ${count} lines`)); + }, 2000); + }); + const records = () => captureLines().map((line) => { + const separator = line.indexOf("|"); + const payload = JSON.parse(line.slice(separator + 1)); + return { subcommand: line.slice(0, separator), sessionId: payload.session_id }; + }); + const releaseOrderedHook = () => fs.writeFileSync( + process.env.TEST_HOOK_RELEASE_FIFO, + "release\\n" + ); + + await hooks.event({ event: { type: "session.created", properties: { info: orderedInfo } } }); + await waitForLineCount(1); + + await hooks.event({ event: { type: "session.idle", properties: { info: orderedInfo } } }); + await hooks.event({ event: { type: "session.idle", properties: { info: orderedInfo } } }); + await hooks.event({ event: { type: "session.deleted", properties: { info: orderedInfo } } }); + await hooks.event({ event: { type: "session.created", properties: { info: orderedInfo } } }); + await hooks.event({ event: { type: "session.created", properties: { info: otherInfo } } }); + await waitForLineCount(2); + + const beforeRelease = records(); + releaseOrderedHook(); + await waitForLineCount(3); + releaseOrderedHook(); + await waitForLineCount(4); + releaseOrderedHook(); + await waitForLineCount(5); + const afterRelease = records(); + releaseOrderedHook(); + console.log(JSON.stringify({ beforeRelease, afterRelease })); + """.write(to: harness, atomically: true, encoding: .utf8) + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: ["node", harness.path], + environment: environment, + timeout: 4 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let snapshot = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + let beforeRelease = try XCTUnwrap(snapshot["beforeRelease"] as? [[String: String]]) + XCTAssertEqual(beforeRelease.compactMap { $0["sessionId"] }.sorted(), ["session-ordered", "session-other"]) + XCTAssertEqual(beforeRelease.compactMap { $0["subcommand"] }, ["session-start", "session-start"]) + + let afterRelease = try XCTUnwrap(snapshot["afterRelease"] as? [[String: String]]) + let orderedCommands = afterRelease + .filter { $0["sessionId"] == "session-ordered" } + .compactMap { $0["subcommand"] } + XCTAssertEqual(orderedCommands, ["session-start", "stop", "session-end", "session-start"]) + } + + func testOpenCodeQueueOverloadPreservesEveryAcceptedStartEndPair() throws { + let fixture = try makeOpenCodePluginFixture(fakeCmuxLines: [ + "payload=\"$(cat)\"", + "printf '%s|%s\\n' \"$3\" \"$payload\" >> \"$TEST_HOOK_CAPTURE\"", + "if [ \"$3\" = \"session-start\" ]; then /usr/bin/nc -U \"$TEST_HOOK_RELEASE_SOCKET\" >/dev/null; fi", + ]) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let capture = fixture.root.appendingPathComponent("hooks.txt", isDirectory: false) + let releaseSocket = fixture.root.appendingPathComponent("release.sock", isDirectory: false) + var environment = fixture.environment + environment["TEST_HOOK_CAPTURE"] = capture.path + environment["TEST_HOOK_RELEASE_SOCKET"] = releaseSocket.path + + let harness = fixture.root.appendingPathComponent("overload.mjs", isDirectory: false) + try """ + import fs from "node:fs"; + import net from "node:net"; + import plugin from \(javaScriptString(fixture.pluginURL.absoluteString)); + + const hooks = await plugin({ directory: process.cwd() }); + let releaseStarts = false; + const heldStarts = []; + const releaseServer = net.createServer((socket) => { + socket.on("error", () => {}); + if (releaseStarts) { + socket.end("release\\n"); + } else { + heldStarts.push(socket); + } + }); + await new Promise((resolve, reject) => { + releaseServer.once("error", reject); + releaseServer.listen(process.env.TEST_HOOK_RELEASE_SOCKET, resolve); + }); + const captureLines = () => fs.existsSync(process.env.TEST_HOOK_CAPTURE) + ? fs.readFileSync(process.env.TEST_HOOK_CAPTURE, "utf8").trim().split("\\n").filter(Boolean) + : []; + const records = () => captureLines().map((line) => { + const separator = line.indexOf("|"); + const payload = JSON.parse(line.slice(separator + 1)); + return { subcommand: line.slice(0, separator), sessionId: payload.session_id }; + }); + const waitFor = (description, predicate) => new Promise((resolve, reject) => { + if (predicate()) return resolve(); + const watcher = fs.watch(\(javaScriptString(fixture.root.path)), () => { + if (!predicate()) return; + watcher.close(); + clearTimeout(timeout); + resolve(); + }); + const timeout = setTimeout(() => { + watcher.close(); + reject(new Error(`timed out waiting for ${description}`)); + }, 6000); + }); + + const sessionIds = [ + "session-terminal-reservation", + ...Array.from({ length: 300 }, (_, index) => `session-overload-${index}`), + ]; + for (const sessionId of sessionIds) { + const info = { id: sessionId, directory: process.cwd() }; + await hooks.event({ event: { type: "session.created", properties: { info } } }); + } + for (const sessionId of sessionIds.slice(1)) { + const info = { id: sessionId, directory: process.cwd() }; + await hooks.event({ event: { type: "session.deleted", properties: { info } } }); + } + const terminalInfo = { id: sessionIds[0], directory: process.cwd() }; + await hooks.event({ event: { type: "session.deleted", properties: { info: terminalInfo } } }); + + await waitFor("four blocked session starts", () => records().length >= 4); + releaseStarts = true; + for (const socket of heldStarts.splice(0)) socket.end("release\\n"); + await waitFor( + "the terminal hook reserved while the queue was full", + () => records().some((record) => + record.sessionId === "session-terminal-reservation" + && record.subcommand === "session-end" + ) + ); + await new Promise((resolve) => releaseServer.close(resolve)); + console.log(JSON.stringify(records())); + """.write(to: harness, atomically: true, encoding: .utf8) + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: ["node", harness.path], + environment: environment, + timeout: 10 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let records = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [[String: String]] + ) + let starts = records.filter { $0["subcommand"] == "session-start" } + let ends = records.filter { $0["subcommand"] == "session-end" } + XCTAssertGreaterThan(starts.count, 100, "fixture did not saturate the 256-slot queue") + XCTAssertLessThan(starts.count, 301, "the bounded queue admitted every overload session") + XCTAssertEqual(Set(starts.compactMap { $0["sessionId"] }), Set(ends.compactMap { $0["sessionId"] })) + XCTAssertEqual(starts.count, ends.count) + XCTAssertTrue(ends.contains { $0["sessionId"] == "session-terminal-reservation" }) + } + + func testOpenCodeRestartTerminalEventsSurviveSaturatedQueue() throws { + let fixture = try makeOpenCodePluginFixture(fakeCmuxLines: [ + "payload=\"$(cat)\"", + "printf '%s|%s\\n' \"$3\" \"$payload\" >> \"$TEST_HOOK_CAPTURE\"", + "if [ \"$3\" = \"session-start\" ]; then /usr/bin/nc -U \"$TEST_HOOK_RELEASE_SOCKET\" >/dev/null; fi", + ]) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let capture = fixture.root.appendingPathComponent("hooks.txt", isDirectory: false) + let releaseSocket = fixture.root.appendingPathComponent("release.sock", isDirectory: false) + var environment = fixture.environment + environment["TEST_HOOK_CAPTURE"] = capture.path + environment["TEST_HOOK_RELEASE_SOCKET"] = releaseSocket.path + + let harness = fixture.root.appendingPathComponent("restart-terminal-overload.mjs", isDirectory: false) + try """ + import fs from "node:fs"; + import net from "node:net"; + import plugin from \(javaScriptString(fixture.pluginURL.absoluteString)); + + let releaseStarts = false; + const heldStarts = []; + const releaseServer = net.createServer((socket) => { + socket.on("error", () => {}); + if (releaseStarts) socket.end("release\\n"); + else heldStarts.push(socket); + }); + await new Promise((resolve, reject) => { + releaseServer.once("error", reject); + releaseServer.listen(process.env.TEST_HOOK_RELEASE_SOCKET, resolve); + }); + const hooks = await plugin({ directory: process.cwd() }); + const captureLines = () => fs.existsSync(process.env.TEST_HOOK_CAPTURE) + ? fs.readFileSync(process.env.TEST_HOOK_CAPTURE, "utf8").trim().split("\\n").filter(Boolean) + : []; + const records = () => captureLines().map((line) => { + const separator = line.indexOf("|"); + const payload = JSON.parse(line.slice(separator + 1)); + return { subcommand: line.slice(0, separator), sessionId: payload.session_id }; + }); + const waitFor = (description, predicate) => new Promise((resolve, reject) => { + if (predicate()) return resolve(); + const watcher = fs.watch(\(javaScriptString(fixture.root.path)), () => { + if (!predicate()) return; + watcher.close(); + clearTimeout(timeout); + resolve(); + }); + const timeout = setTimeout(() => { + watcher.close(); + reject(new Error(`timed out waiting for ${description}`)); + }, 6000); + }); + + const saturatedIds = Array.from( + { length: 301 }, + (_, index) => `session-saturated-${index}` + ); + for (const sessionId of saturatedIds) { + const info = { id: sessionId, directory: process.cwd() }; + await hooks.event({ event: { type: "session.created", properties: { info } } }); + } + for (const sessionId of saturatedIds) { + const info = { id: sessionId, directory: process.cwd() }; + await hooks.event({ event: { type: "session.deleted", properties: { info } } }); + } + + const deletedInfo = { id: "session-restart-deleted", directory: process.cwd() }; + await hooks.event({ event: { type: "session.deleted", properties: { info: deletedInfo } } }); + const archivedInfo = { + id: "session-restart-archived", + directory: process.cwd(), + time: { archived: Date.now() }, + }; + await hooks.event({ event: { type: "session.updated", properties: { info: archivedInfo } } }); + + await waitFor("four blocked starts", () => records().length >= 4); + releaseStarts = true; + for (const socket of heldStarts.splice(0)) socket.end("release\\n"); + await waitFor("both restart terminal events", () => { + const ended = new Set( + records() + .filter((record) => record.subcommand === "session-end") + .map((record) => record.sessionId) + ); + return ended.has("session-restart-deleted") + && ended.has("session-restart-archived"); + }); + await new Promise((resolve) => releaseServer.close(resolve)); + console.log(JSON.stringify(records())); + """.write(to: harness, atomically: true, encoding: .utf8) + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: ["node", harness.path], + environment: environment, + timeout: 10 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let records = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [[String: String]] + ) + for sessionID in ["session-restart-deleted", "session-restart-archived"] { + let commands = records + .filter { $0["sessionId"] == sessionID } + .compactMap { $0["subcommand"] } + XCTAssertEqual(commands, ["session-end"], "restart terminal event was lost for \(sessionID)") + } + } + + func testOpenCodeDisposeWaitsForQueuedLifecycleHooks() throws { + let fixture = try makeOpenCodePluginFixture(fakeCmuxLines: [ + "payload=\"$(cat)\"", + "printf '%s|%s\\n' \"$3\" \"$payload\" >> \"$TEST_HOOK_CAPTURE\"", + "if [ \"$3\" = \"session-start\" ]; then /usr/bin/nc -U \"$TEST_HOOK_RELEASE_SOCKET\" >/dev/null; fi", + ]) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let capture = fixture.root.appendingPathComponent("hooks.txt", isDirectory: false) + let releaseSocket = fixture.root.appendingPathComponent("release.sock", isDirectory: false) + var environment = fixture.environment + environment["TEST_HOOK_CAPTURE"] = capture.path + environment["TEST_HOOK_RELEASE_SOCKET"] = releaseSocket.path + + let harness = fixture.root.appendingPathComponent("dispose-drain.mjs", isDirectory: false) + try """ + import fs from "node:fs"; + import net from "node:net"; + import plugin from \(javaScriptString(fixture.pluginURL.absoluteString)); + + const heldStarts = []; + const releaseServer = net.createServer((socket) => { + socket.on("error", () => {}); + heldStarts.push(socket); + }); + await new Promise((resolve, reject) => { + releaseServer.once("error", reject); + releaseServer.listen(process.env.TEST_HOOK_RELEASE_SOCKET, resolve); + }); + const hooks = await plugin({ directory: process.cwd() }); + const captureLines = () => fs.existsSync(process.env.TEST_HOOK_CAPTURE) + ? fs.readFileSync(process.env.TEST_HOOK_CAPTURE, "utf8").trim().split("\\n").filter(Boolean) + : []; + const waitForLineCount = (count) => new Promise((resolve, reject) => { + if (captureLines().length >= count) return resolve(); + const watcher = fs.watch(\(javaScriptString(fixture.root.path)), () => { + if (captureLines().length < count) return; + watcher.close(); + clearTimeout(timeout); + resolve(); + }); + const timeout = setTimeout(() => { + watcher.close(); + reject(new Error(`hook capture did not reach ${count} lines`)); + }, 2000); + }); + + const info = { id: "session-dispose", directory: process.cwd() }; + await hooks.event({ event: { type: "session.created", properties: { info } } }); + await hooks.event({ event: { type: "session.deleted", properties: { info } } }); + await waitForLineCount(1); + let disposed = false; + const disposal = hooks.dispose().then(() => { disposed = true; }); + await Promise.resolve(); + const resolvedBeforeRelease = disposed; + for (const socket of heldStarts.splice(0)) socket.end("release\\n"); + await disposal; + await new Promise((resolve) => releaseServer.close(resolve)); + console.log(JSON.stringify({ resolvedBeforeRelease, commands: captureLines().map((line) => line.split("|", 1)[0]) })); + """.write(to: harness, atomically: true, encoding: .utf8) + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: ["node", harness.path], + environment: environment, + timeout: 4 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let output = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + XCTAssertEqual(output["resolvedBeforeRelease"] as? Bool, false) + XCTAssertEqual(output["commands"] as? [String], ["session-start", "session-end"]) + } + + func testOpenCodeDisposeCompactsSameSessionBacklogToFinalOutcome() throws { + let fixture = try makeOpenCodePluginFixture(fakeCmuxLines: [ + "payload=\"$(cat)\"", + "printf '%s|%s\\n' \"$3\" \"$payload\" >> \"$TEST_HOOK_CAPTURE\"", + "if [ \"$3\" = \"session-start\" ]; then sleep 1; fi", + ]) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let capture = fixture.root.appendingPathComponent("hooks.txt", isDirectory: false) + var environment = fixture.environment + environment["TEST_HOOK_CAPTURE"] = capture.path + let harness = fixture.root.appendingPathComponent("dispose-compact.mjs", isDirectory: false) + try """ + import fs from "node:fs"; + import plugin from \(javaScriptString(fixture.pluginURL.absoluteString)); + + const hooks = await plugin({ directory: process.cwd() }); + const info = { id: "session-dispose-compact", directory: process.cwd() }; + const captureLines = () => fs.existsSync(process.env.TEST_HOOK_CAPTURE) + ? fs.readFileSync(process.env.TEST_HOOK_CAPTURE, "utf8").trim().split("\\n").filter(Boolean) + : []; + const waitForFirstHook = new Promise((resolve, reject) => { + if (captureLines().length > 0) return resolve(); + const watcher = fs.watch(\(javaScriptString(fixture.root.path)), () => { + if (captureLines().length === 0) return; + watcher.close(); + clearTimeout(timeout); + resolve(); + }); + const timeout = setTimeout(() => { + watcher.close(); + reject(new Error("first hook did not start")); + }, 2000); + }); + + await hooks.event({ event: { type: "session.created", properties: { info } } }); + await waitForFirstHook; + for (let index = 0; index < 128; index += 1) { + await hooks.event({ event: { type: "session.deleted", properties: { info } } }); + await hooks.event({ event: { type: "session.created", properties: { info } } }); + } + await hooks.event({ event: { type: "session.deleted", properties: { info } } }); + + await hooks.dispose(); + const commands = captureLines().map((line) => line.split("|", 1)[0]); + console.log(JSON.stringify({ commands })); + """.write(to: harness, atomically: true, encoding: .utf8) + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: ["node", harness.path], + environment: environment, + timeout: 4 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let output = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] + ) + XCTAssertEqual(output["commands"] as? [String], ["session-start", "session-end"]) + } + + func testOpenCodeDisposeAllowsPluginToReloadInSameProcess() throws { + let fixture = try makeOpenCodePluginFixture(fakeCmuxLines: [ + "payload=\"$(cat)\"", + "printf '%s|%s\\n' \"$3\" \"$payload\" >> \"$TEST_HOOK_CAPTURE\"", + ]) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let capture = fixture.root.appendingPathComponent("hooks.txt", isDirectory: false) + var environment = fixture.environment + environment["TEST_HOOK_CAPTURE"] = capture.path + let harness = fixture.root.appendingPathComponent("dispose-reload.mjs", isDirectory: false) + try """ + import firstPlugin from \(javaScriptString(fixture.pluginURL.absoluteString)); + + const firstHooks = await firstPlugin({ directory: process.cwd() }); + const firstInfo = { id: "session-before-reload", directory: process.cwd() }; + await firstHooks.event({ event: { type: "session.created", properties: { info: firstInfo } } }); + await firstHooks.dispose(); + + const secondModule = await import(\(javaScriptString(fixture.pluginURL.absoluteString + "?reload=1"))); + const secondHooks = await secondModule.default({ directory: process.cwd() }); + if (typeof secondHooks.event !== "function") { + throw new Error("reloaded OpenCode plugin did not install its event hook"); + } + const secondInfo = { id: "session-after-reload", directory: process.cwd() }; + await secondHooks.event({ event: { type: "session.created", properties: { info: secondInfo } } }); + await secondHooks.dispose(); + """.write(to: harness, atomically: true, encoding: .utf8) + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: ["node", harness.path], + environment: environment, + timeout: 3 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let sessionIDs = try String(contentsOf: capture, encoding: .utf8) + .split(separator: "\n") + .compactMap { line -> String? in + guard let separator = line.firstIndex(of: "|") else { return nil } + let payload = line[line.index(after: separator)...] + guard let object = try? JSONSerialization.jsonObject( + with: Data(String(payload).utf8) + ) as? [String: Any] else { + return nil + } + return object["session_id"] as? String + } + XCTAssertEqual(sessionIDs, ["session-before-reload", "session-after-reload"]) + } + + func testOpenCodeDisposedFactoryCanBeReusedFromSameModule() throws { + let fixture = try makeOpenCodePluginFixture(fakeCmuxLines: [ + "payload=\"$(cat)\"", + "printf '%s|%s\\n' \"$3\" \"$payload\" >> \"$TEST_HOOK_CAPTURE\"", + "case \"$payload\" in *session-after-reuse*) /usr/bin/nc -U \"$TEST_HOOK_RELEASE_SOCKET\" >/dev/null ;; esac", + ]) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let capture = fixture.root.appendingPathComponent("hooks.txt", isDirectory: false) + let releaseSocket = fixture.root.appendingPathComponent("release.sock", isDirectory: false) + var environment = fixture.environment + environment["TEST_HOOK_CAPTURE"] = capture.path + environment["TEST_HOOK_RELEASE_SOCKET"] = releaseSocket.path + let harness = fixture.root.appendingPathComponent("dispose-reuse.mjs", isDirectory: false) + try """ + import net from "node:net"; + import plugin from \(javaScriptString(fixture.pluginURL.absoluteString)); + + let releaseHook; + const hookConnected = new Promise((resolve) => { releaseHook = resolve; }); + const releaseServer = net.createServer((socket) => releaseHook(socket)); + await new Promise((resolve, reject) => { + releaseServer.once("error", reject); + releaseServer.listen(process.env.TEST_HOOK_RELEASE_SOCKET, resolve); + }); + const firstHooks = await plugin({ directory: process.cwd() }); + const firstInfo = { id: "session-before-reuse", directory: process.cwd() }; + await firstHooks.event({ event: { type: "session.created", properties: { info: firstInfo } } }); + const firstDisposal = firstHooks.dispose(); + await Promise.resolve(); + const lateInfo = { id: "session-after-dispose", directory: process.cwd() }; + await firstHooks.event({ event: { type: "session.created", properties: { info: lateInfo } } }); + await firstDisposal; + + const secondHooks = await plugin({ directory: process.cwd() }); + const secondInfo = { id: "session-after-reuse", directory: process.cwd() }; + await secondHooks.event({ event: { type: "session.created", properties: { info: secondInfo } } }); + let secondDisposed = false; + const secondDisposal = secondHooks.dispose().then(() => { secondDisposed = true; }); + const heldHook = await hookConnected; + await firstHooks.dispose(); + const replacementPendingAfterStaleDispose = !secondDisposed; + heldHook.end("release\\n"); + await secondDisposal; + await new Promise((resolve) => releaseServer.close(resolve)); + console.log(JSON.stringify({ replacementPendingAfterStaleDispose })); + """.write(to: harness, atomically: true, encoding: .utf8) + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: ["node", harness.path], + environment: environment, + timeout: 3 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let output = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Bool] + ) + XCTAssertEqual(output["replacementPendingAfterStaleDispose"], true) + let sessionIDs = try String(contentsOf: capture, encoding: .utf8) + .split(separator: "\n") + .compactMap { hookSessionID($0) } + XCTAssertEqual(sessionIDs, ["session-before-reuse", "session-after-reuse"]) + } + + func testOpenCodeShutdownPreservesDistinctSessionStopsAtQueueSaturation() throws { + let fixture = try makeOpenCodePluginFixture(fakeCmuxLines: [ + "payload=\"$(cat)\"", + "printf '%s|%s\\n' \"$3\" \"$payload\" >> \"$TEST_HOOK_CAPTURE\"", + "if [ \"$3\" = \"session-start\" ]; then sleep 1; fi", + ]) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let capture = fixture.root.appendingPathComponent("hooks.txt", isDirectory: false) + var environment = fixture.environment + environment["TEST_HOOK_CAPTURE"] = capture.path + let harness = fixture.root.appendingPathComponent("dispose-distinct-stops.mjs", isDirectory: false) + try """ + import fs from "node:fs"; + import plugin from \(javaScriptString(fixture.pluginURL.absoluteString)); + + const hooks = await plugin({ directory: process.cwd() }); + const sessionIds = Array.from({ length: 130 }, (_, index) => `session-stop-${index}`); + const captureLines = () => fs.existsSync(process.env.TEST_HOOK_CAPTURE) + ? fs.readFileSync(process.env.TEST_HOOK_CAPTURE, "utf8").trim().split("\\n").filter(Boolean) + : []; + const waitForFourStarts = new Promise((resolve, reject) => { + if (captureLines().length >= 4) return resolve(); + const watcher = fs.watch(\(javaScriptString(fixture.root.path)), () => { + if (captureLines().length < 4) return; + watcher.close(); + clearTimeout(timeout); + resolve(); + }); + const timeout = setTimeout(() => { + watcher.close(); + reject(new Error("four starts did not dispatch")); + }, 2000); + }); + for (const sessionId of sessionIds) { + const info = { id: sessionId, directory: process.cwd() }; + await hooks.event({ event: { type: "session.created", properties: { info } } }); + } + await waitForFourStarts; + for (const sessionId of sessionIds) { + const info = { id: sessionId, directory: process.cwd() }; + await hooks.event({ event: { type: "session.idle", properties: { info } } }); + } + await hooks.dispose(); + console.log(captureLines().join("\\n")); + """.write(to: harness, atomically: true, encoding: .utf8) + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: ["node", harness.path], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + var commandsBySession: [String: [String]] = [:] + for line in result.stdout.split(separator: "\n") { + guard let separator = line.firstIndex(of: "|"), + let sessionID = hookSessionID(line) + else { continue } + commandsBySession[sessionID, default: []].append(String(line[..> \"$TEST_HOOK_CAPTURE\"", + ]) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let capture = fixture.root.appendingPathComponent("hooks.txt", isDirectory: false) + var environment = fixture.environment + environment["TEST_HOOK_CAPTURE"] = capture.path + let harness = fixture.root.appendingPathComponent("natural-exit-drain.mjs", isDirectory: false) + try """ + import plugin from \(javaScriptString(fixture.pluginURL.absoluteString)); + const hooks = await plugin({ directory: process.cwd() }); + const info = { id: "session-natural-exit", directory: process.cwd() }; + await hooks.event({ event: { type: "session.created", properties: { info } } }); + await hooks.event({ event: { type: "session.deleted", properties: { info } } }); + """.write(to: harness, atomically: true, encoding: .utf8) + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: ["node", harness.path], + environment: environment, + timeout: 3 + ) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let commands = try String(contentsOf: capture, encoding: .utf8) + .split(separator: "\n") + .compactMap { $0.split(separator: "|", maxSplits: 1).first.map(String.init) } + XCTAssertEqual(commands, ["session-start", "session-end"]) + } + func testOpenCodeInstallHooksIsIdempotentForLegacySetupAlias() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-opencode-hooks-\(UUID().uuidString)", isDirectory: true) @@ -69,6 +929,75 @@ final class OpenCodeHookRegressionTests: XCTestCase { try BundledCLITestSupport.bundledCLIPath(for: Self.self) } + private struct OpenCodePluginFixture { + let root: URL + let pluginURL: URL + let environment: [String: String] + } + + private func makeOpenCodePluginFixture(fakeCmuxLines: [String]) throws -> OpenCodePluginFixture { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-opencode-plugin-runtime-\(UUID().uuidString)", isDirectory: true) + let configDir = root.appendingPathComponent("opencode", isDirectory: true) + let binDir = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try #"{"type":"module"}"#.write( + to: configDir.appendingPathComponent("package.json", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + + let fakeOpenCodeURL = binDir.appendingPathComponent("opencode", isDirectory: false) + try "#!/bin/sh\nexit 0\n".write(to: fakeOpenCodeURL, atomically: true, encoding: .utf8) + chmod(fakeOpenCodeURL.path, 0o755) + let fakeCmuxURL = binDir.appendingPathComponent("cmux-hook", isDirectory: false) + try (["#!/bin/sh"] + fakeCmuxLines + ["exit 0"]).joined(separator: "\n") + .appending("\n") + .write(to: fakeCmuxURL, atomically: true, encoding: .utf8) + chmod(fakeCmuxURL.path, 0o755) + + var environment = ProcessInfo.processInfo.environment + environment["OPENCODE_CONFIG_DIR"] = configDir.path + environment["PATH"] = "\(binDir.path):\(environment["PATH"] ?? "/usr/bin:/bin")" + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + let install = runProcess( + executablePath: cliPath, + arguments: ["hooks", "opencode", "install", "--yes"], + environment: environment, + timeout: 5 + ) + guard !install.timedOut, install.status == 0 else { + XCTFail("OpenCode hook install failed: \(install.stderr)") + throw NSError(domain: "OpenCodeHookRegressionTests", code: Int(install.status)) + } + environment["CMUX_SURFACE_ID"] = "surface-opencode-runtime" + environment["CMUX_OPENCODE_CMUX_BIN"] = fakeCmuxURL.path + return OpenCodePluginFixture( + root: root, + pluginURL: configDir.appendingPathComponent("plugins/cmux-session.js", isDirectory: false), + environment: environment + ) + } + + private func javaScriptString(_ value: String) throws -> String { + let data = try JSONSerialization.data(withJSONObject: [value], options: []) + let array = try XCTUnwrap(String(data: data, encoding: .utf8)) + return String(array.dropFirst().dropLast()) + } + + private func hookSessionID(_ line: S) -> String? { + guard let separator = line.firstIndex(of: "|") else { return nil } + let payload = line[line.index(after: separator)...] + guard let object = try? JSONSerialization.jsonObject( + with: Data(String(payload).utf8) + ) as? [String: Any] else { + return nil + } + return object["session_id"] as? String + } + private func runProcess( executablePath: String, arguments: [String], diff --git a/cmuxTests/PiVaultAgentPersistenceTests.swift b/cmuxTests/PiVaultAgentPersistenceTests.swift index f295c789b4ae..14e0fb70a268 100644 --- a/cmuxTests/PiVaultAgentPersistenceTests.swift +++ b/cmuxTests/PiVaultAgentPersistenceTests.swift @@ -1,4 +1,5 @@ import CmuxWorkspaces +import CmuxFoundation import XCTest #if canImport(cmux_DEV) @@ -8,6 +9,512 @@ import XCTest #endif final class PiVaultAgentPersistenceTests: XCTestCase { + func testVaultRegistrationIDsShareProviderPathSafetyAndReservedCaseRules() throws { + func payload(id: String) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "id": id, + "name": "Boundary Agent", + "sessionIdSource": [ + "type": "argvOption", + "argvOption": "--session", + ], + "resumeCommand": "boundary-agent --session {{sessionId}}", + ], options: [.sortedKeys]) + } + + let boundaryID = String(repeating: "a", count: 128) + let decoded = try JSONDecoder().decode( + CmuxVaultAgentRegistration.self, + from: payload(id: boundaryID) + ) + XCTAssertEqual(decoded.id, boundaryID) + + XCTAssertThrowsError( + try JSONDecoder().decode( + CmuxVaultAgentRegistration.self, + from: payload(id: String(repeating: "a", count: 129)) + ) + ) { error in + XCTAssertTrue(String(describing: error).contains("128 UTF-8 bytes")) + } + XCTAssertThrowsError( + try JSONDecoder().decode( + CmuxVaultAgentRegistration.self, + from: payload(id: "Codex") + ) + ) + for id in ["Pi", "Grok", "Antigravity", "Ollama", "OMP", "Campfire"] { + XCTAssertThrowsError( + try JSONDecoder().decode( + CmuxVaultAgentRegistration.self, + from: payload(id: id) + ), + id + ) + } + for id in ["pi", "grok", "antigravity", "ollama", "omp", "campfire"] { + XCTAssertEqual( + try JSONDecoder().decode( + CmuxVaultAgentRegistration.self, + from: payload(id: id) + ).id, + id + ) + } + } + + func testRestorableAgentKindRejectsNativeCaseVariantsWithoutBreakingCanonicalRegistryKinds() throws { + XCTAssertNil(RestorableAgentKind(rawValue: "Codex")) + XCTAssertNil(RestorableAgentKind(rawValue: "GROK")) + XCTAssertThrowsError( + try JSONDecoder().decode(RestorableAgentKind.self, from: Data(#""Codex""#.utf8)) + ) + + let canonicalKinds: [(String, RestorableAgentKind)] = [ + ("grok", .grok), + ("pi", .pi), + ("antigravity", .antigravity), + ("ollama", .ollama), + ] + for (rawValue, expected) in canonicalKinds { + let encoded = try JSONEncoder().encode(rawValue) + XCTAssertEqual( + try JSONDecoder().decode(RestorableAgentKind.self, from: encoded), + expected + ) + } + } + + func testVaultRegistryRejectsCaseCollisionsWithinOneConfigAndAgainstBuiltIns() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-vault-case-collision-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + try writeVaultConfig( + [ + makeVaultRegistration(id: "Custom", name: "Upper Custom"), + makeVaultRegistration(id: "custom", name: "Lower Custom"), + makeVaultRegistration(id: "Pi", name: "Ambiguous Pi"), + ], + to: root.appendingPathComponent(".config/cmux/cmux.json") + ) + + let registry = CmuxVaultAgentRegistry.load( + homeDirectory: root.path, + workingDirectory: nil, + environment: [:] + ) + XCTAssertNil(registry.registration(id: "Custom")) + XCTAssertNil(registry.registration(id: "custom")) + XCTAssertNil(registry.registration(id: "Pi")) + XCTAssertNil(registry.registration(id: "pi")) + + let exactRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-vault-exact-override-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: exactRoot) } + try writeVaultConfig( + [makeVaultRegistration(id: "pi", name: "Project Pi")], + to: exactRoot.appendingPathComponent(".config/cmux/cmux.json") + ) + let exactRegistry = CmuxVaultAgentRegistry.load( + homeDirectory: exactRoot.path, + workingDirectory: nil, + environment: [:] + ) + XCTAssertEqual(exactRegistry.registration(id: "pi")?.name, "Project Pi") + } + + func testVaultRegistryKeepsEveryCanonicalRegistryOwnedOverride() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-vault-registry-owned-overrides-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let ids = ["pi", "grok", "antigravity", "ollama", "omp", "campfire"] + try writeVaultConfig( + ids.map { makeVaultRegistration(id: $0, name: "Project \($0)") }, + to: root.appendingPathComponent(".config/cmux/cmux.json") + ) + + let registry = CmuxVaultAgentRegistry.load( + homeDirectory: root.path, + workingDirectory: nil, + environment: [:] + ) + + for id in ids { + XCTAssertEqual(registry.registration(id: id)?.name, "Project \(id)", id) + } + } + + func testVaultConfigRejectsOversizedFilesAndRegistrationCatalogs() throws { + let oversizedRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-vault-oversized-config-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: oversizedRoot) } + let oversizedURL = oversizedRoot.appendingPathComponent(".config/cmux/cmux.json") + try FileManager.default.createDirectory( + at: oversizedURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let oversizedObject: [String: Any] = [ + "padding": String(repeating: "x", count: 1_024 * 1_024), + "vault": ["agents": [[ + "id": "oversized-agent", + "name": "Oversized Agent", + "detect": ["processName": "oversized-agent"], + "sessionIdSource": ["type": "argvOption", "argvOption": "--session"], + "resumeCommand": "oversized-agent --session {{sessionId}}", + ]]], + ] + let oversizedData = try JSONSerialization.data(withJSONObject: oversizedObject) + XCTAssertGreaterThan(oversizedData.count, 1_024 * 1_024) + try oversizedData.write(to: oversizedURL) + XCTAssertNil(CmuxVaultAgentRegistry.load( + homeDirectory: oversizedRoot.path, + environment: [:] + ).registration(id: "oversized-agent")) + + let catalogRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-vault-oversized-catalog-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: catalogRoot) } + try writeVaultConfig( + (0...CmuxAgentSessionRegistry.maximumProviderEnumerationCount).map { + makeVaultRegistration(id: "catalog-\($0)", name: "Catalog \($0)") + }, + to: catalogRoot.appendingPathComponent(".config/cmux/cmux.json") + ) + let catalog = CmuxVaultAgentRegistry.load( + homeDirectory: catalogRoot.path, + environment: [:] + ) + XCTAssertNil(catalog.registration(id: "catalog-0")) + XCTAssertNil(catalog.registration(id: "catalog-256")) + } + + func testVaultProcessDetectionIndexBoundsNamedAndArgvOnlyCandidates() { + let named = (0.. CmuxVaultAgentRegistration { + CmuxVaultAgentRegistration( + id: id, + name: id, + detect: CmuxVaultAgentDetectRule(processName: id), + sessionIdSource: .argvOption("--session"), + resumeCommand: "\(id) --session {{sessionId}}" + ) + } + func observed( + _ processName: String, + _ arguments: [String], + environment: [String: String] = [:] + ) -> VaultObservedAgentProcess { + VaultObservedAgentProcess( + processName: processName, + processPath: "/opt/bin/\(processName)", + arguments: arguments, + environment: environment + ) + } + func capturedEnvironment(kind: String, arguments: [String]) -> [String: String] { + var bytes = Data() + for argument in arguments { + bytes.append(contentsOf: argument.utf8) + bytes.append(0) + } + return [ + "CMUX_AGENT_LAUNCH_KIND": kind, + "CMUX_AGENT_LAUNCH_EXECUTABLE": arguments.first ?? kind, + "CMUX_AGENT_LAUNCH_ARGV_B64": bytes.base64EncodedString(), + ] + } + + XCTAssertFalse(registration("codex").processDetectedSnapshotIsRestorable( + for: observed( + "codex", + ["/opt/bin/codex", "exec", "fix this"], + environment: capturedEnvironment( + kind: "codex", + arguments: ["/opt/bin/codex"] + ) + ) + )) + XCTAssertFalse(registration("claude").processDetectedSnapshotIsRestorable( + for: observed("claude", ["/opt/bin/claude", "--print", "fix this"]) + )) + XCTAssertFalse(registration("opencode").processDetectedSnapshotIsRestorable( + for: observed("opencode", ["/opt/bin/opencode", "run", "fix this"]) + )) + for kind in ["pi", "omp", "campfire"] { + let captured = ["/opt/bin/\(kind)", "--print", "fix this"] + var environment = capturedEnvironment(kind: kind, arguments: captured) + if kind == "campfire" { environment["CAMPFIRE_SESSION_ROLE"] = "host" } + XCTAssertFalse( + registration(kind).processDetectedSnapshotIsRestorable( + for: observed("node", ["/usr/bin/node"], environment: environment) + ), + kind + ) + } + XCTAssertFalse(registration("campfire").processDetectedSnapshotIsRestorable( + for: observed( + "campfire", + ["/opt/bin/campfire", "--session", "session"], + environment: ["CAMPFIRE_SESSION_ROLE": "joiner"] + ) + )) + XCTAssertTrue(registration("codex").processDetectedSnapshotIsRestorable( + for: observed("codex", ["/opt/bin/codex"]) + )) + XCTAssertTrue(registration("future-agent").processDetectedSnapshotIsRestorable( + for: observed("future-agent", ["/opt/bin/future-agent", "--print", "fix this"]) + )) + } + + func testCustomAliasesOfNativeAgentsStillRejectOneShotAndNonHostLaunches() { + func registration(_ id: String, processName: String) -> CmuxVaultAgentRegistration { + CmuxVaultAgentRegistration( + id: id, + name: id, + detect: CmuxVaultAgentDetectRule(processName: processName), + sessionIdSource: .argvOption("--session"), + resumeCommand: "\(processName) --session {{sessionId}}" + ) + } + func observed( + _ processName: String, + _ arguments: [String], + path: String? = nil, + environment: [String: String] = [:] + ) -> VaultObservedAgentProcess { + VaultObservedAgentProcess( + processName: processName, + processPath: path ?? "/opt/bin/\(processName)", + arguments: arguments, + environment: environment + ) + } + + let claudeAlias = registration("company-claude", processName: "claude") + XCTAssertFalse(claudeAlias.processDetectedSnapshotIsRestorable( + for: observed("claude", ["/opt/bin/claude", "--print", "fix this"]) + )) + XCTAssertTrue(claudeAlias.processDetectedSnapshotIsRestorable( + for: observed("claude", ["/opt/bin/claude"]) + )) + XCTAssertFalse(claudeAlias.processDetectedSnapshotIsRestorable( + for: observed( + "node", + [ + "/usr/bin/node", + "/opt/node_modules/@anthropic-ai/claude-code/cli.js", + "--print", + "fix this", + ], + path: "/usr/bin/node" + ) + )) + XCTAssertTrue(claudeAlias.processDetectedSnapshotIsRestorable( + for: observed( + "node", + ["/usr/bin/node", "/opt/node_modules/@anthropic-ai/claude-code/cli.js"], + path: "/usr/bin/node" + ) + )) + + let codexAlias = registration("company-codex", processName: "codex") + XCTAssertFalse(codexAlias.processDetectedSnapshotIsRestorable( + for: observed("codex", ["/opt/bin/codex", "exec", "fix this"]) + )) + XCTAssertTrue(codexAlias.processDetectedSnapshotIsRestorable( + for: observed("codex", ["/opt/bin/codex"]) + )) + + let campfireAlias = registration("company-campfire", processName: "campfire") + XCTAssertFalse(campfireAlias.processDetectedSnapshotIsRestorable( + for: observed( + "campfire", + ["/opt/bin/campfire", "--session", "session"], + environment: ["CAMPFIRE_SESSION_ROLE": "joiner"] + ) + )) + XCTAssertTrue(campfireAlias.processDetectedSnapshotIsRestorable( + for: observed( + "campfire", + ["/opt/bin/campfire", "--session", "session"], + environment: ["CAMPFIRE_SESSION_ROLE": "host"] + ) + )) + } + + func testVaultRegistryRejectsGlobalProjectCaseCollisionButKeepsExactProjectOverride() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-vault-layer-collision-\(UUID().uuidString)", isDirectory: true) + let project = root.appendingPathComponent("project", isDirectory: true) + let globalConfig = root.appendingPathComponent(".config/cmux/cmux.json") + let projectConfig = project.appendingPathComponent(".cmux/cmux.json") + defer { try? FileManager.default.removeItem(at: root) } + + try writeVaultConfig( + [ + makeVaultRegistration(id: "Layered", name: "Global Layered"), + makeVaultRegistration(id: "exact", name: "Global Exact"), + ], + to: globalConfig + ) + try writeVaultConfig( + [ + makeVaultRegistration(id: "layered", name: "Project Layered"), + makeVaultRegistration(id: "exact", name: "Project Exact"), + ], + to: projectConfig + ) + + let registry = CmuxVaultAgentRegistry.load( + homeDirectory: root.path, + workingDirectory: project.path, + environment: [:] + ) + XCTAssertNil(registry.registration(id: "Layered")) + XCTAssertNil(registry.registration(id: "layered")) + XCTAssertEqual(registry.registration(id: "exact")?.name, "Project Exact") + } + + func testRegistryOwnedSnapshotRegistrationRoundTripsPreserveResumeAndForkOwnership() throws { + let ollama = makeVaultRegistration(id: "ollama", name: "Ollama") + let registrations = [ + CmuxVaultAgentRegistration.builtInGrok, + CmuxVaultAgentRegistration.builtInPi, + ollama, + CmuxVaultAgentRegistration.builtInOmp, + CmuxVaultAgentRegistration.builtInCampfire, + ] + + for base in registrations { + var project = base + project.name = "Project \(base.name)" + project.resumeCommand = "{{executable}} --project-resume {{sessionId}}" + project.forkCommand = "{{executable}} --project-fork {{sessionId}}" + let snapshot = SessionRestorableAgentSnapshot( + kind: .custom(project.id), + sessionId: "session-123", + workingDirectory: "/tmp/project", + launchCommand: AgentLaunchCommandSnapshot( + launcher: project.id, + executablePath: "/opt/bin/\(project.id)", + arguments: ["/opt/bin/\(project.id)"], + workingDirectory: "/tmp/project", + environment: nil, + capturedAt: 123, + source: "test" + ), + registration: project + ) + + let decoded = try JSONDecoder().decode( + SessionRestorableAgentSnapshot.self, + from: JSONEncoder().encode(snapshot) + ) + XCTAssertEqual(decoded.kind, .custom(project.id), project.id) + XCTAssertEqual(decoded.registration, project, project.id) + XCTAssertTrue(decoded.resumeCommand?.contains("'--project-resume'") == true, project.id) + XCTAssertTrue(decoded.forkCommand?.contains("'--project-fork'") == true, project.id) + } + } + + func testSnapshotDecodeRejectsMismatchedEmbeddedRegistrationIdentity() throws { + let snapshot = SessionRestorableAgentSnapshot( + kind: .custom("snapshot-agent"), + sessionId: "session-123", + workingDirectory: nil, + launchCommand: nil, + registration: makeVaultRegistration(id: "different-agent", name: "Different Agent") + ) + + XCTAssertThrowsError( + try JSONDecoder().decode( + SessionRestorableAgentSnapshot.self, + from: JSONEncoder().encode(snapshot) + ) + ) { error in + XCTAssertTrue(String(describing: error).contains("does not match")) + } + } + func testRegisteredSessionAgentCodablePreservesPresentation() throws { let encoded = try JSONEncoder().encode( SessionAgent.registered(RegisteredSessionAgent( @@ -265,6 +772,10 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(registration.id, "grok") XCTAssertEqual(registration.sessionIdSource, .grokSessionDirectory) XCTAssertEqual(registration.sessionDirectory, "~/.grok/sessions") + XCTAssertEqual( + registration.forkCommand, + "{{executable}} --resume {{sessionId}} --fork-session" + ) XCTAssertEqual(registration.detect.processNames, ["grok", "grok-macos-aarch64", "grok-macos-aarch"]) XCTAssertTrue(registration.detect.argvContains.isEmpty) XCTAssertEqual(SessionAgent.grok.assetName, "AgentIcons/Grok") @@ -900,6 +1411,177 @@ final class PiVaultAgentPersistenceTests: XCTestCase { ) } + func testGrokVaultFindsCanonicalObservedHomeBeyondLegacyProjection() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-grok-canonical-home-\(UUID().uuidString)", isDirectory: true) + let homeDirectory = tempDir.appendingPathComponent("home", isDirectory: true) + let stateDirectory = homeDirectory.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let cwd = "/tmp/grok canonical observed home" + let targetSessionID = "grok-session-older-than-projection" + let targetGrokHome = tempDir.appendingPathComponent("canonical-grok-home", isDirectory: true) + let historyURL = targetGrokHome + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(GrokSessionLocator.encodedSessionCWD(cwd), isDirectory: true) + .appendingPathComponent(targetSessionID, isDirectory: true) + .appendingPathComponent("chat_history.jsonl", isDirectory: false) + try FileManager.default.createDirectory( + at: historyURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try #"{"type":"user","content":"Find canonical GROK_HOME","model":"grok-4"}"# + .write(to: historyURL, atomically: true, encoding: .utf8) + + func record( + sessionID: String, + grokHome: String, + updatedAt: TimeInterval + ) throws -> CmuxAgentSessionRegistry.Record { + let json = try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "updatedAt": updatedAt, + "launchCommand": ["environment": ["GROK_HOME": grokHome]], + ], options: [.sortedKeys]) + return CmuxAgentSessionRegistry.Record( + provider: "grok", + sessionID: sessionID, + updatedAt: updatedAt, + json: json + ) + } + + var records = try (0..<300).map { index in + try record( + sessionID: String(format: "recent-%03d", index), + grokHome: tempDir.appendingPathComponent("recent-\(index)").path, + updatedAt: TimeInterval(1_000 + index) + ) + } + records.append(try record( + sessionID: targetSessionID, + grokHome: targetGrokHome.path, + updatedAt: 1 + )) + let registry = CmuxAgentSessionRegistry( + url: stateDirectory.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try registry.apply(provider: "grok", records: records) + + var projectedSessions: [String: Any] = [:] + for projected in records.prefix(256) { + projectedSessions[projected.sessionID] = try JSONSerialization.jsonObject(with: projected.json) + } + let projection = try JSONSerialization.data(withJSONObject: [ + "version": 1, + "sessions": projectedSessions, + ], options: [.sortedKeys]) + try projection.write( + to: stateDirectory.appendingPathComponent("grok-hook-sessions.json"), + options: .atomic + ) + + let entries = await SessionIndexStore.loadGrokEntries( + registration: .builtInGrok, + needle: "", + cwdFilter: nil, + offset: 0, + limit: 10, + environment: [:], + homeDirectory: homeDirectory.path + ) + + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.sessionId, targetSessionID) + XCTAssertEqual(entry.title, "Find canonical GROK_HOME") + XCTAssertEqual(entry.cwd, cwd) + } + + func testGrokObservedHomeDiscoveryBoundsCanonicalRootsAndRetainsActiveOwner() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-grok-home-budget-\(UUID().uuidString)", isDirectory: true) + let homeDirectory = tempDir.appendingPathComponent("home", isDirectory: true) + let stateDirectory = homeDirectory.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + func record( + sessionID: String, + grokHome: String, + updatedAt: TimeInterval + ) throws -> CmuxAgentSessionRegistry.Record { + let json = try JSONSerialization.data(withJSONObject: [ + "sessionId": sessionID, + "updatedAt": updatedAt, + "launchCommand": ["environment": ["GROK_HOME": grokHome]], + ], options: [.sortedKeys]) + return .init( + provider: "grok", + sessionID: sessionID, + updatedAt: updatedAt, + json: json + ) + } + + let activeSessionID = "active-old" + let activeHome = tempDir.appendingPathComponent("active-old-home").path + let activeSurfaceID = UUID().uuidString + var records = try (0..<700).map { index in + try record( + sessionID: String(format: "recent-%03d", index), + grokHome: tempDir.appendingPathComponent("recent-home-\(index)").path, + updatedAt: TimeInterval(1_000 + index) + ) + } + records.append(try record( + sessionID: activeSessionID, + grokHome: activeHome, + updatedAt: 1 + )) + let slotJSON = try JSONSerialization.data(withJSONObject: [ + "sessionId": activeSessionID, + "updatedAt": 1.0, + ], options: [.sortedKeys]) + let registry = CmuxAgentSessionRegistry( + url: stateDirectory.appendingPathComponent(CmuxAgentSessionRegistry.filename) + ) + try registry.apply( + provider: "grok", + records: records, + activeSlots: [ + .init( + provider: "grok", + scope: .surface, + scopeID: activeSurfaceID, + sessionID: activeSessionID, + updatedAt: 1, + json: slotJSON + ), + ] + ) + + let homes = GrokSessionLocator.observedGrokHomes( + homeDirectory: homeDirectory.path, + environment: [:] + ) + XCTAssertEqual(homes.count, GrokSessionLocator.maximumObservedGrokHomes) + XCTAssertEqual(homes.first, activeHome) + XCTAssertEqual(homes.dropFirst().first, tempDir.appendingPathComponent("recent-home-699").path) + + let suppliedHomes = (0..<100).map { + tempDir.appendingPathComponent("supplied-home-\($0)").path + } + let roots = GrokSessionLocator.sessionRoots( + registration: .builtInGrok, + cwdFilter: nil, + environment: [:], + homeDirectory: homeDirectory.path, + observedGrokHomes: suppliedHomes + ) + XCTAssertEqual(roots.count, GrokSessionLocator.maximumObservedGrokHomes + 1) + } + func testRegisteredGrokSessionDirectoryUsesNativeDirectoryLayout() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-registered-grok-vault-\(UUID().uuidString)", isDirectory: true) @@ -1144,4 +1826,30 @@ final class PiVaultAgentPersistenceTests: XCTestCase { ] ) } + + private func makeVaultRegistration( + id: String, + name: String + ) -> CmuxVaultAgentRegistration { + CmuxVaultAgentRegistration( + id: id, + name: name, + detect: CmuxVaultAgentDetectRule(processName: id), + sessionIdSource: .argvOption("--session"), + resumeCommand: "{{executable}} --resume {{sessionId}}", + cwd: .preserve + ) + } + + private func writeVaultConfig( + _ registrations: [CmuxVaultAgentRegistration], + to url: URL + ) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let config = CmuxConfigFile(vault: CmuxVaultConfigDefinition(agents: registrations)) + try JSONEncoder().encode(config).write(to: url, options: .atomic) + } } diff --git a/cmuxTests/RestorableAgentHookProviderResumeTests.swift b/cmuxTests/RestorableAgentHookProviderResumeTests.swift index 151c06c8ae5c..6e3a3e2795d3 100644 --- a/cmuxTests/RestorableAgentHookProviderResumeTests.swift +++ b/cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -12,6 +12,7 @@ extension SocketListenerAcceptPolicyTests { let snapshot = SessionRestorableAgentSnapshot( kind: .gemini, sessionId: "5839bed1-0a60-4c05-b6d1-2410d7a3741e", + transcriptPath: "/tmp/gemini repo/session-2026-07-18T04-52-5839bed1.jsonl", workingDirectory: "/tmp/gemini repo", launchCommand: AgentLaunchCommandSnapshot( launcher: "gemini", @@ -40,7 +41,7 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( snapshot.resumeCommand, - "cd -- '/tmp/gemini repo' 2>/dev/null || [ ! -d '/tmp/gemini repo' ] && 'env' 'GEMINI_CLI_HOME=/tmp/gemini home' '/Users/example/.bun/bin/gemini' '--resume' '5839bed1-0a60-4c05-b6d1-2410d7a3741e' '--model' 'gemini-2.5-pro' '--sandbox' 'danger-full-access' '--approval-mode' 'yolo'" + "cd -- '/tmp/gemini repo' 2>/dev/null || [ ! -d '/tmp/gemini repo' ] && 'env' 'GEMINI_CLI_HOME=/tmp/gemini home' '/Users/example/.bun/bin/gemini' '--session-file' '/tmp/gemini repo/session-2026-07-18T04-52-5839bed1.jsonl' '--model' 'gemini-2.5-pro' '--sandbox' 'danger-full-access' '--approval-mode' 'yolo'" ) } diff --git a/cmuxTests/RestorableAgentNonInteractiveTests.swift b/cmuxTests/RestorableAgentNonInteractiveTests.swift index 8614d2ed75fa..667cc97e6a14 100644 --- a/cmuxTests/RestorableAgentNonInteractiveTests.swift +++ b/cmuxTests/RestorableAgentNonInteractiveTests.swift @@ -213,6 +213,34 @@ final class RestorableAgentNonInteractiveTests: XCTestCase { source: nil ) ) + let kimiPrint = SessionRestorableAgentSnapshot( + kind: .kimi, + sessionId: "kimi-print-session-123", + workingDirectory: nil, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "kimi", + executablePath: "kimi", + arguments: ["kimi", "--print", "fix this"], + workingDirectory: nil, + environment: nil, + capturedAt: nil, + source: nil + ) + ) + let kimiPrompt = SessionRestorableAgentSnapshot( + kind: .kimi, + sessionId: "kimi-prompt-session-123", + workingDirectory: nil, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "kimi", + executablePath: "kimi", + arguments: ["kimi", "--prompt", "fix this"], + workingDirectory: nil, + environment: nil, + capturedAt: nil, + source: nil + ) + ) XCTAssertNil(claudePrint.resumeCommand) XCTAssertNil(claudePrintEquals.resumeCommand) @@ -228,5 +256,7 @@ final class RestorableAgentNonInteractiveTests: XCTestCase { XCTAssertNil(codeBuddyPrint.resumeCommand) XCTAssertNil(factoryExec.resumeCommand) XCTAssertNil(qoderPrint.resumeCommand) + XCTAssertNil(kimiPrint.resumeCommand) + XCTAssertNotNil(kimiPrompt.resumeCommand) } } diff --git a/cmuxTests/RestorableAgentSessionIndexTests.swift b/cmuxTests/RestorableAgentSessionIndexTests.swift index ca1bab72e09b..b07fda290d3d 100644 --- a/cmuxTests/RestorableAgentSessionIndexTests.swift +++ b/cmuxTests/RestorableAgentSessionIndexTests.swift @@ -384,7 +384,7 @@ struct RestorableAgentSessionIndexTests { } @Test - func testPanelFallbackUsesLatestHookRecord() throws { + func testPanelFallbackRejectsAmbiguousHookRecords() throws { let fm = FileManager.default let root = fm.temporaryDirectory .appendingPathComponent("cmux-claude-panel-fallback-\(UUID().uuidString)", isDirectory: true) @@ -442,10 +442,7 @@ struct RestorableAgentSessionIndexTests { index.snapshot(workspaceId: oldWorkspaceId, panelId: panelId)?.sessionId, oldSessionId ) - XCTAssertEqual( - index.snapshot(workspaceId: movedWorkspaceId, panelId: panelId)?.sessionId, - latestSessionId - ) + XCTAssertNil(index.snapshot(workspaceId: movedWorkspaceId, panelId: panelId)) } // A Claude session can start in one directory and `cd` into another (e.g. a repo root then a @@ -561,6 +558,59 @@ struct RestorableAgentSessionIndexTests { XCTAssertEqual(snapshot.workingDirectory, launchCwd.path) } + @Test + func testClaudeForkIgnoresStaleExplicitTranscriptPathWhenExactLookupSucceeds() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-claude-fork-stale-path-\(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 launchCwd = root.appendingPathComponent("repo.main", isDirectory: true) + let driftedCwd = root.appendingPathComponent("worktree", isDirectory: true) + try fm.createDirectory(at: launchCwd, withIntermediateDirectories: true) + try fm.createDirectory(at: driftedCwd, withIntermediateDirectories: true) + + let sessionId = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff" + let realProjectDir = projectsDir.appendingPathComponent( + expectedClaudeProjectDirName(launchCwd.path), + isDirectory: true + ) + let realTranscriptURL = realProjectDir.appendingPathComponent("\(sessionId).jsonl") + try writeClaudeTranscript(sessionId: sessionId, transcriptURL: realTranscriptURL, cwd: launchCwd) + let staleProjectDir = projectsDir.appendingPathComponent( + expectedClaudeProjectDirName(driftedCwd.path), + isDirectory: true + ) + try fm.createDirectory(at: staleProjectDir, withIntermediateDirectories: true) + let staleTranscriptURL = staleProjectDir.appendingPathComponent("\(sessionId).jsonl") + + let workspaceId = UUID() + let panelId = UUID() + try writeClaudeHookStore( + root: root, + sessions: [ + sessionId: driftedHookRecord( + sessionId: sessionId, + workspaceId: workspaceId, + panelId: panelId, + recordedCwd: driftedCwd.path, + launchCwd: launchCwd.path, + configDir: configDir.path, + transcriptPath: staleTranscriptURL.path, + updatedAt: 10 + ), + ] + ) + + let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + let snapshot = try #require(index.snapshot(workspaceId: workspaceId, panelId: panelId)) + #expect(snapshot.workingDirectory == launchCwd.path) + #expect(snapshot.resumeCommand?.contains("cd -- '\(launchCwd.path)'") == true) + #expect(snapshot.resumeCommand?.contains(driftedCwd.path) == false) + } + // The transcript exists but its project directory encodes to neither the launch cwd nor the // drifted cwd (an out-of-tree transcript_path), and the config dir holds no matching project // folder, so neither verifier can confirm a candidate. Resolution must still prefer the launch @@ -638,6 +688,8 @@ struct RestorableAgentSessionIndexTests { let driftedCwd = root.appendingPathComponent("worktree", isDirectory: true) try fm.createDirectory(at: launchCwd, withIntermediateDirectories: true) try fm.createDirectory(at: driftedCwd, withIntermediateDirectories: true) + let transcriptPath = launchCwd.appendingPathComponent("gemini-session.jsonl").path + try "{}\n".write(toFile: transcriptPath, atomically: true, encoding: .utf8) let sessionId = "dddddddd-dddd-dddd-dddd-dddddddddddd" let workspaceId = UUID() @@ -653,6 +705,7 @@ struct RestorableAgentSessionIndexTests { panelId: panelId, recordedCwd: driftedCwd.path, launchCwd: launchCwd.path, + transcriptPath: transcriptPath, updatedAt: 10 ), ] @@ -675,7 +728,42 @@ struct RestorableAgentSessionIndexTests { } @Test - func testPiDetectedLatestSessionDoesNotCollapseExactHookRecordsAcrossPanels() throws { + func testGeminiWithoutNonEmptySessionFileDoesNotRestore() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-gemini-missing-transcript-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let cwd = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: cwd, withIntermediateDirectories: true) + let workspaceId = UUID() + let panelId = UUID() + let sessionId = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" + let emptyTranscriptPath = root.appendingPathComponent("empty.jsonl").path + try Data().write(to: URL(fileURLWithPath: emptyTranscriptPath)) + try writeHookStore( + root: root, + storeFilename: "gemini-hook-sessions.json", + sessions: [ + sessionId: driftedAgentHookRecord( + launcher: "gemini", + sessionId: sessionId, + workspaceId: workspaceId, + panelId: panelId, + recordedCwd: cwd.path, + launchCwd: cwd.path, + transcriptPath: emptyTranscriptPath, + updatedAt: 10 + ), + ] + ) + + let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + XCTAssertNil(index.snapshot(workspaceId: workspaceId, panelId: panelId)) + } + + @Test + func testPiPlainProcessesKeepExactHookRecordsAcrossPanels() throws { let fm = FileManager.default let root = fm.temporaryDirectory .appendingPathComponent("cmux-pi-restore-collapse-\(UUID().uuidString)", isDirectory: true) @@ -754,8 +842,7 @@ struct RestorableAgentSessionIndexTests { } ) - let detectedSessionIds = Set(detectedSnapshots.values.map { $0.snapshot.sessionId }) - XCTAssertEqual(detectedSessionIds.count, 1, "Pi latest-file detection is ambiguous for same-cwd panels") + XCTAssertTrue(detectedSnapshots.isEmpty) let index = RestorableAgentSessionIndex.load( homeDirectory: root.path, @@ -772,7 +859,7 @@ struct RestorableAgentSessionIndexTests { } @Test - func testPiDetectedLatestSessionDoesNotCollapseExactHookRecordsAfterWorkspaceIdRotation() throws { + func testPiPlainProcessesKeepUniquePanelHookRecordsAfterWorkspaceIdRotation() throws { let fm = FileManager.default let root = fm.temporaryDirectory .appendingPathComponent("cmux-pi-restore-workspace-rotation-\(UUID().uuidString)", isDirectory: true) @@ -852,12 +939,7 @@ struct RestorableAgentSessionIndexTests { } ) - let expectedDetectedKeys = Set(zip(restoredWorkspaceIds, panels).map { workspaceId, panelId in - RestorableAgentSessionIndex.PanelKey(workspaceId: workspaceId, panelId: panelId) - }) - XCTAssertEqual(Set(detectedSnapshots.keys), expectedDetectedKeys) - let detectedSessionIds = Set(detectedSnapshots.values.map { $0.snapshot.sessionId }) - XCTAssertEqual(detectedSessionIds.count, 1, "Pi latest-file detection is ambiguous for same-cwd workspaces") + XCTAssertTrue(detectedSnapshots.isEmpty) let index = RestorableAgentSessionIndex.load( homeDirectory: root.path, @@ -874,7 +956,7 @@ struct RestorableAgentSessionIndexTests { } @Test - func testPiDetectedLatestSessionDoesNotUsePanelOnlyFallbackWhenPanelIdIsAmbiguous() throws { + func testPiPlainProcessDoesNotUseAmbiguousPanelOnlyHookFallback() throws { let fm = FileManager.default let root = fm.temporaryDirectory .appendingPathComponent("cmux-pi-restore-panel-id-ambiguous-\(UUID().uuidString)", isDirectory: true) @@ -899,7 +981,6 @@ struct RestorableAgentSessionIndexTests { (sessionId: "pi-old-workspace-b", workspaceId: oldWorkspaceId), (sessionId: "pi-other-old-workspace", workspaceId: otherOldWorkspaceId), ] - let detectedLatestSessionId = "pi-detected-newest" var hookSessions: [String: [String: Any]] = [:] for (index, hookRecord) in hookRecords.enumerated() { let sessionFile = projectSessions.appendingPathComponent("\(hookRecord.sessionId).jsonl", isDirectory: false) @@ -918,12 +999,6 @@ struct RestorableAgentSessionIndexTests { updatedAt: TimeInterval(10 + index) ) } - let detectedLatestFile = projectSessions.appendingPathComponent("\(detectedLatestSessionId).jsonl", isDirectory: false) - try "{}\n".write(to: detectedLatestFile, atomically: true, encoding: .utf8) - try fm.setAttributes( - [.modificationDate: Date(timeIntervalSince1970: 2_000)], - ofItemAtPath: detectedLatestFile.path - ) try writeHookStore(root: root, storeFilename: "pi-hook-sessions.json", sessions: hookSessions) let process = CmuxTopProcessInfo( @@ -963,12 +1038,7 @@ struct RestorableAgentSessionIndexTests { ) } ) - let restoredKey = RestorableAgentSessionIndex.PanelKey( - workspaceId: restoredWorkspaceId, - panelId: panelId - ) - let detected = try XCTUnwrap(detectedSnapshots[restoredKey]) - XCTAssertEqual(detected.snapshot.sessionId, detectedLatestSessionId) + XCTAssertTrue(detectedSnapshots.isEmpty) let index = RestorableAgentSessionIndex.load( homeDirectory: root.path, @@ -977,155 +1047,159 @@ struct RestorableAgentSessionIndexTests { detectedSnapshots: detectedSnapshots, processArgumentsProvider: { _ in nil } ) - let snapshot = try XCTUnwrap(index.snapshot(workspaceId: restoredWorkspaceId, panelId: panelId)) + XCTAssertNil(index.snapshot(workspaceId: restoredWorkspaceId, panelId: panelId)) + } - XCTAssertEqual(snapshot.sessionId, detectedLatestSessionId) + // RestorableAgentKind.cwdNamespacing delegates to the shared AgentResumeWorkingDirectory + // classifier (in CMUXAgentLaunch) so the app-side resolver and the CLI surface-restore publisher + // apply one policy. The shared resolver's own behavior is covered in CMUXAgentLaunchTests. + @Test + func testRestorableAgentKindCwdNamespacingMatchesSharedClassifier() { + for kind in RestorableAgentKind.allCases { + XCTAssertEqual( + kind.cwdNamespacing, + AgentResumeWorkingDirectory().cwdNamespacing(forKind: kind.rawValue), + "\(kind.rawValue) namespacing must match the shared classifier" + ) + } } @Test - func testPiInferredLatestFallbackUsesSameKindPanelHookWhenAnotherKindIsNewer() throws { + func testClaudeWorkflowDirectorySessionDoesNotGuessSiblingSessionForResume() throws { let fm = FileManager.default let root = fm.temporaryDirectory - .appendingPathComponent("cmux-pi-restore-kind-fallback-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("cmux-claude-workflow-directory-\(UUID().uuidString)", isDirectory: true) defer { try? fm.removeItem(at: root) } - let cwd = root.appendingPathComponent("repo", isDirectory: true) let configDir = root.appendingPathComponent("claude-config", isDirectory: true) let projectsDir = configDir.appendingPathComponent("projects", isDirectory: true) + let cwd = root.appendingPathComponent("repo", isDirectory: true) try fm.createDirectory(at: cwd, withIntermediateDirectories: true) + let projectDir = projectsDir.appendingPathComponent( + expectedClaudeProjectDirName(cwd.path), + isDirectory: true + ) + let workflowContainerSessionId = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa" + let resumableSessionId = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb" + let workflowContainerURL = projectDir + .appendingPathComponent(workflowContainerSessionId, isDirectory: true) try fm.createDirectory( - at: projectsDir.appendingPathComponent( - RestorableAgentSessionIndex.encodeClaudeProjectDir(cwd.path), - isDirectory: true - ), + at: workflowContainerURL + .appendingPathComponent("subagents", isDirectory: true), withIntermediateDirectories: true ) + let siblingTranscriptURL = projectDir.appendingPathComponent("\(resumableSessionId).jsonl", isDirectory: false) + try writeClaudeTranscript(sessionId: resumableSessionId, transcriptURL: siblingTranscriptURL, cwd: cwd) let workspaceId = UUID() let panelId = UUID() - let claudeSessionId = "11111111-1111-1111-1111-111111111111" - let piHookSessionId = "pi-exact-panel-session" - let detectedLatestPiSessionId = "pi-newest-cwd-session" - - try writeClaudeTranscript(sessionId: claudeSessionId, cwd: cwd, projectsDir: projectsDir) try writeClaudeHookStore( root: root, sessions: [ - claudeSessionId: hookRecord( - sessionId: claudeSessionId, + workflowContainerSessionId: hookRecord( + sessionId: workflowContainerSessionId, workspaceId: workspaceId, panelId: panelId, cwd: cwd.path, configDir: configDir.path, - updatedAt: 50 + transcriptPath: workflowContainerURL.path, + isRestorable: true, + updatedAt: 10 ), ] ) - try writeHookStore( + + let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + XCTAssertNil( + index.snapshot(workspaceId: workspaceId, panelId: panelId), + "A sibling transcript is not authoritative evidence for the hook's session identity" + ) + } + + @Test + func testClaudeSessionDoesNotSearchUnrelatedProjectDirectoriesForResume() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-claude-unrelated-project-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let configDir = root.appendingPathComponent("claude-config", isDirectory: true) + let recordedCwd = root.appendingPathComponent("recorded-repo", isDirectory: true) + let unrelatedCwd = root.appendingPathComponent("unrelated-repo", isDirectory: true) + try fm.createDirectory(at: recordedCwd, withIntermediateDirectories: true) + try fm.createDirectory(at: unrelatedCwd, withIntermediateDirectories: true) + + let sessionId = "dddddddd-4444-4444-4444-dddddddddddd" + let unrelatedTranscriptURL = configDir + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent(expectedClaudeProjectDirName(unrelatedCwd.path), isDirectory: true) + .appendingPathComponent("\(sessionId).jsonl", isDirectory: false) + try writeClaudeTranscript( + sessionId: sessionId, + transcriptURL: unrelatedTranscriptURL, + cwd: unrelatedCwd + ) + + let workspaceId = UUID() + let panelId = UUID() + try writeClaudeHookStore( root: root, - storeFilename: "pi-hook-sessions.json", sessions: [ - piHookSessionId: driftedAgentHookRecord( - launcher: "pi", - sessionId: piHookSessionId, + sessionId: hookRecord( + sessionId: sessionId, workspaceId: workspaceId, panelId: panelId, - recordedCwd: cwd.path, - launchCwd: cwd.path, + cwd: recordedCwd.path, + configDir: configDir.path, + transcriptPath: nil, + isRestorable: true, updatedAt: 10 ), ] ) - let detectedSnapshot = SessionRestorableAgentSnapshot( - kind: .custom("pi"), - sessionId: detectedLatestPiSessionId, - workingDirectory: cwd.path, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "pi", - executablePath: "/usr/local/bin/pi", - arguments: ["/usr/local/bin/pi"], - workingDirectory: cwd.path, - environment: nil, - capturedAt: 99, - source: "process" - ) - ) - let key = RestorableAgentSessionIndex.PanelKey(workspaceId: workspaceId, panelId: panelId) - let index = RestorableAgentSessionIndex.load( - homeDirectory: root.path, - fileManager: fm, - registry: CmuxVaultAgentRegistry(registrations: [.builtInPi]), - detectedSnapshots: [ - key: ( - snapshot: detectedSnapshot, - updatedAt: 99, - processIDs: Set([123]), agentProcessIDs: Set([123]), - sessionIDSource: .inferredLatestSessionFile - ), - ], - processArgumentsProvider: { _ in nil } + let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + #expect( + index.snapshot(workspaceId: workspaceId, panelId: panelId) == nil, + "A matching ID under another project is not evidence that this panel owns that conversation" ) - let snapshot = try XCTUnwrap(index.snapshot(workspaceId: workspaceId, panelId: panelId)) - - XCTAssertEqual(snapshot.kind, .custom("pi")) - XCTAssertEqual(snapshot.sessionId, piHookSessionId) - XCTAssertEqual(index.processIDs(workspaceId: workspaceId, panelId: panelId), [123]) } - // RestorableAgentKind.cwdNamespacing delegates to the shared AgentResumeWorkingDirectory - // classifier (in CMUXAgentLaunch) so the app-side resolver and the CLI surface-restore publisher - // apply one policy. The shared resolver's own behavior is covered in CMUXAgentLaunchTests. @Test - func testRestorableAgentKindCwdNamespacingMatchesSharedClassifier() { - for kind in RestorableAgentKind.allCases { - XCTAssertEqual( - kind.cwdNamespacing, - AgentResumeWorkingDirectory().cwdNamespacing(forKind: kind.rawValue), - "\(kind.rawValue) namespacing must match the shared classifier" - ) - } - } - - @Test - func testClaudeWorkflowDirectorySessionUsesSiblingJsonlSessionForResume() throws { + func testClaudeSessionRejectsTranscriptPathForAnotherSession() throws { let fm = FileManager.default let root = fm.temporaryDirectory - .appendingPathComponent("cmux-claude-workflow-directory-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("cmux-claude-mismatched-transcript-\(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 cwd = root.appendingPathComponent("repo", isDirectory: true) try fm.createDirectory(at: cwd, withIntermediateDirectories: true) - let projectDir = projectsDir.appendingPathComponent( - expectedClaudeProjectDirName(cwd.path), - isDirectory: true - ) - let workflowContainerSessionId = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa" - let resumableSessionId = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb" - let workflowContainerURL = projectDir - .appendingPathComponent(workflowContainerSessionId, isDirectory: true) - try fm.createDirectory( - at: workflowContainerURL - .appendingPathComponent("subagents", isDirectory: true), - withIntermediateDirectories: true + + let hookSessionId = "eeeeeeee-5555-5555-5555-eeeeeeeeeeee" + let transcriptSessionId = "ffffffff-6666-6666-6666-ffffffffffff" + let transcriptURL = configDir + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent(expectedClaudeProjectDirName(cwd.path), isDirectory: true) + .appendingPathComponent("\(transcriptSessionId).jsonl", isDirectory: false) + try writeClaudeTranscript( + sessionId: transcriptSessionId, + transcriptURL: transcriptURL, + cwd: cwd ) - let siblingTranscriptURL = projectDir.appendingPathComponent("\(resumableSessionId).jsonl", isDirectory: false) - try writeClaudeTranscript(sessionId: resumableSessionId, transcriptURL: siblingTranscriptURL, cwd: cwd) let workspaceId = UUID() let panelId = UUID() try writeClaudeHookStore( root: root, sessions: [ - workflowContainerSessionId: hookRecord( - sessionId: workflowContainerSessionId, + hookSessionId: hookRecord( + sessionId: hookSessionId, workspaceId: workspaceId, panelId: panelId, cwd: cwd.path, configDir: configDir.path, - transcriptPath: workflowContainerURL.path, + transcriptPath: transcriptURL.path, isRestorable: true, updatedAt: 10 ), @@ -1133,18 +1207,57 @@ struct RestorableAgentSessionIndexTests { ) let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) - let snapshot = try XCTUnwrap(index.snapshot(workspaceId: workspaceId, panelId: panelId)) + #expect( + index.snapshot(workspaceId: workspaceId, panelId: panelId) == nil, + "A transcript path for another session must not authorize restore" + ) + } - XCTAssertEqual(snapshot.sessionId, resumableSessionId) - XCTAssertEqual(snapshot.workingDirectory, cwd.path) - let resumeCommand = try XCTUnwrap(snapshot.resumeCommand) - XCTAssertTrue( - resumeCommand.contains(resumableSessionId), - "resume command must target the sibling transcript session; got: \(resumeCommand)" + @Test(arguments: [ + " session-id", + "session-id ", + "\tsession-id", + "session-id\n", + ]) + func testClaudeSessionRejectsIdentityChangedByWhitespaceNormalization( + rawSessionId: String + ) throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-claude-raw-session-id-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let configDir = root.appendingPathComponent("claude-config", isDirectory: true) + let cwd = root.appendingPathComponent("repo", isDirectory: true) + let transcriptURL = configDir + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent(expectedClaudeProjectDirName(cwd.path), isDirectory: true) + .appendingPathComponent("session-id.jsonl", isDirectory: false) + try fm.createDirectory(at: cwd, withIntermediateDirectories: true) + try writeClaudeTranscript(sessionId: "session-id", transcriptURL: transcriptURL, cwd: cwd) + + let workspaceId = UUID() + let panelId = UUID() + try writeClaudeHookStore( + root: root, + sessions: [ + rawSessionId: hookRecord( + sessionId: rawSessionId, + workspaceId: workspaceId, + panelId: panelId, + cwd: cwd.path, + configDir: configDir.path, + transcriptPath: transcriptURL.path, + isRestorable: true, + updatedAt: 10 + ), + ] ) - XCTAssertFalse( - resumeCommand.contains(workflowContainerSessionId), - "The Workflow container id is not accepted by claude --resume." + + let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + #expect( + index.snapshot(workspaceId: workspaceId, panelId: panelId) == nil, + "Claude restore must preserve the hook's exact session identifier" ) } @@ -1292,9 +1405,9 @@ struct RestorableAgentSessionIndexTests { let cases: [(launcher: String, store: String, verbNeedles: [String])] = [ ("codex", "codex-hook-sessions.json", ["'fork'"]), + ("grok", "grok-hook-sessions.json", ["'--fork-session'"]), ("opencode", "opencode-hook-sessions.json", ["'--session'", "'--fork'"]), ("pi", "pi-hook-sessions.json", ["'--fork'"]), - ("omp", "omp-hook-sessions.json", ["'--fork'"]), ] for testCase in cases { let ws = UUID() @@ -1343,17 +1456,24 @@ struct RestorableAgentSessionIndexTests { let dir = root.appendingPathComponent("repo", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) - for launcher in ["gemini", "grok", "amp", "cursor"] { + for launcher in ["gemini", "amp", "cursor", "omp"] { let ws = UUID() let panel = UUID() let sid = "66666666-6666-6666-6666-666666666666" + let transcriptPath = launcher == "gemini" + ? dir.appendingPathComponent("gemini-nofork.jsonl").path + : nil + if let transcriptPath { + try "{}\n".write(toFile: transcriptPath, atomically: true, encoding: .utf8) + } try writeHookStore( root: root, storeFilename: "\(launcher)-hook-sessions.json", sessions: [ sid: driftedAgentHookRecord( launcher: launcher, sessionId: sid, workspaceId: ws, panelId: panel, - recordedCwd: dir.path, launchCwd: dir.path, updatedAt: 10 + recordedCwd: dir.path, launchCwd: dir.path, + transcriptPath: transcriptPath, updatedAt: 10 ), ] ) @@ -1383,17 +1503,23 @@ struct RestorableAgentSessionIndexTests { let panel = UUID() let oldId = "11111111-1111-1111-1111-111111111111" let newId = "22222222-2222-2222-2222-222222222222" + let oldTranscriptPath = dir.appendingPathComponent("gemini-old.jsonl").path + let newTranscriptPath = dir.appendingPathComponent("gemini-new.jsonl").path + try "{}\n".write(toFile: oldTranscriptPath, atomically: true, encoding: .utf8) + try "{}\n".write(toFile: newTranscriptPath, atomically: true, encoding: .utf8) try writeHookStore( root: root, storeFilename: "gemini-hook-sessions.json", sessions: [ oldId: driftedAgentHookRecord( launcher: "gemini", sessionId: oldId, workspaceId: ws, panelId: panel, - recordedCwd: dir.path, launchCwd: dir.path, updatedAt: 10 + recordedCwd: dir.path, launchCwd: dir.path, + transcriptPath: oldTranscriptPath, updatedAt: 10 ), newId: driftedAgentHookRecord( launcher: "gemini", sessionId: newId, workspaceId: ws, panelId: panel, - recordedCwd: dir.path, launchCwd: dir.path, updatedAt: 20 + recordedCwd: dir.path, launchCwd: dir.path, + transcriptPath: newTranscriptPath, updatedAt: 20 ), ] ) @@ -1419,13 +1545,16 @@ struct RestorableAgentSessionIndexTests { let ws = UUID() let panel = UUID() let sid = "33333333-3333-3333-3333-333333333333" + let transcriptPath = dir.appendingPathComponent("gemini-idempotent.jsonl").path + try "{}\n".write(toFile: transcriptPath, atomically: true, encoding: .utf8) try writeHookStore( root: root, storeFilename: "gemini-hook-sessions.json", sessions: [ sid: driftedAgentHookRecord( launcher: "gemini", sessionId: sid, workspaceId: ws, panelId: panel, - recordedCwd: dir.path, launchCwd: dir.path, updatedAt: 10 + recordedCwd: dir.path, launchCwd: dir.path, + transcriptPath: transcriptPath, updatedAt: 10 ), ] ) @@ -1523,10 +1652,11 @@ struct RestorableAgentSessionIndexTests { panelId: UUID, recordedCwd: String, launchCwd: String, + transcriptPath: String? = nil, updatedAt: TimeInterval, pid: Int? = nil ) -> [String: Any] { - [ + var record: [String: Any] = [ "sessionId": sessionId, "workspaceId": workspaceId.uuidString, "surfaceId": panelId.uuidString, @@ -1543,6 +1673,10 @@ struct RestorableAgentSessionIndexTests { "source": "test", ], ] + if let transcriptPath { + record["transcriptPath"] = transcriptPath + } + return record } private func hookRecord( @@ -1758,6 +1892,64 @@ struct RestorableAgentSessionIndexTests { XCTAssertTrue(fork.contains("fork") && fork.contains(sid), "codex fork must use the fork verb and session id; got: \(fork)") } + // Records written before launch-capture validation can claim the correct + // launcher while carrying an unrelated interpreter entrypoint. Only the + // actual script token, not a later prompt/file argument named `codex`, can + // establish executable identity for automatic replay. + @Test + func testMisleadingInterpreterCaptureIsDiscardedForResumeAndFork() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-misleading-interpreter-capture-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + let dir = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + + let workspaceID = UUID() + let panelID = UUID() + let sessionID = "99999999-9999-9999-9999-999999999999" + var record = driftedAgentHookRecord( + launcher: "codex", + sessionId: sessionID, + workspaceId: workspaceID, + panelId: panelID, + recordedCwd: dir.path, + launchCwd: dir.path, + updatedAt: 10 + ) + record["launchCommand"] = [ + "launcher": "codex", + "executablePath": "/usr/local/bin/node", + "arguments": [ + "/usr/local/bin/node", + "/tmp/unrelated-tool.js", + "/tmp/codex", + "--print", + ], + "workingDirectory": dir.path, + "capturedAt": 10, + "source": "process", + ] + try writeHookStore( + root: root, + storeFilename: "codex-hook-sessions.json", + sessions: [sessionID: record] + ) + + let snapshot = try XCTUnwrap( + RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + .snapshot(workspaceId: workspaceID, panelId: panelID) + ) + let resume = try XCTUnwrap(snapshot.resumeCommand) + XCTAssertFalse(resume.contains("'node'"), "codex resume must not run the unrelated interpreter; got: \(resume)") + XCTAssertFalse(resume.contains("unrelated-tool.js"), "codex resume must drop the unrelated script; got: \(resume)") + XCTAssertTrue(resume.contains("CMUX_CODEX_WRAPPER_SHIM"), "codex resume must use canonical Codex replay; got: \(resume)") + let fork = try XCTUnwrap(snapshot.forkCommand) + XCTAssertFalse(fork.contains("'node'"), "codex fork must not run the unrelated interpreter; got: \(fork)") + XCTAssertFalse(fork.contains("unrelated-tool.js"), "codex fork must drop the unrelated script; got: \(fork)") + XCTAssertTrue(fork.contains("CMUX_CODEX_WRAPPER_SHIM"), "codex fork must use canonical Codex replay; got: \(fork)") + } + // Wrapper launchers legitimately differ from the hook kind; their captures must stay trusted. @Test func testWrapperLauncherCaptureStaysTrusted() throws { @@ -1800,6 +1992,294 @@ struct RestorableAgentSessionIndexTests { ) } + @Test + func testRejectedOneShotCaptureIsExcludedFromAppRestoreIndex() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-rejected-one-shot-restore-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + let dir = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + + let workspaceID = UUID() + let panelID = UUID() + let sessionID = "rejected-one-shot" + var record = driftedAgentHookRecord( + launcher: "opencode", + sessionId: sessionID, + workspaceId: workspaceID, + panelId: panelID, + recordedCwd: dir.path, + launchCwd: dir.path, + updatedAt: 10 + ) + record["launchCommand"] = [ + "launcher": "opencode", + "executablePath": "/usr/local/bin/opencode", + "arguments": ["/usr/local/bin/opencode", "run", "one-shot prompt"], + "workingDirectory": dir.path, + "capturedAt": 10, + "source": "rejected", + ] + try writeHookStore( + root: root, + storeFilename: "opencode-hook-sessions.json", + sessions: [sessionID: record] + ) + + let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + XCTAssertNil( + index.snapshot(workspaceId: workspaceID, panelId: panelID), + "A rejected one-shot capture must not become restorable after an app restart." + ) + } + + @Test + func testLegacyOneShotProcessCaptureIsExcludedFromAppRestoreIndex() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-legacy-one-shot-restore-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + let dir = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + + let workspaceID = UUID() + let panelID = UUID() + let sessionID = "legacy-one-shot" + var record = driftedAgentHookRecord( + launcher: "opencode", + sessionId: sessionID, + workspaceId: workspaceID, + panelId: panelID, + recordedCwd: dir.path, + launchCwd: dir.path, + updatedAt: 10 + ) + record.removeValue(forKey: "isRestorable") + record["launchCommand"] = [ + "launcher": "opencode", + "executablePath": "/usr/local/bin/opencode", + "arguments": ["/usr/local/bin/opencode", "run", "one-shot prompt"], + "workingDirectory": dir.path, + "capturedAt": 10, + // Records written before one-shot rejection used the ordinary + // process source and omitted the explicit restorable bit. + "source": "process", + ] + try writeHookStore( + root: root, + storeFilename: "opencode-hook-sessions.json", + sessions: [sessionID: record] + ) + + let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + XCTAssertNil( + index.snapshot(workspaceId: workspaceID, panelId: panelID), + "A legacy one-shot capture with no replay command must not survive in the app restore index." + ) + } + + @Test + func testAppRestoreAuthorityMatchesCanonicalRunProjection() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-canonical-run-restore-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + let dir = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + + struct Fixture { + var name: String + var recordAuthority: Bool? + var activeRunId: String? + var runs: [[String: Any]]? + var expectedAuthority: Bool + } + let fixtures = [ + Fixture( + name: "stale-promote", + recordAuthority: true, + activeRunId: "child", + runs: [ + ["runId": "root", "restoreAuthority": true, "startedAt": 1.0, "updatedAt": 1.0], + ["runId": "child", "restoreAuthority": false, "startedAt": 2.0, "updatedAt": 2.0], + ], + expectedAuthority: false + ), + Fixture( + name: "stale-demote", + recordAuthority: false, + activeRunId: "root", + runs: [[ + "runId": "root", "restoreAuthority": true, "startedAt": 1.0, "updatedAt": 2.0, + ]], + expectedAuthority: true + ), + Fixture( + name: "missing-active-falls-back", + recordAuthority: true, + activeRunId: "pruned-run", + runs: [ + ["runId": "root", "restoreAuthority": true, "startedAt": 1.0, "updatedAt": 1.0], + ["runId": "child", "restoreAuthority": false, "startedAt": 2.0, "updatedAt": 3.0], + ], + expectedAuthority: false + ), + Fixture( + name: "duplicate-authority-conflict", + recordAuthority: true, + activeRunId: "duplicate", + runs: [ + ["runId": "duplicate", "restoreAuthority": true, "startedAt": 1.0, "updatedAt": 2.0], + ["runId": "duplicate", "restoreAuthority": false, "startedAt": 1.0, "updatedAt": 2.0], + ], + expectedAuthority: false + ), + Fixture( + name: "duplicate-process-conflict", + recordAuthority: true, + activeRunId: "duplicate", + runs: [ + [ + "runId": "duplicate", "pid": 101, "restoreAuthority": true, + "startedAt": 1.0, "updatedAt": 2.0, + ], + [ + "runId": "duplicate", "pid": 202, "restoreAuthority": true, + "startedAt": 1.0, "updatedAt": 2.0, + ], + ], + expectedAuthority: false + ), + Fixture( + name: "duplicate-ended", + recordAuthority: true, + activeRunId: "duplicate", + runs: [ + ["runId": "duplicate", "restoreAuthority": true, "startedAt": 1.0, "updatedAt": 2.0], + [ + "runId": "duplicate", "restoreAuthority": true, "startedAt": 1.0, + "updatedAt": 2.0, "endedAt": 3.0, + ], + ], + expectedAuthority: false + ), + Fixture( + name: "legacy-no-runs", + recordAuthority: nil, + activeRunId: nil, + runs: nil, + expectedAuthority: true + ), + Fixture( + name: "legacy-empty-runs-demoted", + recordAuthority: false, + activeRunId: nil, + runs: [], + expectedAuthority: false + ), + ] + + var sessions: [String: [String: Any]] = [:] + var locations: [(fixture: Fixture, workspaceID: UUID, panelID: UUID)] = [] + for fixture in fixtures { + let workspaceID = UUID() + let panelID = UUID() + let sessionID = "canonical-\(fixture.name)" + var record = driftedAgentHookRecord( + launcher: "opencode", + sessionId: sessionID, + workspaceId: workspaceID, + panelId: panelID, + recordedCwd: dir.path, + launchCwd: dir.path, + updatedAt: 10 + ) + record["startedAt"] = 1.0 + if let recordAuthority = fixture.recordAuthority { + record["restoreAuthority"] = recordAuthority + } else { + record.removeValue(forKey: "restoreAuthority") + } + if let activeRunId = fixture.activeRunId { + record["activeRunId"] = activeRunId + } + if let runs = fixture.runs { + record["runs"] = runs + } + + let cliRecordData = try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + let cliRecord = try JSONDecoder().decode(ClaudeHookSessionRecord.self, from: cliRecordData) + let cliAuthority = AgentSessionRunCanonicalizer().projectedRun( + record: cliRecord, + provider: "opencode" + ).restoreAuthority + XCTAssertEqual( + cliAuthority, + fixture.expectedAuthority, + "The fixture must encode the intended CLI canonical projection for \(fixture.name)." + ) + + sessions[sessionID] = record + locations.append((fixture, workspaceID, panelID)) + } + try writeHookStore( + root: root, + storeFilename: "opencode-hook-sessions.json", + sessions: sessions + ) + + let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + for location in locations { + XCTAssertEqual( + index.snapshot(workspaceId: location.workspaceID, panelId: location.panelID) != nil, + location.fixture.expectedAuthority, + "App restore authority must match the CLI canonical run for \(location.fixture.name)." + ) + } + } + + @Test + func testMalformedCanonicalRunFailsAppRestoreClosed() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-malformed-run-restore-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + let dir = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + + let workspaceID = UUID() + let panelID = UUID() + let sessionID = "malformed-run" + var record = driftedAgentHookRecord( + launcher: "opencode", + sessionId: sessionID, + workspaceId: workspaceID, + panelId: panelID, + recordedCwd: dir.path, + launchCwd: dir.path, + updatedAt: 10 + ) + record["restoreAuthority"] = true + record["activeRunId"] = "missing-authority" + record["runs"] = [[ + "runId": "missing-authority", + "startedAt": 1.0, + "updatedAt": 2.0, + ]] + try writeHookStore( + root: root, + storeFilename: "opencode-hook-sessions.json", + sessions: [sessionID: record] + ) + + let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + XCTAssertNil( + index.snapshot(workspaceId: workspaceID, panelId: panelID), + "A malformed nonempty run array must not fall back to stale record-level authority." + ) + } + private func writeHookStore( root: URL, storeFilename: String, diff --git a/cmuxTests/SessionPersistenceResumeBindingTests.swift b/cmuxTests/SessionPersistenceResumeBindingTests.swift index 0dec6405c48b..89af27dc99a5 100644 --- a/cmuxTests/SessionPersistenceResumeBindingTests.swift +++ b/cmuxTests/SessionPersistenceResumeBindingTests.swift @@ -9,7 +9,7 @@ import Testing #endif @Suite struct SessionPersistenceResumeBindingTests { - @Test func agentHookSurfaceResumeStartupInputPreservesCustomAbsoluteAgentExecutable() throws { + @Test func agentHookSurfaceResumeStartupInputRoutesCustomAbsoluteCodexThroughWrapper() throws { let binding = SurfaceResumeBindingSnapshot( kind: "codex", command: "'/opt/company/bin/codex' 'resume' 'session-custom-cli'", @@ -20,7 +20,9 @@ import Testing let startupInput = try #require(binding.startupInput) - #expect(startupInput.contains("'/opt/company/bin/codex'"), "\(startupInput)") + #expect(startupInput.contains("CMUX_CODEX_WRAPPER_SHIM"), "\(startupInput)") + #expect(startupInput.contains("CMUX_CUSTOM_CODEX_PATH=/opt/company/bin/codex"), "\(startupInput)") + #expect(startupInput.contains("'resume' 'session-custom-cli'"), "\(startupInput)") } @Test func decodingAgentHookBindingRewritesPersistedPATHManagedAgentExecutable() throws { @@ -53,7 +55,7 @@ import Testing #expect(!startupInput.contains(executablePath), "\(startupInput)") } - @Test func legacyAgentHookBindingWithoutKindRewritesPersistedPATHManagedAgentExecutable() throws { + @Test func legacyAgentHookBindingWithoutKindRoutesPersistedCodexThroughWrapper() throws { let executablePath = Self.homeManagedExecutablePath( executableName: "codex", ".nvm", @@ -76,11 +78,12 @@ import Testing #expect(binding.kind == nil) #expect(binding.command.contains(executablePath), "\(binding.command)") - #expect(startupInput.contains("codex 'resume' 'session-legacy-cli'"), "\(startupInput)") - #expect(!startupInput.contains(executablePath), "\(startupInput)") + #expect(startupInput.contains("CMUX_CODEX_WRAPPER_SHIM"), "\(startupInput)") + #expect(startupInput.contains("CMUX_CUSTOM_CODEX_PATH=\(executablePath)"), "\(startupInput)") + #expect(startupInput.contains("'resume' 'session-legacy-cli'"), "\(startupInput)") } - @Test func agentHookBindingRewritesSupportedLocalManagedExecutablePaths() throws { + @Test func agentHookBindingRoutesSupportedLocalManagedExecutablePathsThroughWrapper() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-surface-resume-stale-managed-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } @@ -123,12 +126,13 @@ import Testing ) let startupInput = try #require(binding.startupInput) - #expect(startupInput.contains("codex 'resume' 'session-managed-cli'"), "\(startupInput)") - #expect(!startupInput.contains(executablePath), "\(startupInput)") + #expect(startupInput.contains("CMUX_CODEX_WRAPPER_SHIM"), "\(startupInput)") + #expect(startupInput.contains("CMUX_CUSTOM_CODEX_PATH=\(executablePath)"), "\(startupInput)") + #expect(startupInput.contains("'resume' 'session-managed-cli'"), "\(startupInput)") } } - @Test func agentHookBindingWithDirectEnvironmentAssignmentRewritesMovedExecutable() throws { + @Test func agentHookBindingWithDirectEnvironmentAssignmentRoutesMovedExecutableThroughWrapper() throws { let staleExecutablePath = Self.homeManagedExecutablePath( executableName: "codex", ".nvm", @@ -147,11 +151,13 @@ import Testing let startupInput = try #require(binding.startupInput) - #expect(startupInput.contains("CMUX_TRACE=1 codex 'resume' 'session-env-cli'"), "\(startupInput)") - #expect(!startupInput.contains(staleExecutablePath), "\(startupInput)") + #expect(startupInput.contains("CMUX_TRACE=1"), "\(startupInput)") + #expect(startupInput.contains("CMUX_CODEX_WRAPPER_SHIM"), "\(startupInput)") + #expect(startupInput.contains("CMUX_CUSTOM_CODEX_PATH=\(staleExecutablePath)"), "\(startupInput)") + #expect(startupInput.contains("'resume' 'session-env-cli'"), "\(startupInput)") } - @Test func agentHookBindingWithQuotedEnvAssignmentRewritesMovedExecutable() throws { + @Test func agentHookBindingWithQuotedEnvAssignmentRoutesMovedExecutableThroughWrapper() throws { let staleExecutablePath = Self.homeManagedExecutablePath( executableName: "codex", ".nvm", @@ -169,8 +175,10 @@ import Testing ) let startupInput = try #require(binding.startupInput) - #expect(startupInput.contains("env 'CMUX_TRACE=1' codex 'resume' 'session-quoted-env-cli'"), "\(startupInput)") - #expect(!startupInput.contains(staleExecutablePath), "\(startupInput)") + #expect(startupInput.contains("'CMUX_TRACE=1'"), "\(startupInput)") + #expect(startupInput.contains("CMUX_CODEX_WRAPPER_SHIM"), "\(startupInput)") + #expect(startupInput.contains("CMUX_CUSTOM_CODEX_PATH=\(staleExecutablePath)"), "\(startupInput)") + #expect(startupInput.contains("'resume' 'session-quoted-env-cli'"), "\(startupInput)") } @Test func agentHookClaudeBindingWithDirectEnvironmentAssignmentPreservesAssignmentSyntax() throws { @@ -225,7 +233,7 @@ import Testing #expect(!startupInput.contains(staleExecutablePath), "\(startupInput)") } - @Test func agentHookBindingPreservesRemoteManagedExecutablePath() throws { + @Test func localAgentHookBindingRoutesRemoteLookingCodexPathThroughWrapper() throws { let remoteExecutablePath = "/home/me/.nvm/versions/node/v24.2.0/bin/codex" let binding = SurfaceResumeBindingSnapshot( kind: "codex", @@ -236,7 +244,9 @@ import Testing ) let startupInput = try #require(binding.startupInput) - #expect(startupInput.contains("'\(remoteExecutablePath)' 'resume' 'session-remote-cli'"), "\(startupInput)") + #expect(startupInput.contains("CMUX_CODEX_WRAPPER_SHIM"), "\(startupInput)") + #expect(startupInput.contains("CMUX_CUSTOM_CODEX_PATH=\(remoteExecutablePath)"), "\(startupInput)") + #expect(startupInput.contains("'resume' 'session-remote-cli'"), "\(startupInput)") } @Test func remoteStartupInputPreservesLocalLookingManagedExecutablePaths() throws { @@ -387,7 +397,7 @@ import Testing #expect(!codexOutput.contains(staleExecutablePath), "\(codexOutput)") } - @Test func agentHookSurfaceResumeStartupInputPreservesExistingPATHManagedAgentExecutable() throws { + @Test func agentHookSurfaceResumeStartupInputRoutesExistingPATHManagedAgentExecutableThroughWrapper() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cmux-surface-resume-existing-agent-\(UUID().uuidString)", isDirectory: true) @@ -412,7 +422,9 @@ import Testing ) let startupInput = try #require(binding.startupInput) - #expect(startupInput.contains("'\(executable.path)'"), "\(startupInput)") + #expect(startupInput.contains("CMUX_CODEX_WRAPPER_SHIM"), "\(startupInput)") + #expect(startupInput.contains("CMUX_CUSTOM_CODEX_PATH=\(executable.path)"), "\(startupInput)") + #expect(startupInput.contains("'resume' 'session-existing-cli'"), "\(startupInput)") } @Test func agentHookSurfaceResumeStartupInputFallsBackWhenRecordedAgentExecutableMoved() throws { @@ -472,7 +484,7 @@ import Testing let output = try String(contentsOf: outputURL, encoding: .utf8) #expect(output == "\(cwd.path)|resume session-moved-cli -c check_for_update_on_startup=false --yolo\n") - #expect(!startupInput.contains(movedExecutable.path), "\(startupInput)") + #expect(startupInput.contains("CMUX_CUSTOM_CODEX_PATH=\(movedExecutable.path)"), "\(startupInput)") } private struct ResumeShellTimeout: Error, CustomStringConvertible { diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 1f2146f9eeba..49d32da23eb5 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -874,6 +874,30 @@ final class SessionPersistenceTests: XCTestCase { ) } + func testRestoreCompletionSaveUsesOnlyNonblockingAgentCache() throws { + let source = try String(contentsOf: appDelegateSourceURL(), encoding: .utf8) + let body = try XCTUnwrap(appDelegateSourceFunctionBody( + signature: "private func completeSessionRestoreOperation(", + source: source + )) + + XCTAssertTrue(body.contains("restorableAgentIndex:")) + XCTAssertTrue(body.contains("currentIndexSchedulingRefresh()")) + XCTAssertTrue(body.contains("?? .empty")) + } + + func testClosedWindowHistoryUsesOnlyNonblockingAgentCache() throws { + let source = try String(contentsOf: appDelegateSourceURL(), encoding: .utf8) + let body = try XCTUnwrap(appDelegateSourceFunctionBody( + signature: "private func recordClosedWindowHistoryIfNeeded(", + source: source + )) + + XCTAssertTrue(body.contains("currentIndexSchedulingRefresh()")) + XCTAssertTrue(body.contains("?? .empty")) + XCTAssertFalse(body.contains("RestorableAgentSessionIndex.load()")) + } + func testUnchangedAutosaveFingerprintSkipsWithinStalenessWindow() { let now = Date() XCTAssertTrue( @@ -1768,7 +1792,7 @@ final class SessionPersistenceTests: XCTestCase { resolvedEnvironment = ["PI_CODING_AGENT_DIR": "/tmp/pi"] case .amp: resolvedEnvironment = ["AMP_SETTINGS_FILE": "/tmp/amp-settings.json"] - case .cursor, .rovodev, .factory, .ollama, .custom: + case .cursor, .rovodev, .factory, .ollama, .kimi, .custom: resolvedEnvironment = [:] case .gemini: resolvedEnvironment = ["GEMINI_CLI_HOME": "/tmp/gemini"] @@ -1868,6 +1892,38 @@ final class SessionPersistenceTests: XCTestCase { ) } + private func appDelegateSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/AppDelegate.swift") + } + + private func appDelegateSourceFunctionBody(signature: String, source: String) -> String? { + guard let signatureRange = source.range(of: signature), + let openingBrace = source[signatureRange.lowerBound...].firstIndex(of: "{") else { + return nil + } + + var depth = 0 + var index = openingBrace + while index < source.endIndex { + switch source[index] { + case "{": + depth += 1 + case "}": + depth -= 1 + if depth == 0 { + return String(source[openingBrace...index]) + } + default: + break + } + index = source.index(after: index) + } + return nil + } + private func fileNumber(for fileURL: URL) throws -> Int { let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) return try XCTUnwrap(attributes[.systemFileNumber] as? Int) @@ -1875,6 +1931,100 @@ final class SessionPersistenceTests: XCTestCase { } final class SocketListenerAcceptPolicyTests: XCTestCase { + private enum PrivateLauncherScriptFixture: String, CaseIterable, Sendable { + case agentResume + case surfaceResumeBinding + case sessionRestoredTerminal + + var directoryName: String { + switch self { + case .agentResume: + "cmux-agent-resume" + case .surfaceResumeBinding: + "cmux-surface-resume" + case .sessionRestoredTerminal: + "cmux-session-terminal-command" + } + } + + func writeScript( + marker: String, + temporaryDirectory: URL, + fileManager: FileManager = .default + ) -> URL? { + switch self { + case .agentResume: + let snapshot = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: marker, + workingDirectory: "/tmp" + ) + guard let command = snapshot.resumeStartupCommand( + fileManager: fileManager, + temporaryDirectory: temporaryDirectory + ) else { + return nil + } + let prefix = "/bin/zsh '" + guard command.hasPrefix(prefix), command.hasSuffix("'") else { return nil } + return URL(fileURLWithPath: String(command.dropFirst(prefix.count).dropLast())) + case .surfaceResumeBinding: + let binding = SurfaceResumeBindingSnapshot( + kind: "test", + command: "printf '%s\\n' '\(marker)'", + checkpointId: marker, + source: "test" + ) + return SurfaceResumeBindingScriptStore.writeLauncherScript( + inlineInput: binding.inlineStartupInput ?? binding.command, + binding: binding, + fileManager: fileManager, + temporaryDirectory: temporaryDirectory + ) + case .sessionRestoredTerminal: + return SessionRestoredTerminalCommandStore.writeLauncherScript( + command: "printf '%s\\n' '\(marker)'", + workingDirectory: "/tmp", + fileManager: fileManager, + temporaryDirectory: temporaryDirectory + ) + } + } + } + + private final class ConcurrentLauncherScriptState: @unchecked Sendable { + private let lock = NSLock() + private var storedURLs: [URL] = [] + private var storedIssues: [String] = [] + + var urls: [URL] { + lock.lock() + defer { lock.unlock() } + return storedURLs + } + + var issues: [String] { + lock.lock() + defer { lock.unlock() } + return storedIssues + } + + func append(url: URL) { + lock.lock() + storedURLs.append(url) + lock.unlock() + } + + func append(issue: String) { + lock.lock() + storedIssues.append(issue) + lock.unlock() + } + } + + private static let privateLauncherScriptUmaskLock = NSLock() + private static let privateLauncherScriptDirectoryEntryBudget = 256 + func testClaudeResumeCommandRoutesThroughWrapperInsteadOfCapturedRealBinary() { // The captured launch executable is the real claude binary // (CMUX_AGENT_LAUNCH_EXECUTABLE). Resuming with it directly bypasses @@ -2462,7 +2612,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( entry.resumeCommand, - "cd /Users/tiffanysun/fun && /bin/sh -c " + "cd -- '/Users/tiffanysun/fun' 2>/dev/null || [ ! -d '/Users/tiffanysun/fun' ] && /bin/sh -c " + shellQuotedForTest("\(AgentResumeArgv.claudeWrapperShellExecutableToken) --resume a22293b7-bcef-4707-8439-2f538c8517a4") ) } @@ -2641,6 +2791,230 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertNil(snapshot.resumeStartupInput(temporaryDirectory: blockedDirectory)) } + func testPrivateLauncherScriptStoresRejectDirectorySymlinksWithoutTouchingTarget() throws { + for fixture in PrivateLauncherScriptFixture.allCases { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-private-launcher-symlink-\(fixture.rawValue)-\(UUID().uuidString)", + isDirectory: true + ) + let target = root.appendingPathComponent("target", isDirectory: true) + let storeDirectory = root.appendingPathComponent(fixture.directoryName, isDirectory: true) + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let sentinel = target.appendingPathComponent("sentinel.txt") + try "keep".write(to: sentinel, atomically: true, encoding: .utf8) + XCTAssertEqual(chmod(target.path, mode_t(0o755)), 0) + try FileManager.default.createSymbolicLink(at: storeDirectory, withDestinationURL: target) + + XCTAssertNil( + fixture.writeScript(marker: "symlink-\(fixture.rawValue)", temporaryDirectory: root), + "\(fixture.rawValue) must fail closed on a symlinked store directory" + ) + XCTAssertEqual( + try privateLauncherPermissions(at: target), + 0o755, + "\(fixture.rawValue) must not chmod the symlink target" + ) + XCTAssertEqual(try String(contentsOf: sentinel, encoding: .utf8), "keep") + XCTAssertEqual( + try FileManager.default.contentsOfDirectory(atPath: target.path).sorted(), + ["sentinel.txt"], + "\(fixture.rawValue) must not publish or prune through the symlink" + ) + } + } + + func testPrivateLauncherScriptStoresPruneOnlyOwnedSingleLinkRegularScripts() throws { + for fixture in PrivateLauncherScriptFixture.allCases { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-private-launcher-types-\(fixture.rawValue)-\(UUID().uuidString)", + isDirectory: true + ) + let storeDirectory = root.appendingPathComponent(fixture.directoryName, isDirectory: true) + try FileManager.default.createDirectory(at: storeDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let oldDate = Date().addingTimeInterval(-3 * 24 * 60 * 60) + let directoryEntry = storeDirectory.appendingPathComponent("victim.zsh", isDirectory: true) + try FileManager.default.createDirectory(at: directoryEntry, withIntermediateDirectories: false) + let directorySentinel = directoryEntry.appendingPathComponent("keep.txt") + try "keep".write(to: directorySentinel, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: oldDate], + ofItemAtPath: directoryEntry.path + ) + + let fifoEntry = storeDirectory.appendingPathComponent("pipe.zsh") + XCTAssertEqual(mkfifo(fifoEntry.path, mode_t(0o600)), 0) + try FileManager.default.setAttributes( + [.modificationDate: oldDate], + ofItemAtPath: fifoEntry.path + ) + + let outside = root.appendingPathComponent("outside.txt") + try "outside".write(to: outside, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: oldDate], ofItemAtPath: outside.path) + let symlinkEntry = storeDirectory.appendingPathComponent("alias.zsh") + try FileManager.default.createSymbolicLink(at: symlinkEntry, withDestinationURL: outside) + let hardlinkEntry = storeDirectory.appendingPathComponent("hard.zsh") + try FileManager.default.linkItem(at: outside, to: hardlinkEntry) + + XCTAssertNotNil( + fixture.writeScript(marker: "types-\(fixture.rawValue)", temporaryDirectory: root) + ) + var isDirectory: ObjCBool = false + XCTAssertTrue(FileManager.default.fileExists(atPath: directoryEntry.path, isDirectory: &isDirectory)) + XCTAssertTrue(isDirectory.boolValue) + XCTAssertEqual(try? String(contentsOf: directorySentinel, encoding: .utf8), "keep") + XCTAssertTrue(FileManager.default.fileExists(atPath: fifoEntry.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: symlinkEntry.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: hardlinkEntry.path)) + XCTAssertEqual(try String(contentsOf: outside, encoding: .utf8), "outside") + } + } + + func testPrivateLauncherScriptStoresPublish0600FilesInside0700DirectoryUnderOpenUmask() throws { + Self.privateLauncherScriptUmaskLock.lock() + defer { Self.privateLauncherScriptUmaskLock.unlock() } + let previousUmask = umask(0) + defer { _ = umask(previousUmask) } + + for fixture in PrivateLauncherScriptFixture.allCases { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-private-launcher-mode-\(fixture.rawValue)-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let script = try XCTUnwrap( + fixture.writeScript(marker: "mode-\(fixture.rawValue)", temporaryDirectory: root) + ) + XCTAssertEqual( + try privateLauncherPermissions( + at: root.appendingPathComponent(fixture.directoryName, isDirectory: true) + ), + 0o700, + fixture.rawValue + ) + XCTAssertEqual(try privateLauncherPermissions(at: script), 0o600, fixture.rawValue) + } + } + + func testPrivateLauncherScriptStoresBoundPruningAndFailClosedUntilOverflowDrains() throws { + for fixture in PrivateLauncherScriptFixture.allCases { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-private-launcher-cap-\(fixture.rawValue)-\(UUID().uuidString)", + isDirectory: true + ) + let storeDirectory = root.appendingPathComponent(fixture.directoryName, isDirectory: true) + try FileManager.default.createDirectory(at: storeDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let oldDate = Date().addingTimeInterval(-3 * 24 * 60 * 60) + for index in 0...Self.privateLauncherScriptDirectoryEntryBudget { + let url = storeDirectory.appendingPathComponent("stale-\(index).zsh") + try "#!/bin/zsh\n".write(to: url, atomically: false, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: oldDate], ofItemAtPath: url.path) + } + + XCTAssertNil( + fixture.writeScript(marker: "overflow-\(fixture.rawValue)", temporaryDirectory: root), + "\(fixture.rawValue) must not add work after its bounded scan detects overflow" + ) + let afterFirstPass = try stalePrivateLauncherScripts(in: storeDirectory) + XCTAssertEqual( + afterFirstPass.count, + 1, + "\(fixture.rawValue) should make one bounded pass of cleanup progress" + ) + XCTAssertNotNil( + fixture.writeScript(marker: "drained-\(fixture.rawValue)", temporaryDirectory: root), + "\(fixture.rawValue) should recover after a later bounded pass drains overflow" + ) + XCTAssertTrue(try stalePrivateLauncherScripts(in: storeDirectory).isEmpty) + } + } + + func testPrivateLauncherScriptStoresPublishOnlyCompleteFilesDuringConcurrentWrites() throws { + for fixture in PrivateLauncherScriptFixture.allCases { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-private-launcher-concurrent-\(fixture.rawValue)-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let state = ConcurrentLauncherScriptState() + let group = DispatchGroup() + for index in 0..<24 { + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + defer { group.leave() } + if let url = fixture.writeScript( + marker: "complete-\(fixture.rawValue)-\(index)", + temporaryDirectory: root + ) { + state.append(url: url) + } + } + } + + let storeDirectory = root.appendingPathComponent(fixture.directoryName, isDirectory: true) + repeat { + inspectPublishedPrivateLauncherScripts( + in: storeDirectory, + fixture: fixture, + state: state + ) + } while group.wait(timeout: .now() + .milliseconds(2)) == .timedOut + inspectPublishedPrivateLauncherScripts( + in: storeDirectory, + fixture: fixture, + state: state + ) + + XCTAssertFalse(state.urls.isEmpty, "At least one nonblocking writer should publish") + XCTAssertTrue(state.issues.isEmpty, state.issues.joined(separator: "\n")) + for url in state.urls { + let contents = try String(contentsOf: url, encoding: .utf8) + XCTAssertTrue(contents.hasPrefix("#!/bin/zsh\n"), fixture.rawValue) + XCTAssertTrue(contents.hasSuffix("\n"), fixture.rawValue) + XCTAssertTrue(contents.contains("complete-\(fixture.rawValue)-"), fixture.rawValue) + } + } + } + + private func privateLauncherPermissions(at url: URL) throws -> Int { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return try XCTUnwrap(attributes[.posixPermissions] as? NSNumber).intValue & 0o777 + } + + private func stalePrivateLauncherScripts(in directory: URL) throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: directory.path) + .filter { $0.hasPrefix("stale-") && $0.hasSuffix(".zsh") } + } + + private func inspectPublishedPrivateLauncherScripts( + in directory: URL, + fixture: PrivateLauncherScriptFixture, + state: ConcurrentLauncherScriptState + ) { + guard let names = try? FileManager.default.contentsOfDirectory(atPath: directory.path) else { + return + } + for name in names where !name.hasPrefix(".") && name.hasSuffix(".zsh") { + let url = directory.appendingPathComponent(name) + guard let contents = try? String(contentsOf: url, encoding: .utf8) else { + state.append(issue: "\(fixture.rawValue) published unreadable script \(name)") + continue + } + if !contents.hasPrefix("#!/bin/zsh\n") || + !contents.hasSuffix("\n") || + !contents.contains("complete-\(fixture.rawValue)-") { + state.append(issue: "\(fixture.rawValue) exposed partial script \(name)") + } + } + } + func testClaudeResumeCommandPreservesDangerouslySkipPermissionsAndObservedEnvironment() { let snapshot = SessionRestorableAgentSnapshot( kind: .claude, diff --git a/cmuxTests/TaskManagerResourcesTests.swift b/cmuxTests/TaskManagerResourcesTests.swift index 1578ea11db33..152a4d29981c 100644 --- a/cmuxTests/TaskManagerResourcesTests.swift +++ b/cmuxTests/TaskManagerResourcesTests.swift @@ -569,6 +569,9 @@ final class TaskManagerResourcesTests: XCTestCase { ("droid", "factory"), ("factory", "factory"), ("qodercli", "qoder"), + ("kimi", "kimi"), + ("kimi-cli", "kimi"), + ("kimi-code", "kimi"), ] for testCase in cases { @@ -583,6 +586,19 @@ final class TaskManagerResourcesTests: XCTestCase { testCase.processName ) } + + XCTAssertEqual( + CmuxTaskManagerCodingAgentDefinition.matchingDefinition( + processName: "python3.14", + processPath: "/Users/test/.local/share/uv/tools/kimi-cli/bin/python", + arguments: [ + "/Users/test/.local/share/uv/tools/kimi-cli/bin/python", + "/Users/test/.local/share/uv/tools/kimi-cli/bin/kimi", + ], + environment: [:] + )?.id, + "kimi" + ) } private func resourceSummary() -> CmuxTopResourceSummary { diff --git a/cmuxTests/WorkspaceForkConversationContextMenuTests.swift b/cmuxTests/WorkspaceForkConversationContextMenuTests.swift index 285083c3893f..3d4f4526ba7d 100644 --- a/cmuxTests/WorkspaceForkConversationContextMenuTests.swift +++ b/cmuxTests/WorkspaceForkConversationContextMenuTests.swift @@ -60,6 +60,57 @@ struct WorkspaceForkConversationContextMenuTests { ) } + @Test + func forkFromHibernatedParentLeavesParentRecoveryStateUnchanged() async throws { + let workspace = Workspace() + let parentPanelId = try #require(workspace.focusedPanelId) + let parentPanel = try #require(workspace.terminalPanel(for: parentPanelId)) + let snapshot = makeForkableClaudeSnapshot( + sessionId: "hibernated-fork-parent" + ) + workspace.setRestoredAgentSnapshotForTesting(snapshot, panelId: parentPanelId) + let hibernatedAt = Date(timeIntervalSince1970: 200) + let lastActivityAt = Date(timeIntervalSince1970: 100) + #expect(parentPanel.enterAgentHibernation( + agent: snapshot, + lastActivityAt: lastActivityAt, + hibernatedAt: hibernatedAt + )) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let beforeTerminal = try #require( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == parentPanelId }?.terminal + ) + let beforeBytes = try encoder.encode(beforeTerminal) + let beforePendingInput = parentPanel.surface.debugPendingSocketInputForTesting() + let panelIdsBefore = Set(workspace.panels.keys) + + #expect(await workspace.forkAgentConversationFromContextMenu( + fromPanelId: parentPanelId, + destination: .newTab + )) + + let childPanelIds = Set(workspace.panels.keys).subtracting(panelIdsBefore) + #expect(childPanelIds.count == 1) + let afterState = try #require(parentPanel.agentHibernationState) + #expect(afterState.agent == snapshot) + #expect(afterState.hibernatedAt == hibernatedAt) + #expect(afterState.lastActivityAt == lastActivityAt) + #expect(parentPanel.isAgentHibernated) + #expect(parentPanel.surface.debugPendingSocketInputForTesting() == beforePendingInput) + let afterTerminal = try #require( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == parentPanelId }?.terminal + ) + #expect(try encoder.encode(afterTerminal) == beforeBytes) + let childPanel = try #require( + childPanelIds.first.flatMap { workspace.terminalPanel(for: $0) } + ) + #expect(childPanel.surface.debugInitialInputMetadata().hasInitialInput) + } + @Test func liveAgentIndexLoaderUsesProcessDetectedPanelWhenHookBindingIsStale() throws { let fm = FileManager.default @@ -468,13 +519,13 @@ struct WorkspaceForkConversationContextMenuTests { } @Test - func piFamilyCapabilityProbeUsesCoreVersionThresholds() { + func piCapabilityProbeUsesCoreVersionThreshold() { #expect(!AgentForkSupport.piFamilyVersionSupportsFork("0.60.0", agentID: "pi")) #expect(AgentForkSupport.piFamilyVersionSupportsFork("0.60.0", agentID: "pi", acceptsBareVersionOutput: true)) #expect(!AgentForkSupport.piFamilyVersionSupportsFork("0.59.9", agentID: "pi", acceptsBareVersionOutput: true)) #expect(AgentForkSupport.piFamilyVersionSupportsFork("pi 0.60.0", agentID: "pi")) #expect(AgentForkSupport.piFamilyVersionSupportsFork("pi:v0.60.0", agentID: "pi")) - #expect(AgentForkSupport.piFamilyVersionSupportsFork("omp/13.15.0", agentID: "omp")) + #expect(!AgentForkSupport.piFamilyVersionSupportsFork("omp/13.15.0", agentID: "omp")) #expect(!AgentForkSupport.piFamilyVersionSupportsFork("0.60.0-beta.1", agentID: "pi", acceptsBareVersionOutput: true)) #expect(!AgentForkSupport.piFamilyVersionSupportsFork("pi 0.60.0-beta.1", agentID: "pi")) #expect(!AgentForkSupport.piFamilyVersionSupportsFork("omp/13.15.0-rc.1", agentID: "omp")) @@ -485,7 +536,7 @@ struct WorkspaceForkConversationContextMenuTests { #expect(AgentForkSupport.piFamilyVersionSupportsFork("warning: node v22.1.0; pi 0.60.0", agentID: "pi")) #expect(!AgentForkSupport.piFamilyVersionSupportsFork("node v22.1.0\npi 0.59.9", agentID: "pi")) #expect(!AgentForkSupport.piFamilyVersionSupportsFork("node v22.1.0; omp/13.14.2", agentID: "omp")) - #expect(AgentForkSupport.piFamilyVersionSupportsFork("node v22.1.0; omp/13.15.0", agentID: "omp")) + #expect(!AgentForkSupport.piFamilyVersionSupportsFork("node v22.1.0; omp/13.15.0", agentID: "omp")) #expect(!AgentForkSupport.piFamilyVersionSupportsFork("node v22.1.0\nomp/13.14.2", agentID: "omp")) #expect(!AgentForkSupport.piFamilyVersionSupportsFork("16.5.2", agentID: "unknown")) } @@ -1153,7 +1204,7 @@ struct WorkspaceForkConversationContextMenuTests { } @Test - func sharedForkProbeCacheInvalidatesWhenPiFamilyLauncherChanges() async throws { + func sharedForkProbeDropsUnsupportedOmpAfterPi() async throws { let fm = FileManager.default let root = fm.temporaryDirectory .appendingPathComponent("cmux-pi-family-shared-cache-\(UUID().uuidString)", isDirectory: true) @@ -1240,17 +1291,17 @@ struct WorkspaceForkConversationContextMenuTests { await sharedIndex.refreshForkAvailabilityNow() #expect( - sharedIndex.snapshotForForkConversationCandidate(workspaceId: workspaceId, panelId: panelId)? - .launchCommand?.launcher == "omp" + sharedIndex.snapshotForForkConversationCandidate(workspaceId: workspaceId, panelId: panelId) + == nil ) #expect( !sharedIndex.prepareForkAvailabilityProbe(workspaceId: workspaceId, panelId: panelId), - "A Pi probe result must not make an OMP snapshot fresh just because the rendered fork command is unchanged." + "A provider without a fork command must not inherit a prior Pi probe result." ) #expect(sharedIndex.snapshotForForkAvailability(workspaceId: workspaceId, panelId: panelId) == nil) await sharedIndex.refreshForkAvailabilityNow(workspaceId: workspaceId, panelId: panelId) - #expect(probedLaunchers.withLock { $0 } == ["pi", "omp"]) + #expect(probedLaunchers.withLock { $0 } == ["pi"]) #expect(sharedIndex.snapshotForForkAvailability(workspaceId: workspaceId, panelId: panelId) == nil) } @@ -3496,7 +3547,7 @@ struct WorkspaceForkConversationContextMenuTests { } @Test - func sharedForkProbeValidationInvalidatesWhenEarlierPathExecutableAppearsForPiAndOmp() async throws { + func sharedForkProbeValidationInvalidatesWhenEarlierPathExecutableAppearsForPi() async throws { struct Scenario { let launcher: String let kind: RestorableAgentKind @@ -3513,13 +3564,6 @@ struct WorkspaceForkConversationContextMenuTests { supportedOutput: "pi 0.80.6", unsupportedOutput: "pi 0.59.0" ), - Scenario( - launcher: "omp", - kind: .custom("omp"), - registration: .builtInOmp, - supportedOutput: "omp/13.15.0", - unsupportedOutput: "omp/13.14.2" - ), ] let fm = FileManager.default @@ -3720,7 +3764,7 @@ struct WorkspaceForkConversationContextMenuTests { } @Test - func builtInOmpRequiresProbeButProjectForkOverrideDoesNot() { + func builtInOmpDoesNotForkButProjectForkOverrideDoes() { let builtIn = SessionRestorableAgentSnapshot( kind: .custom("omp"), sessionId: "omp-session", @@ -3736,8 +3780,8 @@ struct WorkspaceForkConversationContextMenuTests { ), registration: .builtInOmp ) - #expect(ContentView.commandPaletteSnapshotForkAvailability(builtIn) == .requiresProbe) - #expect(builtIn.forkCommand?.contains("'PATH=/custom/omp/bin:/usr/bin'") == true) + #expect(ContentView.commandPaletteSnapshotForkAvailability(builtIn) == .unsupported) + #expect(builtIn.forkCommand == nil) var metadataOverride = CmuxVaultAgentRegistration.builtInOmp metadataOverride.name = "Project OMP" @@ -3749,7 +3793,7 @@ struct WorkspaceForkConversationContextMenuTests { launchCommand: builtIn.launchCommand, registration: metadataOverride ) - #expect(ContentView.commandPaletteSnapshotForkAvailability(metadataOverridden) == .requiresProbe) + #expect(ContentView.commandPaletteSnapshotForkAvailability(metadataOverridden) == .unsupported) var projectOverride = CmuxVaultAgentRegistration.builtInOmp projectOverride.name = "Project OMP" @@ -4045,7 +4089,7 @@ struct WorkspaceForkConversationContextMenuTests { } @Test - func piFamilyCapabilityProbeCacheSharesExecutableIdentityAcrossSnapshots() async throws { + func piCapabilityProbeCacheSharesExecutableIdentityAcrossSnapshots() async throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cmux-pi-executable-cache-\(UUID().uuidString)", isDirectory: true) @@ -4093,11 +4137,11 @@ struct WorkspaceForkConversationContextMenuTests { #expect(probeCount() == 1) #expect(!(await supportsFork(snapshot(launcher: "omp", sessionId: "omp-one")))) - #expect(probeCount() == 2) + #expect(probeCount() == 1) } @Test - func piFamilyValidationExecutableResolutionWorkIdentityIgnoresSessionAndLauncher() async throws { + func piValidationExecutableResolutionWorkIdentityIgnoresSessionAndRejectsOmp() async throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cmux-pi-resolution-work-key-\(UUID().uuidString)", isDirectory: true) @@ -4124,14 +4168,11 @@ struct WorkspaceForkConversationContextMenuTests { let piTwoIdentity = try #require(AgentForkSupport.forkValidationExecutableResolutionWorkIdentity( snapshot: snapshot(launcher: "pi", sessionId: "pi-two") )) - let ompIdentity = try #require(AgentForkSupport.forkValidationExecutableResolutionWorkIdentity( - snapshot: snapshot(launcher: "omp", sessionId: "omp-one") - )) - #expect(piOneIdentity == piTwoIdentity) #expect( - piOneIdentity == ompIdentity, - "Executable resolution work should key only the wrapper lookup inputs; capability results use a launcher-specific cache key separately." + AgentForkSupport.forkValidationExecutableResolutionWorkIdentity( + snapshot: snapshot(launcher: "omp", sessionId: "omp-one") + ) == nil ) } @@ -4308,10 +4349,11 @@ struct WorkspaceForkConversationContextMenuTests { } @Test - func persistedBuiltInOmpSnapshotMigratesLegacyForkTemplate() throws { + func persistedBuiltInOmpSnapshotMigratesLegacyResumeAndForkTemplates() throws { let sessionId = "omp-session-123" var legacyRegistration = CmuxVaultAgentRegistration.builtInOmp - legacyRegistration.forkCommand = "{{executable}} --session {{sessionId}} --fork" + legacyRegistration.resumeCommand = "{{executable}} --session {{sessionId}}" + legacyRegistration.forkCommand = "{{executable}} --fork {{sessionId}}" let persisted = SessionRestorableAgentSnapshot( kind: .custom("omp"), sessionId: sessionId, @@ -4334,7 +4376,24 @@ struct WorkspaceForkConversationContextMenuTests { ) #expect(decoded.registration == .builtInOmp) - #expect(decoded.forkCommand?.contains("'--fork' '\(sessionId)'") == true) + #expect(decoded.resumeCommand?.contains("'--resume' '\(sessionId)'") == true) + #expect(decoded.forkCommand == nil) + + var olderRegistration = legacyRegistration + olderRegistration.forkCommand = "{{executable}} --session {{sessionId}} --fork" + let decodedOlder = try JSONDecoder().decode( + SessionRestorableAgentSnapshot.self, + from: JSONEncoder().encode(SessionRestorableAgentSnapshot( + kind: .custom("omp"), + sessionId: sessionId, + workingDirectory: nil, + launchCommand: persisted.launchCommand, + registration: olderRegistration + )) + ) + #expect(decodedOlder.registration == .builtInOmp) + #expect(decodedOlder.resumeCommand?.contains("'--resume' '\(sessionId)'") == true) + #expect(decodedOlder.forkCommand == nil) var projectOverride = legacyRegistration projectOverride.name = "Project OMP" @@ -4353,6 +4412,7 @@ struct WorkspaceForkConversationContextMenuTests { var historicalRegistration = CmuxVaultAgentRegistration.builtInOmp historicalRegistration.iconAssetName = nil + historicalRegistration.resumeCommand = "{{executable}} --session {{sessionId}}" historicalRegistration.forkCommand = nil let historical = SessionRestorableAgentSnapshot( kind: .custom("omp"), @@ -4365,7 +4425,8 @@ struct WorkspaceForkConversationContextMenuTests { SessionRestorableAgentSnapshot.self, from: JSONEncoder().encode(historical) ) - #expect(decodedHistorical.registration == historicalRegistration) + #expect(decodedHistorical.registration == .builtInOmp) + #expect(decodedHistorical.resumeCommand?.contains("'--resume' '\(sessionId)'") == true) #expect(decodedHistorical.forkCommand == nil) var legacyWithoutIcon = legacyRegistration @@ -4381,7 +4442,8 @@ struct WorkspaceForkConversationContextMenuTests { )) ) #expect(decodedLegacyWithoutIcon.registration == .builtInOmp) - #expect(decodedLegacyWithoutIcon.forkCommand?.contains("'--fork' '\(sessionId)'") == true) + #expect(decodedLegacyWithoutIcon.resumeCommand?.contains("'--resume' '\(sessionId)'") == true) + #expect(decodedLegacyWithoutIcon.forkCommand == nil) var customForkRegistration = CmuxVaultAgentRegistration.builtInOmp customForkRegistration.forkCommand = "{{executable}} --branch {{sessionId}}" @@ -4467,6 +4529,54 @@ struct WorkspaceForkConversationContextMenuTests { #expect(decodedHistorical.forkCommand == nil) } + @Test + func persistedBuiltInRegistrationsGainNewForkTemplatesWithoutOverridingProjects() throws { + for current in [ + CmuxVaultAgentRegistration.builtInGrok, + CmuxVaultAgentRegistration.builtInCampfire, + ] { + var historical = current + historical.forkCommand = nil + let persisted = SessionRestorableAgentSnapshot( + kind: .custom(current.id), + sessionId: "session-123", + workingDirectory: "/tmp/repo", + launchCommand: AgentLaunchCommandSnapshot( + launcher: current.id, + executablePath: current.defaultExecutable, + arguments: [current.defaultExecutable], + workingDirectory: "/tmp/repo", + environment: nil, + capturedAt: 123, + source: "process" + ), + registration: historical + ) + + let decoded = try JSONDecoder().decode( + SessionRestorableAgentSnapshot.self, + from: JSONEncoder().encode(persisted) + ) + #expect(decoded.registration == current) + #expect(decoded.forkCommand != nil) + + var projectOverride = historical + projectOverride.name = "Project \(current.name)" + let decodedOverride = try JSONDecoder().decode( + SessionRestorableAgentSnapshot.self, + from: JSONEncoder().encode(SessionRestorableAgentSnapshot( + kind: .custom(current.id), + sessionId: "session-123", + workingDirectory: "/tmp/repo", + launchCommand: persisted.launchCommand, + registration: projectOverride + )) + ) + #expect(decodedOverride.registration == projectOverride) + #expect(decodedOverride.forkCommand == nil) + } + } + @Test func directOpenCodePresentationStaysVisibleWhileValidationRefreshes() throws { let workspace = Workspace() diff --git a/cmuxTests/WorkspaceSplitStartupCommandTests.swift b/cmuxTests/WorkspaceSplitStartupCommandTests.swift index a5d904ca81da..c074b2b9c3fa 100644 --- a/cmuxTests/WorkspaceSplitStartupCommandTests.swift +++ b/cmuxTests/WorkspaceSplitStartupCommandTests.swift @@ -324,6 +324,10 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { originalStartupScript, "Restored HUD panes must launch through a fresh script, not a deleted tmux temp script" ) + XCTAssertTrue( + restoredStartupScript.hasPrefix("/bin/zsh '") && restoredStartupScript.hasSuffix("'"), + "Private 0600 launcher scripts must be read through zsh instead of executed directly" + ) XCTAssertTrue(restoredStartupScript.contains("cmux-session-terminal-command")) XCTAssertEqual(restoredHudPanel.requestedWorkingDirectory, requestedDirectory) } diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index 00277246688a..ca26de4f6619 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -11,7 +11,7 @@ cmux hooks setup --agent cmux hooks uninstall ``` -Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `campfire`, `amp`, `cursor`, `gemini`, `kimi`, `kiro`, `rovodev` (or `rovo`), `copilot`, `codebuddy`, `factory`, and `qoder`. `cmux hooks setup` skips agents whose binary is not on `PATH` and prints a summary. +Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `campfire`, `amp`, `cursor`, `gemini`, `kiro`, `antigravity` (or `agy`), `rovodev` (or `rovo`), `hermes-agent`, `copilot`, `codebuddy`, `factory`, `qoder`, and `kimi`. `cmux hooks setup` skips agents whose binary is not on `PATH` and prints a summary. ## Integrations @@ -22,18 +22,20 @@ Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `campfire`, | Grok | `grok` | `~/.grok/hooks/cmux-session.json` | `grok -r ` | PreToolUse | | OpenCode | `opencode` | `~/.config/opencode/plugins/cmux-session.js`, `~/.config/opencode/plugins/cmux-feed.js` | `opencode --session ` | plugin event bus | | Pi | `pi` | `~/.pi/agent/extensions/cmux-session.ts` | `pi --session ` | tool_execution_start / tool_execution_end telemetry | -| OMP | `omp` | `~/.omp/agent/extensions/cmux-omp-session.ts` or `$PI_CODING_AGENT_DIR/extensions/cmux-omp-session.ts` | `omp --session ` | none | +| OMP | `omp` | `~/.omp/agent/extensions/cmux-omp-session.ts` or `$PI_CODING_AGENT_DIR/extensions/cmux-omp-session.ts` | `omp --resume ` | none | | Campfire | `campfire` | `~/.campfire/agent/extensions/cmux-campfire-session.ts` or `$CAMPFIRE_CODING_AGENT_DIR/extensions/cmux-campfire-session.ts` | `campfire --session ` | none | | Amp | `amp` | `~/.config/amp/plugins/cmux-session.ts` | `amp threads continue ` | none | | Cursor CLI | `cursor-agent` | `~/.cursor/hooks.json` | `cursor-agent --resume ` | beforeShellExecution | -| Gemini | `gemini` | `~/.gemini/settings.json` | `gemini --resume ` | PreToolUse | +| Gemini | `gemini` | `~/.gemini/settings.json` | `gemini --session-file ` | PreToolUse | | Kiro CLI | `kiro-cli` | `~/.kiro/agents/cmux.json` or `$KIRO_HOME/agents/cmux.json` | `kiro-cli chat --resume-id ` | preToolUse, postToolUse | +| Antigravity | `agy` | `~/.gemini/config/hooks.json` | `agy --conversation ` | PreToolUse, PostToolUse | | Rovo Dev | `acli` | `~/.rovodev/config.yml` | `acli rovodev run --restore ` | none | +| Hermes Agent | `hermes` | `~/.hermes/config.yaml` or `$HERMES_HOME/config.yaml` | `hermes --resume ` | pre_tool_call, post_tool_call, approvals | | Copilot | `copilot` | `~/.copilot/config.json` | `copilot --resume ` | PreToolUse | | CodeBuddy | `codebuddy` | `~/.codebuddy/settings.json` | `codebuddy --resume ` | PreToolUse | | Factory | `droid` | `~/.factory/settings.json` | `droid --resume ` | PreToolUse | | Qoder | `qodercli` | `~/.qoder/settings.json` | `qodercli --resume ` | PreToolUse | -| Kimi Code | `kimi` | `~/.kimi/config.toml` | not yet | PreToolUse, PostToolUse | +| Kimi Code | `kimi` | `~/.kimi/config.toml` | `kimi --session ` | PreToolUse, PostToolUse | OpenCode also supports project-local Feed installation: @@ -59,7 +61,7 @@ When the opt-in `automation.workspaceAutoNaming` setting is enabled, turn-end ho ## Agent Hibernation -Agent Hibernation kills idle background agent processes to free their RAM and CPU, then resumes each one with its saved session when you return to its tab. It is opt-in and off by default. cmux knows which process belongs to which terminal because the agent hooks associate each session ID with its surface (see the session-restore section above), so it can terminate the right process and bring back the right session. +Agent Hibernation replaces a proven process-free background terminal with a lightweight placeholder, then resumes its saved agent session when you return to the tab. It is opt-in and off by default. cmux never hibernates a terminal while an agent or other child process is still running. ### When a terminal hibernates @@ -67,19 +69,20 @@ A live terminal is only ever a candidate when all of these hold: - it has a saved restorable agent session, and the saved launch data can build a resume command - the agent lifecycle is `idle` (not running, not waiting on input) +- the terminal is at its shell prompt, needs no close confirmation, and has no agent or other child process - the terminal is in the background (its panel is not currently visible) - you have more live restorable agent terminals than the live-terminal limit (`maxLiveTerminals`, default `12`) - the terminal has had no output, input, or lifecycle change for at least the idle window (`idleSeconds`, default `5`) -The live-terminal limit is the first gate. Under the limit, nothing hibernates no matter how long it sits idle. Once you are over the limit, cmux frees only the oldest-idle background terminals, just enough to get back under the limit. Visible terminals are never touched. +The live-terminal limit is the first gate. Under the limit, nothing hibernates no matter how long it sits idle. Once you are over the limit, cmux frees the oldest eligible background terminals. Live agent processes still count toward the limit but are never terminated, so cmux may remain over the limit when no process-free terminal is eligible. Visible terminals are never touched. -Before killing, cmux watches the terminal tail. It samples the last lines of output and a fingerprint of the process, and waits a short confirmation window (`confirmationSeconds`, ~60s) during which the output and process must stay unchanged. Any new output, input, lifecycle change, or PID change cancels the pending hibernation. This is why a small `idleSeconds` is safe: a freshly idle agent that resumes work on its own is never killed mid-task. +Before hibernating, cmux watches the terminal tail and verifies the idle shell's identity, terminal scope, process group, and complete TTY membership. It waits a short confirmation window (`confirmationSeconds`, ~60s), then repeats those checks immediately before freeing the terminal runtime. New output, input, lifecycle changes, process changes, or incomplete process inspection cancel hibernation. -So with the defaults, hibernation only affects power users running more than 12 agents at once, and even then only ~1 minute after an agent has gone quiet off-screen. +With the defaults, hibernation only affects users with more than 12 restorable agent terminals, and only after an agent has exited back to a quiet off-screen shell. -### What gets killed and how it comes back +### What gets released and how it comes back -cmux sends `SIGTERM` to the agent's process group (scoped to that workspace and surface), then swaps the live terminal for a lightweight placeholder, releasing the terminal's memory and CPU. When you visit the tab again, cmux runs the agent's native resume command with the saved session ID, so the session continues where it left off. The placeholder also shows a Resume button as a manual fallback. +cmux frees the verified process-free terminal runtime and swaps it for a lightweight placeholder. It does not signal a live agent process. When you visit the tab again, cmux runs the agent's native resume command with the saved session ID. The placeholder also shows a Resume button as a manual fallback. ### Enable and configure @@ -145,8 +148,10 @@ and browser state. Restored agent terminals stay idle until you resume them manu | Cursor CLI | none | `CMUX_CURSOR_HOOKS_DISABLED=1` | | Gemini | none | `CMUX_GEMINI_HOOKS_DISABLED=1` | | Kiro CLI | `KIRO_HOME` | `CMUX_KIRO_HOOKS_DISABLED=1` | +| Antigravity | none | `CMUX_ANTIGRAVITY_HOOKS_DISABLED=1` | | Kimi Code | `KIMI_SHARE_DIR` | `CMUX_KIMI_HOOKS_DISABLED=1` | | Rovo Dev | none | `CMUX_ROVODEV_HOOKS_DISABLED=1` | +| Hermes Agent | `HERMES_HOME` | `CMUX_HERMES_AGENT_HOOKS_DISABLED=1` | | Copilot | `COPILOT_HOME` | `CMUX_COPILOT_HOOKS_DISABLED=1` | | CodeBuddy | `CODEBUDDY_CONFIG_DIR` | `CMUX_CODEBUDDY_HOOKS_DISABLED=1` | | Factory | none | `CMUX_FACTORY_HOOKS_DISABLED=1` | diff --git a/docs/configuration.md b/docs/configuration.md index 921256b446a4..b3e40a5e32d2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -103,7 +103,7 @@ Default: `cloudFirst`. ## `terminal.agentHibernation` -Opt-in Agent Hibernation. cmux kills idle background agent processes to free RAM and CPU, then resumes each one with its saved session when you visit its tab. See [agent-hooks.md](agent-hooks.md#agent-hibernation) for the full behavior, including the confirmation settle window and how resume works. +Opt-in Agent Hibernation. cmux replaces a proven process-free background terminal with a lightweight placeholder, then resumes its saved agent session when you visit the tab. Live agent and child processes are never terminated. See [agent-hooks.md](agent-hooks.md#agent-hibernation) for the full safety checks and resume behavior. ```json { @@ -118,8 +118,8 @@ Opt-in Agent Hibernation. cmux kills idle background agent processes to free RAM ``` - `enabled`: turn Agent Hibernation on. Default: `false`. -- `idleSeconds`: seconds a background idle agent terminal must be quiet before it can hibernate. A ~60s confirmation settle window still applies on top of this. Default: `5`. Range: `5`-`604800`. -- `maxLiveTerminals`: how many live restorable agent terminals to keep before cmux hibernates the oldest idle background ones. Nothing hibernates while you are at or under this count. Default: `12`. Range: `1`-`256`. +- `idleSeconds`: seconds a process-free background agent terminal must be quiet before it can hibernate. A ~60s confirmation settle window still applies on top of this. Default: `5`. Range: `5`-`604800`. +- `maxLiveTerminals`: how many live restorable agent terminals to keep before cmux hibernates the oldest eligible process-free background ones. Nothing hibernates while you are at or under this count. Default: `12`. Range: `1`-`256`. Enable it from the command palette (`⌘⇧P` -> Enable Agent Hibernation), from **Settings > Terminal > Agent Hibernation**, or with `cmux agent-hibernation on`. diff --git a/docs/vault.md b/docs/vault.md index 3ade5fe2ee1c..42add8577786 100644 --- a/docs/vault.md +++ b/docs/vault.md @@ -78,7 +78,8 @@ For a generic agent that exposes the current session as an argv option: Supported `resumeCommand` placeholders are `{{sessionId}}`, `{{sessionPath}}`, `{{executable}}`, `{{cwd}}`, and `{{sessionDir}}`. Pi uses `pi --session ` instead of `pi --continue` so Vault reopens the exact saved session. -OMP accepts `--session`, `--resume`, and `-r` for existing sessions; Vault emits `omp --session ` so relaunch reopens the exact saved OMP session. +OMP accepts `--resume` and `-r` for existing sessions; Vault emits +`omp --resume ` so relaunch reopens the exact saved OMP session. Campfire resumes with `campfire --session `; only the driver's host session is restorable (joiners are ephemeral views), and resuming restores the conversation in a fresh collaborative session with a new invite link. @@ -89,8 +90,8 @@ example `pi --session {{sessionId}}`. is the argv template for forking (branching) a session into a new copy, for example `{{executable}} --session {{sessionId}} --fork`. Provide it only when the agent supports forking; when omitted, the right-click **Fork Conversation** item -stays hidden for that agent (resume still works via `resumeCommand`). Pi and OMP -ship with `{{executable}} --fork {{sessionId}}`. +stays hidden for that agent (resume still works via `resumeCommand`). Pi ships +with `{{executable}} --fork {{sessionId}}`; OMP does not expose a fork command. `iconAssetName` is optional. When omitted, Vault uses a neutral system icon for registered agents instead of reusing another agent's brand mark. diff --git a/tests/claude_teams_test_utils.py b/tests/claude_teams_test_utils.py index ab5e42e9315f..3b5a1539cc96 100644 --- a/tests/claude_teams_test_utils.py +++ b/tests/claude_teams_test_utils.py @@ -2,10 +2,170 @@ from __future__ import annotations +import json import os +import subprocess +import sys +import time +import uuid from pathlib import Path +_LAUNCHD_SUBPROCESS_HELPER = r""" +import json +import os +import subprocess +import sys +import traceback +from pathlib import Path + +spec_path = Path(sys.argv[1]) +result_path = Path(sys.argv[2]) +spec = json.loads(spec_path.read_text(encoding="utf-8")) +try: + completed = subprocess.run( + spec["argv"], + input=spec["input"], + capture_output=True, + text=True, + check=False, + env=spec["env"], + cwd=spec["cwd"], + timeout=spec["timeout"], + ) + payload = { + "returncode": completed.returncode, + "stdout": completed.stdout, + "stderr": completed.stderr, + } +except BaseException: + payload = { + "returncode": 255, + "stdout": "", + "stderr": traceback.format_exc(), + } + +temporary_path = result_path.with_name(result_path.name + ".tmp") +temporary_path.write_text(json.dumps(payload), encoding="utf-8") +os.chmod(temporary_path, 0o600) +os.replace(temporary_path, result_path) +""" + + +def isolated_hook_environment(base_env: dict[str, str]) -> dict[str, str]: + """Return the small host environment needed by a hook fixture. + + Agent lineage and authority markers must be supplied by each fixture, not + inherited from the developer or CI process that launched the test. + """ + allowed = { + "HOME", + "LANG", + "LC_ALL", + "LOGNAME", + "PATH", + "SHELL", + "TMPDIR", + "USER", + } + environment = {key: value for key, value in base_env.items() if key in allowed} + environment.setdefault("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + return environment + + +def run_root_hook_process( + argv: list[str], + *, + input: str, + env: dict[str, str], + cwd: Path, + root: Path, + timeout: float, +) -> subprocess.CompletedProcess[str]: + """Run a hook with ancestry that proves it has no agent-process parent. + + Local agent-driven tests reparent a small Python launcher to launchd. The + hook CLI is its child, so the production ancestry walk reaches PID 1 and + classifies the hook as a root without any test-only authority override. + CI runners already have no agent ancestor and use a direct subprocess. + """ + if sys.platform != "darwin" or os.environ.get("CI"): + return subprocess.run( + argv, + input=input, + capture_output=True, + text=True, + check=False, + env=env, + cwd=cwd, + timeout=timeout, + ) + + launch_root = root / f"launchd-hook-{uuid.uuid4().hex}" + launch_root.mkdir(mode=0o700) + spec_path = launch_root / "spec.json" + result_path = launch_root / "result.json" + spec_path.write_text( + json.dumps( + { + "argv": argv, + "input": input, + "env": env, + "cwd": str(cwd), + "timeout": timeout, + } + ), + encoding="utf-8", + ) + spec_path.chmod(0o600) + + label = f"com.cmux.tests.hook.{os.getpid()}.{uuid.uuid4().hex[:12]}" + submitted = subprocess.run( + [ + "/bin/launchctl", + "submit", + "-l", + label, + "--", + sys.executable, + "-c", + _LAUNCHD_SUBPROCESS_HELPER, + str(spec_path), + str(result_path), + ], + capture_output=True, + text=True, + check=False, + timeout=5, + ) + if submitted.returncode != 0: + raise RuntimeError( + f"launchctl submit failed ({submitted.returncode}): {submitted.stderr.strip()}" + ) + + deadline = time.monotonic() + timeout + 5 + try: + while time.monotonic() < deadline and not result_path.exists(): + time.sleep(0.02) + if not result_path.exists(): + raise subprocess.TimeoutExpired(argv, timeout) + payload = json.loads(result_path.read_text(encoding="utf-8")) + return subprocess.CompletedProcess( + argv, + int(payload["returncode"]), + str(payload["stdout"]), + str(payload["stderr"]), + ) + finally: + subprocess.run( + ["/bin/launchctl", "remove", label], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=5, + ) + + def resolve_cmux_cli() -> str: explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): diff --git a/tests/test_amp_extension_install.py b/tests/test_amp_extension_install.py index 29b4b2715591..160dfc91e904 100644 --- a/tests/test_amp_extension_install.py +++ b/tests/test_amp_extension_install.py @@ -88,6 +88,7 @@ def main() -> int: fake_bin.mkdir() fake_amp = fake_bin / "amp" make_executable(fake_amp, "#!/usr/bin/env bash\nexit 0\n") + make_executable(fake_bin / "cmux", "#!/usr/bin/env bash\nexit 73\n") make_executable( fake_cmux, """#!/usr/bin/env bash @@ -107,7 +108,7 @@ def main() -> int: check_env = env.copy() check_env["CMUX_TEST_AMP_EXTENSION_PATH"] = str(extension_path) check_env["CMUX_SURFACE_ID"] = "surface-amp-test" - check_env["CMUX_AMP_CMUX_BIN"] = str(fake_cmux) + check_env["CMUX_BUNDLED_CLI_PATH"] = str(fake_cmux) check_env["AMP_API_KEY"] = "secret-should-not-propagate" check_env["FAKE_CMUX_ARGS_LOG"] = str(fake_args_log) check_env["FAKE_CMUX_STDIN_LOG"] = str(fake_stdin_log) @@ -193,7 +194,10 @@ def main() -> int: if "amp_api_key=secret-should-not-propagate" in env_log: print(f"FAIL: plugin propagated AMP_API_KEY into hook subprocess, got {env_log!r}") return 1 - argv_line = next((line for line in env_log.splitlines() if line.startswith("argv=")), "") + argv_line = next( + (line for line in env_log.splitlines() if line.startswith("argv=") and line != "argv="), + "", + ) try: argv_value = argv_line[len("argv="):] if argv_line.startswith("argv=") else argv_line decoded_argv = [ diff --git a/tests/test_campfire_extension_install.py b/tests/test_campfire_extension_install.py index f1ca6b97d170..7bc194a64975 100644 --- a/tests/test_campfire_extension_install.py +++ b/tests/test_campfire_extension_install.py @@ -19,7 +19,11 @@ import threading from pathlib import Path -from claude_teams_test_utils import resolve_cmux_cli +from claude_teams_test_utils import ( + isolated_hook_environment, + resolve_cmux_cli, + run_root_hook_process, +) def make_executable(path: Path, content: str) -> None: @@ -112,13 +116,21 @@ def _handle(self, conn: socket.socket) -> None: return def _response(self, line: str) -> str: + if line.startswith("_cmux_capability_v1 "): + line = line.split(" ", 2)[-1] try: payload = json.loads(line) except json.JSONDecodeError: return "OK" request_id = payload["id"] if "id" in payload else "unknown" method = payload.get("method") - if method == "surface.list": + if method == "system.capabilities": + result = { + "runtime_id": "33333333-3333-3333-3333-333333333333", + "socket_path": str(self.path), + "bundle_identifier": "com.cmuxterm.test.campfire", + } + elif method == "surface.list": result = { "surfaces": [ { @@ -140,6 +152,8 @@ def _response(self, line: str) -> str: def json_rpc_messages(messages: list[str], method: str) -> list[dict[str, object]]: matches: list[dict[str, object]] = [] for line in messages: + if line.startswith("_cmux_capability_v1 "): + line = line.split(" ", 2)[-1] try: payload = json.loads(line) except json.JSONDecodeError: @@ -168,7 +182,7 @@ def verify_hook_persistence(cli_path: str, root: Path, base_env: dict[str, str]) "anthropic/claude-sonnet-4-5", "initial prompt should not persist", ] - hook_env = base_env.copy() + hook_env = isolated_hook_environment(base_env) hook_env.pop("CAMPFIRE_CODING_AGENT_DIR", None) hook_env.update( { @@ -199,13 +213,12 @@ def verify_hook_persistence(cli_path: str, root: Path, base_env: dict[str, str]) ) with MockCmuxSocket(socket_path, workspace_id=workspace_id, surface_id=surface_id) as server: - result = subprocess.run( + result = run_root_hook_process( [cli_path, "hooks", "campfire", "session-start"], input=hook_input, - capture_output=True, - text=True, - check=False, env=hook_env, + cwd=workspace, + root=root, timeout=20, ) if result.returncode != 0 or result.stdout != "{}\n": @@ -358,6 +371,9 @@ def main() -> int: fake_stdin_log = root / "fake-cmux-stdin.log" fake_env_log = root / "fake-cmux-env.log" fake_order_log = root / "fake-cmux-order.log" + fake_bin = root / "bin" + fake_bin.mkdir(exist_ok=True) + make_executable(fake_bin / "cmux", "#!/usr/bin/env bash\nexit 73\n") make_executable( fake_cmux, """#!/usr/bin/env bash @@ -382,7 +398,8 @@ def main() -> int: check_env = env.copy() check_env["CMUX_TEST_CAMPFIRE_EXTENSION_PATH"] = str(extension_path) check_env["CMUX_SURFACE_ID"] = "surface-campfire-test" - check_env["CMUX_CAMPFIRE_CMUX_BIN"] = str(fake_cmux) + check_env["CMUX_BUNDLED_CLI_PATH"] = str(fake_cmux) + check_env["PATH"] = f"{fake_bin}{os.pathsep}{check_env.get('PATH', '')}" check_env["FAKE_CMUX_ARGS_LOG"] = str(fake_args_log) check_env["FAKE_CMUX_STDIN_LOG"] = str(fake_stdin_log) check_env["FAKE_CMUX_ENV_LOG"] = str(fake_env_log) diff --git a/tests/test_omp_extension_install.py b/tests/test_omp_extension_install.py index 2c15ed03016b..dcc0e8fc3f74 100755 --- a/tests/test_omp_extension_install.py +++ b/tests/test_omp_extension_install.py @@ -16,7 +16,11 @@ import threading from pathlib import Path -from claude_teams_test_utils import resolve_cmux_cli +from claude_teams_test_utils import ( + isolated_hook_environment, + resolve_cmux_cli, + run_root_hook_process, +) def make_executable(path: Path, content: str) -> None: @@ -109,13 +113,21 @@ def _handle(self, conn: socket.socket) -> None: return def _response(self, line: str) -> str: + if line.startswith("_cmux_capability_v1 "): + line = line.split(" ", 2)[-1] try: payload = json.loads(line) except json.JSONDecodeError: return "OK" request_id = payload.get("id") or "unknown" method = payload.get("method") - if method == "surface.list": + if method == "system.capabilities": + result = { + "runtime_id": "33333333-3333-3333-3333-333333333333", + "socket_path": str(self.path), + "bundle_identifier": "com.cmuxterm.test.omp", + } + elif method == "surface.list": result = { "surfaces": [ { @@ -137,6 +149,8 @@ def _response(self, line: str) -> str: def json_rpc_messages(messages: list[str], method: str) -> list[dict[str, object]]: matches: list[dict[str, object]] = [] for line in messages: + if line.startswith("_cmux_capability_v1 "): + line = line.split(" ", 2)[-1] try: payload = json.loads(line) except json.JSONDecodeError: @@ -163,7 +177,7 @@ def verify_hook_persistence(cli_path: str, root: Path, base_env: dict[str, str]) "anthropic/claude-sonnet-4-5", "initial prompt should not persist", ] - hook_env = base_env.copy() + hook_env = isolated_hook_environment(base_env) hook_env.pop("PI_CODING_AGENT_DIR", None) hook_env.update( { @@ -193,13 +207,12 @@ def verify_hook_persistence(cli_path: str, root: Path, base_env: dict[str, str]) ) with MockCmuxSocket(socket_path, workspace_id=workspace_id, surface_id=surface_id) as server: - result = subprocess.run( + result = run_root_hook_process( [cli_path, "hooks", "omp", "session-start"], input=hook_input, - capture_output=True, - text=True, - check=False, env=hook_env, + cwd=workspace, + root=root, timeout=20, ) if result.returncode != 0 or result.stdout != "{}\n": @@ -275,7 +288,7 @@ def verify_hook_persistence(cli_path: str, root: Path, base_env: dict[str, str]) print(f"FAIL: surface.resume.set had wrong OMP binding params: {params!r}") return False command = params.get("command") - if not isinstance(command, str) or "--session" not in command or session_id not in command: + if not isinstance(command, str) or "--resume" not in command or session_id not in command: print(f"FAIL: surface.resume.set command cannot resume OMP session: {params!r}") return False return True @@ -355,6 +368,9 @@ def main() -> int: fake_args_log = root / "fake-cmux-args.log" fake_stdin_log = root / "fake-cmux-stdin.log" fake_env_log = root / "fake-cmux-env.log" + fake_bin = root / "bin" + fake_bin.mkdir(exist_ok=True) + make_executable(fake_bin / "cmux", "#!/usr/bin/env bash\nexit 73\n") make_executable( fake_cmux, """#!/usr/bin/env bash @@ -379,7 +395,8 @@ def main() -> int: check_env = env.copy() check_env["CMUX_TEST_OMP_EXTENSION_PATH"] = str(extension_path) check_env["CMUX_SURFACE_ID"] = "surface-omp-test" - check_env["CMUX_OMP_CMUX_BIN"] = str(fake_cmux) + check_env["CMUX_BUNDLED_CLI_PATH"] = str(fake_cmux) + check_env["PATH"] = f"{fake_bin}{os.pathsep}{check_env.get('PATH', '')}" check_env["FAKE_CMUX_ARGS_LOG"] = str(fake_args_log) check_env["FAKE_CMUX_STDIN_LOG"] = str(fake_stdin_log) check_env["FAKE_CMUX_ENV_LOG"] = str(fake_env_log) diff --git a/tests/test_opencode_plugin_install.py b/tests/test_opencode_plugin_install.py index 11cbe7628847..84c40999914a 100644 --- a/tests/test_opencode_plugin_install.py +++ b/tests/test_opencode_plugin_install.py @@ -52,6 +52,8 @@ def main() -> int: encoding="utf-8", ) env = os.environ.copy() + env["HOME"] = str(root) + env["XDG_CONFIG_HOME"] = str(root / ".config") env["OPENCODE_CONFIG_DIR"] = str(config_dir) install = subprocess.run( @@ -106,6 +108,13 @@ def main() -> int: print(f"FAIL: installer did not preserve existing plugin entries: {plugins!r}") return 1 + # Keep the real OpenCode autoload check hermetic. The preservation + # fixture above intentionally names external packages, but asking + # OpenCode to resolve them would make this test depend on the package + # registry and the installed OpenCode version's dependency policy. + config["plugin"] = ["./plugins/cmux-session.js"] + config_json.write_text(json.dumps(config), encoding="utf-8") + opencode = shutil.which("opencode") if opencode is not None: debug = subprocess.run( @@ -126,7 +135,8 @@ def main() -> int: print("FAIL: opencode tried to resolve cmux-session as a package") print(debug_output[-4000:]) return 1 - if f"file://{plugin_path}" not in debug_output: + plugin_url = plugin_path.absolute().as_uri() + if plugin_url not in debug_output: print("FAIL: opencode did not auto-load cmux session plugin file") print(debug_output[-4000:]) return 1 @@ -135,6 +145,9 @@ def main() -> int: fake_args_log = root / "fake-cmux-args.log" fake_stdin_log = root / "fake-cmux-stdin.log" fake_env_log = root / "fake-cmux-env.log" + fake_bin = root / "bin" + fake_bin.mkdir() + make_executable(fake_bin / "cmux", "#!/usr/bin/env bash\nexit 73\n") plugin_copy_path = config_dir / "plugins" / "cmux-session-copy.js" shutil.copyfile(plugin_path, plugin_copy_path) make_executable( @@ -156,7 +169,8 @@ def main() -> int: check_env["CMUX_TEST_OPENCODE_PLUGIN_PATH"] = str(plugin_path) check_env["CMUX_TEST_OPENCODE_PLUGIN_COPY_PATH"] = str(plugin_copy_path) check_env["CMUX_SURFACE_ID"] = "surface-opencode-test" - check_env["CMUX_OPENCODE_CMUX_BIN"] = str(fake_cmux) + check_env["CMUX_BUNDLED_CLI_PATH"] = str(fake_cmux) + check_env["PATH"] = f"{fake_bin}{os.pathsep}{check_env.get('PATH', '')}" check_env["FAKE_CMUX_ARGS_LOG"] = str(fake_args_log) check_env["FAKE_CMUX_STDIN_LOG"] = str(fake_stdin_log) check_env["FAKE_CMUX_ENV_LOG"] = str(fake_env_log) diff --git a/tests/test_pi_extension_install.py b/tests/test_pi_extension_install.py index 635e882ae49a..be3417983bc9 100644 --- a/tests/test_pi_extension_install.py +++ b/tests/test_pi_extension_install.py @@ -98,6 +98,7 @@ def main() -> int: bin_dir.mkdir() fake_pi = bin_dir / "pi" make_executable(fake_pi, "#!/usr/bin/env bash\nexit 0\n") + make_executable(bin_dir / "cmux", "#!/usr/bin/env bash\nexit 73\n") fake_cmux = root / "fake-cmux" fake_args_log = root / "fake-cmux-args.log" @@ -110,8 +111,7 @@ def main() -> int: set -euo pipefail printf '%s\n' "$*" >> "$CMUX_TEST_PI_ARGS_LOG" payload="$(cat)" -printf '%s' "$payload" >> "$CMUX_TEST_PI_STDIN_LOG" -printf '\n---\n' >> "$CMUX_TEST_PI_STDIN_LOG" +printf '%s\n---\n' "$payload" >> "$CMUX_TEST_PI_STDIN_LOG" { printf 'kind=%s\n' "${CMUX_AGENT_LAUNCH_KIND-}" printf 'cwd=%s\n' "${CMUX_AGENT_LAUNCH_CWD-}" @@ -174,7 +174,7 @@ def main() -> int: check_env["CMUX_TEST_PI_EXTENSION_PATH"] = str(extension_path) check_env["CMUX_SURFACE_ID"] = "surface-pi-test" check_env["CMUX_WORKSPACE_ID"] = "workspace-pi-test" - check_env["CMUX_PI_CMUX_BIN"] = str(fake_cmux) + check_env["CMUX_BUNDLED_CLI_PATH"] = str(fake_cmux) check_env["CMUX_TEST_PI_ARGS_LOG"] = str(fake_args_log) check_env["CMUX_TEST_PI_STDIN_LOG"] = str(fake_stdin_log) check_env["CMUX_TEST_PI_ENV_LOG"] = str(fake_env_log) diff --git a/web/app/[locale]/(landing)/docs/session-restore/page.tsx b/web/app/[locale]/(landing)/docs/session-restore/page.tsx index 90b6e6ee7164..eef7f5b45c48 100644 --- a/web/app/[locale]/(landing)/docs/session-restore/page.tsx +++ b/web/app/[locale]/(landing)/docs/session-restore/page.tsx @@ -104,7 +104,7 @@ cmux surface resume clear --checkpoint work`} OMP omp - omp --session <id> + omp --resume <id> {t("none")} @@ -128,7 +128,7 @@ cmux surface resume clear --checkpoint work`} Gemini gemini - gemini --resume <id> + gemini --session-file <transcript-path> {t("feedPreToolUse")} @@ -173,6 +173,12 @@ cmux surface resume clear --checkpoint work`} qodercli --resume <id> {t("feedPreToolUse")} + + Kimi Code + kimi + kimi --session <id> + {t("feedPrePostToolUse")} +

{t("supportedNote")}

diff --git a/web/messages/en.json b/web/messages/en.json index f8650b3a83c2..0655543f0036 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -2472,7 +2472,7 @@ "whatYouGetDesc": "The hooks extension lets omp report lifecycle and session information back to cmux:", "whatYouGet1": "Workspace busy and idle status from omp turn boundaries", "whatYouGet2": "Turn-end notifications in cmux", - "whatYouGet3": "Session tracking so closed panes can be resumed from the session index with omp --session SESSION_ID or forked with omp --fork SESSION_ID", + "whatYouGet3": "Session tracking so closed panes can be resumed from the session index with omp --resume SESSION_ID", "whatYouGet4": "Workspace auto-naming, with omp available as the summarizer model", "whatYouGet5": "Task Manager process attribution for omp processes", "directories": "Directories", @@ -2480,7 +2480,7 @@ "dirPath": "Path", "dirPurpose": "Purpose", "dirExtension": "cmux hooks extension installed and upgraded by cmux", - "dirSessions": "omp session files that cmux reads for restore and fork workflows", + "dirSessions": "omp session files that cmux reads for restore workflows", "envVars": "Environment variables", "envVarName": "Variable", "envVarPurpose": "Purpose", diff --git a/web/messages/ja.json b/web/messages/ja.json index bc8bb53d14a6..3fef76ff0865 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -2328,7 +2328,7 @@ "whatYouGetDesc": "フック拡張により、ompはライフサイクルとセッション情報をcmuxへ報告できます:", "whatYouGet1": "ompのターン境界にもとづくワークスペースの実行中・アイドル状態", "whatYouGet2": "cmux内のターン終了通知", - "whatYouGet3": "閉じたペインをセッションインデックスからomp --session SESSION_IDで再開でき、omp --fork SESSION_IDでフォークできるセッション追跡", + "whatYouGet3": "閉じたペインをセッションインデックスからomp --resume SESSION_IDで再開できるセッション追跡", "whatYouGet4": "ompを要約モデルとして使えるワークスペース自動命名", "whatYouGet5": "ompプロセスのタスクマネージャ識別", "directories": "ディレクトリ", @@ -2336,7 +2336,7 @@ "dirPath": "パス", "dirPurpose": "目的", "dirExtension": "cmuxがインストールし、アップグレードするcmuxフック拡張", - "dirSessions": "復元とフォークのワークフローのためにcmuxが読み取るompセッションファイル", + "dirSessions": "復元ワークフローのためにcmuxが読み取るompセッションファイル", "envVars": "環境変数", "envVarName": "変数", "envVarPurpose": "目的",