Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
312a1e6
Add off-main agent usage sampling for Claude Code and Codex transcripts
teamleaderleo Sep 26, 2026
bf13ca7
Show opt-in coding-agent usage next to the sidebar agent status
teamleaderleo Sep 26, 2026
1b097a4
Address review: bounded reads, per-session usage, live setting, subag…
teamleaderleo Sep 26, 2026
22a2f93
Bound agent usage reads per session and cap concurrent transcript reads
teamleaderleo Sep 26, 2026
713b6a0
Fix sidebar usage refresh edge cases
teamleaderleo Sep 27, 2026
6370f07
Merge main (6431ac283354) into feat/sidebar-agent-usage
teamleaderleo Sep 27, 2026
969842b
Merge upstream/main into feat/sidebar-agent-usage
teamleaderleo Sep 28, 2026
1009948
Address review: gate agent usage on custom metadata, keep usage acros…
teamleaderleo Sep 28, 2026
014c11d
Merge remote-tracking branch 'upstream/main' into feat/sidebar-agent-…
teamleaderleo Sep 28, 2026
de5222f
Localizable.xcstrings: add only the agent-usage keys on top of main
teamleaderleo Sep 28, 2026
761dc29
Merge remote-tracking branch 'upstream/main' into feat/sidebar-agent-…
teamleaderleo Sep 28, 2026
14af25d
Localizable.xcstrings: restore two settings subtitles the catch-up me…
teamleaderleo Sep 28, 2026
55b9d67
Merge remote-tracking branch 'upstream/main' into feat/sidebar-agent-…
teamleaderleo Sep 28, 2026
8cd4f9f
Address review: name legacy Claude models, prove the off path schedul…
teamleaderleo Sep 28, 2026
2bfa6eb
Merge remote-tracking branch 'origin/feat/sidebar-agent-usage' into HEAD
teamleaderleo Sep 28, 2026
2d9bb62
Merge remote-tracking branch 'upstream/main' into feat/sidebar-agent-…
teamleaderleo Sep 28, 2026
e114e1b
Localize the sidebar.showAgentUsage schema description
teamleaderleo Sep 28, 2026
dd70eb7
Merge remote-tracking branch 'origin/main' into HEAD
teamleaderleo Sep 28, 2026
8c5ff61
Regenerate the embedded config schema after adding the description key
teamleaderleo Sep 28, 2026
aee39f1
Merge remote-tracking branch 'origin/main' into HEAD
teamleaderleo Sep 28, 2026
19146cf
Merge origin/main into feat/sidebar-agent-usage
teamleaderleo Sep 28, 2026
97fdd8d
Merge origin/main into feat/sidebar-agent-usage
teamleaderleo Sep 28, 2026
3329be7
Merge remote-tracking branch 'origin/main' into feat/sidebar-agent-usage
teamleaderleo Sep 30, 2026
7810064
Fix sidebar snapshot refresh across visibility changes
teamleaderleo Sep 30, 2026
200f0b6
Merge remote-tracking branch 'origin/main' into feat/sidebar-agent-usage
teamleaderleo Sep 30, 2026
a8cf18d
Merge remote-tracking branch 'origin/main' into feat/sidebar-agent-usage
teamleaderleo Sep 30, 2026
f612584
Fix sidebar visibility refresh environment dependency
teamleaderleo Sep 30, 2026
4635c22
Merge remote-tracking branch 'origin/main' into feat/sidebar-agent-usage
teamleaderleo Sep 30, 2026
fd1c864
Merge main (086c8cbe7b7b) into feat/sidebar-agent-usage
teamleaderleo Sep 30, 2026
9629420
Merge remote-tracking branch 'origin/main' into feat/sidebar-agent-usage
teamleaderleo Sep 30, 2026
472df57
Merge upstream/main into feat/sidebar-agent-usage
teamleaderleo Oct 1, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import Foundation

/// Resolves model ids found in agent transcripts to a display name, context
/// window, and published list prices.
///
/// The price table is deliberately small and explicit: a model that is not
/// listed gets no cost estimate instead of a guessed one. Only Anthropic
/// models are priced; Codex/OpenAI models show model and context only.
///
/// ```swift
/// let info = AgentModelCatalog().info(forModelID: "claude-opus-4-8")
/// // info?.displayName == "Opus 4.8", info?.contextWindow == 1_000_000
/// ```
public struct AgentModelCatalog: Sendable {
/// Context window assumed for Claude models not known to support 1M.
public static let defaultClaudeContextWindow = 200_000
/// Context window for 1M-token models (and `[1m]` model ids).
public static let oneMillionContextWindow = 1_000_000

/// Claude model families (id without the `claude-` prefix and any date
/// suffix) that run with a 1M-token context window by default.
static let claudeOneMillionFamilies: Set<String> = [
"fable-5-1", "fable-5", "mythos-5-1", "mythos-5",
"opus-5-5", "opus-5", "opus-4-8", "opus-4-7", "opus-4-6",
"sonnet-5", "sonnet-4-6",
]

/// Anthropic first-party API list prices in USD per million tokens,
/// keyed by normalized family. Source: Anthropic model/pricing tables as
/// of 2026-06-24 (input, output, cache read; 5-minute and 1-hour cache
/// writes are 1.25x and 2x input). Update this table and its date
/// together.
static let claudePrices: [String: AgentModelPricing] = [
"fable-5-1": .anthropic(input: 10, output: 50, cacheRead: 0.25),
"mythos-5-1": .anthropic(input: 10, output: 50, cacheRead: 0.25),
"fable-5": .anthropic(input: 10, output: 50, cacheRead: 1),
"mythos-5": .anthropic(input: 10, output: 50, cacheRead: 1),
"opus-5-5": .anthropic(input: 4, output: 20, cacheRead: 0.2),
"opus-5": .anthropic(input: 5, output: 25, cacheRead: 0.5),
"opus-4-8": .anthropic(input: 5, output: 25, cacheRead: 0.5),
"opus-4-7": .anthropic(input: 5, output: 25, cacheRead: 0.5),
"opus-4-6": .anthropic(input: 5, output: 25, cacheRead: 0.5),
"opus-4-5": .anthropic(input: 5, output: 25, cacheRead: 0.5),
"opus-4-1": .anthropic(input: 15, output: 75, cacheRead: 1.5),
"opus-4": .anthropic(input: 15, output: 75, cacheRead: 1.5),
"sonnet-5": .anthropic(input: 2, output: 10, cacheRead: 0.2),
"sonnet-4-6": .anthropic(input: 3, output: 15, cacheRead: 0.3),
"sonnet-4-5": .anthropic(input: 3, output: 15, cacheRead: 0.3),
"sonnet-4": .anthropic(input: 3, output: 15, cacheRead: 0.3),
"haiku-4-5": .anthropic(input: 1, output: 5, cacheRead: 0.1),
]

/// Creates a catalog over the built-in tables.
public init() {}

/// Describes a model id as written in a transcript.
///
/// Claude ids may carry a provider prefix (`us.anthropic.`), a date
/// suffix (`-20251001`), a Vertex version (`@20251001`), a Bedrock
/// version (`-v1:0`), or a `[1m]` suffix; all are normalized before
/// lookup. `[1m]` forces a 1M context window.
///
/// - Parameters:
/// - modelID: The raw model id.
/// - reportedContextWindow: A context window the transcript itself
/// reported (Codex `model_context_window`); wins over the table.
/// - Returns: The model description, or `nil` for an empty id.
public func info(forModelID modelID: String, reportedContextWindow: Int? = nil) -> AgentModelInfo? {
let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let lowered = trimmed.lowercased()
if let family = Self.claudeFamily(fromModelID: lowered) {
let forcesOneMillion = lowered.contains("[1m]")
let tableWindow = forcesOneMillion || Self.claudeOneMillionFamilies.contains(family)
? Self.oneMillionContextWindow
: Self.defaultClaudeContextWindow
return AgentModelInfo(
displayName: Self.claudeDisplayName(family: family) ?? trimmed,
contextWindow: reportedContextWindow ?? tableWindow,
pricing: Self.claudePrices[family]
)
}
return AgentModelInfo(displayName: trimmed, contextWindow: reportedContextWindow, pricing: nil)
}

/// Extracts the normalized Claude family (`opus-4-8`) from an id such as
/// `us.anthropic.claude-opus-4-8-20260101-v1:0[1m]` or
/// `claude-haiku-4-5@20251001`, or `nil` when the id is not a Claude id.
static func claudeFamily(fromModelID lowered: String) -> String? {
guard let range = lowered.range(of: "claude-") else { return nil }
var rest = String(lowered[range.upperBound...])
if let cut = rest.firstIndex(where: { $0 == "[" || $0 == "@" || $0 == ":" }) {
rest = String(rest[..<cut])
}
var parts: [String] = []
for part in rest.split(separator: "-") {
// A date stamp (8 digits) or provider version suffix ends the family.
if part.count == 8, part.allSatisfy(\.isNumber) { break }
if part.hasPrefix("v"), part.dropFirst().first?.isNumber == true { break }
parts.append(String(part))
}
guard !parts.isEmpty else { return nil }
return parts.joined(separator: "-")
}

/// `opus-4-8` → `Opus 4.8`, and the legacy order `3-5-sonnet` → `Sonnet
/// 3.5`. Current ids put the name first and the version after it; ids up
/// to Claude 3.7 put the version first. Both forms are named after the
/// first component that starts with a letter, with the numbers on either
/// side of it joined by dots. Returns `nil` only when no component starts
/// with a letter.
static func claudeDisplayName(family: String) -> String? {
let parts = family.split(separator: "-")
guard let nameIndex = parts.firstIndex(where: { $0.first?.isLetter == true }) else {
return nil
}
let name = parts[nameIndex]
let leadingVersion = parts[..<nameIndex]
let trailingVersion = parts[parts.index(after: nameIndex)...]
.prefix { $0.allSatisfy(\.isNumber) }
let version = leadingVersion.isEmpty ? Array(trailingVersion) : Array(leadingVersion)
let title = name.prefix(1).uppercased() + name.dropFirst()
guard !version.isEmpty else { return title }
return title + " " + version.joined(separator: ".")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

/// What cmux knows about one model id: a short display name, its context
/// window, and (when listed) its published prices.
public struct AgentModelInfo: Sendable, Equatable {
/// Short display name, e.g. `Opus 4.8` or `gpt-5-codex`.
public let displayName: String
/// Context window in tokens, or `nil` when neither the transcript nor
/// the built-in table knows it (the context percentage is then omitted).
public let contextWindow: Int?
/// Published list prices, or `nil` when the model is not in the price
/// table (cost is then omitted rather than guessed).
public let pricing: AgentModelPricing?

/// Creates a model description.
public init(displayName: String, contextWindow: Int?, pricing: AgentModelPricing?) {
self.displayName = displayName
self.contextWindow = contextWindow
self.pricing = pricing
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation

/// Published per-million-token list prices for one model, in US dollars.
///
/// These are public API list prices used only to *estimate* what a session
/// would cost at pay-as-you-go rates. Not modelled: subscriptions (a Claude
/// plan is not billed per token), batch and priority tiers, regional and
/// partner (Bedrock/Vertex/Foundry) pricing, data-residency multipliers,
/// long-context premiums of older 1M betas, fast mode, and server-tool fees
/// such as web search. The result is labelled as an estimate wherever shown.
public struct AgentModelPricing: Sendable, Equatable {
/// Base input price per million tokens.
public let inputPerMTok: Double
/// Output price per million tokens.
public let outputPerMTok: Double
/// 5-minute cache-write price per million tokens.
public let cacheWrite5mPerMTok: Double
/// 1-hour cache-write price per million tokens.
public let cacheWrite1hPerMTok: Double
/// Cache-read price per million tokens.
public let cacheReadPerMTok: Double

/// Creates a price row.
public init(
inputPerMTok: Double,
outputPerMTok: Double,
cacheWrite5mPerMTok: Double,
cacheWrite1hPerMTok: Double,
cacheReadPerMTok: Double
) {
self.inputPerMTok = inputPerMTok
self.outputPerMTok = outputPerMTok
self.cacheWrite5mPerMTok = cacheWrite5mPerMTok
self.cacheWrite1hPerMTok = cacheWrite1hPerMTok
self.cacheReadPerMTok = cacheReadPerMTok
}

/// Anthropic pricing shape: cache writes cost 1.25x (5m) and 2x (1h)
/// the input price; the cache-read price is model specific.
static func anthropic(input: Double, output: Double, cacheRead: Double) -> Self {
Self(
inputPerMTok: input,
outputPerMTok: output,
cacheWrite5mPerMTok: input * 1.25,
cacheWrite1hPerMTok: input * 2,
cacheReadPerMTok: cacheRead
)
}

/// Estimated cost of `tokens` at these prices, in US dollars.
///
/// - Parameter tokens: Token counts split by billing class.
/// - Returns: The dollar estimate.
public func estimatedCostUSD(for tokens: AgentUsageTokenCounts) -> Double {
let dollars = Double(tokens.uncachedInput) * inputPerMTok
+ Double(tokens.cacheWrite5m) * cacheWrite5mPerMTok
+ Double(tokens.cacheWrite1h) * cacheWrite1hPerMTok
+ Double(tokens.cacheRead) * cacheReadPerMTok
+ Double(tokens.output) * outputPerMTok
return dollars / 1_000_000
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

/// An estimated API cost and whether it is complete.
public struct AgentUsageCost: Sendable, Equatable {
/// Estimated cost in US dollars at published list prices.
public let usd: Double
/// `true` when some usage could not be priced (unknown model, or a
/// transcript line too large to read), so the real figure is higher.
public let isLowerBound: Bool
/// Whether any usage was actually priced. A cost built only from
/// unknown models carries no information and is not displayed.
public let hasPricedUsage: Bool

/// Creates a cost.
public init(usd: Double, isLowerBound: Bool, hasPricedUsage: Bool = true) {
self.usd = usd
self.isLowerBound = isLowerBound
self.hasPricedUsage = hasPricedUsage
}

/// This cost when it is worth showing, or `nil` when nothing was priced.
public var displayable: Self? {
hasPricedUsage ? self : nil
}

/// Adds two estimates; the sum is a lower bound if either part is.
public static func + (lhs: Self, rhs: Self) -> Self {
Self(
usd: lhs.usd + rhs.usd,
isLowerBound: lhs.isLowerBound || rhs.isLowerBound,
hasPricedUsage: lhs.hasPricedUsage || rhs.hasPricedUsage
)
}

/// Adds two optional estimates; `nil` (unknown) is contagious.
static func combine(_ lhs: Self?, _ rhs: Self?) -> Self? {
guard let lhs, let rhs else { return nil }
return lhs + rhs
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation

/// Incremental read position and parse state for one transcript file.
///
/// Values are handed between the ``AgentUsageSampler`` actor and the
/// detached task that reads the file, so everything here is `Sendable`.
struct AgentUsageFileCursor: Sendable {
/// Usage folded from the lines read so far.
var accumulator: AgentUsageTranscriptAccumulator
/// Bytes consumed from the start of the file.
var byteOffset: UInt64 = 0
/// Inode of the file when last read (from `fstat` on the open fd).
var inode: UInt64?
/// Size and modification time (ns) from the last read's `fstat`; when a
/// plain `stat` still matches them (and the inode), the file is skipped
/// without being opened.
var statSize: UInt64 = 0
var modificationNanos: Int64 = 0
/// The first bytes of the file when last read. A mismatch means the file
/// was rewritten in place, even if it is now larger.
var head = Data()
/// Bytes of an incomplete trailing line, awaiting its newline.
var fragment = Data()
/// Skip bytes until the next newline (an oversized line, or the partial
/// first line of a tail read).
var discardingLine = false

init(source: AgentUsageSource, catalog: AgentModelCatalog) {
accumulator = AgentUsageTranscriptAccumulator(source: source, catalog: catalog)
}
}
Loading
Loading