Split agent usage into data files and rename the plugin to omarchy.agents (#6603)

* Add agent usage collectors that write display-ready data files

One omarchy-agent-usage-scan-<agent> collector per AI coding agent prints a
complete display-ready usage record — identity, tier, status, rate limits,
and today/week/all-time stats. omarchy-agent-usage-update runs every
collector it finds and writes the records atomically to
~/.local/state/omarchy/agents/usage/, so anything that displays usage only
ever reads JSON from there.

The Claude collector absorbs what the shell previously did in-process:
transcript scanning, the stats-cache/history fallback, credentials parsing,
and the OAuth limits probe, now with a probe throttle and last-good limits
kept across network failures. The Codex collector is the existing scanner
reshaped to the shared record contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Redo the model-usage plugin as omarchy.agents watching usage data files

The panel is now strictly a display. It discovers the JSON records that
omarchy-agent-usage-update maintains under
~/.local/state/omarchy/agents/usage/, watches them for changes, and draws
whatever appears — so adding an agent means shipping a collector, never
touching the panel. Marks resolve by convention (assets/<id>.svg with an
optional -light twin), the limits meters read a generic limits array, and
the per-provider QML adapters and in-plugin scanner scripts are gone.

Cross-device sync aggregation stays in the shell and keeps the snapshot
field names older versions wrote, so mixed-version fleets still merge in
both directions.

With the provider fan-out gone, the widget takes its real name: the plugin
id becomes omarchy.agents. A migration renames it wherever a user's config
mentions it — layout entries keep their settings and position, a disabled
widget stays disabled — then primes the data files once and drops the old
scanner cache. The migration test also drops a stale assertion that expected
migrations to restart the shell themselves, which c992cdff moved to
omarchy update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Codex review: synced-only tabs, limits retry, history fallback

Three data-availability gaps from review. An agent whose records only exist
in synced snapshots — a collector installed on just one machine — now gets
its tab by unioning the synced aggregate into the provider list, with rate
limits blank since those never travel. A Claude limits probe that reaches no
server at all writes retryAdvised into its record, and the shell honors it
with one 30-second retry instead of waiting out the full refresh interval,
restoring the old boot-before-DHCP behavior. And a machine with only
history.jsonl — no transcripts, no stats-cache — still reports today's
prompt and session counts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address second Codex pass: history-only visibility, targeted retries

Today's prompt and session counts now count toward an agent's presence in
the bar, so a machine whose only Claude source is history.jsonl shows up
without waiting for limits. And the 30-second limits retry passes the
advising agent ids to the updater, so an outage at one provider no longer
puts every other collector on a retry treadmill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop omarchy-cmd-present jq guards from the agents migrations

jq ships in the default package set, which makes it a runtime invariant per
AGENTS.md — call it directly. The migration tests lose their now-unused
omarchy-cmd-present stubs with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop the scan infix from the collector command names

Collectors are omarchy-agent-usage-<agent>; the updater skips its own name
when globbing them, and the update test proves it with a decoy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep the credential store out of the printed usage record

The Claude collector now reads .credentials.json once into three scalars —
the access token, its expiry, and the plan label — instead of passing the
parsed store around. The token reaches nothing but the Authorization header
of the limits probe, and only the plan label may travel into the record,
which is what CodeQL's clear-text-logging alert on the record print was
unable to see when the whole dict flowed through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-08-07 15:46:10 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent c3bd4a86ae
commit bb8d2f2cb3
28 changed files with 1375 additions and 1315 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ User-installed plugins live alongside these conceptually but on disk under
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
| Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` |
| Tailscale | `omarchy.tailscale` | `bar-widget` | `panels/tailscale/Panel.qml` |
| Model usage | `omarchy.model-usage` | `bar-widget` | `model-usage/Panel.qml` |
| Agents | `omarchy.agents` | `bar-widget` | `agents/Panel.qml` |
| Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` |
| Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` |
| Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` |
+34
View File
@@ -0,0 +1,34 @@
import QtQuick
import Quickshell.Io
// One agent's usage record, read straight off the data file that
// omarchy-agent-usage-update maintains. The panel never learns how the
// numbers were made — a record that appears in the usage directory is an
// agent, whoever wrote it.
Item {
id: root
visible: false
property string agentId: ""
property string path: ""
property var record: null
FileView {
path: root.path
watchChanges: true
printErrors: false
onFileChanged: reload()
onLoaded: root.parse(text())
onLoadFailed: root.record = null
}
function parse(content) {
try {
var parsed = JSON.parse(String(content || ""))
root.record = parsed && typeof parsed === "object" ? parsed : null
} catch (e) {
console.warn("agents", "Ignoring bad usage record", root.path, e)
root.record = null
}
}
}
@@ -1,102 +1,289 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "providers"
// The display side of agent usage. All extraction lives behind
// omarchy-agent-usage-update, which writes one JSON record per agent into
// the usage directory; this file only discovers those records, watches them
// for changes, and optionally merges snapshots synced from other machines.
Item {
id: root
visible: false
property var settings: ({})
Claude {
id: claudeProvider
enabled: root.providerEnabled("claude")
providerSettings: root.settings && root.settings.providers && root.settings.providers.claude ? root.settings.providers.claude : ({})
onLastRefreshedAtMsChanged: root.scheduleSync()
onReadyChanged: root.scheduleSync()
}
Codex {
id: codexProvider
enabled: root.providerEnabled("codex")
providerSettings: root.settings && root.settings.providers && root.settings.providers.codex ? root.settings.providers.codex : ({})
onLastRefreshedAtMsChanged: root.scheduleSync()
onReadyChanged: root.scheduleSync()
}
property var providers: [claudeProvider, codexProvider]
// A subscription earns a place in the bar and the panel by being switched on
// in settings and having actually produced numbers locally or on a synced
// device. Presence on disk is not enough: a box that installed Codex and
// never ran it would get a tab full of zeroes. With nothing to show, the
// whole module collapses out of the bar rather than sitting there dimmed.
property var enabledProviders: {
var rev = syncRevision
var running = syncRunning
var result = []
if (claudeProvider.enabled) {
var claude = displayProvider(claudeProvider)
if (providerHasData(claude)) result.push(claude)
}
if (codexProvider.enabled) {
var codex = displayProvider(codexProvider)
if (providerHasData(codex)) result.push(codex)
}
return result
}
// All-time, not today: a quiet day is not the same as an absent provider.
function providerHasData(p) {
return numberValue(p.totalPrompts) > 0 || numberValue(p.totalSessions) > 0
|| numberValue(p.activeDays) > 0 || Number(p.rateLimitPercent) >= 0
|| Number(p.secondaryRateLimitPercent) >= 0
}
property bool refreshing: claudeProvider.refreshing || codexProvider.refreshing || syncRunning
property double aggregateUpdatedAtMs: aggregateData && aggregateData.updatedAtMs ? Number(aggregateData.updatedAtMs) : 0
property double lastRefreshedAtMs: Math.max(aggregateUpdatedAtMs, claudeProvider.lastRefreshedAtMs || 0, codexProvider.lastRefreshedAtMs || 0)
property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900)))
property var syncModeSetting: setting("syncMode", setting("syncEnabled", false))
property bool syncEnabled: parseSyncEnabled(syncModeSetting)
property string syncDir: String(setting("syncDir", ""))
property string syncFileName: String(setting("syncFileName", ""))
property string syncDeviceId: String(setting("syncDeviceId", ""))
readonly property string home: Quickshell.env("HOME") || ""
property string detectedHostname: ""
readonly property string syncEffectiveDir: expandPath(syncDir)
readonly property string syncEffectiveFileName: safeSnapshotFileName(syncFileName, syncDeviceId)
readonly property string syncEffectiveDeviceId: safeDeviceId(syncDeviceId || syncEffectiveFileName.replace(/\.json$/i, ""))
readonly property string syncSnapshotPath: syncConfigured() ? syncEffectiveDir + "/" + syncEffectiveFileName : home + "/.cache/omarchy/model-usage-disabled.json"
property var aggregateData: ({})
property int syncRevision: 0
property bool syncRunning: false
property bool syncRequestedWhileRunning: false
property string syncStatusText: ""
property int syncDeviceCount: syncConfigured() && aggregateData && aggregateData.deviceCount ? Number(aggregateData.deviceCount) : 0
readonly property string usageDir: (Quickshell.env("XDG_STATE_HOME") || home + "/.local/state") + "/omarchy/agents/usage"
onSyncEnabledChanged: syncSettingsChanged()
onSyncDirChanged: syncSettingsChanged()
onSyncFileNameChanged: if (syncConfigured()) scheduleSync()
onSyncDeviceIdChanged: if (syncConfigured()) scheduleSync()
// ------------------------------------------------------------- discovery
Component.onCompleted: if (syncConfigured()) scheduleSync()
property var agentIds: []
property var agents: []
property int dataRevision: 0
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
Process {
id: listProcess
running: false
command: ["find", root.usageDir, "-maxdepth", "1", "-name", "*.json", "-printf", "%f\n"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.applyAgentListing(text)
}
}
function rescanAgents() {
if (!listProcess.running) listProcess.running = true
}
function applyAgentListing(output) {
var ids = []
var lines = String(output || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var name = lines[i].trim()
if (name.slice(-5) === ".json") ids.push(name.slice(0, -5))
}
ids.sort()
// Same list, same objects: reassigning the model would tear down every
// FileView just to build identical ones.
if (JSON.stringify(ids) !== JSON.stringify(agentIds)) agentIds = ids
}
Instantiator {
id: agentInstantiator
model: root.agentIds
delegate: Agent {
required property var modelData
agentId: modelData
path: root.usageDir + "/" + modelData + ".json"
onRecordChanged: root.recordsChanged()
}
onObjectAdded: (index, object) => root.rebuildAgents()
onObjectRemoved: (index, object) => root.rebuildAgents()
}
function rebuildAgents() {
var result = []
for (var i = 0; i < agentInstantiator.count; i++) {
var agent = agentInstantiator.objectAt(i)
if (agent) result.push(agent)
}
agents = result
recordsChanged()
}
function recordsChanged() {
dataRevision++
scheduleLimitsRetry()
scheduleSync()
}
// A collector that could not reach its limits endpoint at all typically
// the seconds after login before the network is up writes retryAdvised
// into its record. Honor it with one sooner try instead of waiting out the
// full refresh interval; a run that reaches the endpoint clears the flag.
// Only the advising agents rerun, so an outage at one provider does not
// put every other collector on a 30-second treadmill.
property var retryAgentIds: []
Timer {
id: limitsRetry
interval: 30000
repeat: false
onTriggered: root.runUpdate("limits", root.retryAgentIds)
}
function scheduleLimitsRetry() {
var advising = []
for (var i = 0; i < agents.length; i++) {
var record = agents[i] ? agents[i].record : null
if (record && record.retryAdvised === true && providerEnabled(String(record.id || "")))
advising.push(String(record.id))
}
retryAgentIds = advising
if (advising.length > 0) limitsRetry.restart()
else limitsRetry.stop()
}
Component.onCompleted: {
rescanAgents()
if (syncConfigured()) scheduleSync()
}
// -------------------------------------------------------------- refresh
property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900)))
property string pendingUpdateKind: ""
Timer {
interval: root.refreshIntervalSec * 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refreshAll()
onTriggered: root.runUpdate("normal")
}
Process {
id: updateProcess
running: false
onExited: {
root.rescanAgents()
if (root.pendingUpdateKind !== "") {
var kind = root.pendingUpdateKind
root.pendingUpdateKind = ""
root.runUpdate(kind)
}
}
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: if (text.trim() !== "") console.warn("agents", text.trim())
}
}
function updateCommand(kind, agentIds) {
var command = ["omarchy-agent-usage-update"]
if (kind === "force") command.push("--force")
if (kind === "limits") command.push("--limits-only")
var providers = settings && settings.providers ? settings.providers : {}
for (var id in providers) {
if (providers[id] && providers[id].enabled === false) command.push("--except", id)
}
if (agentIds) {
for (var i = 0; i < agentIds.length; i++) command.push(agentIds[i])
}
return command
}
function runUpdate(kind, agentIds) {
if (updateProcess.running) {
// Collapse queued requests to one full rerun; a forced refresh outranks
// the cheaper kinds it might have been queued behind.
if (kind === "force" || root.pendingUpdateKind === "") root.pendingUpdateKind = kind
return
}
updateProcess.command = updateCommand(kind, agentIds)
updateProcess.running = true
}
function refresh() { refreshAll(true) }
function refreshAll(force) { runUpdate(force === true ? "force" : "normal") }
// Opening the panel wants the numbers that go stale on the wire, not
// another walk over every transcript on disk the collectors reuse their
// recent scans in this mode.
function refreshLimits() { runUpdate("limits") }
// ------------------------------------------------------------- providers
// An agent earns a place in the bar and the panel by being switched on in
// settings and having actually produced numbers locally or on a synced
// device. With nothing to show, the whole module collapses out of the bar
// rather than sitting there dimmed.
property var enabledProviders: {
var rev = dataRevision
var syncRev = syncRevision
var result = []
var localIds = {}
for (var i = 0; i < agents.length; i++) {
var record = agents[i] ? agents[i].record : null
if (!record || !record.id) continue
var id = String(record.id)
localIds[id] = true
if (!providerEnabled(id)) continue
var display = displayProvider(record)
if (providerHasData(display)) result.push(display)
}
// An agent that only ever ran on another machine has no local record, but
// its synced numbers still deserve a tab. Rate limits stay blank they
// are per-account and never travel.
var syncedProviders = syncConfigured() && aggregateData && aggregateData.providers ? aggregateData.providers : {}
for (var syncedId in syncedProviders) {
if (localIds[syncedId] || !providerEnabled(syncedId)) continue
var stats = syncedProviders[syncedId] || {}
var syncedDisplay = displayProvider({ id: syncedId, name: stats.providerName || syncedId })
if (providerHasData(syncedDisplay)) result.push(syncedDisplay)
}
return result
}
function providerEnabled(id) {
if (!settings || !settings.providers || !settings.providers[id]) return true
return settings.providers[id].enabled !== false
}
// All-time keeps a quiet day from hiding an agent; today's counts admit a
// machine whose only source is history.jsonl, which knows nothing older.
function providerHasData(p) {
return numberValue(p.totalPrompts) > 0 || numberValue(p.totalSessions) > 0
|| numberValue(p.activeDays) > 0 || numberValue(p.todayPrompts) > 0
|| numberValue(p.todaySessions) > 0 || (p.limits && p.limits.length > 0)
}
function displayProvider(record) {
var stats = syncedStatsFor(String(record.id))
var synced = !!stats
var deviceCount = synced ? Number(stats.deviceCount || aggregateData.deviceCount || 0) : 0
return {
providerId: String(record.id),
providerName: String(record.name || record.id),
ready: record.ready === true || synced,
usageStatusText: String(record.usageStatusText || ""),
authHelpText: String(record.authHelpText || ""),
// Rate limits stay per-account and are never merged across devices.
limits: Array.isArray(record.limits) ? record.limits : [],
tierLabel: String(record.tierLabel || ""),
todayPrompts: synced ? numberValue(stats.todayPrompts) : numberValue(record.todayPrompts),
todaySessions: synced ? numberValue(stats.todaySessions) : numberValue(record.todaySessions),
todayTotalTokens: synced ? numberValue(stats.todayTotalTokens) : numberValue(record.todayTotalTokens),
todayTokensByModel: synced ? (stats.todayTokensByModel || ({})) : (record.todayTokensByModel || ({})),
recentDays: synced ? (stats.recentDays || []) : (record.recentDays || []),
totalPrompts: synced ? numberValue(stats.totalPrompts) : numberValue(record.totalPrompts),
totalSessions: synced ? numberValue(stats.totalSessions) : numberValue(record.totalSessions),
activeDays: synced ? numberValue(stats.activeDays) : numberValue(record.activeDays),
modelUsage: synced ? (stats.modelUsage || ({})) : (record.modelUsage || ({})),
hasLocalStats: synced ? (stats.hasLocalStats !== false) : (record.hasLocalStats !== false),
syncEnabled: synced,
syncDeviceCount: deviceCount,
syncUpdatedAt: aggregateData && aggregateData.updatedAt ? aggregateData.updatedAt : ""
}
}
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
// ------------------------------------------------------------------ sync
property var syncModeSetting: setting("syncMode", setting("syncEnabled", false))
property bool syncEnabled: parseSyncEnabled(syncModeSetting)
property string syncDir: String(setting("syncDir", ""))
property string syncFileName: String(setting("syncFileName", ""))
property string syncDeviceId: String(setting("syncDeviceId", ""))
property string detectedHostname: ""
readonly property string syncEffectiveDir: expandPath(syncDir)
readonly property string syncEffectiveFileName: safeSnapshotFileName(syncFileName, syncDeviceId)
readonly property string syncEffectiveDeviceId: safeDeviceId(syncDeviceId || syncEffectiveFileName.replace(/\.json$/i, ""))
readonly property string syncSnapshotPath: syncConfigured() ? syncEffectiveDir + "/" + syncEffectiveFileName : home + "/.cache/omarchy/agents-disabled.json"
property var aggregateData: ({})
property int syncRevision: 0
property bool syncRunning: false
property bool syncRequestedWhileRunning: false
property string syncStatusText: ""
property double aggregateUpdatedAtMs: aggregateData && aggregateData.updatedAtMs ? Number(aggregateData.updatedAtMs) : 0
onSyncEnabledChanged: syncSettingsChanged()
onSyncDirChanged: syncSettingsChanged()
onSyncFileNameChanged: if (syncConfigured()) scheduleSync()
onSyncDeviceIdChanged: if (syncConfigured()) scheduleSync()
Timer {
id: syncDebounce
interval: 1000
@@ -134,7 +321,7 @@ Item {
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: if (text.trim() !== "") console.warn("model-usage/sync", text.trim())
onStreamFinished: if (text.trim() !== "") console.warn("agents/sync", text.trim())
}
}
@@ -154,11 +341,6 @@ Item {
onLoaded: root.detectedHostname = String(text() || "").trim()
}
function providerEnabled(id) {
if (!settings || !settings.providers || !settings.providers[id]) return id === "claude" || id === "codex"
return settings.providers[id].enabled !== false
}
function parseSyncEnabled(value) {
if (value === true) return true
var text = String(value || "").trim().toLowerCase()
@@ -269,7 +451,7 @@ Item {
var parsed = JSON.parse(raw)
if (parsed && parsed.providers) snapshots.push(parsed)
} catch (e) {
console.warn("model-usage/sync", "Ignoring bad snapshot", currentPath, e)
console.warn("agents/sync", "Ignoring bad snapshot", currentPath, e)
}
currentPath = ""
currentJson = []
@@ -446,30 +628,34 @@ Item {
}
}
function providerSnapshot(provider) {
// Snapshots keep the field names older Omarchy versions wrote, so a fleet
// of machines on mixed versions still merges cleanly in both directions.
function providerSnapshot(record) {
return {
providerId: provider.providerId,
providerName: provider.providerName,
ready: provider.ready === true,
hasLocalStats: provider.hasLocalStats !== false,
todayPrompts: numberValue(provider.todayPrompts),
todaySessions: numberValue(provider.todaySessions),
todayTotalTokens: numberValue(provider.todayTotalTokens),
todayTokensByModel: cloneValue(provider.todayTokensByModel, ({})),
recentDays: cloneValue(provider.recentDays, []),
totalPrompts: numberValue(provider.totalPrompts),
totalSessions: numberValue(provider.totalSessions),
activeDays: numberValue(provider.activeDays),
activeDates: cloneValue(provider.activeDates, []),
modelUsage: cloneValue(provider.modelUsage, ({}))
providerId: String(record.id),
providerName: String(record.name || record.id),
ready: record.ready === true,
hasLocalStats: record.hasLocalStats !== false,
todayPrompts: numberValue(record.todayPrompts),
todaySessions: numberValue(record.todaySessions),
todayTotalTokens: numberValue(record.todayTotalTokens),
todayTokensByModel: cloneValue(record.todayTokensByModel, ({})),
recentDays: cloneValue(record.recentDays, []),
totalPrompts: numberValue(record.totalPrompts),
totalSessions: numberValue(record.totalSessions),
activeDays: numberValue(record.activeDays),
activeDates: cloneValue(record.activeDates, []),
modelUsage: cloneValue(record.modelUsage, ({}))
}
}
function localSnapshot() {
var providerMap = {}
for (var i = 0; i < providers.length; i++) {
var provider = providers[i]
if (provider.enabled) providerMap[provider.providerId] = providerSnapshot(provider)
for (var i = 0; i < agents.length; i++) {
var record = agents[i] ? agents[i].record : null
if (!record || !record.id) continue
if (!providerEnabled(String(record.id))) continue
providerMap[String(record.id)] = providerSnapshot(record)
}
return {
schemaVersion: 1,
@@ -485,68 +671,7 @@ Item {
return aggregateData.providers[providerId] || null
}
function displayProvider(provider) {
var stats = syncedStatsFor(provider.providerId)
var synced = !!stats
var deviceCount = synced ? Number(stats.deviceCount || aggregateData.deviceCount || 0) : 0
return {
providerId: provider.providerId,
providerName: provider.providerName,
providerIcon: provider.providerIcon,
enabled: provider.enabled,
ready: provider.ready || synced,
refreshing: provider.refreshing || root.syncRunning,
lastRefreshedAtMs: Math.max(provider.lastRefreshedAtMs || 0, root.aggregateUpdatedAtMs || 0),
usageStatusText: provider.usageStatusText,
authHelpText: provider.authHelpText,
rateLimitPercent: provider.rateLimitPercent,
rateLimitLabel: provider.rateLimitLabel,
rateLimitResetAt: provider.rateLimitResetAt,
secondaryRateLimitPercent: provider.secondaryRateLimitPercent,
secondaryRateLimitLabel: provider.secondaryRateLimitLabel,
secondaryRateLimitResetAt: provider.secondaryRateLimitResetAt,
tierLabel: provider.tierLabel,
todayPrompts: synced ? numberValue(stats.todayPrompts) : provider.todayPrompts,
todaySessions: synced ? numberValue(stats.todaySessions) : provider.todaySessions,
todayTotalTokens: synced ? numberValue(stats.todayTotalTokens) : provider.todayTotalTokens,
todayTokensByModel: synced ? (stats.todayTokensByModel || ({})) : provider.todayTokensByModel,
recentDays: synced ? (stats.recentDays || []) : provider.recentDays,
totalPrompts: synced ? numberValue(stats.totalPrompts) : provider.totalPrompts,
totalSessions: synced ? numberValue(stats.totalSessions) : provider.totalSessions,
activeDays: synced ? numberValue(stats.activeDays) : provider.activeDays,
modelUsage: synced ? (stats.modelUsage || ({})) : provider.modelUsage,
hasLocalStats: synced ? (stats.hasLocalStats !== false) : provider.hasLocalStats,
syncEnabled: synced,
syncDeviceCount: deviceCount,
syncUpdatedAt: aggregateData && aggregateData.updatedAt ? aggregateData.updatedAt : "",
formatResetTime: function(isoTimestamp) { return provider.formatResetTime(isoTimestamp) }
}
}
function refresh() { refreshAll(true) }
function refreshAll(force) {
for (var i = 0; i < providers.length; i++) {
var p = providers[i]
if (p.enabled && typeof p.refresh === "function") p.refresh(force === true)
}
scheduleSync()
}
// Opening the panel wants the numbers that go stale on the wire, not another
// walk over every transcript on disk. Forcing a whole refresh would do both,
// and re-opening the panel would then rescan the lot each time.
function refreshLimits() {
for (var i = 0; i < providers.length; i++) {
var p = providers[i]
if (p.enabled && typeof p.refreshLimits === "function") p.refreshLimits()
}
}
// ---------------------------------------------------------------- format
function formatTokenCount(n) {
if (n === undefined || n === null) return "0"
@@ -7,8 +7,8 @@ import qs.Ui
Panel {
id: root
moduleName: "omarchy.model-usage"
ipcTarget: "omarchy.model-usage"
moduleName: "omarchy.agents"
ipcTarget: "omarchy.agents"
manageIpc: false
readonly property color foreground: bar ? bar.foreground : Color.foreground
@@ -98,8 +98,12 @@ Panel {
function limitWindows(p) {
if (!p) return []
var out = []
if (p.rateLimitPercent >= 0) out.push(limitWindow(p.rateLimitLabel, p.rateLimitPercent, p.rateLimitResetAt))
if (p.secondaryRateLimitPercent >= 0) out.push(limitWindow(p.secondaryRateLimitLabel, p.secondaryRateLimitPercent, p.secondaryRateLimitResetAt))
var list = p.limits || []
for (var i = 0; i < list.length; i++) {
var entry = list[i] || {}
var percent = Number(entry.percent)
if (percent >= 0) out.push(limitWindow(entry.label, percent, entry.resetsAt))
}
return out
}
@@ -222,8 +226,9 @@ Panel {
return ""
}
// Codex ships as a white mark; swap to the dark one when the surface behind
// it is light. The Claude mark is brand-orange and works on both.
// Agents that ship a white mark carry an `assets/<id>-light.svg` twin for
// light surfaces; marks that work on both (Claude's brand-orange) ship one
// file. The luminance check decides which candidate to try first.
function colorChannelLuminance(value) {
var channel = Number(value)
if (!isFinite(channel)) return 0
@@ -236,14 +241,16 @@ Panel {
+ 0.0722 * colorChannelLuminance(color.b)
}
function iconSourceForProvider(p, surfaceColor) {
if (!p) return ""
if (p.providerId === "claude") return Qt.resolvedUrl("assets/claude.svg")
if (p.providerId === "codex")
return colorLuminance(surfaceColor || Color.background) >= 0.5
? Qt.resolvedUrl("assets/codex-light.svg")
: Qt.resolvedUrl("assets/codex.svg")
return ""
// Marks resolve by convention, so a new agent's data file needs nothing
// from this panel: assets/<id>.svg if it ships one, the module's bar glyph
// if it doesn't.
function iconCandidatesForProvider(p, surfaceColor) {
if (!p) return []
var candidates = []
if (colorLuminance(surfaceColor || Color.background) >= 0.5)
candidates.push(Qt.resolvedUrl("assets/" + p.providerId + "-light.svg"))
candidates.push(Qt.resolvedUrl("assets/" + p.providerId + ".svg"))
return candidates
}
// Nothing to report, nothing in the bar: Bar.qml collapses a slot whose item
@@ -258,7 +265,6 @@ Panel {
cursorActive = false
nowMs = Date.now()
if (panelFlick) panelFlick.contentY = 0
usage.refreshAll()
usage.refreshLimits()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
@@ -358,13 +364,33 @@ Panel {
fontFamily: root.fontFamily
iconComponent: Component {
Image {
source: root.iconSourceForProvider(root.provider, root.surface)
Item {
id: heroMark
property var candidates: root.iconCandidatesForProvider(root.provider, root.surface)
property int candidateIndex: 0
onCandidatesChanged: candidateIndex = 0
width: Style.font.display
height: Style.font.display
sourceSize.width: Style.font.display * 2
sourceSize.height: Style.font.display * 2
fillMode: Image.PreserveAspectFit
Image {
id: heroMarkImage
anchors.fill: parent
source: heroMark.candidateIndex < heroMark.candidates.length ? heroMark.candidates[heroMark.candidateIndex] : ""
sourceSize.width: Style.font.display * 2
sourceSize.height: Style.font.display * 2
fillMode: Image.PreserveAspectFit
onStatusChanged: if (status === Image.Error && heroMark.candidateIndex < heroMark.candidates.length) heroMark.candidateIndex++
}
Text {
anchors.centerIn: parent
visible: heroMarkImage.status !== Image.Ready
text: button.text
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.display
}
}
}
}
@@ -373,7 +399,7 @@ Panel {
visible: root.providers.length === 0
width: parent.width
topPadding: Style.space(24)
text: "No AI coding subscriptions found.\nClaude Code and Codex show up here once you've used them."
text: "No AI coding subscriptions found.\nAgents show up here once you've used them."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.body
@@ -1,16 +1,18 @@
# Model usage
# Agents
One bar icon and one panel for every AI coding subscription on the machine.
`Panel.qml` owns the bar button and the popup; `Main.qml` owns provider
fan-out and the optional cross-device aggregation; `providers/` holds one
adapter per subscription.
The panel is strictly a display: it watches the usage records that
`omarchy-agent-usage-update` writes to `~/.local/state/omarchy/agents/usage/`
and draws whatever appears there. `Panel.qml` owns the bar button and the
popup; `Main.qml` discovers and watches the records (and handles the optional
cross-device aggregation); `Agent.qml` is the per-record file watcher.
## Panel
- **Hero** — the mark, the tool, and the plan it runs on ("Max 20x", "Pro").
Auth and endpoint problems replace the plan line and repeat in a card.
- **Subscription switch** — one chip per enabled provider (`h`/`l` or click).
It appears only when more than one provider is enabled.
- **Subscription switch** — one chip per enabled agent (`h`/`l` or click).
It appears only when more than one agent is enabled.
- **Limits** — the percentage of each allowance used, a matching meter, and
the time until the session or weekly window resets.
- **Tokens by day** — one row per day for the last week: day, bar, tokens, with today
@@ -21,42 +23,55 @@ adapter per subscription.
input / output / cache split.
A subscription appears only when it is enabled in settings and has actually
recorded usage — on this machine or on a synced one. With one such provider
recorded usage — on this machine or on a synced one. With one such agent
there is no switch row at all; with none, the module leaves the bar entirely
rather than sitting there with nothing to say. A CLI installed mid-session
shows up at the next refresh, so nothing polls the disk waiting for it.
That self-hiding is why the widget ships in the default bar layout: a machine
that has never run Claude Code or Codex draws nothing, and the icon arrives on
that has never run an AI coding agent draws nothing, and the icon arrives on
its own the first time a scan finds usage. Drop it with
`omarchy plugin disable omarchy.model-usage`.
`omarchy plugin disable omarchy.agents`.
## Providers
## Data
| Provider | Limits | Local stats |
Each agent is one JSON record in `~/.local/state/omarchy/agents/usage/`,
written by `omarchy-agent-usage-update`. That command runs one
`omarchy-agent-usage-<agent>` collector per agent; the widget invokes it
on its refresh timer and whenever you ask for a refresh, and picks up any
record that lands in the directory regardless of who wrote it.
Adding an agent therefore never touches this plugin: ship a collector that
prints the record contract (see the `claude` and `codex` collectors in
`bin/`), and the panel gains a tab. An `assets/<id>.svg` mark is optional —
with an `assets/<id>-light.svg` twin if the mark needs a dark variant for
light surfaces — and the bar glyph stands in when there is none.
| Collector | Limits | Local stats |
|---|---|---|
| `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` scanned by `scripts/claude_usage_scanner.py`, plus `stats-cache.json` and `history.jsonl` |
| `codex` | `scripts/codex_usage_scanner.py` reading the Codex CLI state | the same scanner |
| `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, plus `stats-cache.json` and `history.jsonl` as fallback |
| `codex` | The Codex app-server RPC | native Codex CLI session files (and pi sessions) |
Claude limits need a signed-in CLI; without credentials the panel says so and
falls back to local stats only.
falls back to local stats only. A non-default Claude directory is honored via
`CLAUDE_CONFIG_DIR`, Codex via `CODEX_HOME`.
## Interactions
- Bar icon: left = panel, right = refresh, middle = next subscription.
- Panel: `h`/`l` switch subscription, `j`/`k` scroll, `r` or Enter refresh,
Tab moves to the neighboring bar panel, Esc closes.
- IPC: `omarchy-shell omarchy.model-usage <open|close|toggle|refresh|next>`.
- IPC: `omarchy-shell omarchy.agents <open|close|toggle|refresh|next>`.
## Settings
Settings live in the widget's entry in `~/.config/omarchy/shell.json`. The
top-level keys can be set with
`omarchy bar set omarchy.model-usage <key> <value>`:
`omarchy bar set omarchy.agents <key> <value>`:
| Key | Default | What it does |
|---|---|---|
| `refreshIntervalSec` | `900` | How often local scans and snapshots refresh |
| `refreshIntervalSec` | `900` | How often the usage records regenerate |
| `syncMode` | `"Off"` | `"On"` writes this machine's snapshot and merges the others |
| `syncDir` | `""` | A folder synced by Syncthing, Dropbox, rsync, … |
| `syncFileName` | `<hostname>.json` | This machine's snapshot file |
@@ -65,34 +80,30 @@ top-level keys can be set with
Numbers need `--json`, or they land in `shell.json` as strings:
```bash
omarchy bar set omarchy.model-usage refreshIntervalSec 300 --json
omarchy bar set omarchy.model-usage syncDir '~/Sync/model-usage'
omarchy bar set omarchy.agents refreshIntervalSec 300 --json
omarchy bar set omarchy.agents syncDir '~/Sync/agent-usage'
```
Per-provider settings are nested, and `set` writes its key literally rather
Per-agent enablement is nested, and `set` writes its key literally rather
than walking a dotted path — so pass the whole `providers` object as JSON (or
edit `shell.json` directly):
```bash
omarchy bar set omarchy.model-usage providers '{
"claude": {
"enabled": true,
"statsPath": "~/.claude/stats-cache.json",
"credentialsPath": "~/.claude/.credentials.json",
"projectsPath": "~/.claude/projects"
},
omarchy bar set omarchy.agents providers '{
"claude": { "enabled": true },
"codex": { "enabled": false }
}' --json
```
`enabled` defaults to `true` for both; set it to `false` to hide a
subscription that is installed. The paths above are the defaults.
`enabled` defaults to `true` for every discovered agent; set it to `false` to
hide a subscription that is installed. Disabled agents are also skipped when
the records regenerate.
With `syncMode` on, every `*.json` snapshot in `syncDir` is merged, so today,
the last 7 days, and the all-time totals cover every machine you code on —
active days are unioned by date rather than summed. Rate limits stay
per-account and are never merged.
One caveat on "all-time": the Codex scanner only reads native session files
One caveat on "all-time": the Codex collector only reads native session files
touched in the last 30 days, so Codex totals and its day count cover that
window. Claude's cover every transcript still on disk.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"id": "omarchy.model-usage",
"name": "Model Usage",
"id": "omarchy.agents",
"name": "Agents",
"version": "1.0.0",
"author": "Omarchy",
"license": "MIT",
@@ -12,22 +12,15 @@
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Model Usage",
"displayName": "Agents",
"description": "One bar icon and one panel: rate-limit meters with pace, today, the last week, and the all-time model breakdown for every subscription.",
"category": "AI",
"aliases": ["model-usage"],
"aliases": ["agents", "model-usage"],
"allowMultiple": false,
"defaults": {
"providers": {
"claude": {
"enabled": true,
"statsPath": "~/.claude/stats-cache.json",
"credentialsPath": "~/.claude/.credentials.json",
"projectsPath": "~/.claude/projects"
},
"codex": {
"enabled": true
}
"claude": { "enabled": true },
"codex": { "enabled": true }
},
"refreshIntervalSec": 900,
"syncMode": "Off",
+3 -3
View File
@@ -8,7 +8,7 @@ the shell for its whole session.
- `manifest.json` declares the plugin (`id: omarchy.bar`, `kind: bar`) and points at `Bar.qml` as the entry point.
- `Bar.qml` is Omarchy-owned bar engine code, loaded by the omarchy-shell host. Users should not edit it directly.
- `widgets/` holds simple first-party bar widgets with sibling manifests.
- Feature plugins such as `../panels/audio/`, `../panels/network/`, `../panels/power/`, and `../model-usage/` provide richer popup bar plugins.
- Feature plugins such as `../panels/audio/`, `../panels/network/`, `../panels/power/`, and `../agents/` provide richer popup bar plugins.
- The bar receives its config from the host shell as a `barConfig` property; the host loads it from `~/.config/omarchy/shell.json` (or `config/omarchy/shell.json` when the user has no file).
- `omarchy bar position` updates only the user shell.json file.
@@ -67,7 +67,7 @@ Example `shell.json` (bar subtree only shown):
| `omarchy.audio` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = popup · scroll = volume |
| `omarchy.network` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup · right = nmtui |
| `omarchy.tailscale` | Tailscale status, connection switcher, machine browser, and copy actions | left = popup · right = toggle · middle = refresh |
| `omarchy.model-usage` | Claude Code and Codex limits with pace, today, last week, and all-time model breakdown | left = panel · right = refresh · middle = next subscription |
| `omarchy.agents` | AI coding agent limits with pace, today, last week, and all-time model breakdown | left = panel · right = refresh · middle = next subscription |
| `omarchy.power` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup · right = toggle percentage |
| `omarchy.bluetooth` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI |
| `omarchy.monitor` | Brightness and laptop display controls | left = popup |
@@ -168,7 +168,7 @@ Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (o
First-party bar widgets are manifest-backed just like third-party widgets.
Simple widgets carry sibling manifests such as `widgets/Workspaces.manifest.json`;
richer popup plugins live in feature directories such as `../panels/audio/`,
`../panels/network/`, and `../model-usage/`; and feature plugins such as
`../panels/network/`, and `../agents/`; and feature plugins such as
`omarchy.menu` and `omarchy.media` declare their bar-widget entry points in their own
`manifest.json`. Bar layout ids are namespaced, e.g. `omarchy.audio`,
`omarchy.network`, and `omarchy.clock`. Older UpperCamelCase ids such as
@@ -1,569 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Io
Item {
id: root
visible: false
property string providerId: "claude"
property string providerName: "Claude Code"
property string providerIcon: "ai"
property bool enabled: false
property bool ready: false
property bool refreshing: false
property double lastRefreshedAtMs: 0
property string usageStatusText: ""
property real rateLimitPercent: -1
property string rateLimitLabel: "Session (5-hour)"
property string rateLimitResetAt: ""
property real secondaryRateLimitPercent: -1
property string secondaryRateLimitLabel: "Weekly (7-day)"
property string secondaryRateLimitResetAt: ""
property int todayPrompts: 0
property int todaySessions: 0
property int todayTotalTokens: 0
property var todayTokensByModel: ({})
property var recentDays: []
property int totalPrompts: 0
property int totalSessions: 0
property int activeDays: 0
property var activeDates: []
property var modelUsage: ({})
property var dailyActivity: []
property string tierLabel: ""
property string authHelpText: "Run `claude auth login` to restore authoritative usage."
property bool hasLocalStats: true
property bool hasProjectStats: false
property string oauthAccessToken: ""
property double oauthExpiresAtMs: 0
property string authMode: "none"
property string subscriptionType: ""
property string rateLimitTier: ""
property bool hasAuthoritativeRateLimit: false
property bool probeInFlight: false
property double lastProbeAtMs: 0
property int probeMinIntervalMs: 15 * 60 * 1000
// Opening the panel asks for fresh limits, so a forced probe skips the
// background interval — but not so freely that flicking the panel open and
// shut turns into a request per flick.
property int probeForcedMinIntervalMs: 15 * 1000
property int probeRetryMs: 30 * 1000
property bool projectScanRerunForce: false
property var providerSettings: ({})
function resolvePath(p) {
if (p && p.startsWith("~"))
return (Quickshell.env("HOME") ?? "/home") + p.substring(1);
return p;
}
function pathFromUrl(url) {
const value = String(url || "");
if (value.indexOf("file://") === 0)
return decodeURIComponent(value.substring(7));
return value;
}
readonly property string projectScannerScriptPath: pathFromUrl(Qt.resolvedUrl("../scripts/claude_usage_scanner.py"))
FileView {
id: statsFile
path: root.resolvePath(root.providerSettings?.statsPath ?? "~/.claude/stats-cache.json")
watchChanges: true
printErrors: false
onFileChanged: reload()
onLoaded: root.parseStats(text())
}
FileView {
id: historyFile
path: root.resolvePath("~/.claude/history.jsonl")
watchChanges: true
onFileChanged: reload()
onLoaded: root.parseHistory(text())
onLoadFailed: error => {
if (error === FileViewError.FileNotFound)
console.error("model-usage/claude", "history.jsonl not found");
}
}
FileView {
id: credentialsFile
path: root.resolvePath(root.providerSettings?.credentialsPath ?? "~/.claude/.credentials.json")
watchChanges: true
onFileChanged: reload()
onLoaded: root.parseCredentials(text())
onLoadFailed: error => {
if (error === FileViewError.FileNotFound)
console.error("model-usage/claude", "credentials.json not found at", credentialsFile.path);
}
}
Process {
id: projectScanner
running: false
command: []
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.applyProjectUsageSummary(text)
}
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: if (text.trim() !== "") console.warn("model-usage/claude", text.trim())
}
onExited: {
root.finishRefresh();
if (root.projectScanRerunForce) {
root.projectScanRerunForce = false;
root.startProjectScanner(true);
}
}
}
Timer {
interval: root.probeMinIntervalMs
running: root.enabled && root.oauthAccessToken !== ""
repeat: true
onTriggered: root.probeRateLimits(false)
}
// The first probe fires seconds after login, often before DHCP has handed
// out a route, and comes back as a transport failure rather than an answer
// from Anthropic. Try again shortly instead of showing "limits unavailable"
// until the next background poll a quarter of an hour later.
Timer {
id: probeRetry
interval: root.probeRetryMs
repeat: false
// Straight to the probe: a retry that answered to the same throttle
// that spaces out ordinary polls would never get off the ground.
onTriggered: if (root.enabled && root.oauthAccessToken && !root.oauthTokenExpired()) root.probeOAuthUsage()
}
// Credentials load whether or not the panel wants this provider, so a
// switched-off Claude must not keep knocking on a downed network forever.
onEnabledChanged: if (!enabled) probeRetry.stop()
function localDateString() {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return y + "-" + m + "-" + d;
}
function parseStats(content) {
try {
const data = JSON.parse(content);
const today = localDateString();
const dailyModelTokens = data.dailyModelTokens ?? [];
const todayTokenEntry = dailyModelTokens.find(d => d.date === today);
root.todayTokensByModel = todayTokenEntry?.tokensByModel ?? {};
let tokenSum = 0;
const toks = root.todayTokensByModel;
for (const k in toks)
tokenSum += toks[k];
root.todayTotalTokens = tokenSum;
root.dailyActivity = data.dailyActivity ?? [];
root.recentDays = root.dailyActivity.slice(-7);
if (!root.hasProjectStats)
root.applyFallbackActiveDays(root.dailyActivity);
root.modelUsage = data.modelUsage ?? {};
root.totalPrompts = data.totalMessages ?? 0;
root.totalSessions = data.totalSessions ?? 0;
root.ready = true;
} catch (e) {
console.error("model-usage/claude", "Failed to parse stats-cache.json:", e);
}
}
// stats-cache.json has no day count of its own, so recover one from the
// daily activity it does carry. The project scan overrides this when it
// finds transcripts.
function applyFallbackActiveDays(dailyActivity) {
const days = Array.isArray(dailyActivity) ? dailyActivity : [];
const dates = [];
for (var i = 0; i < days.length; i++) {
const day = days[i] || {};
if (Number(day.messageCount || 0) > 0 && day.date)
dates.push(String(day.date));
}
root.activeDates = dates;
root.activeDays = dates.length;
}
function applyProjectUsageSummary(content) {
try {
const data = JSON.parse(String(content || "{}"));
const prompts = Math.max(0, Number(data.totalPrompts || 0));
if (prompts <= 0)
return;
root.hasProjectStats = true;
root.todayPrompts = Math.max(0, Number(data.todayPrompts || 0));
root.todaySessions = Math.max(0, Number(data.todaySessions || 0));
root.todayTotalTokens = Math.max(0, Number(data.todayTotalTokens || 0));
root.todayTokensByModel = data.todayTokensByModel || ({});
root.recentDays = data.recentDays || [];
root.modelUsage = data.modelUsage || ({});
root.totalPrompts = prompts;
root.totalSessions = Math.max(0, Number(data.totalSessions || 0));
root.activeDays = Math.max(0, Number(data.activeDays || 0));
root.activeDates = data.activeDates || [];
root.dailyActivity = data.dailyActivity || root.recentDays;
root.ready = true;
} catch (e) {
console.error("model-usage/claude", "Failed to parse project usage summary:", e);
}
}
function parseHistory(content) {
if (root.hasProjectStats)
return;
try {
const now = new Date();
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const lines = content.split("\n");
let prompts = 0;
const sessions = {};
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (!line)
continue;
try {
const entry = JSON.parse(line);
if ((entry.timestamp ?? 0) < startOfDay)
break;
prompts++;
if (entry.sessionId)
sessions[entry.sessionId] = true;
} catch (e) {
continue;
}
}
root.todayPrompts = prompts;
root.todaySessions = Object.keys(sessions).length;
} catch (e) {
console.error("model-usage/claude", "Failed to parse history.jsonl:", e);
}
}
function parseCredentials(content) {
try {
const data = JSON.parse(content);
const oauth = data.claudeAiOauth ?? {};
const fileAccessToken = oauth.accessToken ?? "";
const fileExpiresAtMs = root.normalizeExpiresAtMs(oauth.expiresAt);
const fileHasOAuth = fileAccessToken !== "";
const tokenChanged = (root.oauthAccessToken !== fileAccessToken || root.oauthExpiresAtMs !== fileExpiresAtMs);
root.oauthAccessToken = fileAccessToken;
root.oauthExpiresAtMs = fileExpiresAtMs;
if (tokenChanged)
root.clearAuthoritativeRateLimits();
root.authMode = fileHasOAuth ? "oauth" : "none";
root.subscriptionType = oauth.subscriptionType ?? "";
root.rateLimitTier = oauth.rateLimitTier ?? "";
root.tierLabel = formatTier();
if (root.oauthAccessToken && !root.oauthTokenExpired()) {
if (root.usageStatusText === "Waiting for auth")
root.clearUsageStatus();
// A fresh token just wiped the limits it replaced, so don't let
// the old token's throttle keep them blank for the next quarter
// of an hour.
root.probeRateLimits(tokenChanged);
} else if (!root.oauthAccessToken) {
root.usageStatusText = "Waiting for auth";
root.clearAuthoritativeRateLimits();
} else {
root.clearUsageStatus();
}
} catch (e) {
console.error("model-usage/claude", "Failed to parse credentials.json:", e);
root.usageStatusText = "Waiting for auth";
root.clearAuthoritativeRateLimits();
}
}
function formatTier() {
if (!root.rateLimitTier)
return root.subscriptionType || "";
const match = root.rateLimitTier.match(/max_(\d+x)/i);
if (match)
return "Max " + match[1];
if (root.subscriptionType)
return root.subscriptionType.charAt(0).toUpperCase() + root.subscriptionType.slice(1);
return "";
}
function normalizeExpiresAtMs(value) {
const n = Number(value ?? 0);
return (isFinite(n) && n > 0) ? n : 0;
}
function oauthTokenExpired() {
if (!root.oauthAccessToken)
return true;
if (!root.oauthExpiresAtMs || !(root.oauthExpiresAtMs > 0))
return false;
return root.oauthExpiresAtMs <= Date.now();
}
function clearAuthoritativeRateLimits() {
root.hasAuthoritativeRateLimit = false;
root.rateLimitPercent = -1;
root.rateLimitLabel = "Session (5-hour)";
root.rateLimitResetAt = "";
root.secondaryRateLimitPercent = -1;
root.secondaryRateLimitLabel = "Weekly (7-day)";
root.secondaryRateLimitResetAt = "";
}
function clearUsageStatus() {
root.usageStatusText = "";
}
function parseNumber(value) {
if (value === null || value === undefined)
return NaN;
return parseFloat(String(value).trim().replace("%", ""));
}
function utilizationPayloadUsesPercentScale(values) {
for (let i = 0; i < values.length; i++) {
const n = parseNumber(values[i]);
if (n >= 1)
return true;
}
return false;
}
function normalizeUtilization(value, percentScale) {
const n = parseNumber(value);
if (!(n >= 0))
return -1;
// Anthropic's OAuth usage endpoint currently reports percentages
// (for example 37.0 or 1.0). Older clients/examples sometimes used
// fractions (0.37). Treat a payload containing any value >= 1 as
// percent-scaled so 1.0 renders as 1%, not 100%.
if (percentScale === true || n > 1)
return Math.min(1, n / 100);
return Math.min(1, n);
}
function normalizeResetAt(value) {
if (value === null || value === undefined)
return "";
const raw = String(value).trim();
if (raw === "")
return "";
if (/^\d+$/.test(raw)) {
let ts = parseInt(raw, 10);
if (ts < 1e12)
ts = ts * 1000;
const d = new Date(ts);
if (!isNaN(d.getTime()))
return d.toISOString();
}
const parsed = new Date(raw);
if (!isNaN(parsed.getTime()))
return parsed.toISOString();
return raw;
}
function oauthUsageBucket(payload, key) {
const bucket = payload?.[key];
if (bucket && typeof bucket === "object")
return bucket;
return null;
}
function applyAuthoritativeRateLimits(weekly, weeklyReset, session, sessionReset, sourceLabel) {
const percentScale = root.utilizationPayloadUsesPercentScale([weekly, session]);
const weeklyNorm = root.normalizeUtilization(weekly, percentScale);
const sessionNorm = root.normalizeUtilization(session, percentScale);
if (weeklyNorm < 0 && sessionNorm < 0)
return false;
root.hasAuthoritativeRateLimit = true;
root.rateLimitPercent = -1;
root.rateLimitLabel = "Session (5-hour)";
root.rateLimitResetAt = "";
root.secondaryRateLimitPercent = -1;
root.secondaryRateLimitLabel = "Weekly (7-day)";
root.secondaryRateLimitResetAt = "";
if (sessionNorm >= 0)
root.rateLimitPercent = sessionNorm;
if (weeklyNorm >= 0)
root.secondaryRateLimitPercent = weeklyNorm;
if (sessionReset !== null && sessionReset !== undefined)
root.rateLimitResetAt = root.normalizeResetAt(sessionReset);
if (weeklyReset !== null && weeklyReset !== undefined)
root.secondaryRateLimitResetAt = root.normalizeResetAt(weeklyReset);
if (sourceLabel) {
if (root.secondaryRateLimitPercent >= 0)
root.secondaryRateLimitLabel = root.secondaryRateLimitLabel + " (" + sourceLabel + ")";
else if (root.rateLimitPercent >= 0)
root.rateLimitLabel = root.rateLimitLabel + " (" + sourceLabel + ")";
}
return true;
}
function finishRefresh() {
root.refreshing = false;
root.lastRefreshedAtMs = Date.now();
}
function probeOAuthUsage() {
// One probe at a time. The retry timer and a forced panel-open refresh
// can otherwise both be in flight, and the slower answer wins.
if (root.probeInFlight)
return;
root.probeInFlight = true;
root.refreshing = true;
root.lastProbeAtMs = Date.now();
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.anthropic.com/api/oauth/usage");
xhr.setRequestHeader("Authorization", "Bearer " + root.oauthAccessToken);
xhr.setRequestHeader("anthropic-beta", "oauth-2025-04-20");
xhr.setRequestHeader("Accept", "application/json");
xhr.onreadystatechange = function () {
if (xhr.readyState !== XMLHttpRequest.DONE)
return;
root.probeInFlight = false;
if (xhr.status >= 200 && xhr.status < 300) {
try {
const payload = JSON.parse(xhr.responseText ?? "{}");
const weeklyBucket = root.oauthUsageBucket(payload, "seven_day_oauth_apps") || root.oauthUsageBucket(payload, "seven_day");
const sessionBucket = root.oauthUsageBucket(payload, "five_hour");
if (root.applyAuthoritativeRateLimits(weeklyBucket?.utilization, weeklyBucket?.resets_at, sessionBucket?.utilization, sessionBucket?.resets_at, "")) {
probeRetry.stop();
root.clearUsageStatus();
root.finishRefresh();
return;
}
} catch (e) {
console.error("model-usage/claude", "Failed to parse oauth usage response:", e);
}
}
const body = xhr.responseText ? String(xhr.responseText).slice(0, 220) : "";
const retryAfter = xhr.getResponseHeader("retry-after") || "";
console.warn("model-usage/claude", "OAuth usage probe unavailable (status " + xhr.status + ")" + (body ? " body=" + body : ""));
if (!root.hasAuthoritativeRateLimit) {
root.usageStatusText = "Claude limits unavailable";
root.authHelpText = xhr.status === 0
? "Couldn't reach Anthropic's usage endpoint. Retrying shortly. Local Claude Code stats are still shown."
: xhr.status === 429
? "Anthropic's usage endpoint is rate limiting checks right now" + (retryAfter ? " (retry after " + retryAfter + "s)" : "") + ". Local Claude Code stats are still shown."
: "Anthropic's usage endpoint returned status " + xhr.status + ". Local Claude Code stats are still shown.";
}
// Status 0 is a transport failure — no route, no DNS, no server
// reached. Nothing to be a good citizen about, so keep knocking.
// Any real answer, including 429, disarms the retry: a server that
// replied is a server we should stop pestering.
if (xhr.status === 0)
probeRetry.restart();
else
probeRetry.stop();
root.finishRefresh();
};
xhr.send();
}
function startProjectScanner(force) {
if (projectScanner.running) {
if (force === true)
root.projectScanRerunForce = true;
return false;
}
const command = ["python3", root.projectScannerScriptPath, root.resolvePath(root.providerSettings?.projectsPath ?? "~/.claude/projects")];
if (force === true)
command.push("--force");
projectScanner.command = command;
projectScanner.running = true;
return true;
}
function refresh(force) {
root.refreshing = true;
statsFile.reload();
historyFile.reload();
credentialsFile.reload();
root.startProjectScanner(force === true);
if (root.oauthAccessToken && root.authMode === "oauth" && !root.oauthTokenExpired())
root.probeRateLimits(force === true);
}
// The cheap half of a refresh: Anthropic's numbers without the disk walk.
function refreshLimits() {
if (root.oauthAccessToken && root.authMode === "oauth" && !root.oauthTokenExpired())
root.probeRateLimits(true);
}
function formatResetTime(isoTimestamp) {
if (!isoTimestamp)
return "";
const reset = new Date(isoTimestamp);
const now = new Date();
const diffMs = reset.getTime() - now.getTime();
if (diffMs <= 0)
return "now";
const hours = Math.floor(diffMs / 3600000);
const mins = Math.floor((diffMs % 3600000) / 60000);
if (hours > 24)
return Math.floor(hours / 24) + "d " + (hours % 24) + "h";
if (hours > 0)
return hours + "h " + mins + "m";
return mins + "m";
}
function probeRateLimits(force) {
if (!root.oauthAccessToken || root.authMode !== "oauth") {
root.usageStatusText = "Waiting for auth";
root.clearAuthoritativeRateLimits();
root.finishRefresh();
return;
}
if (root.oauthTokenExpired()) {
root.clearUsageStatus();
root.finishRefresh();
return;
}
const minIntervalMs = force === true ? root.probeForcedMinIntervalMs : root.probeMinIntervalMs;
if (root.lastProbeAtMs > 0 && (Date.now() - root.lastProbeAtMs) < minIntervalMs) {
root.finishRefresh();
return;
}
root.probeOAuthUsage();
}
}
@@ -1,143 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Io
Item {
id: root
visible: false
property string providerId: "codex"
property string providerName: "Codex"
property string providerIcon: "ai"
property bool enabled: false
property bool ready: false
property bool refreshing: false
property double lastRefreshedAtMs: 0
property real rateLimitPercent: -1
property string rateLimitLabel: ""
property string rateLimitResetAt: ""
property real secondaryRateLimitPercent: -1
property string secondaryRateLimitLabel: ""
property string secondaryRateLimitResetAt: ""
property int todayPrompts: 0
property int todaySessions: 0
property real todayTotalTokens: 0
property var todayTokensByModel: ({})
property var recentDays: []
property int totalPrompts: 0
property int totalSessions: 0
property int activeDays: 0
property var activeDates: []
property var modelUsage: ({})
property string tierLabel: ""
property string usageStatusText: ""
property string authHelpText: "Run `codex login` to authenticate."
property bool hasLocalStats: true
property string configModel: ""
property var providerSettings: ({})
readonly property string scannerPath: String(Qt.resolvedUrl("../scripts/codex_usage_scanner.py")).replace("file://", "")
Process {
id: usageScanner
command: ["python3", root.scannerPath]
running: false
stdout: StdioCollector {
onStreamFinished: root.parseScannerOutput(text)
}
onExited: root.finishRefresh()
stderr: StdioCollector {
onStreamFinished: if (text.trim() !== "") console.warn("model-usage/codex", text.trim())
}
}
Timer {
interval: 5 * 60 * 1000
running: root.enabled
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
onEnabledChanged: if (enabled) refresh()
function finishRefresh() {
root.refreshing = false
root.lastRefreshedAtMs = Date.now()
}
function refresh(force) {
if (usageScanner.running)
return
root.refreshing = true
usageScanner.running = true
}
// Codex reports limits and local stats from the same scanner run, and that
// run has no expensive mode to skip, so there is nothing cheaper to do.
function refreshLimits() { refresh() }
function parseScannerOutput(output) {
const raw = String(output || "").trim()
if (raw === "")
return
try {
const data = JSON.parse(raw.split("\n").pop())
root.ready = !!data.ready
root.hasLocalStats = data.hasLocalStats !== false
root.todayPrompts = data.todayPrompts || 0
root.todaySessions = data.todaySessions || 0
root.todayTotalTokens = data.todayTotalTokens || 0
root.todayTokensByModel = data.todayTokensByModel || ({})
root.recentDays = data.recentDays || []
root.totalPrompts = data.totalPrompts || 0
root.totalSessions = data.totalSessions || 0
root.activeDays = data.activeDays || 0
root.activeDates = data.activeDates || []
root.modelUsage = data.modelUsage || ({})
root.rateLimitPercent = data.rateLimitPercent ?? -1
root.rateLimitLabel = data.rateLimitLabel || ""
root.rateLimitResetAt = data.rateLimitResetAt || ""
root.secondaryRateLimitPercent = data.secondaryRateLimitPercent ?? -1
root.secondaryRateLimitLabel = data.secondaryRateLimitLabel || ""
root.secondaryRateLimitResetAt = data.secondaryRateLimitResetAt || ""
root.tierLabel = data.tierLabel || ""
root.usageStatusText = data.usageStatusText || ""
root.authHelpText = data.authHelpText || "Run `codex login` to authenticate."
} catch (e) {
console.error("model-usage/codex", "Failed to parse scanner output:", e, raw)
root.usageStatusText = "Codex scan failed"
root.authHelpText = String(e)
root.ready = true
}
}
function formatResetTime(isoTimestamp) {
if (!isoTimestamp)
return ""
const reset = new Date(isoTimestamp)
const now = new Date()
const diffMs = reset.getTime() - now.getTime()
if (diffMs <= 0)
return "now"
const hours = Math.floor(diffMs / 3600000)
const mins = Math.floor((diffMs % 3600000) / 60000)
if (hours > 24)
return Math.floor(hours / 24) + "d " + (hours % 24) + "h"
if (hours > 0)
return hours + "h " + mins + "m"
return mins + "m"
}
}
@@ -1,245 +0,0 @@
#!/usr/bin/env python3
"""Stream Claude Code project JSONL files and emit compact usage stats.
This replaces the QML-side `rg --json ... | StdioCollector` path, which can
materialize 100MB+ of ripgrep JSON in the Quickshell process. The helper keeps
that work in a short-lived Python process, parses line-by-line, and returns a
single compact JSON object that matches the fields Claude.qml expects.
"""
from __future__ import annotations
import argparse
import datetime as dt
import fcntl
import hashlib
import json
import os
import sys
import time
from pathlib import Path
from typing import Any
def expand_path(value: str) -> Path:
return Path(os.path.expandvars(os.path.expanduser(value))).resolve()
def date_string(value: dt.date) -> str:
return value.strftime("%Y-%m-%d")
def recent_date_strings() -> list[str]:
today = dt.datetime.now().date()
return [date_string(today - dt.timedelta(days=offset)) for offset in range(6, -1, -1)]
def local_date_string() -> str:
return date_string(dt.datetime.now().date())
def local_date_from_timestamp(value: Any) -> str:
if value is None:
return local_date_string()
if isinstance(value, (int, float)):
try:
seconds = float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value)
return date_string(dt.datetime.fromtimestamp(seconds).date())
except Exception:
return local_date_string()
raw = str(value).strip()
if not raw:
return local_date_string()
# Claude JSONL timestamps are usually ISO-8601. Python accepts offsets but
# not a trailing Z until we normalize it to +00:00.
try:
parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
if parsed.tzinfo is not None:
parsed = parsed.astimezone()
return date_string(parsed.date())
except Exception:
return local_date_string()
def usage_token(usage: dict[str, Any], snake_key: str, camel_key: str) -> int:
value = usage.get(snake_key, usage.get(camel_key, 0))
try:
return round(float(value or 0))
except Exception:
return 0
def empty_bucket() -> dict[str, int]:
return {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
}
def iter_jsonl_files(projects_path: Path):
if not projects_path.is_dir():
return
yield from projects_path.rglob("*.jsonl")
def scan(projects_path: Path) -> dict[str, Any]:
today = local_date_string()
recent_dates = recent_date_strings()
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
seen: set[str] = set()
sessions: set[str] = set()
active_days: set[str] = set()
today_sessions: set[str] = set()
today_tokens: dict[str, int] = {}
usage_by_model: dict[str, dict[str, int]] = {}
prompts = 0
today_prompt_count = 0
today_token_total = 0
malformed_lines = 0
scanned_files = 0
for path in iter_jsonl_files(projects_path) or []:
scanned_files += 1
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
for line_number, line in enumerate(handle, 1):
# Cheap pre-filter before JSON parsing. Matches the old rg
# search and keeps files with unrelated lines inexpensive.
if '"usage":' not in line:
continue
try:
entry = json.loads(line)
except Exception:
malformed_lines += 1
continue
message = entry.get("message") if isinstance(entry.get("message"), dict) else {}
if entry.get("type") != "assistant" and message.get("role") != "assistant":
continue
usage = message.get("usage") or entry.get("usage")
if not isinstance(usage, dict):
continue
message_id = message.get("id") or entry.get("messageId") or ""
unique_key = str(message_id) if message_id else f"{path}:{entry.get('uuid') or entry.get('requestId') or line_number}"
if unique_key in seen:
continue
seen.add(unique_key)
input_tokens = usage_token(usage, "input_tokens", "inputTokens")
output_tokens = usage_token(usage, "output_tokens", "outputTokens")
cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens")
cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens")
total = input_tokens + output_tokens + cache_read + cache_write
if total <= 0:
continue
model = str(message.get("model") or entry.get("model") or "claude")
day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp"))
session_key = str(entry.get("sessionId") or path)
sessions.add(session_key)
active_days.add(day)
prompts += 1
bucket = usage_by_model.setdefault(model, empty_bucket())
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
# Preserve existing QML behavior: recentDays.messageCount
# is actually a token total, despite the legacy name.
recent[day]["messageCount"] += total
if day == today:
today_prompt_count += 1
today_sessions.add(session_key)
today_token_total += total
today_tokens[model] = today_tokens.get(model, 0) + total
except Exception as exc:
print(f"Ignoring unreadable Claude project file {path}: {exc}", file=sys.stderr)
recent_days = [recent[day] for day in recent_dates]
return {
"schemaVersion": 1,
"todayPrompts": today_prompt_count,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_token_total,
"todayTokensByModel": today_tokens,
"recentDays": recent_days,
"modelUsage": usage_by_model,
"totalPrompts": prompts,
"totalSessions": len(sessions),
# Days with any recorded usage, for the all-time "N days" summary.
# The dates travel too: merging snapshots from several machines needs
# their union, which a count alone cannot give.
"activeDays": len(active_days),
"activeDates": sorted(active_days),
"dailyActivity": recent_days,
"scannedFiles": scanned_files,
"malformedLines": malformed_lines,
}
def cache_paths(projects_path: Path) -> tuple[Path, Path]:
cache_root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "omarchy" / "model-usage"
cache_root.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16]
return cache_root / f"claude-projects-{digest}.json", cache_root / f"claude-projects-{digest}.lock"
def read_fresh_cache(path: Path, max_age_seconds: int) -> str | None:
if max_age_seconds <= 0 or not path.exists():
return None
try:
if time.time() - path.stat().st_mtime <= max_age_seconds:
return path.read_text(encoding="utf-8")
except Exception:
return None
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("projects_path", nargs="?", default="~/.claude/projects")
parser.add_argument("--cache-seconds", type=int, default=20)
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
projects_path = expand_path(args.projects_path)
cache_file, lock_file = cache_paths(projects_path)
if not args.force:
cached = read_fresh_cache(cache_file, args.cache_seconds)
if cached is not None:
print(cached, end="" if cached.endswith("\n") else "\n")
return 0
with lock_file.open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
if not args.force:
cached = read_fresh_cache(cache_file, args.cache_seconds)
if cached is not None:
print(cached, end="" if cached.endswith("\n") else "\n")
return 0
summary = scan(projects_path)
output = json.dumps(summary, separators=(",", ":"), sort_keys=True) + "\n"
tmp = cache_file.with_suffix(".json.tmp")
tmp.write_text(output, encoding="utf-8")
tmp.replace(cache_file)
print(output, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,356 +0,0 @@
import json
import os
import select
import shutil
import subprocess
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
def local_day(value):
if value is None:
return datetime.now().strftime("%Y-%m-%d")
if isinstance(value, (int, float)):
# pi message timestamps are milliseconds; Codex timestamps are usually seconds.
if value > 10_000_000_000:
value = value / 1000
return datetime.fromtimestamp(value).strftime("%Y-%m-%d")
text = str(value)
try:
if text.endswith("Z"):
dt = datetime.fromisoformat(text[:-1] + "+00:00")
else:
dt = datetime.fromisoformat(text)
if dt.tzinfo is not None:
dt = dt.astimezone()
return dt.strftime("%Y-%m-%d")
except Exception:
return datetime.now().strftime("%Y-%m-%d")
def number(value):
try:
return int(value or 0)
except Exception:
return 0
def model_name(raw):
value = str(raw or "codex")
return value if value else "codex"
def runtime_env():
home = str(Path.home())
path_parts = [
os.environ.get("PATH", ""),
f"{home}/.local/bin",
f"{home}/.npm-global/bin",
f"{home}/.local/share/mise/shims",
]
env = os.environ.copy()
env["PATH"] = os.pathsep.join(part for part in path_parts if part)
return env
ENV = runtime_env()
def find_command(name):
return shutil.which(name, path=ENV.get("PATH"))
now = datetime.now()
today = now.strftime("%Y-%m-%d")
recent_dates = [(now - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)]
recent_set = set(recent_dates)
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
today_tokens_by_model = {}
model_usage = {}
sessions_by_day = {day: set() for day in recent_dates}
today_sessions = set()
active_days = set()
today_prompts = 0
today_total_tokens = 0
total_prompts = 0
total_sessions = set()
seen_pi_messages = set()
usage_status = ""
usage_help = ""
def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, cache_write):
global today_prompts, today_total_tokens, total_prompts
total = input_tokens + output_tokens + cache_read + cache_write
total_prompts += 1
total_sessions.add(session_key)
active_days.add(day)
bucket = model_usage.setdefault(model, {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
})
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
recent[day]["messageCount"] += total
sessions_by_day[day].add(session_key)
if day == today:
today_prompts += 1
today_sessions.add(session_key)
today_total_tokens += total
today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total
def scan_pi_sessions():
root = Path.home() / ".pi" / "agent" / "sessions"
if not root.exists():
return
try:
rg = find_command("rg") or "rg"
proc = subprocess.Popen(
[rg, "--json", "-e", '"provider":"openai-codex"', "-e", '"api":"openai-codex"', str(root)],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
errors="replace",
env=ENV,
)
except FileNotFoundError:
return
assert proc.stdout is not None
for raw in proc.stdout:
try:
event = json.loads(raw)
if event.get("type") != "match":
continue
line = event.get("data", {}).get("lines", {}).get("text", "")
path = event.get("data", {}).get("path", {}).get("text", "pi-session")
entry = json.loads(line)
except Exception:
continue
if entry.get("type") != "message":
continue
message_key = path + ":" + str(entry.get("id") or "")
if message_key in seen_pi_messages:
continue
seen_pi_messages.add(message_key)
message = entry.get("message") or {}
if message.get("role") != "assistant":
continue
provider = str(message.get("provider") or "")
api = str(message.get("api") or "")
if provider != "openai-codex" and not api.startswith("openai-codex"):
continue
usage = message.get("usage") or {}
if not usage:
continue
total = number(usage.get("totalTokens"))
input_tokens = number(usage.get("input"))
output_tokens = number(usage.get("output"))
cache_read = number(usage.get("cacheRead"))
cache_write = number(usage.get("cacheWrite"))
if total and not (input_tokens or output_tokens or cache_read or cache_write):
input_tokens = total
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
day = local_day(entry.get("timestamp") or message.get("timestamp"))
session_key = path
add_usage(day, session_key, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
try:
proc.wait(timeout=1)
except Exception:
proc.kill()
def scan_native_codex_sessions():
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
roots = [codex_home / "sessions", codex_home / "archived_sessions"]
files = []
cutoff = time.time() - 30 * 24 * 60 * 60
for root in roots:
if not root.exists():
continue
for path in root.rglob("*.jsonl"):
try:
if path.stat().st_mtime >= cutoff:
files.append(path)
except OSError:
pass
for path in files:
current_model = "codex"
try:
with path.open(errors="replace") as handle:
for raw in handle:
try:
entry = json.loads(raw)
except Exception:
continue
if entry.get("type") == "turn_context":
payload = entry.get("payload") or {}
current_model = model_name(payload.get("model") or payload.get("model_slug") or current_model)
continue
payload = entry.get("payload") or entry
if entry.get("type") == "response_item" and isinstance(payload, dict):
payload = payload.get("payload") or payload
if not isinstance(payload, dict):
continue
if payload.get("type") != "token_count":
continue
info = payload.get("info") or {}
# total_token_usage is cumulative for the session. Adding every
# snapshot makes usage grow quadratically, so count the last turn.
usage = info.get("last_token_usage") or {}
cache_read = number(usage.get("cached_input_tokens"))
cache_write = number(usage.get("cache_write_input_tokens"))
# Cached tokens are included in input_tokens, and reasoning tokens
# are included in output_tokens. Keep the cache split without
# counting either category twice.
input_tokens = max(0, number(usage.get("input_tokens")) - cache_read - cache_write)
output_tokens = number(usage.get("output_tokens"))
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
day = local_day(entry.get("timestamp") or path.stat().st_mtime)
add_usage(day, str(path), current_model, input_tokens, output_tokens, cache_read, cache_write)
except Exception:
continue
def rpc_request(proc, request_id, method, params=None, timeout=8):
payload = {"id": request_id, "method": method, "params": params or {}}
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
deadline = time.time() + timeout
while time.time() < deadline:
ready, _, _ = select.select([proc.stdout], [], [], 0.25)
if not ready:
continue
line = proc.stdout.readline()
if not line:
break
try:
message = json.loads(line)
except Exception:
continue
if message.get("id") == request_id:
return message
raise TimeoutError(method)
def fetch_codex_rpc():
result = {
"rateLimitPercent": -1,
"rateLimitLabel": "",
"rateLimitResetAt": "",
"secondaryRateLimitPercent": -1,
"secondaryRateLimitLabel": "",
"secondaryRateLimitResetAt": "",
"tierLabel": "",
}
codex = find_command("codex")
if not codex:
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = "codex not found in PATH"
return result
try:
proc = subprocess.Popen(
[codex, "-s", "read-only", "-a", "untrusted", "app-server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
env=ENV,
)
except Exception as exc:
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = str(exc)
return result
try:
rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-model-usage", "version": "1"}}, timeout=8)
proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
proc.stdin.flush()
account_msg = rpc_request(proc, 2, "account/read", timeout=4)
limits_msg = rpc_request(proc, 3, "account/rateLimits/read", timeout=4)
account = (account_msg.get("result") or {}).get("account") or {}
limits = (limits_msg.get("result") or {}).get("rateLimits") or {}
plan = limits.get("planType") or account.get("planType") or account.get("type") or ""
result["tierLabel"] = str(plan) if plan else ""
def fill(prefix, window):
if not isinstance(window, dict):
return
used = window.get("usedPercent")
if used is not None:
result[prefix + "Percent"] = float(used) / 100.0
mins = number(window.get("windowDurationMins"))
if mins:
if mins == 10080:
result[prefix + "Label"] = "Weekly (7-day)"
elif mins % 60 == 0:
result[prefix + "Label"] = f"{mins // 60}h window"
else:
result[prefix + "Label"] = f"{mins}m window"
reset = window.get("resetsAt")
if reset:
result[prefix + "ResetAt"] = datetime.fromtimestamp(number(reset), timezone.utc).isoformat()
fill("rateLimit", limits.get("primary"))
fill("secondaryRateLimit", limits.get("secondary"))
except Exception as exc:
result["usageStatusText"] = "Codex limits unavailable"
result["authHelpText"] = str(exc)
finally:
try:
proc.terminate()
proc.wait(timeout=1)
except Exception:
try:
proc.kill()
except Exception:
pass
return result
scan_pi_sessions()
scan_native_codex_sessions()
rpc = fetch_codex_rpc()
out = {
"ready": True,
"hasLocalStats": True,
"todayPrompts": today_prompts,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_total_tokens,
"todayTokensByModel": today_tokens_by_model,
"recentDays": [recent[day] for day in recent_dates],
"totalPrompts": total_prompts,
"totalSessions": len(total_sessions),
# Days with any recorded usage, for the all-time "N days" summary.
# The dates travel too: merging snapshots from several machines needs
# their union, which a count alone cannot give.
"activeDays": len(active_days),
"activeDates": sorted(active_days),
"modelUsage": model_usage,
"usageStatusText": usage_status,
"authHelpText": usage_help,
}
out.update(rpc)
print(json.dumps(out, separators=(",", ":")))