445 lines · 16.8 KB · Swift
RawDownload
import Foundation public struct AgentSession: Codable, Equatable, Sendable, Identifiable {    public enum Provider: String, Codable, Sendable {        case codex        case claude    }     public enum Source: String, Codable, Sendable {        case cli        case desktopApp        case ide        case unknown    }     public enum State: String, Codable, Sendable {        case active        case idle    }     public var id: String    public var provider: Provider    public var source: Source    public var state: State    public var pid: Int32?    public var cwd: String?    public var projectName: String?    public var startedAt: Date?    public var lastActivityAt: Date?    public var transcriptPath: String?    public var host: String     public init(        id: String,        provider: Provider,        source: Source,        state: State,        pid: Int32?,        cwd: String?,        projectName: String?,        startedAt: Date?,        lastActivityAt: Date?,        transcriptPath: String?,        host: String)    {        self.id = id        self.provider = provider        self.source = source        self.state = state        self.pid = pid        self.cwd = cwd        self.projectName = projectName        self.startedAt = startedAt        self.lastActivityAt = lastActivityAt        self.transcriptPath = transcriptPath        self.host = host    }} public struct SessionScanConfig: Equatable, Sendable {    public var activeWindow: TimeInterval    public var fileOnlyWindow: TimeInterval     public init(activeWindow: TimeInterval = 120, fileOnlyWindow: TimeInterval = 30 * 60) {        self.activeWindow = activeWindow        self.fileOnlyWindow = fileOnlyWindow    }     public func state(lastActivityAt: Date?, now: Date, hasLiveProcess: Bool) -> AgentSession.State {        guard let lastActivityAt else { return hasLiveProcess ? .active : .idle }        return now.timeIntervalSince(lastActivityAt) <= self.activeWindow ? .active : .idle    }} public struct AgentProcessRecord: Equatable, Sendable {    public let pid: Int32    public let ppid: Int32    public let startedAt: Date?    public let command: String     public init(pid: Int32, ppid: Int32, startedAt: Date?, command: String) {        self.pid = pid        self.ppid = ppid        self.startedAt = startedAt        self.command = command    }     public var executableBasename: String {        let firstToken = self.command.split(whereSeparator: \ .isWhitespace).first.map(String.init) ?? ""        let firstBasename = URL(fileURLWithPath: firstToken).lastPathComponent        if firstBasename == "disclaimer" {            return firstBasename        }        if self.command.contains("Application Support/Claude/claude-code/claude") {            return "claude"        }        return firstBasename    }} public enum AgentPSOutputParser {    public static func parse(_ output: String) -> [AgentProcessRecord] {        let formatter = DateFormatter()        formatter.locale = Locale(identifier: "en_US_POSIX")        formatter.dateFormat = "EEE MMM d HH:mm:ss yyyy"        return output.split(whereSeparator: \ .isNewline).compactMap { rawLine -> AgentProcessRecord? in            let fields = rawLine.split(maxSplits: 7, omittingEmptySubsequences: true, whereSeparator: \ .isWhitespace)            guard fields.count == 8,                  let pid = Int32(fields[0]),                  let ppid = Int32(fields[1])            else { return nil }             let dateText = fields[2...6].joined(separator: " ")            return AgentProcessRecord(                pid: pid,                ppid: ppid,                startedAt: formatter.date(from: dateText),                command: String(fields[7]))        }    }     public static func agentProcesses(from records: [AgentProcessRecord]) -> [AgentProcessRecord] {        let candidates = records.filter { record in            let basename = record.executableBasename.lowercased()            if basename == "codex" {                let arguments = self.arguments(record.command)                return self.isCodexAgentExecutable(record.command) &&                    !arguments.contains("app-server") &&                    !arguments.contains("--help") &&                    !arguments.contains("--version")            }            if basename == "claude" {                return self.isClaudeAgentExecutable(record.command) && !self.isObviousClaudeHelper(record.command)            }            return basename == "disclaimer" && record.command.contains("claude")        }         let recordsByPID = Dictionary(uniqueKeysWithValues: candidates.map { ($0.pid, $0) })        return candidates.filter { record in            guard record.executableBasename.lowercased() == "disclaimer" else { return true }            return !candidates.contains { child in                child.ppid == record.pid &&                    child.executableBasename.lowercased() == "claude" &&                    self.normalizedClaudeArguments(child.command) == self.normalizedClaudeArguments(record.command)            } && recordsByPID[record.ppid] == nil        }    }     public static func provider(for record: AgentProcessRecord) -> AgentSession.Provider? {        let basename = record.executableBasename.lowercased()        if basename == "codex" {            return .codex        }        if basename == "claude" || basename == "disclaimer" {            return .claude        }        return nil    }     public static func source(for record: AgentProcessRecord) -> AgentSession.Source {        guard self.provider(for: record) == .claude else { return .cli }        return record.command.contains("Application Support/Claude/claude-code") ? .desktopApp : .cli    }     public static func hasCodexAppServer(in records: [AgentProcessRecord]) -> Bool {        records.contains { record in            record.executableBasename.lowercased() == "codex" &&                self.isCodexAgentExecutable(record.command) &&                self.arguments(record.command).contains("app-server")        }    }     private static func arguments(_ command: String) -> [String] {        command.split(whereSeparator: \ .isWhitespace).dropFirst().map(String.init)    }     private static func normalizedClaudeArguments(_ command: String) -> [String] {        let arguments = self.arguments(command)        if let index = arguments.firstIndex(where: { URL(fileURLWithPath: $0).lastPathComponent == "claude" }) {            return Array(arguments.suffix(from: arguments.index(after: index)))        }        return arguments    }     private static func isObviousClaudeHelper(_ command: String) -> Bool {        let lowercased = command.lowercased()        return lowercased.contains("--version") ||            lowercased.contains("--help") ||            lowercased.contains("claude-code-acp")    }     private static func isCodexAgentExecutable(_ command: String) -> Bool {        let lowercased = command.lowercased()        guard lowercased.contains(".app/") else { return true }        return lowercased.hasPrefix("/applications/codex.app/contents/resources/codex ") ||            lowercased.hasPrefix("/applications/codex.app/contents/resources/codex\t")    }     private static func isClaudeAgentExecutable(_ command: String) -> Bool {        let lowercased = command.lowercased()        return !lowercased.contains(".app/") || lowercased.contains("application support/claude/claude-code/claude")    }} public enum LSOFCWDOutputParser {    public static func parse(_ output: String) -> [Int32: String] {        var result: [Int32: String] = [:]        var currentPID: Int32?        for line in output.split(whereSeparator: \ .isNewline).map(String.init) {            guard let prefix = line.first else { continue }            switch prefix {            case "p":                currentPID = Int32(line.dropFirst())            case "n":                if let currentPID {                    result[currentPID] = String(line.dropFirst())                }            default:                continue            }        }        return result    }} public enum ClaudeSessionProjectMapper {    public struct Transcript: Equatable, Sendable {        public let url: URL        public let modifiedAt: Date         public init(url: URL, modifiedAt: Date) {            self.url = url            self.modifiedAt = modifiedAt        }    }     public static func escapedCWD(_ cwd: String) -> String {        String(cwd.unicodeScalars.map { scalar in            let value = scalar.value            let isASCIIAlphanumeric = (48...57).contains(value) ||                (65...90).contains(value) ||                (97...122).contains(value)            return isASCIIAlphanumeric ? Character(scalar) : "-"        })    }     public static func projectDirectories(        cwd: String,        homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,        fileManager: FileManager = .default) -> [URL]    {        let standardRoot = homeDirectory            .appendingPathComponent(".claude", isDirectory: true)            .appendingPathComponent("projects", isDirectory: true)        return ([standardRoot] + ClaudeDesktopProjectsLocator.roots(            homeDirectory: homeDirectory,            fileManager: fileManager))            .map { $0.appendingPathComponent(self.escapedCWD(cwd), isDirectory: true) }    }     public static func newestTranscript(        cwd: String,        homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,        fileManager: FileManager = .default) -> (url: URL, modifiedAt: Date)?    {        self.transcripts(cwd: cwd, homeDirectory: homeDirectory, fileManager: fileManager).first            .map { (url: $0.url, modifiedAt: $0.modifiedAt) }    }     public static func transcripts(        cwd: String,        homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,        fileManager: FileManager = .default) -> [Transcript]    {        self.projectDirectories(cwd: cwd, homeDirectory: homeDirectory, fileManager: fileManager)            .flatMap { directory -> [(URL, Date)] in                guard let files = try? fileManager.contentsOfDirectory(                    at: directory,                    includingPropertiesForKeys: [.contentModificationDateKey],                    options: [.skipsHiddenFiles])                else { return [] }                return files.compactMap { file in                    guard file.pathExtension == "jsonl",                          let modifiedAt = try? file.resourceValues(                              forKeys: [.contentModificationDateKey]).contentModificationDate                    else { return nil }                    return (file, modifiedAt)                }            }            .sorted { $0.1 > $1.1 }            .map { Transcript(url: $0.0, modifiedAt: $0.1) }    }} public enum AgentSessionCorrelation {    public static func newestProcessesFirst(_ processes: [AgentProcessRecord]) -> [AgentProcessRecord] {        processes.sorted { lhs, rhs in            let lhsDate = lhs.startedAt ?? .distantPast            let rhsDate = rhs.startedAt ?? .distantPast            if lhsDate != rhsDate { return lhsDate > rhsDate }            return lhs.pid > rhs.pid        }    }     public static func assignClaudeTranscripts(        processes: [AgentProcessRecord],        cwdByPID: [Int32: String],        transcriptsByCWD: [String: [ClaudeSessionProjectMapper.Transcript]])        -> [Int32: ClaudeSessionProjectMapper.Transcript]    {        var assigned: [Int32: ClaudeSessionProjectMapper.Transcript] = [:]        var usedPaths = Set<String>()        let unambiguousPIDs = self.unambiguousProcessIDs(processes: processes, cwdByPID: cwdByPID)        for process in self.newestProcessesFirst(processes) {            guard unambiguousPIDs.contains(process.pid),                  let cwd = cwdByPID[process.pid],                  let candidates = transcriptsByCWD[cwd]            else { continue }            let candidate = candidates.first { transcript in                guard !usedPaths.contains(transcript.url.path) else { return false }                guard let startedAt = process.startedAt else { return true }                return transcript.modifiedAt >= startedAt            }            if let candidate {                assigned[process.pid] = candidate                usedPaths.insert(candidate.url.path)            }        }        return assigned    }     public static func unambiguousProcessIDs(        processes: [AgentProcessRecord],        cwdByPID: [Int32: String]) -> Set<Int32>    {        let grouped = Dictionary(grouping: processes.compactMap { process -> (Int32, String)? in            guard let cwd = cwdByPID[process.pid] else { return nil }            return (process.pid, URL(fileURLWithPath: cwd).standardizedFileURL.path)        }, by: { $0.1 })        return Set(grouped.values.compactMap { records in            records.count == 1 ? records[0].0 : nil        })    }     public static func codexWorkingDirectoriesMatch(_ lhs: String?, _ rhs: String?) -> Bool {        guard let lhs, let rhs else { return false }        return URL(fileURLWithPath: lhs).standardizedFileURL.path ==            URL(fileURLWithPath: rhs).standardizedFileURL.path    }     public static func fileOnlyCodexSource(        metadataSource: AgentSession.Source,        appServerPresent: Bool) -> AgentSession.Source    {        metadataSource == .unknown && appServerPresent ? .desktopApp : metadataSource    }} public struct CodexRolloutMetadata: Equatable, Sendable {    public let sessionID: String    public let cwd: String?    public let originator: String?    public let source: String?     public init(sessionID: String, cwd: String?, originator: String?, source: String?) {        self.sessionID = sessionID        self.cwd = cwd        self.originator = originator        self.source = source    }     public var sessionSource: AgentSession.Source {        let value = [self.originator, self.source]            .compactMap { $0?.lowercased() }            .joined(separator: " ")        if value.contains("desktop") || value.contains("app-server") {            return .desktopApp        }        if value.contains("ide") || value.contains("vscode") || value.contains("cursor") || value.contains("zed") {            return .ide        }        if value.contains("codex_exec") || value.contains("exec") || value.contains("cli") {            return .cli        }        return .unknown    }} public enum CodexRolloutFirstLineParser {    public static func parse(_ line: String) -> CodexRolloutMetadata? {        guard let data = line.data(using: .utf8),              let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],              object["type"] as? String == "session_meta",              let payload = object["payload"] as? [String: Any],              let sessionID = (payload["session_id"] as? String) ?? (payload["id"] as? String)        else { return nil }        return CodexRolloutMetadata(            sessionID: sessionID,            cwd: payload["cwd"] as? String,            originator: payload["originator"] as? String,            source: payload["source"] as? String)    }     public static func read(from url: URL) -> CodexRolloutMetadata? {        guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }        defer { try? handle.close() }        var data = Data()        while data.count < 256 * 1024 {            guard let chunk = try? handle.read(upToCount: 4096), !chunk.isEmpty else { break }            if let newline = chunk.firstIndex(of: 0x0A) {                data.append(chunk.prefix(upTo: newline))                break            }            data.append(chunk)        }        guard let line = String(data: data, encoding: .utf8) else { return nil }        return self.parse(line)    }     public static func makeSession(        metadata: CodexRolloutMetadata,        transcriptURL: URL,        modifiedAt: Date,        pid: Int32? = nil,        startedAt: Date? = nil,        host: String,        config: SessionScanConfig = SessionScanConfig(),        now: Date = Date()) -> AgentSession?    {        guard pid != nil || now.timeIntervalSince(modifiedAt) <= config.fileOnlyWindow else { return nil }        let cwd = metadata.cwd        return AgentSession(            id: metadata.sessionID,            provider: .codex,            source: metadata.sessionSource,            state: config.state(lastActivityAt: modifiedAt, now: now, hasLiveProcess: pid != nil),            pid: pid,            cwd: cwd,            projectName: cwd.map { URL(fileURLWithPath: $0).lastPathComponent },            startedAt: startedAt,            lastActivityAt: modifiedAt,            transcriptPath: transcriptURL.path,            host: host)    }}