Files
omarchy/shell/plugins/agents/Main.qml
T
+14 75250d37ac Fix Codex limits, Claude counting, and agent usage reliability from community PRs (#14049)
* Read Codex app-server replies from the raw fd (#13703)

* Resolve Codex through mise which instead of running the lazy launcher (#13109)

* Skip the Codex app-server probe when there are no credentials (#13106)

Adapted: credentials are checked in the home being probed rather than in
the CODEX_HOME environment variable, since each registered account is
probed in its own home, so a signed-out secondary account isn't hidden
behind the primary's login. A home without credentials reports "Waiting
for auth" like any other signed-out home. The credentials store setting is
read with tomllib, so a single-quoted value counts too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the Codex CLI's own error when its app-server dies (#8977)

Detect an app-server that exits or stops answering, and report the end of
its stderr instead of a bare RPC method name. Rebased onto the raw-fd
reply reader; the switch from "-a on-request" to "-a never" is left out,
keeping the current approval flags.

* Count pi sessions when HOME is a git checkout (#13209)

* Count only OpenAI-backed native sessions as Codex usage (#12032)

* Deduplicate Pi usage across forked sessions (#8602)

* Skip unchanged native Codex token snapshots (#10531)

* Count omp and pi profile sessions in the agent usage collectors (#9546)

`omp --profile=<name>` (and pi's equivalent) relocates the whole agent
tree under <base>/profiles/<name>/. The Claude and Codex collectors only
ever scanned <base>/agent/sessions, so a subscription driven entirely
through a profile was invisible to the agents panel: no tokens by day, no
tokens by model, no prompt or session counts.

Discover the profile roots alongside the default one. Sessions are keyed
by file path, so a profile adds sessions instead of double-counting the
default root, and a missing or unreadable profiles directory leaves the
existing behavior untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zFbJcDEEpV5BAmsH6kAB3

* Skip unrelated Codex session lines before JSON parsing (#12803)

Adapted: session_meta lines also pass the pre-filter, since the provider
filter from #12032 reads them to skip rollouts served by a non-OpenAI
provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read only the Codex session files that changed since the last scan (#12595)

Native Codex rollouts keep per-file totals between runs, replayed while a
file's mtime and size are unchanged. Rebased onto the session_meta
provider filter, snapshot dedup, and line pre-filter, which now live in
the per-file reader. pi and omp sessions are left out of the per-file
cache: a forked pi session repeats its parent's messages, so they are
deduplicated across the whole tree on every scan.

* Count streamed Claude messages by their highest-output usage line (#10606)

Claude Code writes a streamed assistant response as several transcript
lines that share one message id, one per content block. Each line
carries a usage object. The first line's output_tokens is a placeholder,
often 1, and the last line has the real count. Input and cache fields
usually match across the lines.

The scanner dedupes by message id and keeps the first line it sees, so
it under-counts output tokens. On a machine with 2,577 transcripts it
reported 39.0M output tokens against 60.1M used, a 35% shortfall. Input
and both cache fields differed by under 0.01%.

Keep the line with the highest output count, with the last one scanned
winning a tie. The whole line is kept because a response can fall back
to another model mid-stream. Those lines are separate snapshots with
different cache figures and a different model, and taking a maximum per
field across them over-counts cache tokens and credits the wrong model.

The zero-usage check now runs before dedup, so a zero-usage first line
no longer claims a message id and hides a later line with real usage.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: GPT-6 Astra <noreply@openai.com>

* Index Claude transcripts so the agents refresh reads only what was appended (#8313)

omarchy-agent-usage-claude re-parsed every line of every transcript under
~/.claude/projects on each refresh: no mtime cutoff, no memory of the last
pass. The agents widget is on by default and ticks every 15 minutes, so the
cost grew for the life of the machine. After one month here that was 803
files, 640 MB, 127k lines and 57k JSON parses per tick, about 1 core-second,
pushed through the page cache every quarter hour forever.

Keep a per-file index next to the scan cache: the unique usage records
already parsed out of each transcript and the byte offset they end at. A
file whose size and mtime match is not opened; a file that grew is read
from the stored offset; a file that shrank or was rewritten is read from
the start. --force drops the index and rescans from scratch.

The summary is built from the indexed records in the same directory order
the walk always used. That matters: when a resumed session carries earlier
messages, the same message id appears in two files with different usage,
and the first file visited wins. 91 ids differed on this machine; sorting
the walk moved one model's output total by 25k tokens. Output is now
byte-identical to the previous scan on a frozen copy of the corpus, cold,
warm, and after an append.

Warm refresh: 1.0 s -> 0.10 s of CPU, of which the scan itself is 70 ms;
the index for this corpus is 2.9 MB.

Adapted:
- Rebased onto #10606: the highest-output rule for streamed messages now
  lives where the index parses records, and decides between files too.
- The index records the timezone it was written in, and a change rereads
  every transcript, since its records hold local days.
- A file only counts as appended to when its inode and the hash of what
  was already read still match, so a transcript replaced by a larger one,
  or rewritten in place, is read from the start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Label a Claude Team seat by its subscription, not its rate-limit tier (#11109)

The collector built the plan label from the OAuth rateLimitTier first, so a
Team premium seat, which runs on default_claude_max_5x, showed in the agents
panel as "Max 5x". Lead with subscriptionType and keep the multiplier as its
qualifier: Max still reads "Max 5x", a Team seat reads "Team 5x".

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

* Label the Claude plan from the profile the CLI refreshes (#7225)

Adapted: the profile is found the same way current_account_id() finds it,
now shared as profile_path(): ~/.claude.json for the default home, the
home's own .claude.json otherwise. The original fell back to ~/.claude.json
for any home without CLAUDE_CONFIG_DIR set, so a secondary account read the
primary's tier. The profile's tier also keeps the subscription in the label,
so a Team seat stays "Team" (#11109).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Call a lapsed Claude access token paused, not signed out (#8093)

* Refresh Claude usage after the clock moves backwards (#9956)

* Bound unreadable Claude transcript warnings (#12414)

* Count Claude usage from opencode v2 sessions (#13894)

* Reload agent usage records when an inotify watch fails to rearm (#10067)

* Reload agent usage records after each update run instead of on a timer

Rather than #10067's two-minute timer per record, reload every record when
the omarchy-agent-usage-update process exits, the moment its files can have
been replaced. A reload that finds a file unchanged keeps its record, so the
panel isn't stirred up by identical data. The grep test now runs the QML
functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the agent status when the trouble line has no help text (#8497)

* Clear stale agent login guidance after a successful probe (#8892)

* Clear the Grok login hint after a successful probe

#8892 cleared the default login hint after a successful probe in the
Claude and Codex collectors; Grok's collector had the same stale hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read Fireworks credentials from pi's auth.json (#7455)

The Fireworks collector skipped pi, Omarchy's default agent, when
walking its credential ladder, so a machine signed in to Fireworks only
through pi (/login fireworks) never showed the tab. Insert the key pi
stores in $PI_CODING_AGENT_DIR/auth.json (default ~/.pi/agent) between
the firectl auth.ini and the opencode fallback.

pi keys can be literals, $ENV_VAR/${ENV_VAR} references, or !command
shell lookups. The collector resolves the first two; command lookups
stay pi-only and are skipped rather than sent to the API verbatim.

* Call a lapsed Grok access token paused, not signed out

Grok's access token lives six hours and Grok mints a new one from its
refresh token whenever it starts, so a lapsed one is routine. Reporting
it as an expired sign-in made the panel offer Sign-in required several
times a day, sending people through grok login for nothing. With a
refresh token present it now reads as paused, like Claude's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep showing Grok's last limits while it sits idle

While Grok hasn't run, nothing on the machine has spent its allowance,
so with a refresh token on hand the last numbers still stand: they show
as current rather than dimmed under a status line. A weekly window that
reset in the meantime starts over at 0%, a whole number of weeks on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ask for a Grok sign-in once its refresh token is past 30 days

A refresh token older than Grok's 30-day sign-in can't renew anything,
so the panel offers Sign-in required again instead of showing the last
limits as current. With nothing cached yet it says to start Grok, rather
than showing an empty section without a word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check both ends of what the Claude index read before resuming a transcript

A transcript rewritten in place could grow and change only after its
first kilobytes, and the index took it for an append. It now compares
the last kilobytes before the resume point too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Simplify the agent usage collectors

- Codex: pass the forced-scan choice down instead of a module global, make
  the per-file reader's cache arguments required, shrink the cache record
  check, and drop guards for shapes that can't occur: an empty launcher
  path, realpath raising, mise itself being a lazy launcher, multi-line
  `mise which` output, and probing without a temp file for stderr.
- Claude: decide an append by the digest of both ends of what was read
  alone; the inode and mtime checks it made redundant are gone.
- Snapshot: the device id falls back to the hostname, which always exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Share fixture setup in the agent usage scanner tests

Every fixture home lives under one scratch directory with a single cleanup
trap, instead of a trap rewritten with a longer list for each new home, and
the Codex test builds its signed-in homes with one helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Treat a replaced Claude transcript as new even when its ends match

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Probe Codex without its error text when there's no temporary space

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: tossbaws <17258053+tossbaws@users.noreply.github.com>
Co-authored-by: surim0n <suritech@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: anonwurcod <anonwurcod@proton.me>
Co-authored-by: Kevin Rajan <7121943+kvnloo@users.noreply.github.com>
Co-authored-by: Nate Ashby <nate.ashby11@gmail.com>
Co-authored-by: Aris Gysel <aris.gysel@me.com>
Co-authored-by: Brams <76213579+Brams-s@users.noreply.github.com>
Co-authored-by: This_Is_NPC <gabrielfollone27@gmail.com>
Co-authored-by: sanjyay <102979855+sanjyay@users.noreply.github.com>
Co-authored-by: PapistProtocol <12738904+PapistProtocol@users.noreply.github.com>
Co-authored-by: steez <stevedimakos97@gmail.com>
Co-authored-by: GPT-6 Astra <noreply@openai.com>
Co-authored-by: Ryan Yogan <ryanyogan@gmail.com>
Co-authored-by: Oli Denton <41393837+omdenton@users.noreply.github.com>
Co-authored-by: Igor Kramar <i@ikramar.ru>
Co-authored-by: Martin Eidensten <martin@meibe.se>
Co-authored-by: Romain Perron <rdj.perron@gmail.com>
Co-authored-by: Omarchy Contributor <contributor@users.noreply.github.com>
Co-authored-by: manuaudio <manu@arimaka.com>
Co-authored-by: Tyler South <tsouth2@gmail.com>
Co-authored-by: whathek <Hek846@users.noreply.github.com>
Co-authored-by: Ty Richards <me@tyrichards.com>
2026-10-02 22:03:07 -04:00

832 lines
30 KiB
QML

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()
}
// Each record's FileView follows its file on its own; this catches the ones
// whose watch was lost when an update replaced the file underneath them.
function reloadRecords() {
for (var i = 0; i < agents.length; i++)
if (agents[i]) agents[i].reload()
}
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: ""
// A fifteen-minute interval can't catch an account crossing its switch
// threshold, so while any provider with several accounts has its active one
// within 15 points of that threshold (80% at the default 95%), the limits
// are checked every three minutes. Those runs
// reuse the transcript scans; only the limits probes are new, and any more
// often than this Anthropic starts refusing them.
readonly property bool nearLimit: {
var rev = dataRevision
for (var i = 0; i < agents.length; i++) {
var record = agents[i] ? agents[i].record : null
if (!record || !Array.isArray(record.accounts) || record.accounts.length < 2) continue
var limits = Array.isArray(record.limits) ? record.limits : []
var threshold = Number(record.accountSwitch && record.accountSwitch.threshold || 95)
var from = Math.min(0.8, (threshold - 15) / 100)
for (var j = 0; j < limits.length; j++)
if (Number(limits[j] && limits[j].percent) >= from) return true
}
return false
}
Timer {
interval: root.nearLimit ? Math.min(180, root.refreshIntervalSec) * 1000 : root.refreshIntervalSec * 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.runUpdate(root.nearLimit ? "limits" : "normal")
}
Process {
id: updateProcess
running: false
onExited: {
root.rescanAgents()
root.reloadRecords()
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 orderedProviders(result)
}
// ----------------------------------------------------------------- order
//
// The panel's agents in the order someone dragged them into, kept beside the
// usage records. An agent the order doesn't name yet keeps its place after
// the ones it does.
readonly property string orderPath: (Quickshell.env("XDG_STATE_HOME") || home + "/.local/state") + "/omarchy/agents/order.json"
property var providerOrder: []
function orderedProviders(list) {
var order = providerOrder
var rank = function(p) {
var i = order.indexOf(p.providerId)
return i < 0 ? order.length : i
}
return list.slice().sort(function(a, b) { return rank(a) - rank(b) })
}
// Moves one agent to a position among the ones shown, and saves the result.
function moveProvider(id, to) {
var ids = enabledProviders.map(function(p) { return p.providerId })
var from = ids.indexOf(id)
if (from < 0) return
to = Math.max(0, Math.min(ids.length - 1, to))
if (from === to) return
ids.splice(to, 0, ids.splice(from, 1)[0])
for (var i = 0; i < providerOrder.length; i++)
if (ids.indexOf(providerOrder[i]) < 0) ids.push(providerOrder[i])
providerOrder = ids
orderFile.setText(JSON.stringify(ids) + "\n")
}
FileView {
id: orderFile
path: root.orderPath
watchChanges: true
atomicWrites: true
printErrors: false
onLoaded: {
var parsed = []
try { parsed = JSON.parse(text()) } catch (e) { parsed = [] }
root.providerOrder = Array.isArray(parsed) ? parsed.map(String) : []
}
onFileChanged: reload()
}
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. A
// signed-in agent with a plan shows before its first numbers arrive, so a
// lapsed sign-in still has somewhere to say so.
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)
|| (p.accounts && p.accounts.length > 0) || !!p.balance
|| (p.ready === true && p.tierLabel !== "")
}
// A prepaid agent's credit ledger. Like rate limits, the balance is
// per-account and never merged across devices.
function balanceValue(raw) {
if (!raw || typeof raw !== "object") return null
var remaining = Number(raw.remaining)
var funded = Number(raw.funded)
if (!isFinite(remaining) || remaining < 0) return null
return {
remaining: remaining,
funded: isFinite(funded) && funded > 0 ? funded : 0,
spent: Math.max(0, Number(raw.spent) || 0),
currency: String(raw.currency || "USD"),
estimated: raw.estimated === true
}
}
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 and balances stay per-account and are never merged
// across devices.
limits: Array.isArray(record.limits) ? record.limits : [],
limitsStale: record.limitsStale === true,
limitsFetchedAt: numberValue(record.limitsFetchedAt),
tierLabel: String(record.tierLabel || ""),
// Every subscription account's own limits, once there's more than one.
accounts: Array.isArray(record.accounts) ? record.accounts : [],
accountSwitch: record.accountSwitch || ({ mode: "manual", threshold: 95 }),
// Codex's free full resets of its rate limits, when it has any.
resetCredits: record.resetCredits || null,
balance: balanceValue(record.balance),
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),
hasPromptStats: synced ? (stats.hasPromptStats !== false) : (record.hasPromptStats !== 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 }
}
// Device-scoped stats add up across machines; account-scoped stats
// (Fireworks' billing API) are replicas of the same upstream truth on
// every synced device, so the widest value wins — summing them would
// double every token per machine.
function combineNumber(additive, current, value) {
return additive ? numberValue(current) + numberValue(value) : Math.max(numberValue(current), numberValue(value))
}
function combineObjectNumbers(additive, target, source) {
if (!source) return
for (var key in source) target[key] = combineNumber(additive, target[key], 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,
hasPromptStats: 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
// Snapshots from before the field existed only came from agents that
// count prompts, so a missing value reads as true.
acc.hasPromptStats = acc.hasPromptStats || stats.hasPromptStats !== false
var additive = String(stats.scope || "device") !== "account"
acc.todayPrompts = combineNumber(additive, acc.todayPrompts, stats.todayPrompts)
acc.todaySessions = combineNumber(additive, acc.todaySessions, stats.todaySessions)
acc.todayTotalTokens = combineNumber(additive, acc.todayTotalTokens, stats.todayTotalTokens)
acc.totalPrompts = combineNumber(additive, acc.totalPrompts, stats.totalPrompts)
acc.totalSessions = combineNumber(additive, acc.totalSessions, 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))
combineObjectNumbers(additive, 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] = combineNumber(additive, acc.recentByDay[date], day.messageCount)
}
var usage = stats.modelUsage || {}
for (var modelId in usage) {
var bucket = acc.modelUsage[modelId]
if (!bucket) bucket = acc.modelUsage[modelId] = emptyTokenBucket()
combineObjectNumbers(additive, bucket, usage[modelId] || {})
}
}
}
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,
hasPromptStats: acc.hasPromptStats,
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,
hasPromptStats: record.hasPromptStats !== false,
scope: String(record.scope || "device"),
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"
if (word === "deepseek") return "DeepSeek"
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"
}
}