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
+714
View File
@@ -0,0 +1,714 @@
import QtQuick
import Quickshell
import Quickshell.Io
// 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: ({})
readonly property string home: Quickshell.env("HOME") || ""
readonly property string usageDir: (Quickshell.env("XDG_STATE_HOME") || home + "/.local/state") + "/omarchy/agents/usage"
// ------------------------------------------------------------- discovery
property var agentIds: []
property var agents: []
property int dataRevision: 0
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.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
repeat: false
onTriggered: root.runSync()
}
Process {
id: syncMkdirProcess
running: false
onRunningChanged: root.updateSyncRunning()
onExited: function(exitCode) {
if (exitCode !== 0) {
if (root.syncConfigured()) root.syncStatusText = "Usage sync mkdir failed"
root.finishSyncRun()
return
}
root.writeSyncSnapshot()
}
}
Process {
id: syncScanProcess
running: false
onRunningChanged: root.updateSyncRunning()
onExited: function(exitCode) {
if (exitCode !== 0 && root.syncConfigured()) root.syncStatusText = "Usage sync scan failed"
root.finishSyncRun()
}
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.parseSyncScanOutput(text)
}
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: if (text.trim() !== "") console.warn("agents/sync", text.trim())
}
}
FileView {
id: syncSnapshotFile
path: root.syncSnapshotPath
watchChanges: false
atomicWrites: true
printErrors: false
}
FileView {
id: hostnameFile
path: "/etc/hostname"
watchChanges: false
printErrors: false
onLoaded: root.detectedHostname = String(text() || "").trim()
}
function parseSyncEnabled(value) {
if (value === true) return true
var text = String(value || "").trim().toLowerCase()
return text === "on" || text === "enabled" || text === "true" || text === "yes" || text === "1"
}
function syncConfigured() {
return root.syncEnabled === true && String(root.syncDir || "").trim() !== ""
}
function syncSettingsChanged() {
if (syncConfigured()) {
scheduleSync()
} else {
syncDebounce.stop()
syncRequestedWhileRunning = false
aggregateData = ({})
syncStatusText = ""
syncRevision++
}
}
function updateSyncRunning() {
root.syncRunning = syncMkdirProcess.running || syncScanProcess.running
}
function scheduleSync() {
if (!syncConfigured()) return
syncDebounce.restart()
}
function runSync() {
if (!syncConfigured()) return
if (root.syncRunning) {
syncRequestedWhileRunning = true
return
}
syncRequestedWhileRunning = false
syncStatusText = ""
syncMkdirProcess.command = ["mkdir", "-p", root.syncEffectiveDir]
syncMkdirProcess.running = true
}
function writeSyncSnapshot() {
if (!syncConfigured()) {
finishSyncRun()
return
}
syncSnapshotFile.setText(JSON.stringify(localSnapshot(), null, 2) + "\n")
Qt.callLater(root.startSyncScan)
}
function startSyncScan() {
if (!syncConfigured()) {
finishSyncRun()
return
}
var script = "dir=$0; [[ -d \"$dir\" ]] || exit 0; shopt -s nullglob; for f in \"$dir\"/*.json; do [[ -f \"$f\" ]] || continue; printf '===%s===\\n' \"$f\"; cat \"$f\"; printf '\\n=== EOM ===\\n'; done"
syncScanProcess.command = ["bash", "-c", script, root.syncEffectiveDir]
syncScanProcess.running = true
}
function finishSyncRun() {
if (syncRequestedWhileRunning && syncConfigured()) {
syncRequestedWhileRunning = false
scheduleSync()
}
}
function expandPath(path) {
var value = String(path || "").trim()
if (value === "") return ""
if (value === "~") return home
if (value.indexOf("~/") === 0) return home + value.substring(1)
if (value.indexOf("$HOME/") === 0) return home + value.substring(5)
if (value.charAt(0) !== "/") return home + "/" + value
return value
}
function safeDeviceId(raw) {
var value = String(raw || "").trim()
if (value === "") value = Quickshell.env("HOSTNAME") || root.detectedHostname || Quickshell.env("HOST") || Quickshell.env("USER") || "device"
value = value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "")
if (value === "") value = "device"
return value.length > 80 ? value.substring(0, 80) : value
}
function safeSnapshotFileName(rawFileName, rawDeviceId) {
var value = String(rawFileName || "").trim()
if (value === "") value = safeDeviceId(rawDeviceId) + ".json"
value = value.split("/").pop().replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "")
if (value === "") value = safeDeviceId(rawDeviceId) + ".json"
if (!/\.json$/i.test(value)) value += ".json"
return value.length > 100 ? value.substring(0, 95) + ".json" : value
}
function parseSyncScanOutput(output) {
var lines = String(output || "").split("\n")
var snapshots = []
var currentPath = ""
var currentJson = []
function flush() {
if (currentPath === "") return
var raw = currentJson.join("\n").trim()
try {
var parsed = JSON.parse(raw)
if (parsed && parsed.providers) snapshots.push(parsed)
} catch (e) {
console.warn("agents/sync", "Ignoring bad snapshot", currentPath, e)
}
currentPath = ""
currentJson = []
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i]
var start = line.match(/^===(.+)===$/)
if (start && line !== "=== EOM ===") {
flush()
currentPath = start[1]
currentJson = []
continue
}
if (line === "=== EOM ===") {
flush()
continue
}
if (currentPath !== "") currentJson.push(line)
}
flush()
aggregateData = aggregateSnapshots(snapshots)
syncStatusText = ""
syncRevision++
}
function cloneValue(value, fallback) {
if (value === undefined || value === null) return fallback
try {
return JSON.parse(JSON.stringify(value))
} catch (e) {
return fallback
}
}
function numberValue(value) {
var n = Number(value || 0)
return isFinite(n) ? Math.round(n) : 0
}
function dateString(date) {
var y = date.getFullYear()
var m = String(date.getMonth() + 1).padStart(2, "0")
var d = String(date.getDate()).padStart(2, "0")
return y + "-" + m + "-" + d
}
function recentDateStrings() {
var result = []
for (var offset = 6; offset >= 0; offset--) {
var date = new Date()
date.setDate(date.getDate() - offset)
result.push(dateString(date))
}
return result
}
function emptyTokenBucket() {
return { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }
}
function addObjectNumbers(target, source) {
if (!source) return
for (var key in source) target[key] = numberValue(target[key]) + numberValue(source[key])
}
function aggregateSnapshots(snapshots) {
var dates = recentDateStrings()
var devices = {}
var providers = {}
function providerAcc(id) {
if (providers[id]) return providers[id]
var recentByDay = {}
for (var d = 0; d < dates.length; d++) recentByDay[dates[d]] = 0
providers[id] = {
providerId: id,
providerName: "",
ready: false,
hasLocalStats: false,
todayPrompts: 0,
todaySessions: 0,
todayTotalTokens: 0,
todayTokensByModel: ({}),
recentByDay: recentByDay,
totalPrompts: 0,
totalSessions: 0,
activeDays: 0,
activeDates: ({}),
modelUsage: ({}),
devices: ({})
}
return providers[id]
}
for (var i = 0; i < snapshots.length; i++) {
var snapshot = snapshots[i]
var device = safeDeviceId(snapshot.deviceId || "device")
devices[device] = true
var snapshotProviders = snapshot.providers || {}
for (var providerId in snapshotProviders) {
var stats = snapshotProviders[providerId] || {}
var acc = providerAcc(String(providerId))
acc.devices[device] = true
if (stats.providerName && acc.providerName === "") acc.providerName = String(stats.providerName)
acc.ready = acc.ready || stats.ready === true
acc.hasLocalStats = acc.hasLocalStats || stats.hasLocalStats !== false
acc.todayPrompts += numberValue(stats.todayPrompts)
acc.todaySessions += numberValue(stats.todaySessions)
acc.todayTotalTokens += numberValue(stats.todayTotalTokens)
acc.totalPrompts += numberValue(stats.totalPrompts)
acc.totalSessions += numberValue(stats.totalSessions)
// Active days overlap between machines, so union the dates rather than
// summing counts. Snapshots written before activeDates existed only
// carry a count; the widest one stands in for them.
var activeDates = Array.isArray(stats.activeDates) ? stats.activeDates : []
for (var ad = 0; ad < activeDates.length; ad++) acc.activeDates[String(activeDates[ad])] = true
acc.activeDays = Math.max(acc.activeDays, numberValue(stats.activeDays))
addObjectNumbers(acc.todayTokensByModel, stats.todayTokensByModel || {})
var recent = Array.isArray(stats.recentDays) ? stats.recentDays : []
for (var r = 0; r < recent.length; r++) {
var day = recent[r] || {}
var date = String(day.date || "")
if (acc.recentByDay[date] !== undefined) acc.recentByDay[date] += numberValue(day.messageCount)
}
var usage = stats.modelUsage || {}
for (var modelId in usage) {
var bucket = acc.modelUsage[modelId]
if (!bucket) bucket = acc.modelUsage[modelId] = emptyTokenBucket()
var source = usage[modelId] || {}
bucket.inputTokens += numberValue(source.inputTokens)
bucket.outputTokens += numberValue(source.outputTokens)
bucket.cacheReadInputTokens += numberValue(source.cacheReadInputTokens)
bucket.cacheCreationInputTokens += numberValue(source.cacheCreationInputTokens)
}
}
}
var outProviders = {}
for (var id in providers) {
var acc = providers[id]
var recentDays = []
for (var di = 0; di < dates.length; di++) recentDays.push({ date: dates[di], messageCount: acc.recentByDay[dates[di]] || 0 })
var providerDevices = Object.keys(acc.devices).sort()
outProviders[id] = {
providerId: acc.providerId,
providerName: acc.providerName,
ready: acc.ready || providerDevices.length > 0,
hasLocalStats: acc.hasLocalStats,
todayPrompts: acc.todayPrompts,
todaySessions: acc.todaySessions,
todayTotalTokens: acc.todayTotalTokens,
todayTokensByModel: acc.todayTokensByModel,
recentDays: recentDays,
totalPrompts: acc.totalPrompts,
totalSessions: acc.totalSessions,
activeDays: Math.max(acc.activeDays, Object.keys(acc.activeDates).length),
modelUsage: acc.modelUsage,
deviceCount: providerDevices.length,
devices: providerDevices
}
}
return {
schemaVersion: 1,
updatedAt: new Date().toISOString(),
updatedAtMs: Date.now(),
deviceCount: Object.keys(devices).length,
devices: Object.keys(devices).sort(),
providers: outProviders
}
}
// 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: 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 < 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,
deviceId: syncEffectiveDeviceId,
updatedAt: new Date().toISOString(),
providers: providerMap
}
}
function syncedStatsFor(providerId) {
var rev = syncRevision
if (!syncConfigured() || !aggregateData || !aggregateData.providers) return null
return aggregateData.providers[providerId] || null
}
// ---------------------------------------------------------------- format
function formatTokenCount(n) {
if (n === undefined || n === null) return "0"
if (n >= 1e9) return (n / 1e9).toFixed(1) + "B"
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"
return String(n)
}
function modelWordCase(word) {
if (word === "gpt") return "GPT"
return word.charAt(0).toUpperCase() + word.slice(1)
}
// Model ids arrive hyphenated with the version split across segments
// (`claude-opus-4-8`, `gpt-5.6-sol`). Rejoin the numeric run into one
// version and title-case the words around it.
function friendlyModelName(id) {
if (!id) return "Unknown"
var name = String(id).replace(/^claude-/, "").replace(/-\d{8}$/, "")
var parts = name.split("-")
var words = []
var version = []
for (var i = 0; i < parts.length; i++) {
var part = parts[i]
if (part === "") continue
if (/^\d/.test(part)) {
version.push(part)
continue
}
if (version.length > 0) {
words.push(version.join("."))
version = []
}
words.push(modelWordCase(part))
}
if (version.length > 0) words.push(version.join("."))
return words.length > 0 ? words.join(" ") : "Unknown"
}
}