* Let Grok updates through mise take effect Grok's npm launcher runs ~/.grok/bin/grok whenever it exists, and the install script that repoints it at a new release doesn't run under mise, so every machine kept running the release it first unpacked. Updating mise tools now drops a link to an older release and the old binary, and lets the launcher unpack the installed one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep several Grok accounts, the way Claude and Codex do Grok honors GROK_HOME, so an added Grok account gets its own home holding only its login and settings cache, with the CLI binary, sessions, skills, plugins, memory, and config linked back to ~/.grok. The account commands, the add flow (a second account signs in through a private window), the launcher, and a grok shell function all take it like the others, and its identity and plan come from the files Grok writes at login. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show Grok's plan and credits in the agents panel A grok collector reads each account's plan from the settings Grok caches and its credits from the endpoint behind Grok's own /usage view, so Grok gets a section, per-account limits, and autoswitch like Claude and Codex. A signed-in agent with a plan now shows before its first numbers, so a lapsed sign-in has somewhere to say so. Grok's mark joins the assets, and with every agent able to take another account the add screen no longer needs to dim one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document Grok accounts and limits alongside Claude and Codex Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read Grok's credits the way xAI actually answers The answer nests under config, names a weekly period with its end, and as protobuf JSON leaves the usage percentage out while it's zero. The collector now reads that shape, so a fresh week shows as 0% until it resets rather than as nothing known. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Count Grok's prompts and sessions from its session summaries Grok keeps a summary beside each session with when it was last active and how many prompts it had, so its section and the hero's summary get today's prompts and sessions and its active days. It records no token counts there, so none are claimed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Give each Grok limits cache write its own temporary file Two overlapping collector runs wrote the same cache through one .tmp path, so one rename could leave the other's failing and its record not updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read a single Grok account from GROK_HOME when it's set The launcher and the CLI honor GROK_HOME, but the collector always read ~/.grok, so a Grok signed in only in a custom home showed nothing. With one account, the collector now reads the home the CLI would; with several registered, the primary stays ~/.grok. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop guesses the real Grok credits answer made unnecessary The collector carried fallbacks for fields and shapes guessed from the binary before xAI's actual answer was seen, a numeric timestamp branch nothing writes, the panel's default for prompt stats, and guards that an empty sign-in already covers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the working Grok release until its update is in place The update dropped the link and older binaries before the new release was unpacked and ignored a failed unpack, which could leave every Grok account without a CLI. The old release now stays until the new one is linked, and its link comes back if it never is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Count only Grok sessions for today, not prompts A session's summary holds all its prompts and only when it was last active, so a resumed session put its whole history on today. Today now counts the sessions active in it; prompts stay an all-time total. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reuse the Grok session scan on a limits-only refresh Opening the panel and near-limit checks only need fresh limits, so they reuse a scan up to 15 minutes old, as the Codex collector does; --force still rescans. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
779 lines
28 KiB
QML
779 lines
28 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()
|
|
}
|
|
|
|
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()
|
|
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. 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"
|
|
}
|
|
}
|