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 @@ -6,6 +6,7 @@
- Settings: edit saved token-account labels and credentials in place and reorder accounts without changing the active account (#4612). Thanks @lghsigma597!
### Fixed

- Pi: keep cache scope stable when the same session roots are discovered in a different order (#4618). Thanks @dhruva77!
- Claude cost history: preserve the last valid cache when transcript roots or directories are unreadable (#4617). Thanks @dhruva77!
- Codex: open the Usage Dashboard on the overview tab to avoid incorrect redirects (#4607). Thanks @Alex-Keyes!
- Claude: try readable OAuth credentials before starting the CLI in Linux Auto mode, avoiding unnecessary interactive startup while preserving explicit source selection (#4613, #4615). Thanks @yeseoLee!
Expand Down
18 changes: 5 additions & 13 deletions Sources/CodexBarCore/PiSessionCostScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,8 @@ enum PiSessionCostScanner {
].joined(separator: "\u{1F}")
}
}
// Discovery order follows working directories and process contexts; it is not part of the scope.
.sorted()
.joined(separator: "\u{1E}")
}

Expand Down Expand Up @@ -1045,26 +1047,16 @@ enum PiSessionCostScanner {
var seenEntries = seenEntriesBySessionID[sessionID] ?? []
for entryID in usage.entryUsages.keys.sorted() where seenEntries.insert(entryID).inserted {
guard let entryUsage = usage.entryUsages[entryID] else { continue }
guard self.applyEntryUsage(daysByProvider: &days, entryUsage: entryUsage) else { return false }
guard self.applyContributions(daysByProvider: &days, contributions: [
entryUsage.providerRawValue: [entryUsage.dayKey: [entryUsage.modelName: entryUsage.usage]],
]) else { return false }
}
seenEntriesBySessionID[sessionID] = seenEntries
}
cache.daysByProvider = days
return true
}

private static func applyEntryUsage(
daysByProvider: inout [String: [String: [String: PiPackedUsage]]],
entryUsage: PiSessionEntryUsage) -> Bool
{
let contributions = [
entryUsage.providerRawValue: [
entryUsage.dayKey: [entryUsage.modelName: entryUsage.usage],
],
]
return self.applyContributions(daysByProvider: &daysByProvider, contributions: contributions)
}

private static func modelContext(from object: [String: Any]) -> PiModelContext? {
guard let providerText = object["provider"] as? String else { return nil }
guard let provider = self.mappedProvider(fromPiProvider: providerText) else {
Expand Down
66 changes: 66 additions & 0 deletions Tests/CodexBarTests/PiSessionProcessContextTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -838,3 +838,69 @@ struct PiSessionProcessContextTests {
.contains(firstRoot.path) != true)
}
}

extension PiSessionProcessContextTests {
@Test
func `pi cost scope ignores the discovery order of identical session roots`() throws {
let env = try CostUsageTestEnvironment()
defer { env.cleanup() }

let projects = try ["alpha", "beta", "gamma"].map { name in
let project = env.root.appendingPathComponent("pi-project-\(name)", isDirectory: true)
let sessionRoot = env.root.appendingPathComponent("pi-sessions-\(name)", isDirectory: true)
let settingsDirectory = project.appendingPathComponent(".pi", isDirectory: true)
try [sessionRoot, settingsDirectory].forEach {
try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true)
}
try Data(("{\"sessionDir\":\"" + sessionRoot.path + "\"}").utf8).write(
to: settingsDirectory.appendingPathComponent("settings.json"),
options: .atomic)
return project
}
func scope(_ workingDirectories: [URL]) -> String {
PiSessionCostScanner.scopeFingerprint(options: PiSessionCostScanner.Options(
cacheRoot: env.cacheRoot,
environment: ["HOME": env.root.path],
workingDirectories: workingDirectories))
}

let forward = scope([projects[0], projects[1]])
#expect(forward.contains("pi-sessions-alpha"))
#expect(forward.contains("pi-sessions-beta"))
#expect(scope([projects[1], projects[0]]) == forward)
#expect(scope([projects[0], projects[2]]) != forward)

let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1)
for name in ["alpha", "beta"] {
let rows = try env.jsonl([[
"type": "message", "timestamp": env.isoString(for: day),
"message": [
"role": "assistant", "provider": "openai-codex", "model": "gpt-5.4",
"usage": ["input": 10, "output": 0],
],
]])
try Data(rows.utf8).write(to: env.root.appendingPathComponent("pi-sessions-\(name)/session.jsonl"))
}
var options = PiSessionCostScanner.Options(
cacheRoot: env.cacheRoot,
environment: ["HOME": env.root.path],
workingDirectories: [projects[0], projects[1]])
let initial = PiSessionCostScanner.loadDailyReport(
provider: .pi, since: day, until: day, now: day, options: options)
#expect(initial.summary?.totalTokens == 20)
options.workingDirectories.reverse()
let cached = PiSessionCostScanner.loadCachedDailyReportResult(
provider: .pi, since: day, until: day, now: day, cacheRoot: env.cacheRoot, options: options)
#expect(cached?.report.summary?.totalTokens == 20)
#expect(cached?.isComplete == true)

var legacy = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot)
legacy.sessionRootsFingerprint = legacy.sessionRootsFingerprint?
.components(separatedBy: "\u{1E}").reversed().joined(separator: "\u{1E}")
PiSessionCostCacheIO.save(cache: legacy, cacheRoot: env.cacheRoot)
let upgraded = PiSessionCostScanner.loadDailyReport(
provider: .pi, since: day, until: day, now: day, options: options)
#expect(upgraded.summary == initial.summary)
#expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).sessionRootsFingerprint == forward)
}
}
2 changes: 2 additions & 0 deletions docs/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Default session roots include `~/.pi/agent/sessions` and the supported OMP agent

Root canonicalization preserves an explicit directory marker even when the session directory does not exist yet.

Discovering the same roots in a different order does not change the cache scope. Older unsorted scope fingerprints are refreshed once while retaining the same source selection and totals.

Running Pi/OMP processes also contribute their environment, profile, `--session-dir`, and project settings. Relative paths resolve against that process's working directory. A missing working directory cannot turn an unresolved relative selector into a successful empty scan. Retained roots from explicit command-line or settings selectors survive process exit; settings are revalidated before reuse. Removing a setting from an accessible project drops its former root, while an inaccessible project or broken settings symlink preserves the previous scoped report and its original age.

Live processes with an unreadable environment and no explicit command-line session/profile selector are skipped during cost-root discovery. A single summary diagnostic records the skipped count per discovery pass without logging arguments, paths, or environment values. Default/configured roots and other readable processes still contribute history, including on the first scan. This is best-effort discovery: a custom root visible only in an unreadable process environment cannot be discovered. Explicit selectors that cannot resolve still keep history incomplete; an absolute `--session-dir` remains usable without an environment or working directory.
Expand Down
Loading