diff --git a/CHANGELOG.md b/CHANGELOG.md index 7554ff0e18..05b365649e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Kimi: enrich Code API and CLI usage with the monthly membership pool from a signed-in Kimi Desktop session, using WAL-safe read-only cookie access (#2351). Thanks @Leehow! +- Sessions: discover live pi and OMP sessions through one Pi-family scanner, with dialect-aware metadata, PID-only startup rows, and mixed-version CLI/remote support (#2529). Thanks @wdmitchelluk! - Kimi/GLM: distinguish Kimi Code from the regional Open Platform, bind China and international keys to their issuing hosts, and show GLM Coding Plan's 5-hour window as primary with MCP separate (#2351). Thanks @Leehow! ### Changed diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index ba247ba95f..f2b804212f 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -193,9 +193,14 @@ struct MenuDescriptor { now: Date) -> String { let state = session.state == .active ? "●" : "○" - let providerGlyph = session.provider == .codex ? "⌘" : "✦" + let providerGlyph = switch session.provider { + case .codex: "⌘" + case .claude: "✦" + case .pi: "π" + } let label = labelStyle.label(for: session) - return "\(state) \(providerGlyph) \(label) — \(session.provider.rawValue) · " + + let providerTag = session.dialect?.rawValue ?? session.provider.rawValue + return "\(state) \(providerGlyph) \(label) — \(providerTag) · " + "\(session.source.rawValue) · \(self.agentSessionAge(session, now: now))" } diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index 6f0caf74ed..06cfe10ea0 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -186,13 +186,13 @@ enum CodexBarCLI { signature: costSignature), CommandDescriptor( name: "sessions", - abstract: "List live Codex and Claude Code sessions", + abstract: "List live Codex, Claude Code, pi, and OMP sessions", discussion: nil, signature: CommandSignature(), subcommands: [ CommandDescriptor( name: "list", - abstract: "List live Codex and Claude Code sessions", + abstract: "List live Codex, Claude Code, pi, and OMP sessions", discussion: nil, signature: sessionsSignature), CommandDescriptor( diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 8931f24d2d..16854efa90 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -117,17 +117,20 @@ extension CodexBarCLI { CodexBar \(version) Usage: - codexbar sessions [--json] [--pretty] + codexbar sessions [--json|--json-v2] [--pretty] codexbar sessions focus Description: - List live local Codex and Claude Code agent sessions. + List live local Codex, Claude Code, pi, and OMP agent sessions. + --json emits the legacy v1 array with only Codex and Claude providers. + --json-v2 emits the complete current array, including Pi-family sessions. JSON uses stable AgentSession field names and ISO-8601 dates. Focus activates the owning terminal or desktop app on macOS. Examples: codexbar sessions codexbar sessions --json + codexbar sessions --json-v2 codexbar sessions focus 019f3497-73bf-7df3-a173-4f67d968914a """ } @@ -426,7 +429,7 @@ extension CodexBarCLI { [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh] [--days ] [--group-by project] - codexbar sessions [--json] [--pretty] + codexbar sessions [--json|--json-v2] [--pretty] codexbar sessions focus codexbar dashboard [--pretty] [--timeout ] codexbar serve [--host ] [--port ] [--refresh-interval ] diff --git a/Sources/CodexBarCLI/CLISessionsCommand.swift b/Sources/CodexBarCLI/CLISessionsCommand.swift index 437f4ccb6f..1379865269 100644 --- a/Sources/CodexBarCLI/CLISessionsCommand.swift +++ b/Sources/CodexBarCLI/CLISessionsCommand.swift @@ -5,13 +5,30 @@ import Foundation extension CodexBarCLI { static func runSessions(_ values: ParsedValues) async { let sessions = await LocalAgentSessionScanner().scan() - if values.flags.contains("jsonShortcut") { - Self.printJSON(sessions, pretty: values.flags.contains("pretty")) + if let jsonVersion = Self.sessionsJSONProtocolVersion(from: values) { + Self.printJSON( + Self.sessionsForJSON(sessions, includePiFamily: jsonVersion == 2), + pretty: values.flags.contains("pretty")) } else { print(Self.renderSessionsTable(sessions)) } } + static func sessionsJSONProtocolVersion(from values: ParsedValues) -> Int? { + if values.flags.contains("jsonV2") { + return 2 + } + if values.flags.contains("jsonShortcut") { + return 1 + } + return nil + } + + static func sessionsForJSON(_ sessions: [AgentSession], includePiFamily: Bool) -> [AgentSession] { + guard !includePiFamily else { return sessions } + return sessions.filter { $0.provider == .codex || $0.provider == .claude } + } + static func runSessionsFocus(_ values: ParsedValues) async { guard let sessionID = values.positional.first, !sessionID.isEmpty else { writeStderr("Missing session id.\n") @@ -46,13 +63,14 @@ extension CodexBarCLI { [ session.state == .active ? "active" : "idle", session.provider.rawValue, + session.dialect?.rawValue ?? "—", session.source.rawValue, session.projectName ?? "—", Self.sessionAge(session, now: now), session.id, ] } - let headers = ["STATE", "PROVIDER", "SOURCE", "PROJECT", "ACTIVITY", "ID"] + let headers = ["STATE", "PROVIDER", "DIALECT", "SOURCE", "PROJECT", "ACTIVITY", "ID"] let widths = headers.indices.map { index in ([headers[index]] + rows.map { $0[index] }).map(\ .count).max() ?? headers[index].count } @@ -81,9 +99,12 @@ extension CodexBarCLI { } struct SessionsOptions: CommanderParsable { - @Flag(name: .long("json"), help: "Emit JSON") + @Flag(name: .long("json"), help: "Emit legacy JSON compatible with older clients") var jsonShortcut: Bool = false + @Flag(name: .long("json-v2"), help: "Emit complete JSON, including Pi-family sessions") + var jsonV2: Bool = false + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") var pretty: Bool = false } diff --git a/Sources/CodexBarCore/AgentSession.swift b/Sources/CodexBarCore/AgentSession.swift index 73ca905421..074a823c8a 100644 --- a/Sources/CodexBarCore/AgentSession.swift +++ b/Sources/CodexBarCore/AgentSession.swift @@ -4,6 +4,12 @@ public struct AgentSession: Codable, Equatable, Sendable, Identifiable { public enum Provider: String, Codable, Sendable { case codex case claude + case pi + } + + public enum Dialect: String, Codable, Sendable { + case pi + case omp } public enum Source: String, Codable, Sendable { @@ -20,6 +26,7 @@ public struct AgentSession: Codable, Equatable, Sendable, Identifiable { public var id: String public var provider: Provider + public var dialect: Dialect? public var source: Source public var state: State public var pid: Int32? @@ -34,6 +41,7 @@ public struct AgentSession: Codable, Equatable, Sendable, Identifiable { public init( id: String, provider: Provider, + dialect: Dialect? = nil, source: Source, state: State, pid: Int32?, @@ -47,6 +55,7 @@ public struct AgentSession: Codable, Equatable, Sendable, Identifiable { { self.id = id self.provider = provider + self.dialect = dialect self.source = source self.state = state self.pid = pid @@ -216,6 +225,9 @@ public enum AgentPSOutputParser { public static func agentProcesses(from records: [AgentProcessRecord]) -> [AgentProcessRecord] { let candidates = records.filter { record in let basename = record.executableBasename.lowercased() + if self.piDialect(for: record) != nil { + return !self.isObviousPiFamilyHelper(record.command) + } if basename == "codex" { let arguments = self.arguments(record.command) return self.isCodexAgentExecutable(record.command) && @@ -248,9 +260,29 @@ public enum AgentPSOutputParser { if basename == "claude" || basename == "disclaimer" { return .claude } + if self.piDialect(for: record) != nil, !self.isObviousPiFamilyHelper(record.command) { + return .pi + } return nil } + public static func piDialect(for record: AgentProcessRecord) -> AgentSession.Dialect? { + let tokens = record.command.split(whereSeparator: \ .isWhitespace).map(String.init) + guard let firstToken = tokens.first else { return nil } + + let firstBasename = URL(fileURLWithPath: firstToken).lastPathComponent.lowercased() + if firstBasename == "pi" { + return .pi + } + if firstBasename == "omp" { + return .omp + } + guard firstBasename == "bun" else { return nil } + return tokens.dropFirst().contains { + URL(fileURLWithPath: $0).lastPathComponent.lowercased() == "omp" + } ? .omp : 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 @@ -294,6 +326,14 @@ public enum AgentPSOutputParser { let lowercased = command.lowercased() return !lowercased.contains(".app/") || lowercased.contains("application support/claude/claude-code/claude") } + + private static func isObviousPiFamilyHelper(_ command: String) -> Bool { + let lowercased = command.lowercased() + return lowercased.contains("--help") || + lowercased.contains("--version") || + lowercased.contains("--smoke-test") || + lowercased.contains("__omp_worker_") + } } public enum LSOFCWDOutputParser { diff --git a/Sources/CodexBarCore/LocalAgentSessionScanner.swift b/Sources/CodexBarCore/LocalAgentSessionScanner.swift index 4d522763e6..7bdd986877 100644 --- a/Sources/CodexBarCore/LocalAgentSessionScanner.swift +++ b/Sources/CodexBarCore/LocalAgentSessionScanner.swift @@ -37,6 +37,7 @@ public struct LocalAgentSessionScanner: Sendable { let codexAppServerPresent: Bool let includeFileOnlySessions: Bool let threadMetadata: [String: CodexThreadMetadata] + let piFamilySessions: [AgentSession] } public let config: SessionScanConfig @@ -104,6 +105,22 @@ public struct LocalAgentSessionScanner: Sendable { ? self.config.directoryScanBudget : min(self.config.directoryScanBudget, self.config.adaptiveDirectoryScanBudget), didVisitEntry: self.didVisitDirectoryEntry) + var piFamilyDirectoryBudget = DirectoryMetadataScanBudget( + maxEntryCount: self.config.maxDirectoryEntryCount, + maxDepth: self.config.maxDirectoryDepth, + timeLimit: includeFileOnlySessions + ? self.config.directoryScanBudget + : min(self.config.directoryScanBudget, self.config.adaptiveDirectoryScanBudget), + didVisitEntry: self.didVisitDirectoryEntry) + let piFamilySessions = PiFamilySessionScanner.scan( + input: PiFamilySessionScanner.ScanInput( + processes: processes, + cwdByPID: cwdByPID, + environment: environment, + now: now, + host: host, + config: self.config), + directoryBudget: &piFamilyDirectoryBudget) let rollouts: [Rollout] = if includeFileOnlySessions || !codexCWDs.isEmpty { self.codexRollouts( now: now, @@ -127,7 +144,8 @@ public struct LocalAgentSessionScanner: Sendable { now: now, codexAppServerPresent: codexAppServerPresent, includeFileOnlySessions: includeFileOnlySessions, - threadMetadata: threadMetadata), + threadMetadata: threadMetadata, + piFamilySessions: piFamilySessions), directoryBudget: &directoryBudget) } @@ -254,6 +272,8 @@ public struct LocalAgentSessionScanner: Sendable { lastActivityAt: rollout?.modifiedAt, transcriptPath: rollout?.url.path, host: context.host)) + case .pi: + continue } } @@ -275,6 +295,7 @@ public struct LocalAgentSessionScanner: Sendable { appServerPresent: context.codexAppServerPresent) sessions.append(session) } + sessions.append(contentsOf: context.piFamilySessions) var seen = Set() return sessions diff --git a/Sources/CodexBarCore/PiFamilySessionScanner.swift b/Sources/CodexBarCore/PiFamilySessionScanner.swift new file mode 100644 index 0000000000..f8dec9fb66 --- /dev/null +++ b/Sources/CodexBarCore/PiFamilySessionScanner.swift @@ -0,0 +1,899 @@ +import Foundation + +struct PiFamilySessionRecord: Equatable, Sendable { + let id: String + let cwd: String? + let sessionName: String? + let startedAt: Date? + let modifiedAt: Date + let url: URL +} + +enum PiFamilySessionFileParser { + private static let maximumReadSize = 16 * 1024 + + static func parse( + url: URL, + dialect: AgentSession.Dialect, + modifiedAt: Date, + now: Date) -> PiFamilySessionRecord? + { + guard let data = readPrefix(from: url), + let lines = completeLines(in: data) + else { return nil } + + var nonEmptyLines = lines.filter { !$0.isEmpty } + guard !nonEmptyLines.isEmpty else { return nil } + + var titleSlotWasPresent = false + var titleSlot: String? + if dialect == .omp, + let first = Self.jsonObject(from: nonEmptyLines[0]), + first["type"] as? String == "title" + { + titleSlotWasPresent = true + titleSlot = first["title"] as? String + nonEmptyLines.removeFirst() + } + + guard let headerData = nonEmptyLines.first, + let header = Self.jsonObject(from: headerData), + header["type"] as? String == "session", + let id = header["id"] as? String + else { return nil } + if dialect == .pi, header["version"] as? Int != 3 { + return nil + } + + let rawTitle = switch dialect { + case .pi: + Self.latestPiSessionName(in: url, prefixLines: nonEmptyLines) + case .omp: + titleSlotWasPresent ? titleSlot : header["title"] as? String + } + let sessionName = rawTitle.flatMap(Self.sanitizedTitle) + let startedAt = (header["timestamp"] as? String).flatMap(Self.parseDate) + + return PiFamilySessionRecord( + id: id, + cwd: header["cwd"] as? String, + sessionName: sessionName, + startedAt: startedAt, + modifiedAt: min(modifiedAt, now), + url: url) + } + + private static func latestPiSessionName(in url: URL, prefixLines: [Data]) -> String? { + var latest = Self.latestPiSessionName(in: prefixLines) + guard let handle = try? FileHandle(forReadingFrom: url) else { return latest } + defer { try? handle.close() } + guard let size = try? handle.seekToEnd(), size > UInt64(Self.maximumReadSize) else { return latest } + + let tailReadSize = 64 * 1024 + let offset = size > UInt64(tailReadSize) ? size - UInt64(tailReadSize) : 0 + do { + try handle.seek(toOffset: offset) + guard let tail = try handle.read(upToCount: tailReadSize), !tail.isEmpty else { return latest } + var lines: [Data] = [] + for line in [UInt8](tail).split(separator: 0x0A, omittingEmptySubsequences: true) { + lines.append(Data(line)) + } + if offset > 0, !lines.isEmpty { + lines.removeFirst() + } + latest = Self.latestPiSessionName(in: lines) ?? latest + } catch { + return latest + } + return latest + } + + private static func latestPiSessionName(in lines: [Data]) -> String? { + lines.reversed().compactMap { line -> String? in + guard let entry = Self.jsonObject(from: line), + entry["type"] as? String == "session_info" + else { return nil } + return entry["name"] as? String + }.first + } + + private static func readPrefix(from url: URL) -> Data? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + return try? handle.read(upToCount: Self.maximumReadSize) + } + + private static func completeLines(in data: Data) -> [Data]? { + var lines: [Data] = [] + var lineStart = data.startIndex + + for index in data.indices where data[index] == 0x0A { + lines.append(data.subdata(in: lineStart.. [String: Any]? { + guard let object = try? JSONSerialization.jsonObject(with: data, options: []), + let dictionary = object as? [String: Any] + else { return nil } + return dictionary + } + + private static func parseDate(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { + return date + } + + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } + + private static func sanitizedTitle(_ value: String) -> String? { + var result = "" + for scalar in value.unicodeScalars { + guard !CharacterSet.controlCharacters.contains(scalar), + !CharacterSet.newlines.contains(scalar) + else { continue } + guard result.unicodeScalars.count < 64 else { break } + result.unicodeScalars.append(scalar) + } + return result.isEmpty ? nil : result + } +} + +struct OMPSessionRootResolver: Sendable { + static func sessionRoots( + environment: [String: String], + fileManager: FileManager = .default) -> [URL] + { + self.sessionRoots( + environment: environment, + baseDirectory: self.currentDirectory(fileManager: fileManager), + fileManager: fileManager) + } + + static func sessionRoots( + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager = .default) -> [URL] + { + guard let profile = activeProfile(in: environment) else { + // `nil` is the valid default profile. An invalid profile is + // represented separately so a malformed environment fails closed. + guard self.profileValueIsValid(in: environment) else { return [] } + return self.defaultProfileRoots( + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager) + } + + return Self.namedProfileRoots( + profile: profile, + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager) + } + + static func defaultProfileSessionRoots( + environment: [String: String], + fileManager: FileManager = .default) -> [URL] + { + self.defaultProfileSessionRoots( + environment: environment, + baseDirectory: self.currentDirectory(fileManager: fileManager), + fileManager: fileManager) + } + + static func defaultProfileSessionRoots( + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager = .default) -> [URL] + { + self.defaultProfileRoots( + environment: self.sanitizedDefaultEnvironment(environment), + baseDirectory: baseDirectory, + fileManager: fileManager) + } + + private static func defaultProfileRoots( + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager) -> [URL] + { + guard let home = homeURL( + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager) + else { return [] } + guard let configRoot = Self.configRoot(home: home, environment: environment) else { return [] } + let customAgentRoot = Self.customAgentRoot( + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager) + let agentRoot: URL + if let customAgentRoot { + agentRoot = customAgentRoot + } else { + guard let canonicalAgentRoot = Self.canonicalAgentRoot( + configRoot.appendingPathComponent("agent", isDirectory: true), + home: home) + else { return [] } + agentRoot = canonicalAgentRoot + } + + guard let root = Self.sessionRoot(agentRoot: agentRoot, fileManager: fileManager) else { return [] } + + #if os(macOS) || os(Linux) + if customAgentRoot == nil, + let xdgDataHome = Self.environmentURL( + environment["XDG_DATA_HOME"], + baseDirectory: baseDirectory, + fileManager: fileManager) + { + let xdgSessions = xdgDataHome + .appendingPathComponent("omp", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + if Self.isDirectory(xdgSessions, fileManager: fileManager), + let root = Self.sessionRoot( + agentRoot: xdgDataHome.appendingPathComponent("omp", isDirectory: true), + fileManager: fileManager) + { + return [root] + } + } + #endif + + return [root] + } + + private static func namedProfileRoots( + profile: String, + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager) -> [URL] + { + guard let home = homeURL( + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager) + else { return [] } + guard let configRoot = Self.configRoot(home: home, environment: environment) else { return [] } + let profileRoot = configRoot + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent(profile, isDirectory: true) + guard let agentRoot = Self.canonicalAgentRoot( + profileRoot.appendingPathComponent("agent", isDirectory: true), + home: home) + else { return [] } + + guard let root = Self.sessionRoot(agentRoot: agentRoot, fileManager: fileManager) else { return [] } + #if os(macOS) || os(Linux) + if let xdgDataHome = Self.environmentURL( + environment["XDG_DATA_HOME"], + baseDirectory: baseDirectory, + fileManager: fileManager) + { + let xdgProfileRoot = xdgDataHome + .appendingPathComponent("omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent(profile, isDirectory: true) + let xdgSessions = xdgProfileRoot.appendingPathComponent("sessions", isDirectory: true) + if Self.isDirectory(xdgSessions, fileManager: fileManager), + let root = Self.sessionRoot( + agentRoot: xdgProfileRoot, + fileManager: fileManager) + { + return [root] + } + } + #endif + + return [root] + } + + private static func profileValueIsValid(in environment: [String: String]) -> Bool { + let value = if let omp = environment["OMP_PROFILE"] { + omp + } else { + environment["PI_PROFILE"] + } + if case .invalid = Self.normalizedProfile(value) { + return false + } + return true + } + + private static func activeProfile(in environment: [String: String]) -> String? { + let value = if let omp = environment["OMP_PROFILE"] { + omp + } else { + environment["PI_PROFILE"] + } + guard case let .named(profile) = Self.normalizedProfile(value) else { return nil } + return profile + } + + private enum ProfileValue { + case `default` + case named(String) + case invalid + } + + private static func normalizedProfile(_ value: String?) -> ProfileValue { + let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if normalized.isEmpty || normalized == "default" { + return .default + } + + let scalars = Array(normalized.unicodeScalars) + guard let first = scalars.first, + scalars.count <= 64, + Self.isASCIIAlphaNumeric(first), + scalars.dropFirst().allSatisfy(Self.isProfileTailScalar), + normalized != ".", + normalized != "..", + !normalized.hasSuffix("."), + !Self.isWindowsReservedProfileName(normalized) + else { return .invalid } + + return .named(normalized) + } + + private static func isASCIIAlphaNumeric(_ scalar: Unicode.Scalar) -> Bool { + (scalar.value >= 48 && scalar.value <= 57) || + (scalar.value >= 97 && scalar.value <= 122) + } + + private static func isProfileTailScalar(_ scalar: Unicode.Scalar) -> Bool { + self.isASCIIAlphaNumeric(scalar) || + scalar.value == 46 || + scalar.value == 95 || + scalar.value == 45 + } + + private static func isWindowsReservedProfileName(_ value: String) -> Bool { + let uppercased = value.uppercased() + let base = uppercased.split(separator: ".", omittingEmptySubsequences: false).first.map(String.init) ?? "" + switch base { + case "CON", "PRN", "AUX", "NUL": + return true + default: + return (base.hasPrefix("COM") || base.hasPrefix("LPT")) && + base.count == 4 && + base.last.map(\.isNumber) == true + } + } + + private static func homeURL( + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager) -> URL? + { + guard let home = environmentURL( + environment["HOME"], + baseDirectory: baseDirectory, + fileManager: fileManager) + else { return nil } + return home + } + + private static func configRoot(home: URL, environment: [String: String]) -> URL? { + let name: String = if let configuredPath = environment["PI_CONFIG_DIR"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !configuredPath.isEmpty + { + configuredPath + } else { + ".omp" + } + guard !name.hasPrefix("/") else { return nil } + + let canonicalHome = Self.canonicalURL(home) + let configRoot = Self.canonicalURL( + canonicalHome.appendingPathComponent(name, isDirectory: true)) + guard Self.isWithin(root: canonicalHome, candidate: configRoot) else { return nil } + return configRoot + } + + private static func customAgentRoot( + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager) -> URL? + { + self.environmentURL( + environment["PI_CODING_AGENT_DIR"], + baseDirectory: baseDirectory, + fileManager: fileManager) + } + + private static func environmentURL( + _ value: String?, + baseDirectory: URL?, + fileManager: FileManager) -> URL? + { + guard let value else { return nil } + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { return nil } + + let url: URL + if path.hasPrefix("/") { + url = URL(fileURLWithPath: path, isDirectory: true) + } else { + guard let baseDirectory else { return nil } + url = baseDirectory.appendingPathComponent(path, isDirectory: true) + } + return Self.canonicalURL(url) + } + + private static func sanitizedDefaultEnvironment(_ environment: [String: String]) -> [String: String] { + // A process with an inaccessible environment must not inherit + // process-specific config, custom roots, XDG roots, or profile + // selectors from the scanner's ambient environment. HOME is the only + // input needed to identify the standard default profile root. + guard let home = environment["HOME"] else { return [:] } + return ["HOME": home] + } + + private static func currentDirectory(fileManager: FileManager) -> URL { + URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true) + } + + private static func sessionRoot(agentRoot: URL, fileManager: FileManager) -> URL? { + let canonicalAgentRoot = Self.canonicalURL(agentRoot) + let candidate = Self.canonicalURL( + agentRoot.appendingPathComponent("sessions", isDirectory: true)) + guard Self.isWithin(root: canonicalAgentRoot, candidate: candidate) else { return nil } + return candidate + } + + private static func canonicalAgentRoot(_ agentRoot: URL, home: URL) -> URL? { + let canonicalHome = Self.canonicalURL(home) + let canonicalAgentRoot = Self.canonicalURL(agentRoot) + guard Self.isWithin(root: canonicalHome, candidate: canonicalAgentRoot) else { return nil } + return canonicalAgentRoot + } + + private static func canonicalURL(_ url: URL) -> URL { + url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL + } + + private static func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && + isDirectory.boolValue + } + + fileprivate static func isWithin(root: URL, candidate: URL) -> Bool { + let rootPath = root.standardizedFileURL.path + let candidatePath = candidate.standardizedFileURL.path + if rootPath == "/" { + return candidatePath.hasPrefix("/") + } + return candidatePath == rootPath || candidatePath.hasPrefix(rootPath + "/") + } +} + +struct PiFamilySessionScanner: Sendable { + struct ScanInput: Sendable { + let processes: [AgentProcessRecord] + let cwdByPID: [Int32: String] + let environment: [String: String] + let now: Date + let host: String + let config: SessionScanConfig + } + + private enum RootLayout: Hashable, Sendable { + case projectDirectories + case direct + } + + private struct SessionRoot: Hashable, Sendable { + let url: URL + let layout: RootLayout + } + + static func scan( + input: ScanInput, + directoryBudget: inout DirectoryMetadataScanBudget) -> [AgentSession] + { + let processes = input.processes + let cwdByPID = input.cwdByPID + let now = input.now + let host = input.host + let config = input.config + let liveProcesses = Array(AgentSessionCorrelation.newestProcessesFirst( + processes.filter { AgentPSOutputParser.provider(for: $0) == .pi }) + .prefix(max(0, config.maxProcessCount))) + guard !liveProcesses.isEmpty else { + // Pi-family sessions are process-backed in the local scanner. Never + // turn an old session file into a file-only AgentSession. + return [] + } + + var recordsByRoot: [String: [PiFamilySessionRecord]] = [:] + var usedRecordURLs = Set() + var sessions: [AgentSession] = [] + + for process in liveProcesses { + guard let dialect = AgentPSOutputParser.piDialect(for: process) else { continue } + let processCWD = cwdByPID[process.pid] + let processStandardizedCWD = processCWD + .flatMap { $0.isEmpty ? nil : Self.standardizedPath($0) } + + var record: PiFamilySessionRecord? + if let processStartedAt = process.startedAt, + let processStandardizedCWD, + let processCWD + { + let roots = Self.sessionRoots( + for: process, + dialect: dialect, + cwd: processCWD, + environment: input.environment) + for root in roots { + guard directoryBudget.hasTimeRemaining() else { break } + let canonicalRoot = Self.canonicalURL(root.url) + let rootKey = "\(dialect.rawValue):\(root.layout):\(canonicalRoot.path)" + let rootRecords: [PiFamilySessionRecord] + if let cached = recordsByRoot[rootKey] { + rootRecords = cached + } else { + let discovered = Self.records( + in: canonicalRoot, + now: now, + dialect: dialect, + layout: root.layout, + directoryBudget: &directoryBudget) + recordsByRoot[rootKey] = discovered + rootRecords = discovered + } + + if let candidate = rootRecords.first(where: { candidate in + guard candidate.modifiedAt >= processStartedAt, + let recordCWD = candidate.cwd, + !recordCWD.isEmpty, + Self.standardizedPath(recordCWD) == processStandardizedCWD + else { return false } + return !usedRecordURLs.contains(Self.canonicalURL(candidate.url).path) + }) { + record = candidate + usedRecordURLs.insert(Self.canonicalURL(candidate.url).path) + break + } + } + } + + let cwd = processCWD ?? record?.cwd + let id = record?.id ?? "pid:\(process.pid)" + let startedAt = record?.startedAt ?? process.startedAt + + sessions.append(AgentSession( + id: id, + provider: .pi, + dialect: dialect, + source: .cli, + state: config.state( + lastActivityAt: record?.modifiedAt, + now: now, + hasLiveProcess: true), + pid: process.pid, + cwd: cwd, + projectName: Self.projectName(cwd), + sessionName: record?.sessionName, + startedAt: startedAt, + lastActivityAt: record?.modifiedAt, + transcriptPath: record?.url.path, + host: host)) + } + + var seen = Set() + return sessions + .sorted { lhs, rhs in + if lhs.state != rhs.state { + return lhs.state == .active + } + let lhsDate = lhs.lastActivityAt ?? lhs.startedAt ?? .distantPast + let rhsDate = rhs.lastActivityAt ?? rhs.startedAt ?? .distantPast + if lhsDate != rhsDate { + return lhsDate > rhsDate + } + return (lhs.pid ?? Int32.min) > (rhs.pid ?? Int32.min) + } + .filter { seen.insert("\($0.host):\($0.id)").inserted } + } + + private static func sessionRoots( + for process: AgentProcessRecord, + dialect: AgentSession.Dialect, + cwd: String, + environment: [String: String]) -> [SessionRoot] + { + if let explicit = commandLineValue("--session-dir", in: process.command), + let url = pathURL(explicit, cwd: cwd, home: environment["HOME"]) + { + return [SessionRoot(url: url, layout: .direct)] + } + if let configured = environment["PI_CODING_AGENT_SESSION_DIR"], + let url = pathURL(configured, cwd: cwd, home: environment["HOME"]) + { + return [SessionRoot(url: url, layout: .direct)] + } + + switch dialect { + case .pi: + if let agentDirectory = environment["PI_CODING_AGENT_DIR"], + let agentRoot = pathURL(agentDirectory, cwd: cwd, home: environment["HOME"]) + { + return [SessionRoot( + url: agentRoot.appendingPathComponent("sessions", isDirectory: true), + layout: .projectDirectories)] + } + if let configured = Self.piSettingsSessionDirectory(cwd: cwd, environment: environment) { + return [SessionRoot(url: configured, layout: .direct)] + } + guard let home = Self.homeURL(environment) else { return [] } + return [SessionRoot( + url: home + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true), + layout: .projectDirectories)] + case .omp: + return Self.ompSessionRoots(process: process, cwd: cwd, environment: environment) + } + } + + private static func ompSessionRoots( + process: AgentProcessRecord, + cwd: String, + environment: [String: String]) -> [SessionRoot] + { + guard let home = homeURL(environment) else { return [] } + var safeEnvironment = ["HOME": home.path] + for key in [ + "PI_CONFIG_DIR", + "PI_CODING_AGENT_DIR", + "XDG_DATA_HOME", + "OMP_PROFILE", + "PI_PROFILE", + ] { + safeEnvironment[key] = environment[key] + } + if let profile = Self.commandLineValue("--profile", in: process.command) { + safeEnvironment["OMP_PROFILE"] = profile + } + + let baseDirectory = URL(fileURLWithPath: cwd, isDirectory: true) + var urls = OMPSessionRootResolver.sessionRoots( + environment: safeEnvironment, + baseDirectory: baseDirectory) + + if safeEnvironment["OMP_PROFILE"] == nil { + let profileParents = [ + home + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true), + Self.xdgDataHome(environment, home: home) + .appendingPathComponent("omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true), + ] + for parent in profileParents { + urls.append(contentsOf: Self.profileSessionRoots(in: parent)) + } + } + + var seen = Set() + return urls.compactMap { url in + let canonical = Self.canonicalURL(url) + guard seen.insert(canonical.path).inserted else { return nil } + return SessionRoot(url: canonical, layout: .projectDirectories) + } + } + + private static func profileSessionRoots(in profilesDirectory: URL) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: profilesDirectory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants]) + else { return [] } + + var roots: [URL] = [] + let canonicalProfilesDirectory = Self.canonicalURL(profilesDirectory) + while roots.count < 64, let profile = enumerator.nextObject() as? URL { + let canonicalProfile = Self.canonicalURL(profile) + guard (try? canonicalProfile.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true, + OMPSessionRootResolver.isWithin( + root: canonicalProfilesDirectory, + candidate: canonicalProfile) + else { continue } + let xdgLayout = canonicalProfile.appendingPathComponent("sessions", isDirectory: true) + if Self.isDirectory(xdgLayout) { + roots.append(xdgLayout) + continue + } + roots.append(canonicalProfile + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true)) + } + return roots.sorted { $0.path < $1.path } + } + + private static func piSettingsSessionDirectory(cwd: String, environment: [String: String]) -> URL? { + guard let home = homeURL(environment) else { return nil } + let globalSettings = home + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("settings.json") + let projectSettings = URL(fileURLWithPath: cwd, isDirectory: true) + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("settings.json") + + let configured = Self.sessionDirectory(in: projectSettings) ?? Self.sessionDirectory(in: globalSettings) + return configured.flatMap { Self.pathURL($0, cwd: cwd, home: home.path) } + } + + private static func sessionDirectory(in settingsURL: URL) -> String? { + guard let values = try? settingsURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]), + values.isRegularFile == true, + let fileSize = values.fileSize, + fileSize <= 1024 * 1024, + let data = try? Data(contentsOf: settingsURL, options: [.mappedIfSafe]), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let sessionDir = object["sessionDir"] as? String, + !sessionDir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return nil } + return sessionDir + } + + private static func commandLineValue(_ flag: String, in command: String) -> String? { + let tokens = command.split(whereSeparator: \ .isWhitespace).map(String.init) + for index in tokens.indices { + if tokens[index] == flag, index + 1 < tokens.count { + let value = tokens[index + 1] + return value.hasPrefix("-") ? nil : value + } + let prefix = flag + "=" + if tokens[index].hasPrefix(prefix) { + let value = String(tokens[index].dropFirst(prefix.count)) + return value.isEmpty ? nil : value + } + } + return nil + } + + private static func pathURL(_ path: String, cwd: String, home: String?) -> URL? { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let expanded: String = if trimmed == "~", let home { + home + } else if trimmed.hasPrefix("~/"), let home { + URL(fileURLWithPath: home, isDirectory: true) + .appendingPathComponent(String(trimmed.dropFirst(2)), isDirectory: true).path + } else { + trimmed + } + if expanded.hasPrefix("/") { + return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL + } + return URL(fileURLWithPath: cwd, isDirectory: true) + .appendingPathComponent(expanded, isDirectory: true).standardizedFileURL + } + + private static func homeURL(_ environment: [String: String]) -> URL? { + guard let home = environment["HOME"], !home.isEmpty else { return nil } + return URL(fileURLWithPath: home, isDirectory: true).standardizedFileURL + } + + private static func xdgDataHome(_ environment: [String: String], home: URL) -> URL { + if let configured = environment["XDG_DATA_HOME"], !configured.isEmpty { + return URL(fileURLWithPath: configured, isDirectory: true).standardizedFileURL + } + return home + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } + + private static func isDirectory(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + } + + private static func records( + in root: URL, + now: Date, + dialect: AgentSession.Dialect, + layout: RootLayout, + directoryBudget: inout DirectoryMetadataScanBudget) -> [PiFamilySessionRecord] + { + let fileManager = FileManager.default + var records: [PiFamilySessionRecord] = [] + let canonicalRoot = Self.canonicalURL(root) + + guard directoryBudget.hasTimeRemaining() else { return [] } + let projectDirectories: [URL] = switch layout { + case .direct: + [canonicalRoot] + case .projectDirectories: + directoryBudget + .childDirectories(in: canonicalRoot, fileManager: fileManager) + .map(Self.canonicalURL) + .filter { OMPSessionRootResolver.isWithin(root: canonicalRoot, candidate: $0) } + .sorted { $0.path < $1.path } + } + + for projectDirectory in projectDirectories { + guard directoryBudget.hasTimeRemaining() else { break } + let files = directoryBudget + .files(in: projectDirectory, fileManager: fileManager) + .filter { $0.pathExtension == "jsonl" } + .map(Self.canonicalURL) + .filter { file in + OMPSessionRootResolver.isWithin(root: canonicalRoot, candidate: file) && + Self.isDirectFile(in: file, projectDirectory: projectDirectory) + } + .sorted { $0.path < $1.path } + + for file in files { + guard directoryBudget.hasTimeRemaining() else { break } + guard let values = try? file.resourceValues( + forKeys: [.contentModificationDateKey, .isRegularFileKey]), + values.isRegularFile == true, + let modifiedAt = values.contentModificationDate, + let record = PiFamilySessionFileParser.parse( + url: file, + dialect: dialect, + modifiedAt: modifiedAt, + now: now) + else { continue } + records.append(record) + } + } + + var seenURLs = Set() + var seenIDs = Set() + return records + .sorted { lhs, rhs in + if lhs.modifiedAt != rhs.modifiedAt { + return lhs.modifiedAt > rhs.modifiedAt + } + if lhs.id != rhs.id { + return lhs.id < rhs.id + } + return lhs.url.path < rhs.url.path + } + .filter { + seenURLs.insert(Self.canonicalURL($0.url).path).inserted && + seenIDs.insert($0.id).inserted + } + } + + private static func standardizedPath(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.path + } + + private static func projectName(_ cwd: String?) -> String? { + guard let cwd, !cwd.isEmpty else { return nil } + let name = URL(fileURLWithPath: cwd).standardizedFileURL.lastPathComponent + return name.isEmpty ? nil : name + } + + private static func canonicalURL(_ url: URL) -> URL { + url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL + } + + private static func isDirectFile(in file: URL, projectDirectory: URL) -> Bool { + file.deletingLastPathComponent().standardizedFileURL.path == projectDirectory.path + } +} diff --git a/Sources/CodexBarCore/RemoteSessionFetcher.swift b/Sources/CodexBarCore/RemoteSessionFetcher.swift index e69c23db28..af9a0b93cf 100644 --- a/Sources/CodexBarCore/RemoteSessionFetcher.swift +++ b/Sources/CodexBarCore/RemoteSessionFetcher.swift @@ -186,8 +186,7 @@ public struct RemoteSessionFetcher: Sendable { else { return RemoteSessionHostResult(host: host, sessions: [], error: "ssh not found") } - let command = "codexbar sessions --json || " + - "\(Self.shellQuote(Self.bundledCLIFallback)) sessions --json" + let command = Self.remoteSessionsCommand() do { let result = try await SubprocessRunner.run( binary: ssh, @@ -212,6 +211,18 @@ public struct RemoteSessionFetcher: Sendable { } } + /// Tries the v2 session JSON protocol first, then the legacy v1 form, for both PATH and the + /// bundled app CLI. Each fallback is reached only when the preceding command exits non-zero. + package static func remoteSessionsCommand() -> String { + let bundledCLI = Self.shellQuote(Self.bundledCLIFallback) + return [ + "codexbar sessions --json-v2", + "codexbar sessions --json", + "\(bundledCLI) sessions --json-v2", + "\(bundledCLI) sessions --json", + ].joined(separator: " || ") + } + /// Ordered candidate paths for the `tailscale` CLI, most-preferred first. /// /// The macOS app ships its CLI as a thin `/bin/sh` wrapper (usually diff --git a/Tests/CodexBarTests/AgentSessionJSONTests.swift b/Tests/CodexBarTests/AgentSessionJSONTests.swift index 9e3bf75b06..536e6bb779 100644 --- a/Tests/CodexBarTests/AgentSessionJSONTests.swift +++ b/Tests/CodexBarTests/AgentSessionJSONTests.swift @@ -1,6 +1,8 @@ import CodexBarCore +import Commander import Foundation import Testing +@testable import CodexBarCLI struct AgentSessionJSONTests { @Test @@ -38,4 +40,122 @@ struct AgentSessionJSONTests { #expect(legacySession.sessionName == nil) #expect(legacySession.id == session.id) } + + @Test + func `legacy v1 JSON excludes Pi-family sessions and remains decodable by closed provider clients`() throws { + let sessions = self.makeProtocolFixture() + let legacySessions = CodexBarCLI.sessionsForJSON(sessions, includePiFamily: false) + #expect(legacySessions.map(\.provider) == [.codex, .claude]) + + let legacyData = try self.encode(legacySessions) + let decoded = try JSONDecoder().decode([LegacyAgentSession].self, from: legacyData) + #expect(decoded.map(\.provider) == [.codex, .claude]) + } + + @Test + func `versioned JSON flags preserve legacy compatibility and expose Pi-family sessions only in v2`() throws { + let sessions = self.makeProtocolFixture() + let parser = CommandParser(signature: CommandSignature.describe(SessionsOptions())) + let expectations = [ + (flag: "--json", version: 1, includesPiFamily: false), + (flag: "--json-v2", version: 2, includesPiFamily: true), + ] + + for expectation in expectations { + let parsed = try parser.parse(arguments: [expectation.flag]) + let protocolVersion = CodexBarCLI.sessionsJSONProtocolVersion(from: parsed) + #expect(protocolVersion == expectation.version) + + let currentSessions = CodexBarCLI.sessionsForJSON( + sessions, + includePiFamily: protocolVersion == 2) + let currentData = try self.encode(currentSessions) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode([AgentSession].self, from: currentData) + + #expect(decoded == (expectation.includesPiFamily ? sessions : sessions.filter { $0.provider != .pi })) + #expect(decoded.contains { $0.provider == .pi } == expectation.includesPiFamily) + } + } + + @Test + func `mixed-version clients decode the repaired protocol in both upgrade directions`() throws { + let sessions = self.makeProtocolFixture() + let parser = CommandParser(signature: CommandSignature.describe(SessionsOptions())) + let newHostLegacyFlag = try parser.parse(arguments: ["--json"]) + #expect(CodexBarCLI.sessionsJSONProtocolVersion(from: newHostLegacyFlag) == 1) + + // A new host answering an old client keeps Pi-family rows out of the legacy payload. + let newHostLegacySessions = CodexBarCLI.sessionsForJSON(sessions, includePiFamily: false) + let newHostLegacyData = try self.encode(newHostLegacySessions) + let oldClientSessions = try JSONDecoder().decode([LegacyAgentSession].self, from: newHostLegacyData) + #expect(oldClientSessions.map(\.provider) == [.codex, .claude]) + + // A new client falling back to an old host can decode that same v1 payload. + let oldHostLegacyData = try self.encode(sessions.filter { $0.provider != .pi }) + let newClientDecoder = JSONDecoder() + newClientDecoder.dateDecodingStrategy = .iso8601 + let newClientSessions = try newClientDecoder.decode([AgentSession].self, from: oldHostLegacyData) + #expect(newClientSessions == newHostLegacySessions) + } + + @Test + func `human-readable sessions table includes Pi-family dialect tags`() { + let table = CodexBarCLI.renderSessionsTable(self.makeProtocolFixture()) + #expect(table.contains("DIALECT")) + #expect(table.contains("omp")) + } + + private func makeProtocolFixture() -> [AgentSession] { + [ + self.makeSession(id: "codex-session", provider: .codex), + self.makeSession(id: "claude-session", provider: .claude), + self.makeSession(id: "omp-session", provider: .pi, dialect: .omp), + ] + } + + private func makeSession( + id: String, + provider: AgentSession.Provider, + dialect: AgentSession.Dialect? = nil) -> AgentSession + { + AgentSession( + id: id, + provider: provider, + dialect: dialect, + source: .cli, + state: .active, + pid: 42, + cwd: "/tmp/project", + projectName: "project", + startedAt: Date(timeIntervalSince1970: 100), + lastActivityAt: Date(timeIntervalSince1970: 200), + transcriptPath: nil, + host: "local-mac") + } + + private func encode(_ sessions: [AgentSession]) throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(sessions) + } +} + +private struct LegacyAgentSession: Decodable { + enum Provider: String, Decodable { + case codex + case claude + } + + let provider: Provider + + private enum CodingKeys: String, CodingKey { + case provider + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.provider = try container.decode(Provider.self, forKey: .provider) + } } diff --git a/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift index 601f115c0c..e971acf994 100644 --- a/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift +++ b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift @@ -208,6 +208,27 @@ struct AgentSessionMenuDescriptorTests { .contains("⌘ Fix Claude reauthorization · alpha —")) } + @Test + func `Pi-family session rows use the dedicated glyph and dialect tag`() { + let now = Date(timeIntervalSince1970: 1000) + let session = AgentSession( + id: "omp", + provider: .pi, + dialect: .omp, + source: .cli, + state: .active, + pid: 42, + cwd: "/Users/test/alpha", + projectName: "alpha", + startedAt: nil, + lastActivityAt: now, + transcriptPath: nil, + host: "local-mac") + + let title = Self.actionTitle(for: session, style: .project, now: now) + #expect(title.contains("π alpha — omp · cli · 0s")) + } + @Test func `remote refresh gate retries changed settings and rejects stale result`() throws { var gate = AgentSessionRemoteRefreshGate() diff --git a/Tests/CodexBarTests/AgentSessionParserTests.swift b/Tests/CodexBarTests/AgentSessionParserTests.swift index 9f14f648e2..5c0d0d83ee 100644 --- a/Tests/CodexBarTests/AgentSessionParserTests.swift +++ b/Tests/CodexBarTests/AgentSessionParserTests.swift @@ -9,13 +9,40 @@ struct AgentSessionParserTests { let records = AgentPSOutputParser.parse(output) let agents = AgentPSOutputParser.agentProcesses(from: records) - #expect(records.count == 9) - #expect(agents.map(\ .pid) == [102, 201]) + #expect(records.count == 18) + #expect(agents.map(\ .pid) == [102, 201, 501, 502, 509]) #expect(AgentPSOutputParser.provider(for: agents[0]) == .claude) #expect(AgentPSOutputParser.source(for: agents[0]) == .desktopApp) #expect(AgentPSOutputParser.provider(for: agents[1]) == .codex) #expect(agents[1].command.hasSuffix("strange argv here")) #expect(AgentPSOutputParser.hasCodexAppServer(in: records)) + #expect(AgentPSOutputParser.provider(for: agents[2]) == .pi) + #expect(AgentPSOutputParser.piDialect(for: agents[2]) == .omp) + #expect(AgentPSOutputParser.source(for: agents[2]) == .cli) + #expect(AgentPSOutputParser.provider(for: agents[3]) == .pi) + #expect(AgentPSOutputParser.piDialect(for: agents[3]) == .omp) + #expect(AgentPSOutputParser.source(for: agents[3]) == .cli) + #expect(AgentPSOutputParser.provider(for: agents[4]) == .pi) + #expect(AgentPSOutputParser.piDialect(for: agents[4]) == .pi) + let unrelatedBun = try #require(records.first { $0.pid == 507 }) + let unrelatedNpm = try #require(records.first { $0.pid == 508 }) + #expect(AgentPSOutputParser.provider(for: unrelatedBun) == nil) + #expect(AgentPSOutputParser.provider(for: unrelatedNpm) == nil) + for helperPID in [503, 504, 505, 506] { + let helper = try #require(records.first { $0.pid == helperPID }) + #expect(AgentPSOutputParser.provider(for: helper) == nil) + #expect(AgentPSOutputParser.source(for: helper) == .cli) + } + } + + @Test + func `pi dialects use stable Codable raw values`() throws { + let provider = try JSONEncoder().encode(AgentSession.Provider.pi) + let dialects = try JSONEncoder().encode([AgentSession.Dialect.pi, .omp]) + + #expect(String(data: provider, encoding: .utf8) == "\"pi\"") + #expect(String(data: dialects, encoding: .utf8) == "[\"pi\",\"omp\"]") + #expect(try JSONDecoder().decode(AgentSession.Provider.self, from: provider) == .pi) } @Test diff --git a/Tests/CodexBarTests/Fixtures/PiFamily/omp-legacy/--tmp-pi-family-project--/2026-08-03T11-00-00-000Z_omp-legacy.jsonl b/Tests/CodexBarTests/Fixtures/PiFamily/omp-legacy/--tmp-pi-family-project--/2026-08-03T11-00-00-000Z_omp-legacy.jsonl new file mode 100644 index 0000000000..589b595efd --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/PiFamily/omp-legacy/--tmp-pi-family-project--/2026-08-03T11-00-00-000Z_omp-legacy.jsonl @@ -0,0 +1 @@ +{"type":"session","id":"omp-legacy","timestamp":"2026-08-03T11:00:00.000Z","cwd":"/tmp/pi-family-project","title":"OMP legacy fixture"} diff --git a/Tests/CodexBarTests/Fixtures/PiFamily/omp/abs-pi-family-project-0bff77ccc1794123b5c69216e8a176e470093f8ebe392db0e42a2df5b9f5d17a/2026-08-03T12-00-00-000Z_omp-fixture.jsonl b/Tests/CodexBarTests/Fixtures/PiFamily/omp/abs-pi-family-project-0bff77ccc1794123b5c69216e8a176e470093f8ebe392db0e42a2df5b9f5d17a/2026-08-03T12-00-00-000Z_omp-fixture.jsonl new file mode 100644 index 0000000000..ab611b29ae --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/PiFamily/omp/abs-pi-family-project-0bff77ccc1794123b5c69216e8a176e470093f8ebe392db0e42a2df5b9f5d17a/2026-08-03T12-00-00-000Z_omp-fixture.jsonl @@ -0,0 +1,2 @@ +{"type":"title","v":1,"title":"OMP fixture","updatedAt":"2026-08-03T12:00:02.000Z"} +{"type":"session","id":"omp-fixture","timestamp":"2026-08-03T12:00:00.000Z","cwd":"/tmp/pi-family-project"} diff --git a/Tests/CodexBarTests/Fixtures/PiFamily/pi/--tmp-pi-family-project--/2026-08-03T12-00-00-000Z_pi-fixture.jsonl b/Tests/CodexBarTests/Fixtures/PiFamily/pi/--tmp-pi-family-project--/2026-08-03T12-00-00-000Z_pi-fixture.jsonl new file mode 100644 index 0000000000..5b482c5d19 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/PiFamily/pi/--tmp-pi-family-project--/2026-08-03T12-00-00-000Z_pi-fixture.jsonl @@ -0,0 +1,3 @@ +{"type":"session","version":3,"id":"pi-fixture","timestamp":"2026-08-03T12:00:00.000Z","cwd":"/tmp/pi-family-project"} +{"type":"message","id":"1","parentId":null,"timestamp":"2026-08-03T12:00:01.000Z","message":{"role":"assistant","content":[]}} +{"type":"session_info","id":"2","parentId":"1","timestamp":"2026-08-03T12:00:02.000Z","name":"Plain pi fixture"} diff --git a/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt b/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt index 53c2f31ee2..e0fe8bcd07 100644 --- a/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt +++ b/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt @@ -7,3 +7,12 @@ 401 1 Mon Jul 6 09:05:00 2026 /Applications/Codex.app/Contents/Frameworks/Codex Framework.framework/Helpers/Codex (Renderer) --type=renderer 402 1 Mon Jul 6 09:06:00 2026 /Applications/Claude.app/Contents/MacOS/Claude 403 1 Mon Jul 6 09:07:00 2026 ./Codex Computer Use.app/Contents/MacOS/helper mcp + 501 1 Mon Jul 6 09:08:00 2026 /Users/test/.local/bin/omp --project /Users/test/Projects/oh-my-pi + 502 1 Mon Jul 6 09:08:01 2026 /opt/homebrew/bin/bun /Users/test/.bun/install/global/node_modules/oh-my-pi/omp --project /Users/test/Projects/oh-my-pi + 503 1 Mon Jul 6 09:08:02 2026 /Users/test/.local/bin/omp --help + 504 1 Mon Jul 6 09:08:03 2026 /opt/homebrew/bin/bun /Users/test/.bun/install/global/node_modules/oh-my-pi/omp --VeRsIoN + 505 1 Mon Jul 6 09:08:04 2026 /Users/test/.local/bin/omp --SMOKE-TEST + 506 1 Mon Jul 6 09:08:05 2026 /opt/homebrew/bin/bun /Users/test/.bun/install/global/node_modules/oh-my-pi/omp __OMP_WORKER_123 + 507 1 Mon Jul 6 09:08:06 2026 /opt/homebrew/bin/bun /Users/test/.bun/install/global/node_modules/other-cli/index.js + 508 1 Mon Jul 6 09:08:07 2026 /opt/homebrew/bin/npm /Users/test/.bun/install/global/node_modules/oh-my-pi/omp + 509 1 Mon Jul 6 09:08:08 2026 /Users/test/.local/bin/pi diff --git a/Tests/CodexBarTests/PiFamilySessionTests.swift b/Tests/CodexBarTests/PiFamilySessionTests.swift new file mode 100644 index 0000000000..c2fb954e8b --- /dev/null +++ b/Tests/CodexBarTests/PiFamilySessionTests.swift @@ -0,0 +1,331 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiFamilySessionTests { + @Test + func `fixture parsers cover plain pi omp title and legacy header dialects`() throws { + let now = Date(timeIntervalSince1970: 1_900_000_000) + let piURL = try Self.fixtureFile( + "PiFamily/pi/--tmp-pi-family-project--/2026-08-03T12-00-00-000Z_pi-fixture.jsonl") + let hashedOMPBucket = "abs-pi-family-project-" + + "0bff77ccc1794123b5c69216e8a176e470093f8ebe392db0e42a2df5b9f5d17a" + let ompURL = try Self.fixtureFile("PiFamily/omp/\(hashedOMPBucket)/" + + "2026-08-03T12-00-00-000Z_omp-fixture.jsonl") + let legacyURL = try Self.fixtureFile( + "PiFamily/omp-legacy/--tmp-pi-family-project--/2026-08-03T11-00-00-000Z_omp-legacy.jsonl") + + let pi = try #require(PiFamilySessionFileParser.parse( + url: piURL, + dialect: .pi, + modifiedAt: now, + now: now)) + #expect(pi.id == "pi-fixture") + #expect(pi.cwd == "/tmp/pi-family-project") + #expect(pi.sessionName == "Plain pi fixture") + + let omp = try #require(PiFamilySessionFileParser.parse( + url: ompURL, + dialect: .omp, + modifiedAt: now, + now: now)) + #expect(omp.id == "omp-fixture") + #expect(omp.sessionName == "OMP fixture") + #expect(ompURL.deletingLastPathComponent().lastPathComponent.hasPrefix("abs-pi-family-project-")) + + let legacy = try #require(PiFamilySessionFileParser.parse( + url: legacyURL, + dialect: .omp, + modifiedAt: now, + now: now)) + #expect(legacy.id == "omp-legacy") + #expect(legacy.sessionName == "OMP legacy fixture") + #expect(legacyURL.deletingLastPathComponent().lastPathComponent == "--tmp-pi-family-project--") + } + + @Test + func `plain pi session info reads from the tail and bounds labels to 64 scalars`() throws { + let root = try Self.temporaryDirectory(named: "PiTailTitle") + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("session.jsonl") + let title = String(repeating: "🙂", count: 70) + "\nignored" + var content = try Self.jsonLine([ + "type": "session", + "version": 3, + "id": "tail-title", + "timestamp": "2026-08-03T12:00:00.000Z", + "cwd": "/tmp/project", + ]) + content += String(repeating: "{\"type\":\"custom\",\"data\":\"padding-padding-padding\"}\n", count: 2000) + content += try Self.jsonLine(["type": "session_info", "name": title]) + try Data(content.utf8).write(to: file) + + let now = Date(timeIntervalSince1970: 1_900_000_000) + let record = try #require(PiFamilySessionFileParser.parse( + url: file, + dialect: .pi, + modifiedAt: now.addingTimeInterval(30), + now: now)) + #expect(record.sessionName?.unicodeScalars.count == 64) + #expect(record.sessionName == String(repeating: "🙂", count: 64)) + #expect(record.modifiedAt == now) + } + + @Test + func `process classification recognizes both pi dialects and excludes helpers`() { + let records = [ + Self.process(pid: 1, command: "pi"), + Self.process(pid: 2, command: "/usr/local/bin/pi --model test"), + Self.process(pid: 3, command: "omp --profile work"), + Self.process(pid: 4, command: "bun /tools/oh-my-pi/omp"), + Self.process(pid: 5, command: "pi --help"), + Self.process(pid: 6, command: "omp --version"), + Self.process(pid: 7, command: "bun /tools/unrelated.js"), + ] + + let agents = AgentPSOutputParser.agentProcesses(from: records) + #expect(agents.map(\.pid) == [1, 2, 3, 4]) + #expect(AgentPSOutputParser.piDialect(for: records[0]) == .pi) + #expect(AgentPSOutputParser.piDialect(for: records[2]) == .omp) + #expect(AgentPSOutputParser.piDialect(for: records[3]) == .omp) + #expect(AgentPSOutputParser.provider(for: records[0]) == .pi) + #expect(AgentPSOutputParser.provider(for: records[2]) == .pi) + } + + @Test + func `scanner correlates fixture directories for both dialects`() throws { + let root = try Self.temporaryDirectory(named: "PiFamilyFixtures") + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("home", isDirectory: true) + let piRoot = home.appendingPathComponent(".pi/agent/sessions", isDirectory: true) + let ompRoot = home.appendingPathComponent(".omp/agent/sessions", isDirectory: true) + try Self.copyFixtureDirectory("PiFamily/pi", to: piRoot) + try Self.copyFixtureDirectory("PiFamily/omp", to: ompRoot) + + let now = Date(timeIntervalSince1970: 1_900_000_000) + try Self.setJSONModificationDates(in: home, to: now.addingTimeInterval(-5)) + let sessions = Self.scan( + processes: [ + Self.process(pid: 11, startedAt: now.addingTimeInterval(-60), command: "pi"), + Self.process(pid: 12, startedAt: now.addingTimeInterval(-60), command: "omp"), + ], + cwdByPID: [11: "/tmp/pi-family-project", 12: "/tmp/pi-family-project"], + environment: ["HOME": home.path], + now: now) + + #expect(sessions.count == 2) + let byDialect = Dictionary(uniqueKeysWithValues: sessions.compactMap { session in + session.dialect.map { ($0, session) } + }) + #expect(byDialect[.pi]?.id == "pi-fixture") + #expect(byDialect[.pi]?.sessionName == "Plain pi fixture") + #expect(byDialect[.omp]?.id == "omp-fixture") + #expect(byDialect[.omp]?.sessionName == "OMP fixture") + #expect(sessions.allSatisfy { $0.provider == .pi && $0.transcriptPath != nil }) + } + + @Test + func `scanner uses legacy omp buckets and xdg roots`() throws { + let root = try Self.temporaryDirectory(named: "PiFamilyXDG") + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("home", isDirectory: true) + let xdg = root.appendingPathComponent("xdg", isDirectory: true) + let sessionsRoot = xdg.appendingPathComponent("omp/sessions", isDirectory: true) + try Self.copyFixtureDirectory("PiFamily/omp-legacy", to: sessionsRoot) + + let now = Date(timeIntervalSince1970: 1_900_000_000) + try Self.setJSONModificationDates(in: xdg, to: now.addingTimeInterval(-5)) + let sessions = Self.scan( + processes: [Self.process(pid: 20, startedAt: now.addingTimeInterval(-60), command: "omp")], + cwdByPID: [20: "/tmp/pi-family-project"], + environment: ["HOME": home.path, "XDG_DATA_HOME": xdg.path], + now: now) + + let session = try #require(sessions.first) + #expect(session.id == "omp-legacy") + #expect(session.dialect == .omp) + #expect(session.sessionName == "OMP legacy fixture") + } + + @Test + func `custom session directories resolve from cli and plain pi settings`() throws { + let root = try Self.temporaryDirectory(named: "PiCustomRoots") + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("home", isDirectory: true) + let cwd = root.appendingPathComponent("project", isDirectory: true) + let cliSessions = root.appendingPathComponent("cli-sessions", isDirectory: true) + let settingsSessions = root.appendingPathComponent("settings-sessions", isDirectory: true) + try FileManager.default.createDirectory( + at: cwd.appendingPathComponent(".pi"), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: cliSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: settingsSessions, withIntermediateDirectories: true) + try Data("{\"sessionDir\":\"\(settingsSessions.path)\"}\n".utf8) + .write(to: cwd.appendingPathComponent(".pi/settings.json")) + + let now = Date(timeIntervalSince1970: 1_900_000_000) + try Self.writeSession( + at: cliSessions.appendingPathComponent("omp.jsonl"), + dialect: .omp, + id: "omp-custom", + cwd: cwd.path, + modifiedAt: now.addingTimeInterval(-5)) + try Self.writeSession( + at: settingsSessions.appendingPathComponent("pi.jsonl"), + dialect: .pi, + id: "pi-settings", + cwd: cwd.path, + modifiedAt: now.addingTimeInterval(-5)) + + let sessions = Self.scan( + processes: [ + Self.process( + pid: 31, + startedAt: now.addingTimeInterval(-60), + command: "omp --session-dir \(cliSessions.path)"), + Self.process(pid: 32, startedAt: now.addingTimeInterval(-60), command: "pi"), + ], + cwdByPID: [31: cwd.path, 32: cwd.path], + environment: ["HOME": home.path], + now: now) + + #expect(Set(sessions.map(\.id)) == ["omp-custom", "pi-settings"]) + #expect(sessions.first { $0.id == "omp-custom" }?.dialect == .omp) + #expect(sessions.first { $0.id == "pi-settings" }?.dialect == .pi) + } + + @Test + func `missing jsonl and unresolved custom roots retain pid only rows`() throws { + let root = try Self.temporaryDirectory(named: "PiPIDOnly") + defer { try? FileManager.default.removeItem(at: root) } + let now = Date(timeIntervalSince1970: 1_900_000_000) + let sessions = Self.scan( + processes: [ + Self.process(pid: 41, startedAt: now.addingTimeInterval(-10), command: "pi"), + Self.process(pid: 42, startedAt: now.addingTimeInterval(-10), command: "omp --profile missing"), + ], + cwdByPID: [41: "/tmp/no-jsonl-pi", 42: "/tmp/no-jsonl-omp"], + environment: ["HOME": root.path], + now: now) + + #expect(Set(sessions.map(\.id)) == ["pid:41", "pid:42"]) + #expect(sessions.allSatisfy { $0.transcriptPath == nil && $0.state == .active }) + #expect(Set(sessions.compactMap(\.dialect)) == [.pi, .omp]) + } + + @Test + func `correlation assigns each transcript once and leaves unmatched processes visible`() throws { + let root = try Self.temporaryDirectory(named: "PiCorrelation") + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("home", isDirectory: true) + let project = home.appendingPathComponent(".pi/agent/sessions/--tmp-correlation--", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + let now = Date(timeIntervalSince1970: 1_900_000_000) + try Self.writeSession( + at: project.appendingPathComponent("one.jsonl"), + dialect: .pi, + id: "one-session", + cwd: "/tmp/correlation", + modifiedAt: now.addingTimeInterval(-5)) + + let sessions = Self.scan( + processes: [ + Self.process(pid: 51, startedAt: now.addingTimeInterval(-30), command: "pi"), + Self.process(pid: 52, startedAt: now.addingTimeInterval(-20), command: "pi"), + ], + cwdByPID: [51: "/tmp/correlation", 52: "/tmp/correlation"], + environment: ["HOME": home.path], + now: now) + + #expect(sessions.count == 2) + #expect(sessions.count(where: { $0.id == "one-session" }) == 1) + #expect(sessions.count(where: { $0.id.hasPrefix("pid:") }) == 1) + #expect(Set(sessions.compactMap(\.transcriptPath)).count == 1) + } + + private static func scan( + processes: [AgentProcessRecord], + cwdByPID: [Int32: String], + environment: [String: String], + now: Date) -> [AgentSession] + { + var budget = DirectoryMetadataScanBudget(maxEntryCount: 512, maxDepth: 1, timeLimit: 5) + return PiFamilySessionScanner.scan( + input: PiFamilySessionScanner.ScanInput( + processes: processes, + cwdByPID: cwdByPID, + environment: environment, + now: now, + host: "fixture-host", + config: SessionScanConfig()), + directoryBudget: &budget) + } + + private static func process( + pid: Int32, + startedAt: Date? = Date(timeIntervalSince1970: 1_899_999_900), + command: String) -> AgentProcessRecord + { + AgentProcessRecord(pid: pid, ppid: 1, startedAt: startedAt, command: command) + } + + private static func fixtureFile(_ relativePath: String) throws -> URL { + let fixtures = try #require(Bundle.module.resourceURL?.appendingPathComponent("Fixtures", isDirectory: true)) + let url = fixtures.appendingPathComponent(relativePath) + #expect(FileManager.default.fileExists(atPath: url.path)) + return url + } + + private static func copyFixtureDirectory(_ relativePath: String, to destination: URL) throws { + let fixtures = try #require(Bundle.module.resourceURL?.appendingPathComponent("Fixtures", isDirectory: true)) + let source = fixtures.appendingPathComponent(relativePath, isDirectory: true) + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true) + try FileManager.default.copyItem(at: source, to: destination) + } + + private static func setJSONModificationDates(in root: URL, to date: Date) throws { + guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) else { return } + for case let url as URL in enumerator where url.pathExtension == "jsonl" { + try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) + } + } + + private static func writeSession( + at url: URL, + dialect: AgentSession.Dialect, + id: String, + cwd: String, + modifiedAt: Date) throws + { + var lines: [String] = [] + if dialect == .omp { + try lines.append(Self.jsonLine(["type": "title", "v": 1, "title": "Custom OMP"])) + } + try lines.append(Self.jsonLine([ + "type": "session", + "version": 3, + "id": id, + "timestamp": "2026-08-03T12:00:00.000Z", + "cwd": cwd, + ])) + if dialect == .pi { + try lines.append(Self.jsonLine(["type": "session_info", "name": "Custom pi"])) + } + try Data(lines.joined().utf8).write(to: url) + try FileManager.default.setAttributes([.modificationDate: modifiedAt], ofItemAtPath: url.path) + } + + private static func jsonLine(_ object: [String: Any]) throws -> String { + let data = try JSONSerialization.data(withJSONObject: object) + return try #require(String(data: data, encoding: .utf8)) + "\n" + } + + private static func temporaryDirectory(named name: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("\(name)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } +} diff --git a/Tests/CodexBarTests/TailscaleSessionTests.swift b/Tests/CodexBarTests/TailscaleSessionTests.swift index 7cb4eba3e7..f08541e0a4 100644 --- a/Tests/CodexBarTests/TailscaleSessionTests.swift +++ b/Tests/CodexBarTests/TailscaleSessionTests.swift @@ -132,4 +132,12 @@ struct TailscaleSessionTests { #expect(hosts == ["user@clawmac", "linuxbox"]) } + + @Test + func `remote session command negotiates v2 then v1 for PATH and bundled CLIs`() { + #expect(RemoteSessionFetcher.remoteSessionsCommand() == + "codexbar sessions --json-v2 || codexbar sessions --json || " + + "'/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI' sessions --json-v2 || " + + "'/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI' sessions --json") + } } diff --git a/docs/agent-sessions-design.md b/docs/agent-sessions-design.md index c74d770023..06572fd0c8 100644 --- a/docs/agent-sessions-design.md +++ b/docs/agent-sessions-design.md @@ -1,98 +1,42 @@ -# Agent Sessions (prototype) +# Agent Sessions design -Track live Codex + Claude Code agent sessions — local Mac first, other Macs on the tailnet second — and surface them in the CodexBar menu with click-to-focus of the owning terminal window. +CodexBar tracks live Codex, Claude Code, and Pi-family agent sessions locally and over SSH. Discovery is process-backed: a transcript, session file, or terminal breadcrumb by itself is not evidence that a session is live. -## Why in CodexBar +## Data model -CodexBar already parses `~/.claude/projects` JSONL (cost scanner) and ships a bundled CLI on macOS + Linux. Sessions reuse both: the local scanner feeds the menu UI, and the same scanner exposed as `codexbar sessions --json` is what remote Macs run over SSH. No daemon, no new app. +`AgentSession.Provider` contains `codex`, `claude`, and `pi`. Pi-family rows additionally carry `AgentSession.Dialect.pi` or `.omp`; other providers leave `dialect` absent. A resolved upstream session ID is preferred, while an unmatched process uses `pid:`. -## Data model (CodexBarCore) +The v1 JSON protocol remains a top-level array restricted to `codex` and `claude`. The v2 protocol retains the same array shape and adds current providers and optional fields, including Pi-family dialects. Remote fetching negotiates v2 first and falls back to v1 for mixed-version compatibility. -```swift -public struct AgentSession: Codable, Sendable, Identifiable { - public enum Provider: String, Codable, Sendable { case codex, claude } - public enum Source: String, Codable, Sendable { case cli, desktopApp, ide, unknown } - public enum State: String, Codable, Sendable { case active, idle } +## Local scanner - public var id: String // session UUID when resolvable, else "pid:" - public var provider: Provider - public var source: Source - public var state: State - public var pid: Int32? // nil for file-only (e.g. Codex desktop) sessions - public var cwd: String? - public var projectName: String? // last path component of cwd - public var startedAt: Date? - public var lastActivityAt: Date? // transcript mtime - public var transcriptPath: String? - public var host: String // local hostname, or remote host label -} -``` +`LocalAgentSessionScanner` combines bounded process and metadata signals. macOS process and cwd discovery stays in-process through libproc; Linux uses the existing guarded `ps` and `/proc` paths. -`active` = last activity ≤ 120 s ago. `idle` = live process (or recent file) with older activity. Constants live in one `SessionScanConfig` struct (activeWindow 120 s, fileOnlyWindow 30 min) so thresholds are tunable/testable. +Pi-family processes are handed to one `PiFamilySessionScanner`: -## Local scanner (CodexBarCore, no new deps) +- Plain pi uses process title `pi`; upstream also marks the process with `PI_CODING_AGENT=true`, which CodexBar does not need to read. Its default root is `~/.pi/agent/sessions`, whose project buckets encode cwd as `----`. A version-3 `session` header supplies id, timestamp, and cwd. The latest bounded `session_info.name` supplies the optional label. +- OMP uses process title/executable `omp`, including its Bun launcher form. It supports default, named-profile, and XDG roots; hashed `home|tmp|abs--` buckets; legacy bucket names; title-slot headers; and the legacy header-title form. +- `--session-dir` resolves to a direct session directory for either dialect. The scanner also honors custom-directory/profile values already present in its own environment, and plain pi resolves project-over-global `settings.json` `sessionDir` values. Relative paths use the live process cwd and leading `~` uses the scanner home. +- Profile flags are read from argv. Standard OMP profile directories can also be enumerated from their bounded roots. Custom roots and profiles that exist only in the target process remain unresolved because reading that process's environment would capture unrelated secrets. Those processes still produce PID-only rows. +- A record matches only when its normalized cwd equals the process cwd, its modification time is no older than process start, and its URL has not already been assigned. Records sort newest-first with deterministic id/path tie-breaks. +- Pi-family records never become file-only rows. This is especially important for plain pi, which creates its filename before launch but delays materializing JSONL until the first assistant message. -`LocalAgentSessionScanner` combines two signals: +The Pi-family scan receives its own directory entry/time budget, preserving the independent Codex rollout and Claude transcript budgets. Header reads are bounded, pi name lookup uses bounded head/tail windows, future modification dates are clamped to scan time, and displayed titles are stripped of control characters and limited to 64 Unicode scalars. -1. **Process scan** — parse `ps -axo pid=,ppid=,lstart=,command=`. - - Claude: command basename `claude` (skip obvious non-agent helpers). Source: path contains `Application Support/Claude/claude-code` → `.desktopApp`, else `.cli`. Deduplicate the wrapper/child pair (desktop spawns `disclaimer` parent + `claude` child with same argv; keep the child). - - Codex: basename `codex` with no `app-server` argument → `.cli` (TUI or `exec`). `codex app-server` marks the desktop app as present but is not itself a session. - - cwd per pid via one batched `lsof -a -d cwd -Fn -p ` call (parse `p`/`n` records). Failure → cwd nil, session still listed. -2. **Transcript correlation** - - Claude: cwd → `~/.claude/projects//` (escape: every non-alphanumeric ASCII → `-`), newest `*.jsonl` by mtime → session id (filename UUID), lastActivityAt (mtime). Also reuse `ClaudeDesktopProjectsLocator` roots so desktop local-agent-mode sessions resolve. - - Codex: enumerate `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` for today + yesterday (`$CODEX_HOME` respected). Read only the first line (`session_meta`: `session_id`, `cwd`, `originator`, `source`). File with mtime ≤ fileOnlyWindow and no matching live pid → file-only session, source from `originator` (`codex_exec`/`exec` → `.cli`; ide-ish originators → `.ide`; desktop → `.desktopApp`). Live `codex` pids match to rollouts by cwd (newest wins); unmatched live pid still listed with nil transcript. - - Never read more than the first line of any JSONL; never load whole transcripts. +## Presentation and focus -Scanner is `Sendable`, pure functions where possible; ps/lsof output parsing lives in dedicated parser types fed by strings so tests use fixtures. +The menu uses `⌘` for Codex, `✦` for Claude Code, and `π` for the Pi family. Pi rows show their dialect tag (`pi` or `omp`) rather than a second provider name. The CLI table includes a `DIALECT` column. Project labels remain the default because descriptive titles can contain sensitive text. -## CLI (CodexBarCLI) +Local focus walks from the session PID to the owning terminal/editor application and raises the best matching window when Accessibility permission is available. Remote focus invokes the same command over SSH. Pi-family sessions have no file-only focus target. -- `codexbar sessions` — table; `--json` — `[AgentSession]` (stable field names above; ISO-8601 dates). -- `codexbar sessions focus ` — macOS only: focus the session's terminal window (see Focus). Exit 1 if id unknown, 2 if focus failed. -- Follows existing `CLI*Command.swift` conventions. Works on Linux for listing (ps/proc paths guarded), focus is Darwin-only. +## Privacy and safety -## Remote hosts (CodexBarCore + app) +CodexBar never invokes `ps eww`, reads `/proc//environ`, or otherwise captures full target-process environments for session discovery. It reads only bounded session metadata under resolved roots, never loads prompt/tool transcript bodies for this feature, never changes upstream session state, and never persists extracted titles separately. -`RemoteSessionFetcher`: +## Tests -- Host list = manual entries (settings, ssh destinations like `steipete@clawmac`) ∪ automatic Tailscale discovery (no-op when tailscale is absent): run `tailscale status --json` (PATH, then `/Applications/Tailscale.app/Contents/MacOS/Tailscale`), take online peers with `"OS": "macOS"|"linux"`, use first `DNSName` label as host. Local host excluded. -- Fetch per host (parallel, 5 s budget): `ssh -o BatchMode=yes -o ConnectTimeout=3 sh -lc 'codexbar sessions --json'` with fallback to the bundled app CLI path (resolve the canonical bundled location from `Scripts/package_app.sh` and hardcode it as fallback: `… || sessions --json`). Host errors are non-fatal: host shown as unreachable, others still render. -- Remote focus: fire-and-forget `ssh sh -lc 'codexbar sessions focus '`. -- Refresh: local scan every 30 s while the status item exists (cheap), remote every 60 s and immediately on menu open; both skipped when the feature is off. Reuse existing refresh loop plumbing rather than new timers if it fits. +Fixture directories cover pi version-3 headers and `session_info`, OMP title slots, hashed and legacy project buckets, XDG roots, resolvable custom directories, missing-JSONL PID fallbacks, process classification/correlation, one-record allocation, 64-scalar title bounds, JSON protocol compatibility, menu dialect tags, and remote v2-before-v1 negotiation. Tests use stubs and temporary roots; they do not probe live accounts, Keychain, or Accessibility. -## Menu UI (CodexBar app) +## Non-goals -- New menu section **Agent Sessions (N)** (N = total, all hosts) above the settings/footer area, built through the existing `MenuDescriptor`-style seam so it's testable headless. -- Local sessions first, then one group per remote host (`clawmac — 2`, unreachable hosts greyed with a tooltip). Row: state dot (● active / ○ idle), provider glyph, `projectName — provider · source · 12m`. -- Click local row → `SessionWindowFocuser`. Click remote row → remote focus ssh call. -- Settings: "Sessions" group — a single enable toggle (default on) plus a manual hosts text field (comma-separated); Tailscale discovery is always on while the feature is enabled. Persist in `SettingsStore` like neighboring prefs. - -## Focus (macOS, app + CLI shared in Core or app-adjacent target) - -`SessionWindowFocuser`: - -1. pid → walk ppid chain to the nearest ancestor whose `NSRunningApplication.bundleIdentifier` is a known terminal/editor host: Ghostty, iTerm2, Apple Terminal, Warp, WezTerm, kitty, Alacritty, VS Code, Cursor, Zed, Claude desktop (`com.anthropic.claudefordesktop`). Fallback: the app owning the pid. -2. Activate the app, then AX (`AXUIElementCreateApplication` → `AXWindows`): raise the window whose title contains projectName or the cwd tail; fallback to frontmost window of that app. Requires Accessibility permission — call `AXIsProcessTrustedWithOptions` with prompt on first use; degrade gracefully (activate app only) when untrusted. -3. File-only sessions (no pid): Claude desktop → activate Claude.app; Codex desktop → activate Codex.app; otherwise no-op with log. - -tmux pane / terminal-tab precision is out of scope for the prototype. - -## Tests (Tests/CodexBarTests) - -Fixture-driven, no live processes, no Keychain/AX: - -- ps output parser: desktop `disclaimer`+`claude` dedupe, codex vs `codex app-server`, weird argv. -- lsof `-Fn` parser. -- Claude cwd escaping → project dir mapping; newest-jsonl selection (temp dirs). -- Codex rollout first-line parse → AgentSession (fixture JSONL), file-only window cutoff. -- Tailscale status JSON → host list (fixture; offline/iOS peers excluded). -- Sessions JSON round-trip (CLI output schema stability). -- Menu section descriptor: counts, grouping, unreachable-host rendering. - -## Non-goals (prototype) - -Claude.ai chat sessions; Codex cloud tasks; historical session browsing/analytics; "waiting on permission" state; tmux pane/tab focus; Bonjour/mDNS; persistent remote daemon or push transport; widget changes. No new SPM dependencies. - -## Proof - -`make check` clean; `make test` (or focused `swift test --filter` covering the new tests) green; `swift run CodexBarCLI sessions --json` produces plausible output on this Mac. +Historical browsing/analytics, cloud chat/task sessions, permission-waiting state, exact tmux pane focus, a persistent remote daemon, and treating either upstream on-disk dialect as a public compatibility guarantee are out of scope. diff --git a/docs/sessions.md b/docs/sessions.md index 3f658a8330..9d9626ad62 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -1,18 +1,27 @@ # Agent Sessions -CodexBar can list live Codex and Claude Code sessions on this Mac and other Macs or Linux hosts reachable over SSH. +CodexBar can list live Codex, Claude Code, pi, and OMP sessions on this Mac and on macOS or Linux hosts reachable over SSH. Enable **Settings → Menu → Agent sessions**. Local sessions refresh every 30 seconds. Remote sessions refresh every 60 seconds and whenever the menu opens. Tailscale discovery includes online macOS and Linux peers; add extra SSH destinations as a comma-separated list, such as `user@host`. +The setting is off by default. While it is off, CodexBar clears published local and remote session rows and does not fetch remote sessions. Adaptive agent-aware refresh may still collect a local activity timestamp after explicit consent, but it does not retain or publish session identities or paths. + +Pi-family discovery is process-backed. Plain pi is recognized by its `pi` process title (upstream also sets `PI_CODING_AGENT=true`); OMP is recognized from an `omp` process or a Bun launcher whose command line contains an `omp` executable. Both feed one scanner and use normalized provider `pi`, with a `dialect` value of `pi` or `omp` on each row. + +The scanner understands both storage dialects: + +- pi: `~/.pi/agent/sessions/----/*.jsonl`, beginning with a version-3 `session` header. A later `session_info` entry supplies the optional display name. pi does not materialize a new JSONL until the first assistant message, so a new live process initially appears as `pid:`. +- OMP: default, named-profile, and XDG session roots, including hashed `home|tmp|abs--` project buckets and legacy bucket names. OMP files use the title-slot/session-header formats supported by the upstream v1/v2 transition. + +Explicit `--session-dir` paths, custom-directory values already present in CodexBar's own environment, and plain-pi `settings.json` `sessionDir` values are used when they can be resolved. Environment-only roots that exist only inside the agent process are deliberately not read from it. If a custom root or profile cannot be resolved safely, CodexBar keeps the PID-only row instead of inspecting the process's full environment. A session file is never shown without a matching live process. + Choose the row label format in the same settings section: - **Project** keeps the working-directory name used by earlier releases. -- **Descriptive** uses the Codex thread title or named subagent task, with the project as a fallback. +- **Descriptive** uses the Codex thread title, pi/OMP session name when available, or named subagent task, with the project as a fallback. - **Descriptive + project** shows both when they differ. -Thread titles can contain sensitive text. **Project** remains the default; choose a descriptive mode only if you are comfortable showing those titles in the menu. CodexBar reads title metadata without modifying Codex state and does not persist it to disk. - -Claude Code sessions currently fall back to the project name because Claude does not expose equivalent session-title metadata. +Thread and session titles can contain sensitive text. **Project** remains the default. CodexBar reads only bounded metadata, limits labels to 64 Unicode scalars, does not modify provider state, and does not persist titles separately. The menu groups local sessions first, followed by each remote host. A filled dot is active; an empty dot is idle. Select a local row to activate its terminal, editor, or desktop app. The first focus attempt can request macOS Accessibility permission so CodexBar can raise the matching window. Remote rows run the same focus command over SSH. @@ -21,7 +30,12 @@ The CLI exposes the same scanner: ```console codexbar sessions codexbar sessions --json +codexbar sessions --json-v2 codexbar sessions focus ``` +`codexbar sessions --json` emits the legacy v1 top-level array with only `codex` and `claude` provider values. `codexbar sessions --json-v2` emits the complete array, including provider `pi` and its `pi`/`omp` dialect tag. Dates use ISO 8601. + +Remote fetching tries `sessions --json-v2` before the legacy `sessions --json`, first through `codexbar` on `PATH` and then through the bundled app CLI. This lets current hosts return Pi-family rows while both host-first and client-first mixed-version upgrades remain decodable. + Remote hosts need key-based, non-interactive SSH and either `codexbar` on `PATH` or CodexBar installed in `/Applications`.