Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))"
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/CodexBarCLI/CLIEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 6 additions & 3 deletions Sources/CodexBarCLI/CLIHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,20 @@ extension CodexBarCLI {
CodexBar \(version)

Usage:
codexbar sessions [--json] [--pretty]
codexbar sessions [--json|--json-v2] [--pretty]
codexbar sessions focus <id>

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
"""
}
Expand Down Expand Up @@ -426,7 +429,7 @@ extension CodexBarCLI {
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>] [-v|--verbose]
[--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh]
[--days <days>] [--group-by project]
codexbar sessions [--json] [--pretty]
codexbar sessions [--json|--json-v2] [--pretty]
codexbar sessions focus <id>
codexbar dashboard [--pretty] [--timeout <seconds>]
codexbar serve [--host <host>] [--port <port>] [--refresh-interval <seconds>]
Expand Down
29 changes: 25 additions & 4 deletions Sources/CodexBarCLI/CLISessionsCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
40 changes: 40 additions & 0 deletions Sources/CodexBarCore/AgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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?
Expand All @@ -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?,
Expand All @@ -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
Expand Down Expand Up @@ -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) &&
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 22 additions & 1 deletion Sources/CodexBarCore/LocalAgentSessionScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -127,7 +144,8 @@ public struct LocalAgentSessionScanner: Sendable {
now: now,
codexAppServerPresent: codexAppServerPresent,
includeFileOnlySessions: includeFileOnlySessions,
threadMetadata: threadMetadata),
threadMetadata: threadMetadata,
piFamilySessions: piFamilySessions),
directoryBudget: &directoryBudget)
}

Expand Down Expand Up @@ -254,6 +272,8 @@ public struct LocalAgentSessionScanner: Sendable {
lastActivityAt: rollout?.modifiedAt,
transcriptPath: rollout?.url.path,
host: context.host))
case .pi:
continue
}
}

Expand All @@ -275,6 +295,7 @@ public struct LocalAgentSessionScanner: Sendable {
appServerPresent: context.codexAppServerPresent)
sessions.append(session)
}
sessions.append(contentsOf: context.piFamilySessions)

var seen = Set<String>()
return sessions
Expand Down
Loading