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

- 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!
- Claude: include Desktop Cowork audit transcripts in local token and cost history, using the existing response deduplication and ignoring result totals (#3209). Thanks @vinschger and @Akagilnc!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@ struct CostUsageClaudeFileStamp: Equatable, Sendable, Codable {
}

static func read(at url: URL) -> Self? {
try? self.readIfPresent(at: url)
}

/// Returns nil for a missing path, but throws when an existing source cannot be stat'ed.
static func readIfPresent(at url: URL) throws -> Self? {
var info = stat()
guard url.path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { return nil }
guard url.path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else {
let code = errno
if code == ENOENT || code == ENOTDIR { return nil }
throw NSError(domain: NSPOSIXErrorDomain, code: Int(code), userInfo: [NSFilePathErrorKey: url.path])
}
guard info.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) else { return nil }
#if os(Linux)
let modifiedTime = info.st_mtim
Expand All @@ -26,6 +35,18 @@ struct CostUsageClaudeFileStamp: Equatable, Sendable, Codable {
modifiedSeconds: Int64(modifiedTime.tv_sec),
modifiedNanoseconds: Int64(modifiedTime.tv_nsec))
}

static func isMissingFileError(_ error: Error) -> Bool {
let error = error as NSError
if error.domain == NSPOSIXErrorDomain { return error.code == Int(ENOENT) || error.code == Int(ENOTDIR) }
if error.domain == NSCocoaErrorDomain,
error.code == NSFileNoSuchFileError || error.code == NSFileReadNoSuchFileError
{
return true
}
guard let underlying = error.userInfo[NSUnderlyingErrorKey] as? Error else { return false }
return self.isMissingFileError(underlying)
}
}

struct CostUsageClaudeReportMemoKey: Equatable, Sendable, Codable {
Expand Down
109 changes: 44 additions & 65 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,26 +109,6 @@ extension CostUsageScanner {
workingDirectory: workingDirectory)
}

static func parseClaudeFile(
fileURL: URL,
range: CostUsageDayRange,
providerFilter: ClaudeLogProviderFilter,
startOffset: Int64 = 0,
modelsDevCatalog: ModelsDevCatalog? = nil,
modelsDevCacheRoot: URL? = nil) -> ClaudeParseResult
{
let pricingResolver = modelsDevCatalog.map { CostUsagePricing.ClaudeResolver(catalog: $0) }
?? CostUsagePricing.ClaudeResolver(now: Date(), cacheRoot: modelsDevCacheRoot)
return (
try? self.parseClaudeFileCancellable(
fileURL: fileURL,
range: range,
providerFilter: providerFilter,
startOffset: startOffset,
pricingResolver: pricingResolver,
checkCancellation: nil)) ?? ClaudeParseResult(rows: [], parsedBytes: startOffset)
}

static func parseClaudeFileCancellable(
fileURL: URL,
range: CostUsageDayRange,
Expand Down Expand Up @@ -580,6 +560,16 @@ extension CostUsageScanner {
return lower.contains("vertex") || (includeGCP && lower.contains("gcp"))
}

private static func claudeRootExists(atPath path: String) throws -> Bool {
var info = stat()
guard path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else {
let code = errno
if code == ENOENT || code == ENOTDIR { return false }
throw NSError(domain: NSPOSIXErrorDomain, code: Int(code), userInfo: [NSFilePathErrorKey: path])
}
return true
}

private static func claudeRootCandidates(for rootPath: String) -> [String] {
if rootPath.hasPrefix("/var/") {
return ["/private" + rootPath, rootPath]
Expand All @@ -591,19 +581,6 @@ extension CostUsageScanner {
return [rootPath]
}

private struct ClaudeSourceFile {
let url: URL
let stamp: CostUsageClaudeFileStamp
}

private struct ClaudeSourceInventory {
var files: [String: ClaudeSourceFile] = [:]

var stamps: [String: CostUsageClaudeFileStamp] {
self.files.mapValues(\.stamp)
}
}

private struct ClaudeScanState {
var cache: CostUsageCache
var sourceFileIDs: [String: String]
Expand All @@ -616,12 +593,11 @@ extension CostUsageScanner {
}

private static func processClaudeFile(
source: ClaudeSourceFile,
path: String,
stamp: CostUsageClaudeFileStamp,
state: inout ClaudeScanState) throws
{
try state.checkCancellation?()
let path = source.url.path
let stamp = source.stamp
let cached = state.cache.files[path]
let sameFile = state.sourceFileIDs[path] == stamp.fileID

Expand Down Expand Up @@ -649,7 +625,7 @@ extension CostUsageScanner {
Self.recordClaudeScanWork(.transcriptParse(startOffset: startOffset))
#endif
let parsed = try Self.parseClaudeFileCancellable(
fileURL: source.url,
fileURL: URL(fileURLWithPath: path),
range: state.range,
providerFilter: state.providerFilter,
startOffset: startOffset,
Expand All @@ -670,33 +646,50 @@ extension CostUsageScanner {

private static func inventoryClaudeRoots(
_ roots: [URL],
checkCancellation: CancellationCheck?) throws -> ClaudeSourceInventory
checkCancellation: CancellationCheck?) throws -> [String: CostUsageClaudeFileStamp]
{
var inventory = ClaudeSourceInventory()
var inventory: [String: CostUsageClaudeFileStamp] = [:]

for root in roots {
try checkCancellation?()
let rootPath = root.path
let rootCandidates = Self.claudeRootCandidates(for: rootPath)
guard let existingRootPath = rootCandidates.first(where: { FileManager.default.fileExists(atPath: $0) })
// A root that exists but cannot be inspected must fail the scan, not silently drop its sources.
guard let existingRootPath = try rootCandidates.first(where: { try Self.claudeRootExists(atPath: $0) })
else { continue }
let existingRoot = existingRootPath == rootPath ? root : URL(fileURLWithPath: existingRootPath)
if existingRoot.pathExtension == "jsonl", let stamp = CostUsageClaudeFileStamp.read(at: existingRoot) {
inventory.files[existingRoot.path] = ClaudeSourceFile(url: existingRoot, stamp: stamp)
if existingRoot.pathExtension == "jsonl",
let stamp = try CostUsageClaudeFileStamp.readIfPresent(at: existingRoot)
{
inventory[existingRoot.path] = stamp
continue
}
var enumerationError: Error?
guard let enumerator = FileManager.default.enumerator(
at: existingRoot,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles, .skipsPackageDescendants])
else { continue }
options: [.skipsHiddenFiles, .skipsPackageDescendants],
errorHandler: { _, error in
// Files removed mid-walk are ordinary churn; unreadable directories would undercount.
if CostUsageClaudeFileStamp.isMissingFileError(error) { return true }
enumerationError = error
return false
})
else {
guard try Self.claudeRootExists(atPath: existingRootPath) else { continue }
throw NSError(
domain: NSCocoaErrorDomain,
code: NSFileReadUnknownError,
userInfo: [NSFilePathErrorKey: existingRootPath])
}

for case let url as URL in enumerator {
try checkCancellation?()
guard url.pathExtension.lowercased() == "jsonl" else { continue }
guard let stamp = CostUsageClaudeFileStamp.read(at: url), stamp.size > 0 else { continue }
inventory.files[url.path] = ClaudeSourceFile(url: url, stamp: stamp)
guard let stamp = try CostUsageClaudeFileStamp.readIfPresent(at: url), stamp.size > 0 else { continue }
inventory[url.path] = stamp
}
if let enumerationError { throw enumerationError }
}
return inventory
}
Expand All @@ -710,7 +703,7 @@ extension CostUsageScanner {
checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport
{
let roots = self.defaultClaudeProjectsRoots(options: options)
let inventory = try Self.inventoryClaudeRoots(roots, checkCancellation: checkCancellation)
let sourceInventory = try Self.inventoryClaudeRoots(roots, checkCancellation: checkCancellation)
try checkCancellation?()

let cacheContext = reportContext ?? .regular
Expand All @@ -730,7 +723,6 @@ extension CostUsageScanner {
artifactStamps: (cache: cacheArtifactStamp, pricing: pricingArtifactStamp))
let memo = CostUsageClaudeReportMemo.shared
let priorMemo = memo.entry(provider: provider, canonicalCachePath: canonicalCachePath)
let sourceInventory = inventory.stamps

if !options.forceRescan,
let priorMemo,
Expand Down Expand Up @@ -786,7 +778,7 @@ extension CostUsageScanner {
artifact.sourceFileIDs = [:]
}
let changedPaths: Set<String> = if let priorMemo {
Set(inventory.files.keys.filter { path in
Set(sourceInventory.keys.filter { path in
priorMemo.sourceInventory[path] != sourceInventory[path]
})
} else {
Expand All @@ -802,9 +794,9 @@ extension CostUsageScanner {
pricingResolver: pricingResolver,
checkCancellation: checkCancellation)

for path in inventory.files.keys.sorted() {
guard let source = inventory.files[path] else { continue }
try Self.processClaudeFile(source: source, state: &scanState)
for path in sourceInventory.keys.sorted() {
guard let stamp = sourceInventory[path] else { continue }
try Self.processClaudeFile(path: path, stamp: stamp, state: &scanState)
}
try checkCancellation?()

Expand Down Expand Up @@ -898,19 +890,6 @@ extension CostUsageScanner {
}

static func buildClaudeReportFromCache(
cache: CostUsageCache,
range: CostUsageDayRange,
now: Date = Date(),
modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport
{
self.buildClaudeReportFromCache(
cache: cache,
rows: self.reconciledClaudeRows(cache: cache),
range: range,
pricingResolver: CostUsagePricing.ClaudeResolver(now: now, cacheRoot: modelsDevCacheRoot))
}

private static func buildClaudeReportFromCache(
cache: CostUsageCache,
rows: [ClaudeUsageRow],
range: CostUsageDayRange,
Expand Down
36 changes: 36 additions & 0 deletions Tests/CodexBarTests/CostUsageClaudeCacheTestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,39 @@ extension CostUsageClaudeCacheIO {
ArtifactMemo.shared.entries.removeObject(forKey: url.standardizedFileURL.resolvingSymlinksInPath() as NSURL)
}
}

/// Convenience entry points used only by scanner tests.
extension CostUsageScanner {
static func parseClaudeFile(
fileURL: URL,
range: CostUsageDayRange,
providerFilter: ClaudeLogProviderFilter,
startOffset: Int64 = 0,
modelsDevCatalog: ModelsDevCatalog? = nil,
modelsDevCacheRoot: URL? = nil) -> ClaudeParseResult
{
let pricingResolver = modelsDevCatalog.map { CostUsagePricing.ClaudeResolver(catalog: $0) }
?? CostUsagePricing.ClaudeResolver(now: Date(), cacheRoot: modelsDevCacheRoot)
return (
try? self.parseClaudeFileCancellable(
fileURL: fileURL,
range: range,
providerFilter: providerFilter,
startOffset: startOffset,
pricingResolver: pricingResolver,
checkCancellation: nil)) ?? ClaudeParseResult(rows: [], parsedBytes: startOffset)
}

static func buildClaudeReportFromCache(
cache: CostUsageCache,
range: CostUsageDayRange,
now: Date = Date(),
modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport
{
self.buildClaudeReportFromCache(
cache: cache,
rows: self.reconciledClaudeRows(cache: cache),
range: range,
pricingResolver: CostUsagePricing.ClaudeResolver(now: now, cacheRoot: modelsDevCacheRoot))
}
}
34 changes: 33 additions & 1 deletion Tests/CodexBarTests/CostUsageClaudeReportContextTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,42 @@ struct CostUsageClaudeReportContextTests {
}
}

@Test(.enabled(if: getuid() != 0, "root ignores directory permissions"), arguments: [false, true])
func `unreadable source inventory fails without replacing the cache or report memo`(lockRoot: Bool) throws {
var fixture = try Fixture()
defer { fixture.env.cleanup() }
let gate = fixture.env.root.appendingPathComponent("gate", isDirectory: true)
let gatedRoot = gate.appendingPathComponent("projects", isDirectory: true)
try FileManager.default.createDirectory(at: gatedRoot, withIntermediateDirectories: true)
fixture.options.claudeProjectsRoots = [fixture.env.claudeProjectsRoot, gatedRoot]
_ = try fixture.write(path: "project/session.jsonl", day: fixture.now, id: "first", input: 10)
let locked = lockRoot ? gate : fixture.env.claudeProjectsRoot.appendingPathComponent("locked")
if !lockRoot {
_ = try fixture.write(path: "locked/session.jsonl", day: fixture.now, id: "locked", input: 5)
}
let (original, _) = try fixture.load(context: .regular, days: 30)
#expect(original.summary?.totalInputTokens == (lockRoot ? 10 : 15))
let cacheURL = CostUsageClaudeCacheIO.cacheFileURL(provider: .claude, cacheRoot: fixture.env.cacheRoot)
let memoURL = CostUsageClaudeReportMemo.reportMemoFileURL(cacheFileURL: cacheURL)
let cacheBefore = try Data(contentsOf: cacheURL)
let memoBefore = try Data(contentsOf: memoURL)

try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: locked.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: locked.path) }
#expect(throws: (any Error).self) { try fixture.load(context: .regular, days: 30) }
#expect(try Data(contentsOf: cacheURL) == cacheBefore)
#expect(try Data(contentsOf: memoURL) == memoBefore)

try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: locked.path)
let (recovered, work) = try fixture.load(context: .regular, days: 30)
#expect(recovered.data == original.data)
#expect(work == CostUsageScanner.ClaudeScanWorkMetrics())
}

struct Fixture {
let env: CostUsageTestEnvironment
let now: Date
let options: CostUsageScanner.Options
var options: CostUsageScanner.Options

init(now: Date? = nil) throws {
self.env = try CostUsageTestEnvironment()
Expand Down
15 changes: 15 additions & 0 deletions TestsLinux/CostUsageHourlyReportLinuxTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -346,3 +346,18 @@ struct CostUsageHourlyReportLinuxTests {
return calendar
}
}

extension CostUsageScanner {
static func buildClaudeReportFromCache(
cache: CostUsageCache,
range: CostUsageDayRange,
now: Date = Date(),
modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport
{
self.buildClaudeReportFromCache(
cache: cache,
rows: self.reconciledClaudeRows(cache: cache),
range: range,
pricingResolver: CostUsagePricing.ClaudeResolver(now: now, cacheRoot: modelsDevCacheRoot))
}
}
1 change: 1 addition & 0 deletions docs/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential
- GPT usage recorded through Claude Code uses the bundled OpenAI model's long-context boundary (272K for supported models), while retaining catalog rates. Uncached input and cache-read/create tokens all contribute to the prompt length. Saved reports are recalculated after pricing corrections without discarding retained Codex history.
- Native provider cache: `~/Library/Caches/CodexBar/cost-usage/claude-v6.json`
- Report memo: `~/Library/Caches/CodexBar/cost-usage/claude-v6.report-memo.json` stores source stamps and the daily report across launches. It is reused only while transcript inventory, cache/pricing artifacts, requested window, and report-semantics revision still match.
- An unreadable transcript root or directory fails the refresh without replacing the last valid cache or report memo. Restoring access allows the next refresh to reuse that history; genuinely missing paths still count as removed sources.
- A pricing catalog replacement during a refresh preserves certification of the parsed transcript window. The next refresh applies the replacement prices to cached rows without reparsing unchanged transcripts, including after a restart. Externally replaced transcript caches still require window certification again.
- Unchanged sources reuse the memo even when a menu refresh bypasses the scan debounce. Explicit rescans still reparse transcripts, but identical cache and report-memo content is not rewritten; an unchanged rebuild retains its previous scan timestamp. Existing artifacts may be rewritten once to establish deterministic key ordering. Changed transcripts or report metadata still replace the corresponding complete JSON artifacts.
- Persistence keeps at most eight lightweight artifact identities independently of the decoded-row cache. An unchanged cache identity plus matching device/inode, size, and nanosecond mtime skips encoding; otherwise sorted JSON fingerprints avoid full-file read-back while preserving identical rebuilds. External replacement invalidates reuse, and changed files still use temporary-file rename. Evicted identities are re-established on load or the next save.
Expand Down