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:
co-authored by
Claude Fable 5
parent
c3bd4a86ae
commit
bb8d2f2cb3
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
Panel {
|
||||
id: root
|
||||
moduleName: "omarchy.agents"
|
||||
ipcTarget: "omarchy.agents"
|
||||
manageIpc: false
|
||||
|
||||
readonly property color foreground: bar ? bar.foreground : Color.foreground
|
||||
readonly property color urgent: bar ? bar.urgent : Color.urgent
|
||||
readonly property color dim: Qt.darker(foreground, 1.55)
|
||||
readonly property color surface: Color.popups.background
|
||||
readonly property color track: Style.selectedFillFor(foreground, Color.accent)
|
||||
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
||||
|
||||
readonly property var providers: usage.enabledProviders
|
||||
// The selection follows the provider, not the slot it happens to sit in: a
|
||||
// provider whose first scan lands while the panel is open would otherwise
|
||||
// shift the list underneath you and swap out what you were reading.
|
||||
property string selectedProviderId: ""
|
||||
readonly property int providerIndex: {
|
||||
for (var i = 0; i < providers.length; i++)
|
||||
if (providers[i].providerId === selectedProviderId) return i
|
||||
return 0
|
||||
}
|
||||
readonly property var provider: providers.length > 0 ? providers[providerIndex] : null
|
||||
|
||||
property bool cursorActive: false
|
||||
|
||||
// Countdowns and "updated" read this instead of Date.now() so the
|
||||
// panel keeps telling the truth while it sits open.
|
||||
property double nowMs: Date.now()
|
||||
|
||||
readonly property var limits: limitWindows(provider)
|
||||
readonly property var models: modelRows(provider)
|
||||
readonly property var headline: bindingWindow(provider)
|
||||
readonly property bool alarming: !!headline && headline.percent >= 0.9
|
||||
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) }
|
||||
function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) }
|
||||
|
||||
function selectProvider(index) {
|
||||
if (providers.length === 0) return
|
||||
var wrapped = ((index % providers.length) + providers.length) % providers.length
|
||||
selectedProviderId = providers[wrapped].providerId
|
||||
}
|
||||
|
||||
function refreshNow() {
|
||||
usage.refreshAll(true)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- limits
|
||||
//
|
||||
// Both providers report the same two shapes: a short rolling session window
|
||||
// and a long weekly one. Everything below normalizes them into one record so
|
||||
// the meters and the hero speak a single language.
|
||||
|
||||
// Claude spells its windows out ("Session (5-hour)"), Codex abbreviates
|
||||
// them ("5h window", "30m window"). Both have to land on the same record.
|
||||
function windowIsLong(text) {
|
||||
return text.indexOf("week") >= 0 || text.indexOf("7-day") >= 0 || text.indexOf("seven") >= 0
|
||||
|| text.indexOf("month") >= 0 || text.indexOf("30-day") >= 0
|
||||
}
|
||||
|
||||
function windowSpanMs(label) {
|
||||
var text = String(label || "").toLowerCase()
|
||||
if (text.indexOf("month") >= 0 || text.indexOf("30-day") >= 0) return 30 * 24 * 3600 * 1000
|
||||
if (windowIsLong(text)) return 7 * 24 * 3600 * 1000
|
||||
var hours = text.match(/(\d+)\s*-?\s*h(?:our)?\b/)
|
||||
if (hours) return Number(hours[1]) * 3600 * 1000
|
||||
var minutes = text.match(/(\d+)\s*-?\s*m(?:in(?:ute)?s?)?\b/)
|
||||
if (minutes) return Number(minutes[1]) * 60 * 1000
|
||||
return 0
|
||||
}
|
||||
|
||||
function windowTitle(label) {
|
||||
var text = String(label || "").toLowerCase()
|
||||
if (text.indexOf("month") >= 0) return "Monthly"
|
||||
if (windowIsLong(text)) return "Weekly"
|
||||
if (text.indexOf("session") >= 0 || windowSpanMs(label) > 0) return "Session"
|
||||
var plain = String(label || "").replace(/\s*\(.*\)\s*/, "").trim()
|
||||
return plain === "" ? "Limit" : plain
|
||||
}
|
||||
|
||||
function limitWindow(label, percent, resetAt) {
|
||||
return {
|
||||
title: windowTitle(label),
|
||||
percent: Number(percent),
|
||||
resetAt: String(resetAt || "")
|
||||
}
|
||||
}
|
||||
|
||||
function limitWindows(p) {
|
||||
if (!p) return []
|
||||
var out = []
|
||||
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
|
||||
}
|
||||
|
||||
// The window that decides how much room is left — the fullest one, since
|
||||
// that is what stops the next prompt.
|
||||
function bindingWindow(p) {
|
||||
var windows = limitWindows(p)
|
||||
var best = null
|
||||
for (var i = 0; i < windows.length; i++) {
|
||||
if (!best || windows[i].percent > best.percent) best = windows[i]
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function resetMsFor(w) {
|
||||
if (!w || w.resetAt === "") return -1
|
||||
var ms = new Date(w.resetAt).getTime()
|
||||
return isFinite(ms) ? ms - root.nowMs : -1
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (!(ms > 0)) return "now"
|
||||
var minutes = Math.floor(ms / 60000)
|
||||
var hours = Math.floor(minutes / 60)
|
||||
var days = Math.floor(hours / 24)
|
||||
if (days > 0) return days + "d " + (hours % 24) + "h"
|
||||
if (hours > 0) return hours + "h " + (minutes % 60) + "m"
|
||||
return Math.max(1, minutes) + "m"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- content
|
||||
|
||||
// The plan you pay for, under the name of the tool it pays for. Limits live
|
||||
// in their own section; the hero just says what this is.
|
||||
function heroMeta(p) {
|
||||
if (!p) return ""
|
||||
if (String(p.usageStatusText || "") !== "") return p.usageStatusText
|
||||
var tier = String(p.tierLabel || "")
|
||||
if (tier === "") return "Subscription"
|
||||
return tier.charAt(0).toUpperCase() + tier.slice(1)
|
||||
}
|
||||
|
||||
// Local calendar date, recomputed from nowMs so a panel left open across
|
||||
// midnight moves the "Today" row with the clock.
|
||||
function todayDate() {
|
||||
var now = new Date(root.nowMs)
|
||||
return now.getFullYear()
|
||||
+ "-" + String(now.getMonth() + 1).padStart(2, "0")
|
||||
+ "-" + String(now.getDate()).padStart(2, "0")
|
||||
}
|
||||
|
||||
function dayName(date) {
|
||||
var parsed = new Date(String(date || "") + "T00:00:00")
|
||||
if (isNaN(parsed.getTime())) return String(date || "")
|
||||
return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][parsed.getDay()]
|
||||
}
|
||||
|
||||
function dayLabel(date, today) {
|
||||
if (today) return "Today"
|
||||
return dayName(date)
|
||||
}
|
||||
|
||||
function dayTooltip(day, today) {
|
||||
if (!day) return ""
|
||||
var parsed = new Date(String(day.date) + "T00:00:00")
|
||||
var label = isNaN(parsed.getTime())
|
||||
? String(day.date)
|
||||
: dayName(day.date) + " " + (parsed.getMonth() + 1) + "/" + parsed.getDate()
|
||||
var text = label + " · " + usage.formatTokenCount(Number(day.messageCount || 0)) + " tokens"
|
||||
// Prompt and session counts only exist for today, so they ride along here
|
||||
// instead of taking a section of their own.
|
||||
if (today && provider)
|
||||
text += " · " + Number(provider.todayPrompts || 0) + " prompts · "
|
||||
+ Number(provider.todaySessions || 0) + " sessions"
|
||||
return text
|
||||
}
|
||||
|
||||
function weekPeak(p) {
|
||||
var days = p ? (p.recentDays || []) : []
|
||||
var peak = 0
|
||||
for (var i = 0; i < days.length; i++) peak = Math.max(peak, Number(days[i].messageCount || 0))
|
||||
return peak
|
||||
}
|
||||
|
||||
function modelRows(p) {
|
||||
var usageByModel = p ? (p.modelUsage || {}) : {}
|
||||
var rows = []
|
||||
for (var id in usageByModel) {
|
||||
var bucket = usageByModel[id] || {}
|
||||
var input = Number(bucket.inputTokens || 0)
|
||||
var output = Number(bucket.outputTokens || 0)
|
||||
var cacheRead = Number(bucket.cacheReadInputTokens || 0)
|
||||
var cacheWrite = Number(bucket.cacheCreationInputTokens || 0)
|
||||
rows.push({
|
||||
name: usage.friendlyModelName(id),
|
||||
total: input + output + cacheRead + cacheWrite,
|
||||
input: input,
|
||||
output: output,
|
||||
cacheRead: cacheRead,
|
||||
cacheWrite: cacheWrite
|
||||
})
|
||||
}
|
||||
rows.sort(function(a, b) { return b.total - a.total })
|
||||
return rows.slice(0, 4)
|
||||
}
|
||||
|
||||
function modelTooltip(row) {
|
||||
if (!row) return ""
|
||||
return "In " + usage.formatTokenCount(row.input)
|
||||
+ " · out " + usage.formatTokenCount(row.output)
|
||||
+ " · cache read " + usage.formatTokenCount(row.cacheRead)
|
||||
+ " · cache write " + usage.formatTokenCount(row.cacheWrite)
|
||||
}
|
||||
|
||||
// Only speaks up when the numbers cover more than this machine.
|
||||
function footerText() {
|
||||
if (usage.syncStatusText !== "") return usage.syncStatusText
|
||||
if (provider && provider.syncEnabled && provider.syncDeviceCount > 0)
|
||||
return "Merged from " + provider.syncDeviceCount + " device" + (provider.syncDeviceCount === 1 ? "" : "s")
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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
|
||||
return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
|
||||
function colorLuminance(color) {
|
||||
return 0.2126 * colorChannelLuminance(color.r)
|
||||
+ 0.7152 * colorChannelLuminance(color.g)
|
||||
+ 0.0722 * colorChannelLuminance(color.b)
|
||||
}
|
||||
|
||||
// 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
|
||||
// is invisible, so the icon appears the moment the first scan finds usage and
|
||||
// stays away entirely on a machine that has never run either CLI.
|
||||
visible: providers.length > 0
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
onProviderIndexChanged: if (panelFlick) panelFlick.contentY = 0
|
||||
onOpenedChanged: if (opened) {
|
||||
cursorActive = false
|
||||
nowMs = Date.now()
|
||||
if (panelFlick) panelFlick.contentY = 0
|
||||
usage.refreshLimits()
|
||||
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
|
||||
Main {
|
||||
id: usage
|
||||
settings: root.settings
|
||||
}
|
||||
|
||||
// Cheap enough to keep running: it only re-evaluates text bindings, and a
|
||||
// stale "resets in 2h" on a panel that is open is worse than a timer.
|
||||
Timer {
|
||||
interval: 30000
|
||||
running: root.opened
|
||||
repeat: true
|
||||
onTriggered: root.nowMs = Date.now()
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: root.ipcTarget
|
||||
function open(): void { root.open() }
|
||||
function close(): void { root.close() }
|
||||
function show(): void { root.open() }
|
||||
function hide(): void { root.close() }
|
||||
function toggle(): void { root.toggle() }
|
||||
function refresh(): string { root.refreshNow(); return "ok" }
|
||||
function next(): string { root.selectProvider(root.providerIndex + 1); return "ok" }
|
||||
}
|
||||
|
||||
BarIconButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: ""
|
||||
active: root.alarming
|
||||
onPressed: function(buttonCode) {
|
||||
if (buttonCode === Qt.RightButton) root.refreshNow()
|
||||
else if (buttonCode === Qt.MiddleButton) root.selectProvider(root.providerIndex + 1)
|
||||
else root.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardPanel {
|
||||
id: panel
|
||||
anchorItem: button
|
||||
owner: root
|
||||
bar: root.bar
|
||||
open: root.opened
|
||||
focusTarget: keyCatcher
|
||||
contentWidth: panel.fittedContentWidth(Style.space(380))
|
||||
// Taller than the control panels on purpose: this one is a dashboard, and
|
||||
// the whole point is reading limits and history without scrolling.
|
||||
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(640))
|
||||
|
||||
PanelKeyCatcher {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
|
||||
onMoveRequested: function(dx, dy) {
|
||||
if (dx !== 0) {
|
||||
root.cursorActive = true
|
||||
root.selectProvider(root.providerIndex + dx)
|
||||
}
|
||||
if (dy !== 0)
|
||||
panelFlick.contentY = root.clamp(panelFlick.contentY + dy * Style.space(56), 0,
|
||||
Math.max(0, panelFlick.contentHeight - panelFlick.height))
|
||||
}
|
||||
onActivateRequested: root.refreshNow()
|
||||
onCloseRequested: root.close()
|
||||
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||||
onTextKey: function(t) { if (t === "r" || t === "R") root.refreshNow() }
|
||||
|
||||
Flickable {
|
||||
id: panelFlick
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: column.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
flickableDirection: Flickable.VerticalFlick
|
||||
interactive: contentHeight > height
|
||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
|
||||
Column {
|
||||
id: column
|
||||
width: panelFlick.width
|
||||
spacing: Style.space(12)
|
||||
|
||||
// ---------- Hero: provider mark · name · plan ----------
|
||||
PanelHero {
|
||||
id: hero
|
||||
visible: !!root.provider
|
||||
width: parent.width
|
||||
title: root.provider ? root.provider.providerName : ""
|
||||
meta: root.heroMeta(root.provider)
|
||||
foreground: root.foreground
|
||||
fontFamily: root.fontFamily
|
||||
|
||||
iconComponent: Component {
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.providers.length === 0
|
||||
width: parent.width
|
||||
topPadding: Style.space(24)
|
||||
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
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
// ---------- Provider switch ----------
|
||||
Row {
|
||||
id: providerSwitch
|
||||
visible: root.providers.length > 1
|
||||
width: parent.width
|
||||
spacing: Style.spacing.md
|
||||
|
||||
readonly property real cellWidth: root.providers.length > 0
|
||||
? (width - spacing * (root.providers.length - 1)) / root.providers.length
|
||||
: 0
|
||||
|
||||
Repeater {
|
||||
model: root.providers
|
||||
|
||||
Button {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: providerSwitch.cellWidth
|
||||
text: modelData.providerName
|
||||
selected: index === root.providerIndex
|
||||
hasCursor: root.cursorActive && index === root.providerIndex
|
||||
bordered: true
|
||||
foreground: root.foreground
|
||||
fontFamily: root.fontFamily
|
||||
fontSize: Style.font.bodySmall
|
||||
verticalPadding: Style.spacing.controlPaddingY
|
||||
onClicked: {
|
||||
root.cursorActive = true
|
||||
root.selectProvider(index)
|
||||
}
|
||||
onHovered: function(isHovered) { if (isHovered) root.cursorActive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Status ----------
|
||||
BorderSurface {
|
||||
visible: !!root.provider && String(root.provider.usageStatusText || "") !== ""
|
||||
width: parent.width
|
||||
implicitHeight: statusText.implicitHeight + Style.spacing.xl * 2
|
||||
color: root.alpha(root.urgent, 0.10)
|
||||
borderSpec: Border.flat(root.alpha(root.urgent, 0.35), 1)
|
||||
radius: Style.cornerRadius
|
||||
|
||||
Text {
|
||||
id: statusText
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Style.space(12)
|
||||
anchors.rightMargin: Style.space(12)
|
||||
text: root.provider ? String(root.provider.authHelpText || "") : ""
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Limits ----------
|
||||
PanelSeparator {
|
||||
visible: limitsSection.visible
|
||||
foreground: root.foreground
|
||||
}
|
||||
|
||||
Column {
|
||||
id: limitsSection
|
||||
visible: root.limits.length > 0
|
||||
width: parent.width
|
||||
spacing: Style.space(10)
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "LIMITS"
|
||||
foreground: root.foreground
|
||||
fontFamily: root.fontFamily
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.limits
|
||||
|
||||
LimitRow {
|
||||
required property var modelData
|
||||
width: limitsSection.width
|
||||
window: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Usage ----------
|
||||
PanelSeparator {
|
||||
visible: usageSection.visible
|
||||
foreground: root.foreground
|
||||
}
|
||||
|
||||
Column {
|
||||
id: usageSection
|
||||
visible: !!root.provider && root.provider.recentDays && root.provider.recentDays.length > 0
|
||||
width: parent.width
|
||||
spacing: Style.spacing.md
|
||||
|
||||
readonly property var days: root.provider ? (root.provider.recentDays || []) : []
|
||||
readonly property real peak: Math.max(1, root.weekPeak(root.provider))
|
||||
|
||||
PanelSectionHeader {
|
||||
width: parent.width
|
||||
text: "TOKENS BY DAY"
|
||||
foreground: root.foreground
|
||||
fontFamily: root.fontFamily
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: usageSection.days
|
||||
|
||||
DayRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: usageSection.width
|
||||
day: modelData
|
||||
ratio: Number(modelData.messageCount || 0) / usageSection.peak
|
||||
// By date, not by position: the Claude stats-cache fallback can
|
||||
// hand us a window that stops short of today.
|
||||
today: String(modelData.date || "") === root.todayDate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Models ----------
|
||||
PanelSeparator {
|
||||
visible: modelSection.visible
|
||||
foreground: root.foreground
|
||||
}
|
||||
|
||||
Column {
|
||||
id: modelSection
|
||||
visible: root.models.length > 0
|
||||
width: parent.width
|
||||
spacing: Style.spacing.md
|
||||
|
||||
PanelSectionHeader {
|
||||
width: parent.width
|
||||
text: "TOKENS BY MODEL"
|
||||
foreground: root.foreground
|
||||
fontFamily: root.fontFamily
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.models
|
||||
|
||||
ModelRow {
|
||||
required property var modelData
|
||||
width: modelSection.width
|
||||
row: modelData
|
||||
// Scaled to the heaviest model, so the top row is always full —
|
||||
// the same scale-to-peak the weekly chart uses for its busiest day.
|
||||
share: modelData.total / Math.max(1, root.models[0].total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: text !== ""
|
||||
width: parent.width
|
||||
topPadding: Style.space(2)
|
||||
text: root.footerText()
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A limit window: label and percentage, meter, and reset countdown.
|
||||
component LimitRow: Column {
|
||||
id: limitRow
|
||||
property var window: null
|
||||
|
||||
readonly property bool alarming: window && window.percent >= 0.9
|
||||
|
||||
spacing: Style.space(6)
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: Math.max(limitLabel.implicitHeight, limitValue.implicitHeight)
|
||||
|
||||
Text {
|
||||
id: limitLabel
|
||||
text: limitRow.window ? limitRow.window.title : ""
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
id: limitValue
|
||||
text: limitRow.window && limitRow.window.percent >= 0
|
||||
? Math.round(limitRow.window.percent * 100) + "%"
|
||||
: "—"
|
||||
color: limitRow.alarming ? root.urgent : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
Meter {
|
||||
width: parent.width
|
||||
value: limitRow.window ? limitRow.window.percent : -1
|
||||
alarming: limitRow.alarming
|
||||
}
|
||||
|
||||
Text {
|
||||
id: resetText
|
||||
width: parent.width
|
||||
text: {
|
||||
var remainingMs = root.resetMsFor(limitRow.window)
|
||||
return remainingMs > 0 ? "Resets in " + root.formatDuration(remainingMs) : ""
|
||||
}
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
// Rounded track showing the percentage of the allowance used.
|
||||
component Meter: Item {
|
||||
id: meter
|
||||
property real value: -1
|
||||
property bool alarming: false
|
||||
property real thickness: Math.max(Style.space(4), Math.round(Style.spacing.controlHeight * 0.14))
|
||||
|
||||
implicitHeight: thickness
|
||||
|
||||
Rectangle {
|
||||
id: meterTrack
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: root.track
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: meterTrack.left
|
||||
anchors.verticalCenter: meterTrack.verticalCenter
|
||||
height: meterTrack.height
|
||||
radius: meterTrack.radius
|
||||
width: meterTrack.width * root.clamp(meter.value, 0, 1)
|
||||
color: meter.alarming ? root.urgent : root.foreground
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// One row per day: label, bar, tokens. Today is picked out in full
|
||||
// foreground so the week reads as a run-up to right now.
|
||||
component DayRow: Item {
|
||||
id: dayRow
|
||||
property var day: null
|
||||
property real ratio: 0
|
||||
property bool today: false
|
||||
|
||||
implicitHeight: Math.max(dayLabel.implicitHeight, dayValue.implicitHeight) + Style.spacing.sm
|
||||
|
||||
Text {
|
||||
id: dayLabel
|
||||
text: root.dayLabel(dayRow.day ? dayRow.day.date : "", dayRow.today)
|
||||
color: dayRow.today ? root.foreground : root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.bold: dayRow.today
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Style.space(52)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: dayTrack
|
||||
anchors.left: dayLabel.right
|
||||
anchors.right: dayValue.left
|
||||
anchors.leftMargin: Style.space(8)
|
||||
anchors.rightMargin: Style.space(10)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: Math.max(Style.space(4), Math.round(Style.spacing.controlHeight * 0.14))
|
||||
radius: height / 2
|
||||
color: root.track
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
width: parent.width * root.clamp(dayRow.ratio, 0, 1)
|
||||
color: dayRow.today ? root.foreground : root.alpha(root.foreground, 0.55)
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: dayValue
|
||||
text: usage.formatTokenCount(dayRow.day ? Number(dayRow.day.messageCount || 0) : 0)
|
||||
color: dayRow.today ? root.foreground : root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignRight
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Style.space(52)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: dayHover
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
|
||||
PanelToolTip {
|
||||
visible: dayHover.containsMouse
|
||||
text: root.dayTooltip(dayRow.day, dayRow.today)
|
||||
fontFamily: root.fontFamily
|
||||
}
|
||||
}
|
||||
|
||||
// Model rows read as a table: the share bar fills the row behind the label
|
||||
// instead of stacking under it, which keeps the whole dashboard on one screen.
|
||||
component ModelRow: Item {
|
||||
id: modelRow
|
||||
property var row: null
|
||||
property real share: 0
|
||||
|
||||
implicitHeight: modelName.implicitHeight + Style.spacing.lg
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Style.cornerRadius
|
||||
color: root.alpha(root.foreground, 0.05)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * root.clamp(modelRow.share, 0, 1)
|
||||
radius: Style.cornerRadius
|
||||
color: root.alpha(root.foreground, 0.14)
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: modelName
|
||||
text: modelRow.row ? modelRow.row.name : ""
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
elide: Text.ElideRight
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(8)
|
||||
anchors.right: modelTokens.left
|
||||
anchors.rightMargin: Style.space(8)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
id: modelTokens
|
||||
text: modelRow.row ? usage.formatTokenCount(modelRow.row.total) : ""
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.bold: true
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.space(8)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: modelHover
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
|
||||
PanelToolTip {
|
||||
visible: modelHover.containsMouse
|
||||
text: root.modelTooltip(modelRow.row)
|
||||
fontFamily: root.fontFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# Agents
|
||||
|
||||
One bar icon and one panel for every AI coding subscription on the machine.
|
||||
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 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
|
||||
bolded at the bottom. Hover today for its prompt and session count.
|
||||
- **Tokens by model** — tokens per model with the bar behind each row scaled
|
||||
to the heaviest model,
|
||||
the same way the weekly chart scales to its busiest day. Hover for the
|
||||
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 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 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.agents`.
|
||||
|
||||
## Data
|
||||
|
||||
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` 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. 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.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.agents <key> <value>`:
|
||||
|
||||
| Key | Default | What it does |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `syncDeviceId` | hostname | Stable device name inside the snapshot |
|
||||
|
||||
Numbers need `--json`, or they land in `shell.json` as strings:
|
||||
|
||||
```bash
|
||||
omarchy bar set omarchy.agents refreshIntervalSec 300 --json
|
||||
omarchy bar set omarchy.agents syncDir '~/Sync/agent-usage'
|
||||
```
|
||||
|
||||
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.agents providers '{
|
||||
"claude": { "enabled": true },
|
||||
"codex": { "enabled": false }
|
||||
}' --json
|
||||
```
|
||||
|
||||
`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 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.
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="257" preserveAspectRatio="xMidYMid" viewBox="0 0 256 257"><path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="#111" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="#fff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "omarchy.agents",
|
||||
"name": "Agents",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"license": "MIT",
|
||||
"description": "Claude Code and Codex usage, limits, and pace in a native Omarchy bar panel.",
|
||||
"kinds": ["bar-widget"],
|
||||
"activation": "on-demand",
|
||||
"entryPoints": {
|
||||
"barWidget": "Panel.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"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": ["agents", "model-usage"],
|
||||
"allowMultiple": false,
|
||||
"defaults": {
|
||||
"providers": {
|
||||
"claude": { "enabled": true },
|
||||
"codex": { "enabled": true }
|
||||
},
|
||||
"refreshIntervalSec": 900,
|
||||
"syncMode": "Off",
|
||||
"syncDir": "",
|
||||
"syncFileName": "",
|
||||
"syncDeviceId": ""
|
||||
},
|
||||
"schema": [
|
||||
{ "key": "refreshIntervalSec", "type": "integer", "label": "Refresh interval (seconds)", "min": 30, "max": 3600, "step": 30, "defaultValue": 900 },
|
||||
{ "key": "syncMode", "type": "enum", "label": "Synced aggregation", "options": ["Off", "On"], "defaultValue": "Off", "description": "When On, write this machine's local usage snapshot and merge snapshots from other machines." },
|
||||
{ "key": "syncDir", "type": "path", "label": "Sync folder", "defaultValue": "", "description": "A folder synced by Syncthing, Dropbox, rsync, etc." },
|
||||
{ "key": "syncFileName", "type": "string", "label": "Snapshot file name", "defaultValue": "", "description": "Optional. Defaults to <hostname>.json. Use a different file name on each machine, such as laptop.json or desktop.json." },
|
||||
{ "key": "syncDeviceId", "type": "string", "label": "Device id", "defaultValue": "", "description": "Optional stable device name used inside synced aggregate snapshots." }
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user