@cad0p/pi-usage 0.11.3 → 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +274 -12
- package/bin/piquota.js +49 -4
- package/extensions/quota-panel.ts +188 -31
- package/omarchy/cad0p.pi-usage/Agent.qml +33 -0
- package/omarchy/cad0p.pi-usage/Main.qml +333 -0
- package/omarchy/cad0p.pi-usage/Panel.qml +136 -0
- package/omarchy/cad0p.pi-usage/README.md +40 -0
- package/omarchy/cad0p.pi-usage/UsageContent.qml +1001 -0
- package/omarchy/cad0p.pi-usage/UsageLogic.js +603 -0
- package/omarchy/cad0p.pi-usage/assets/antigravity.svg +3 -0
- package/omarchy/cad0p.pi-usage/assets/claude.svg +1 -0
- package/omarchy/cad0p.pi-usage/assets/codex-light.svg +1 -0
- package/omarchy/cad0p.pi-usage/assets/codex.svg +1 -0
- package/omarchy/cad0p.pi-usage/assets/fireworks.svg +7 -0
- package/omarchy/cad0p.pi-usage/assets/github-actions.svg +3 -0
- package/omarchy/cad0p.pi-usage/assets/opencode-go.svg +3 -0
- package/omarchy/cad0p.pi-usage/manifest.json +56 -0
- package/package.json +2 -1
- package/src/auth/pi-auth.js +3 -0
- package/src/cli/args.js +5 -0
- package/src/cli/commands/stats.js +76 -0
- package/src/engine.js +44 -0
- package/src/local/history.js +1160 -0
- package/src/local/pi-sessions.js +1511 -0
- package/src/omarchy/publish.js +1 -65
- package/src/omarchy/record.js +82 -7
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pi quota line — read-only quota for the providers Pi already owns.
|
|
3
3
|
*
|
|
4
|
-
* Surface: nothing at session start. /usage
|
|
4
|
+
* Surface: nothing at session start. Bare /usage shows the panel until the next
|
|
5
|
+
* prompt or a second /usage. /usage box, /usage line or /usage panel pins
|
|
5
6
|
* a surface above the editor, /usage status enables the footer, and /usage hide
|
|
6
7
|
* removes it. No timer or post-turn refresh runs until a surface is on.
|
|
7
8
|
*
|
|
@@ -69,12 +70,49 @@ type QuotaProvider = {
|
|
|
69
70
|
expiresInMin: number | null;
|
|
70
71
|
};
|
|
71
72
|
|
|
73
|
+
type LocalModelUsage = {
|
|
74
|
+
inputTokens?: number;
|
|
75
|
+
outputTokens?: number;
|
|
76
|
+
cacheReadInputTokens?: number;
|
|
77
|
+
cacheCreationInputTokens?: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type LocalAccountStats = {
|
|
81
|
+
todayTokens?: number;
|
|
82
|
+
weekTokens?: number;
|
|
83
|
+
todayPrompts?: number;
|
|
84
|
+
totalPrompts?: number;
|
|
85
|
+
totalTokens?: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pi's own token counts for one family, computed by the CLI from the session
|
|
90
|
+
* files. It only ever holds aggregate counts: no prompt text, no identities.
|
|
91
|
+
*/
|
|
92
|
+
type LocalFamilyStats = {
|
|
93
|
+
hasLocalStats?: boolean;
|
|
94
|
+
todayPrompts?: number;
|
|
95
|
+
todaySessions?: number;
|
|
96
|
+
todayTotalTokens?: number;
|
|
97
|
+
todayTokensByModel?: Record<string, number>;
|
|
98
|
+
weekTokensByModel?: Record<string, number>;
|
|
99
|
+
recentDays?: Array<{ date: string; messageCount: number }>;
|
|
100
|
+
totalPrompts?: number;
|
|
101
|
+
totalSessions?: number;
|
|
102
|
+
activeDays?: number;
|
|
103
|
+
activeDates?: string[];
|
|
104
|
+
modelUsage?: Record<string, LocalModelUsage>;
|
|
105
|
+
byAccount?: Record<string, LocalAccountStats>;
|
|
106
|
+
};
|
|
107
|
+
|
|
72
108
|
type QuotaReport = {
|
|
73
109
|
providers: QuotaProvider[];
|
|
74
110
|
generatedAt: string;
|
|
75
111
|
sources: string[];
|
|
76
112
|
warnings: string[];
|
|
77
113
|
byFamily: Record<string, QuotaProvider[]>;
|
|
114
|
+
localStats?: Record<string, LocalFamilyStats>;
|
|
115
|
+
localStatsMeta?: { partial?: boolean } | null;
|
|
78
116
|
};
|
|
79
117
|
|
|
80
118
|
type Theme = {
|
|
@@ -550,6 +588,89 @@ function summarize(report: QuotaReport): string {
|
|
|
550
588
|
.join(" · ");
|
|
551
589
|
}
|
|
552
590
|
|
|
591
|
+
/** Window names for the one-line account rows (same words as the Omarchy panel). */
|
|
592
|
+
const WINDOW_TAG: Record<string, string> = { "5h": "session", weekly: "week", monthly: "month" };
|
|
593
|
+
|
|
594
|
+
/** A reset countdown in its largest unit, so a row of three stays narrow. */
|
|
595
|
+
function shortDuration(seconds: number | null): string {
|
|
596
|
+
if (seconds === null || !Number.isFinite(seconds)) return "";
|
|
597
|
+
if (seconds <= 0) return "now";
|
|
598
|
+
if (seconds >= 86400) return `${Math.floor(seconds / 86400)}d`;
|
|
599
|
+
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h`;
|
|
600
|
+
return `${Math.max(1, Math.floor(seconds / 60))}m`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Token counts stay readable in a widget: 812, 4.1k, 1.2M. */
|
|
604
|
+
function formatTokens(value: number): string {
|
|
605
|
+
const tokens = Math.max(0, Math.round(Number(value) || 0));
|
|
606
|
+
if (tokens < 1_000) return String(tokens);
|
|
607
|
+
if (tokens < 100_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
608
|
+
if (tokens < 1_000_000) return `${Math.round(tokens / 1_000)}k`;
|
|
609
|
+
if (tokens < 1_000_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
610
|
+
return `${(tokens / 1_000_000_000).toFixed(1)}B`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Model ids are long and provider-prefixed; the panel only needs the tail. */
|
|
614
|
+
function shortModelName(name: string): string {
|
|
615
|
+
const text = String(name ?? "");
|
|
616
|
+
const tail = text.split("/").pop() || text;
|
|
617
|
+
return tail.length <= 22 ? tail : `${tail.slice(0, 21)}…`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The heaviest models of the last seven days, plus how many were left out.
|
|
622
|
+
* The all-time buckets are the fallback for a report from a CLI that predates
|
|
623
|
+
* the weekly breakdown, and for a week with no usage at all.
|
|
624
|
+
*/
|
|
625
|
+
function topModels(usage: Record<string, LocalModelUsage> | undefined, weekUsage: Record<string, number> | undefined, limit: number) {
|
|
626
|
+
const byName = (a: { name: string; total: number }, b: { name: string; total: number }) =>
|
|
627
|
+
b.total - a.total || a.name.localeCompare(b.name);
|
|
628
|
+
const weekRows = Object.entries(weekUsage ?? {})
|
|
629
|
+
.map(([name, total]) => ({ name, total: Number(total) || 0 }))
|
|
630
|
+
.filter((row) => row.total > 0)
|
|
631
|
+
.sort(byName);
|
|
632
|
+
const rows = weekRows.length > 0
|
|
633
|
+
? weekRows
|
|
634
|
+
: Object.entries(usage ?? {})
|
|
635
|
+
.map(([name, bucket]) => ({
|
|
636
|
+
name,
|
|
637
|
+
total:
|
|
638
|
+
(Number(bucket?.inputTokens) || 0) +
|
|
639
|
+
(Number(bucket?.outputTokens) || 0) +
|
|
640
|
+
(Number(bucket?.cacheReadInputTokens) || 0) +
|
|
641
|
+
(Number(bucket?.cacheCreationInputTokens) || 0),
|
|
642
|
+
}))
|
|
643
|
+
.sort(byName);
|
|
644
|
+
return { rows: rows.slice(0, limit), more: Math.max(0, rows.length - limit) };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* The token section of one provider: today and the last seven days, then the
|
|
649
|
+
* models that account for that usage. Two lines at most, aligned with the
|
|
650
|
+
* window rows above it.
|
|
651
|
+
*/
|
|
652
|
+
function renderTokenRows(stats: LocalFamilyStats | undefined, labelWidth: number, theme: Theme, partial = false): string[] {
|
|
653
|
+
if (!stats || stats.hasLocalStats === false) return [];
|
|
654
|
+
const week = (stats.recentDays ?? []).reduce((sum, day) => sum + (Number(day?.messageCount) || 0), 0);
|
|
655
|
+
const today = Number(stats.todayTotalTokens) || 0;
|
|
656
|
+
const prompts = Number(stats.todayPrompts) || 0;
|
|
657
|
+
const parts = [
|
|
658
|
+
`${theme.fg("dim", "today")} ${formatTokens(today)}`,
|
|
659
|
+
`${theme.fg("dim", "7d")} ${formatTokens(week)}`,
|
|
660
|
+
];
|
|
661
|
+
if (prompts > 0) parts.push(theme.fg("dim", `${prompts} prompt${prompts === 1 ? "" : "s"}`));
|
|
662
|
+
if (partial) parts.push(theme.fg("warning", "scan partial"));
|
|
663
|
+
const lines = [` ${theme.fg("dim", "tokens".padEnd(labelWidth))} ${parts.join(theme.fg("dim", " · "))}`];
|
|
664
|
+
|
|
665
|
+
const { rows, more } = topModels(stats.modelUsage, stats.weekTokensByModel, 3);
|
|
666
|
+
if (rows.length > 0) {
|
|
667
|
+
const cells = rows.map((row) => `${shortModelName(row.name)} ${formatTokens(row.total)}`);
|
|
668
|
+
if (more > 0) cells.push(theme.fg("dim", `+${more} more`));
|
|
669
|
+
lines.push(` ${" ".repeat(labelWidth)} ${cells.join(theme.fg("dim", " · "))}`);
|
|
670
|
+
}
|
|
671
|
+
return lines;
|
|
672
|
+
}
|
|
673
|
+
|
|
553
674
|
/** The account's fullest window: the one that stops its next prompt. */
|
|
554
675
|
function bindingWindow(provider: QuotaProvider): QuotaWindow | null {
|
|
555
676
|
let best: QuotaWindow | null = null;
|
|
@@ -579,8 +700,10 @@ function isMultiAccount(providers: QuotaProvider[], provider: QuotaProvider): bo
|
|
|
579
700
|
/**
|
|
580
701
|
* One family, many accounts: a header plus one line per account with only its
|
|
581
702
|
* binding window, so fourteen keys stay fourteen lines instead of fifty-six.
|
|
703
|
+
* Each line carries the account's own tokens for today, because on a
|
|
704
|
+
* fourteen-key provider "which key burned today's quota" is the question.
|
|
582
705
|
*/
|
|
583
|
-
function renderAccountRows(accounts: QuotaProvider[], theme: Theme): string[] {
|
|
706
|
+
function renderAccountRows(accounts: QuotaProvider[], theme: Theme, stats?: LocalFamilyStats): string[] {
|
|
584
707
|
const family = accounts[0].family;
|
|
585
708
|
const healthy = accounts.filter((account) => account.ok).length;
|
|
586
709
|
const header = `${paint(BRAND[family] ?? "#8B949E", SHORT_NAME[family] ?? accounts[0].label)} ${theme.fg("dim", `· ${healthy} of ${accounts.length} accounts ok`)}`;
|
|
@@ -600,13 +723,19 @@ function renderAccountRows(accounts: QuotaProvider[], theme: Theme): string[] {
|
|
|
600
723
|
lines.push(` ${label} ${theme.fg("dim", "no rate-limit windows reported")}`);
|
|
601
724
|
continue;
|
|
602
725
|
}
|
|
726
|
+
// Every window on one line, each in its own band colour; the leading glyph
|
|
727
|
+
// is the fullest window's, so the column still sorts the accounts by eye.
|
|
603
728
|
const band = bandFor(used);
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
729
|
+
const cells = account.windows
|
|
730
|
+
.filter((each) => usedOf(each) !== null)
|
|
731
|
+
.map((each) => {
|
|
732
|
+
const pct = usedOf(each) as number;
|
|
733
|
+
const reset = each.resetsInSec === null ? "" : ` ${theme.fg("dim", `(${shortDuration(each.resetsInSec)})`.padEnd(5))}`;
|
|
734
|
+
return `${theme.fg("dim", WINDOW_TAG[each.id] ?? each.id)} ${paint(bandFor(pct).color, `${Math.round(pct)}%`.padStart(4))}${reset}`;
|
|
735
|
+
});
|
|
736
|
+
const today = Number(stats?.byAccount?.[account.account]?.todayTokens) || 0;
|
|
737
|
+
const tokens = today > 0 ? ` · ${formatTokens(today)} today` : "";
|
|
738
|
+
lines.push(` ${label} ${paint(band.color, band.glyph)} ${cells.join(theme.fg("dim", " · "))}${theme.fg("dim", tokens)}`);
|
|
610
739
|
}
|
|
611
740
|
return lines;
|
|
612
741
|
}
|
|
@@ -617,11 +746,15 @@ function renderPanel(report: QuotaReport, theme: Theme): string[] {
|
|
|
617
746
|
const unconfigured = report.providers.filter((provider) => provider.notConfigured === true);
|
|
618
747
|
const renderedFamilies = new Set<string>();
|
|
619
748
|
|
|
749
|
+
const partialScan = report.localStatsMeta?.partial === true;
|
|
620
750
|
for (const provider of active) {
|
|
751
|
+
const localStats = report.localStats?.[provider.family];
|
|
752
|
+
const labelWidth = Math.max(6, ...provider.windows.map((window) => String(window.label ?? "").length));
|
|
621
753
|
if (isMultiAccount(active, provider)) {
|
|
622
754
|
if (renderedFamilies.has(provider.family)) continue;
|
|
623
755
|
renderedFamilies.add(provider.family);
|
|
624
|
-
lines.push(...renderAccountRows(active.filter((other) => other.family === provider.family), theme));
|
|
756
|
+
lines.push(...renderAccountRows(active.filter((other) => other.family === provider.family), theme, localStats));
|
|
757
|
+
lines.push(...renderTokenRows(localStats, labelWidth, theme, partialScan));
|
|
625
758
|
continue;
|
|
626
759
|
}
|
|
627
760
|
const name = paint(BRAND[provider.family] ?? "#8B949E", provider.label);
|
|
@@ -644,30 +777,31 @@ function renderPanel(report: QuotaReport, theme: Theme): string[] {
|
|
|
644
777
|
const color = kind === "transient" || kind === "throttle" ? "warning" : kind === "expired" || kind === "auth" ? "error" : "dim";
|
|
645
778
|
lines.push(` ${theme.fg(color, glyph)} ${theme.fg("warning", provider.note ?? errorCaption(provider.error))}`);
|
|
646
779
|
if (isRecoverableError(provider.error) && provider.note) lines.push(` ${theme.fg("warning", provider.note)}`);
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
if (provider.note) lines.push(` ${theme.fg("warning", `~ ${provider.note}`)}`);
|
|
650
|
-
if (provider.windows.length === 0) {
|
|
780
|
+
} else if (provider.windows.length === 0) {
|
|
781
|
+
if (provider.note) lines.push(` ${theme.fg("warning", `~ ${provider.note}`)}`);
|
|
651
782
|
lines.push(` ${theme.fg("dim", "no rate-limit windows reported")}`);
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
783
|
+
} else {
|
|
784
|
+
if (provider.note) lines.push(` ${theme.fg("warning", `~ ${provider.note}`)}`);
|
|
785
|
+
for (const window of provider.windows) {
|
|
786
|
+
const used = usedOf(window);
|
|
787
|
+
const label = theme.fg("dim", window.label.padEnd(labelWidth));
|
|
788
|
+
if (used === null) {
|
|
789
|
+
lines.push(` ${label} ${theme.fg("dim", window.note ?? "no percentage reported")}`);
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
const band = bandFor(used);
|
|
793
|
+
const reset = window.resetsInSec === null ? "" : theme.fg("dim", ` · reset in ${humanDuration(window.resetsInSec)}`);
|
|
794
|
+
lines.push(
|
|
795
|
+
` ${label} ${paint(band.color, band.glyph)} ${paint(band.color, bar(used))} ` +
|
|
796
|
+
`${paint(band.color, `${used.toFixed(used < 10 ? 1 : 0)}%`.padStart(5))} ` +
|
|
797
|
+
`${theme.fg("dim", "used")}${reset}`,
|
|
798
|
+
);
|
|
662
799
|
}
|
|
663
|
-
const band = bandFor(used);
|
|
664
|
-
const reset = window.resetsInSec === null ? "" : theme.fg("dim", ` · reset in ${humanDuration(window.resetsInSec)}`);
|
|
665
|
-
lines.push(
|
|
666
|
-
` ${label} ${paint(band.color, band.glyph)} ${paint(band.color, bar(used))} ` +
|
|
667
|
-
`${paint(band.color, `${used.toFixed(used < 10 ? 1 : 0)}%`.padStart(5))} ` +
|
|
668
|
-
`${theme.fg("dim", "used")}${reset}`,
|
|
669
|
-
);
|
|
670
800
|
}
|
|
801
|
+
|
|
802
|
+
// Local token stats survive a failed quota probe: the credential may be
|
|
803
|
+
// rejected while the tokens already burned are still a fact on disk.
|
|
804
|
+
lines.push(...renderTokenRows(localStats, labelWidth, theme, partialScan));
|
|
671
805
|
}
|
|
672
806
|
lines.push(theme.fg("dim", `used % · read-only · ${report.generatedAt}`));
|
|
673
807
|
if (unconfigured.length > 0) {
|
|
@@ -686,6 +820,11 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
686
820
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
687
821
|
let agentEndTimer: ReturnType<typeof setTimeout> | null = null;
|
|
688
822
|
let panelVisible = false;
|
|
823
|
+
/**
|
|
824
|
+
* The panel bare `/usage` opens is a glance, not a pin: it goes away when the
|
|
825
|
+
* next prompt starts or when `/usage` is run again. `/usage panel` pins it.
|
|
826
|
+
*/
|
|
827
|
+
let transientPanel = false;
|
|
689
828
|
/**
|
|
690
829
|
* Nothing is pinned by default. A box or line above the editor is a persistent
|
|
691
830
|
* surface, and a quota command should not claim a row until asked.
|
|
@@ -933,7 +1072,17 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
933
1072
|
paintUi(ui);
|
|
934
1073
|
});
|
|
935
1074
|
|
|
936
|
-
|
|
1075
|
+
function dismissTransientPanel(ui: UiContext): void {
|
|
1076
|
+
panelVisible = false;
|
|
1077
|
+
transientPanel = false;
|
|
1078
|
+
if (!surfaceActive()) stopPolling();
|
|
1079
|
+
paintUi(ui);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
1083
|
+
// SAFETY: Pi's agent_start context implements UiContext when hasUI is true.
|
|
1084
|
+
const ui = ctx as unknown as UiContext;
|
|
1085
|
+
if (transientPanel && panelVisible && ui?.hasUI && !disposed) dismissTransientPanel(ui);
|
|
937
1086
|
if (agentEndTimer) {
|
|
938
1087
|
clearTimeout(agentEndTimer);
|
|
939
1088
|
agentEndTimer = null;
|
|
@@ -1002,6 +1151,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
1002
1151
|
return;
|
|
1003
1152
|
case "panel":
|
|
1004
1153
|
panelVisible = true;
|
|
1154
|
+
transientPanel = false;
|
|
1005
1155
|
lineVisible = false;
|
|
1006
1156
|
boxVisible = false;
|
|
1007
1157
|
explicitBox = false;
|
|
@@ -1048,8 +1198,15 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
1048
1198
|
break;
|
|
1049
1199
|
}
|
|
1050
1200
|
|
|
1201
|
+
// Bare /usage again while its glance is open closes it.
|
|
1202
|
+
if (panelVisible && transientPanel) {
|
|
1203
|
+
dismissTransientPanel(ctx);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1051
1207
|
// Bare /usage: one on-demand refresh, show the panel, report the percentages.
|
|
1052
1208
|
panelVisible = true;
|
|
1209
|
+
transientPanel = true;
|
|
1053
1210
|
lineVisible = false;
|
|
1054
1211
|
boxVisible = false;
|
|
1055
1212
|
await refresh(ctx, true);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import QtQuick
|
|
2
|
+
import Quickshell.Io
|
|
3
|
+
|
|
4
|
+
// One usage record, read straight off its data file. Carried over unchanged
|
|
5
|
+
// from Omarchy's built-in Agents plugin (plugins/agents/Agent.qml, MIT): a
|
|
6
|
+
// record that appears in the usage directory is a provider, whoever wrote it.
|
|
7
|
+
Item {
|
|
8
|
+
id: root
|
|
9
|
+
visible: false
|
|
10
|
+
|
|
11
|
+
property string agentId: ""
|
|
12
|
+
property string path: ""
|
|
13
|
+
property var record: null
|
|
14
|
+
|
|
15
|
+
FileView {
|
|
16
|
+
path: root.path
|
|
17
|
+
watchChanges: true
|
|
18
|
+
printErrors: false
|
|
19
|
+
onFileChanged: reload()
|
|
20
|
+
onLoaded: root.parse(text())
|
|
21
|
+
onLoadFailed: root.record = null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parse(content) {
|
|
25
|
+
try {
|
|
26
|
+
var parsed = JSON.parse(String(content || ""))
|
|
27
|
+
root.record = parsed && typeof parsed === "object" ? parsed : null
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.warn("cad0p.pi-usage", "Ignoring bad usage record", root.path, e)
|
|
30
|
+
root.record = null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import QtQuick
|
|
2
|
+
import Quickshell
|
|
3
|
+
import Quickshell.Io
|
|
4
|
+
import "UsageLogic.js" as Logic
|
|
5
|
+
|
|
6
|
+
// The data side of the AI Usage panel: discover every usage record in
|
|
7
|
+
// Omarchy's usage directory (pi-usage's pi-*.json and Omarchy's own
|
|
8
|
+
// claude/codex/fireworks records alike), watch them, keep the per-section
|
|
9
|
+
// expansion state, and trigger the publishers on demand.
|
|
10
|
+
//
|
|
11
|
+
// Discovery and the per-record watcher follow Omarchy's built-in Agents plugin
|
|
12
|
+
// (plugins/agents/Main.qml, MIT, by Omarchy).
|
|
13
|
+
Item {
|
|
14
|
+
id: root
|
|
15
|
+
visible: false
|
|
16
|
+
|
|
17
|
+
property var settings: ({})
|
|
18
|
+
// Live bar, used only to see whether the built-in Agents widget is also on
|
|
19
|
+
// it (see collectorsWanted).
|
|
20
|
+
property QtObject bar: null
|
|
21
|
+
// file:// URL of the plugin directory, so the panel can find the pi-usage
|
|
22
|
+
// CLI of the package it ships in.
|
|
23
|
+
property string pluginDir: ""
|
|
24
|
+
|
|
25
|
+
readonly property string home: Quickshell.env("HOME") || ""
|
|
26
|
+
readonly property string stateHome: Quickshell.env("XDG_STATE_HOME") || (home + "/.local/state")
|
|
27
|
+
// PI_QUOTA_OMARCHY_DIR is the writer's own override; honoring it here keeps
|
|
28
|
+
// the two ends pointed at the same directory.
|
|
29
|
+
readonly property string usageDir: Quickshell.env("PI_QUOTA_OMARCHY_DIR") || (stateHome + "/omarchy/agents/usage")
|
|
30
|
+
readonly property string uiStateDir: Logic.uiStateDirectory(stateHome, Quickshell.env("PI_USAGE_PANEL_STATE_DIR"))
|
|
31
|
+
readonly property string uiStatePath: uiStateDir + "/cad0p.pi-usage.json"
|
|
32
|
+
|
|
33
|
+
function setting(name, fallback) {
|
|
34
|
+
var value = settings ? settings[name] : undefined
|
|
35
|
+
return value === undefined || value === null ? fallback : value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ------------------------------------------------------------- discovery
|
|
39
|
+
|
|
40
|
+
property var recordIds: []
|
|
41
|
+
property var agents: []
|
|
42
|
+
property int dataRevision: 0
|
|
43
|
+
|
|
44
|
+
Process {
|
|
45
|
+
id: listProcess
|
|
46
|
+
running: false
|
|
47
|
+
command: ["find", root.usageDir, "-maxdepth", "1", "-name", "*.json", "-printf", "%f\n"]
|
|
48
|
+
|
|
49
|
+
stdout: StdioCollector {
|
|
50
|
+
waitForEnd: true
|
|
51
|
+
onStreamFinished: root.applyListing(text)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// New records are found by listing the directory again on a slow timer and
|
|
56
|
+
// after every publisher run; each record's content is watched by its own
|
|
57
|
+
// FileView, which is what actually moves the numbers.
|
|
58
|
+
Timer {
|
|
59
|
+
interval: 60000
|
|
60
|
+
running: true
|
|
61
|
+
repeat: true
|
|
62
|
+
triggeredOnStart: true
|
|
63
|
+
onTriggered: root.rescan()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rescan() {
|
|
67
|
+
if (!listProcess.running) listProcess.running = true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function applyListing(output) {
|
|
71
|
+
var ids = []
|
|
72
|
+
var lines = String(output || "").split("\n")
|
|
73
|
+
for (var i = 0; i < lines.length; i++) {
|
|
74
|
+
var name = lines[i].trim()
|
|
75
|
+
if (name.slice(-5) === ".json") ids.push(name.slice(0, -5))
|
|
76
|
+
}
|
|
77
|
+
ids.sort()
|
|
78
|
+
if (JSON.stringify(ids) !== JSON.stringify(recordIds)) recordIds = ids
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Instantiator {
|
|
82
|
+
id: agentInstantiator
|
|
83
|
+
model: root.recordIds
|
|
84
|
+
|
|
85
|
+
delegate: Agent {
|
|
86
|
+
required property var modelData
|
|
87
|
+
agentId: modelData
|
|
88
|
+
path: root.usageDir + "/" + modelData + ".json"
|
|
89
|
+
onRecordChanged: root.recordsChanged()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
onObjectAdded: (index, object) => root.rebuildAgents()
|
|
93
|
+
onObjectRemoved: (index, object) => root.rebuildAgents()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function rebuildAgents() {
|
|
97
|
+
var result = []
|
|
98
|
+
for (var i = 0; i < agentInstantiator.count; i++) {
|
|
99
|
+
var agent = agentInstantiator.objectAt(i)
|
|
100
|
+
if (agent) result.push(agent)
|
|
101
|
+
}
|
|
102
|
+
agents = result
|
|
103
|
+
recordsChanged()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function recordsChanged() {
|
|
107
|
+
dataRevision++
|
|
108
|
+
scheduleLimitsRetry()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
property var retryAgentIds: []
|
|
112
|
+
property string pendingUpdateKind: ""
|
|
113
|
+
property bool pendingPublisher: false
|
|
114
|
+
|
|
115
|
+
Timer {
|
|
116
|
+
id: limitsRetry
|
|
117
|
+
interval: 30000
|
|
118
|
+
repeat: false
|
|
119
|
+
// A retry is a collector run like any other: the same switch that gates the
|
|
120
|
+
// interval timer gates this one, even if it flips while the retry waits.
|
|
121
|
+
onTriggered: if (root.collectorsWanted) root.runCollectors("limits", root.retryAgentIds)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function scheduleLimitsRetry() {
|
|
125
|
+
var records = []
|
|
126
|
+
for (var i = 0; i < agents.length; i++) {
|
|
127
|
+
var record = agents[i] ? agents[i].record : null
|
|
128
|
+
if (record) records.push(record)
|
|
129
|
+
}
|
|
130
|
+
retryAgentIds = Logic.advisedRetryIds(records, settings, collectorsWanted)
|
|
131
|
+
if (retryAgentIds.length > 0) limitsRetry.restart()
|
|
132
|
+
else limitsRetry.stop()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Turning the collectors off (or putting the built-in widget back on the
|
|
136
|
+
// bar) cancels a retry that is already waiting.
|
|
137
|
+
onCollectorsWantedChanged: scheduleLimitsRetry()
|
|
138
|
+
|
|
139
|
+
readonly property var providers: {
|
|
140
|
+
var rev = dataRevision
|
|
141
|
+
var records = []
|
|
142
|
+
for (var i = 0; i < agents.length; i++) if (agents[i] && agents[i].record) records.push(agents[i].record)
|
|
143
|
+
return Logic.visibleProviders(records, settings)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
readonly property string newestUpdate: {
|
|
147
|
+
var rev = dataRevision
|
|
148
|
+
var best = ""
|
|
149
|
+
for (var i = 0; i < agents.length; i++) {
|
|
150
|
+
var record = agents[i] ? agents[i].record : null
|
|
151
|
+
var at = record && record.updatedAt ? String(record.updatedAt) : ""
|
|
152
|
+
if (at !== "" && (best === "" || new Date(at).getTime() > new Date(best).getTime())) best = at
|
|
153
|
+
}
|
|
154
|
+
return best
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ------------------------------------------------------------ expansion
|
|
158
|
+
|
|
159
|
+
property var expandedOverrides: ({})
|
|
160
|
+
|
|
161
|
+
FileView {
|
|
162
|
+
id: uiStateFile
|
|
163
|
+
path: root.uiStatePath
|
|
164
|
+
watchChanges: false
|
|
165
|
+
atomicWrites: true
|
|
166
|
+
printErrors: false
|
|
167
|
+
onLoaded: root.expandedOverrides = Logic.parseUiState(text()).expanded
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
Process {
|
|
171
|
+
id: uiStateMkdir
|
|
172
|
+
running: false
|
|
173
|
+
command: ["mkdir", "-p", root.uiStateDir]
|
|
174
|
+
onExited: function(exitCode) {
|
|
175
|
+
if (exitCode === 0) uiStateFile.setText(Logic.serializeUiState({ expanded: root.expandedOverrides }))
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
Timer {
|
|
180
|
+
id: uiStateSave
|
|
181
|
+
interval: 400
|
|
182
|
+
repeat: false
|
|
183
|
+
onTriggered: if (!uiStateMkdir.running) uiStateMkdir.running = true
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function setExpanded(id, expanded) {
|
|
187
|
+
expandedOverrides = Logic.withExpanded(expandedOverrides, String(id), expanded)
|
|
188
|
+
uiStateSave.restart()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --------------------------------------------------------------- refresh
|
|
192
|
+
//
|
|
193
|
+
// pi-usage's records already have two writers: the systemd user timer
|
|
194
|
+
// (./install.sh --omarchy-timer) and the Pi extension on every quota
|
|
195
|
+
// refresh. A third periodic writer would only multiply the calls to the
|
|
196
|
+
// usage endpoints, so the panel never schedules piquota itself; it runs it
|
|
197
|
+
// when you ask for a refresh.
|
|
198
|
+
//
|
|
199
|
+
// Omarchy's own collectors are different: the built-in Agents widget is what
|
|
200
|
+
// runs them. "Auto" runs them here only while that widget is not on the bar,
|
|
201
|
+
// so replacing it keeps claude/codex/fireworks fresh and keeping both never
|
|
202
|
+
// doubles the work.
|
|
203
|
+
|
|
204
|
+
property string collectorsSetting: String(setting("omarchyCollectors", "Auto"))
|
|
205
|
+
property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900)))
|
|
206
|
+
property bool builtinOnBar: false
|
|
207
|
+
readonly property bool collectorsWanted: Logic.collectorsWanted(collectorsSetting, builtinOnBar)
|
|
208
|
+
readonly property bool refreshing: collectorProcess.running || publisherProcess.running
|
|
209
|
+
|
|
210
|
+
function updateBuiltinOnBar() {
|
|
211
|
+
var widgets = bar && typeof bar.moduleWidgets === "function" ? bar.moduleWidgets("omarchy.agents") : []
|
|
212
|
+
builtinOnBar = !!widgets && widgets.length > 0
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
Timer {
|
|
216
|
+
interval: root.refreshIntervalSec * 1000
|
|
217
|
+
running: root.collectorsSetting !== "Off"
|
|
218
|
+
repeat: true
|
|
219
|
+
triggeredOnStart: true
|
|
220
|
+
onTriggered: {
|
|
221
|
+
root.updateBuiltinOnBar()
|
|
222
|
+
if (root.collectorsWanted) root.runCollectors("normal")
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
Process {
|
|
227
|
+
id: collectorProcess
|
|
228
|
+
running: false
|
|
229
|
+
onExited: {
|
|
230
|
+
root.rescan()
|
|
231
|
+
if (root.pendingUpdateKind !== "") {
|
|
232
|
+
var kind = root.pendingUpdateKind
|
|
233
|
+
root.pendingUpdateKind = ""
|
|
234
|
+
root.runCollectors(kind)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
stderr: StdioCollector {
|
|
238
|
+
waitForEnd: true
|
|
239
|
+
onStreamFinished: if (text.trim() !== "") console.warn("cad0p.pi-usage", text.trim())
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function collectorCommand(kind, agentIds) {
|
|
244
|
+
var args = []
|
|
245
|
+
if (kind === "force") args.push("--force")
|
|
246
|
+
if (kind === "limits") args.push("--limits-only")
|
|
247
|
+
var providerSettings = settings && settings.providers ? settings.providers : {}
|
|
248
|
+
for (var id in providerSettings) {
|
|
249
|
+
if (id.indexOf("pi-") === 0) continue
|
|
250
|
+
if (providerSettings[id] && providerSettings[id].enabled === false) args.push("--except", id)
|
|
251
|
+
}
|
|
252
|
+
if (agentIds) {
|
|
253
|
+
for (var i = 0; i < agentIds.length; i++) args.push(agentIds[i])
|
|
254
|
+
}
|
|
255
|
+
// Absent on a machine without Omarchy's collectors: nothing to run.
|
|
256
|
+
return ["bash", "-c", "command -v omarchy-agent-usage-update >/dev/null 2>&1 || exit 0; exec omarchy-agent-usage-update \"$@\"", "omarchy-agent-usage-update"].concat(args)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function runCollectors(kind, agentIds) {
|
|
260
|
+
// The one choke point every collector run passes through: the interval
|
|
261
|
+
// timer, an explicit refresh and the retryAdvised retry.
|
|
262
|
+
if (!collectorsWanted) return
|
|
263
|
+
if (collectorProcess.running) {
|
|
264
|
+
pendingUpdateKind = Logic.queueUpdateKind(pendingUpdateKind, kind)
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
collectorProcess.command = collectorCommand(kind, agentIds)
|
|
268
|
+
collectorProcess.running = true
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
Process {
|
|
272
|
+
id: publisherProcess
|
|
273
|
+
running: false
|
|
274
|
+
onExited: {
|
|
275
|
+
root.rescan()
|
|
276
|
+
if (root.pendingPublisher) {
|
|
277
|
+
root.pendingPublisher = false
|
|
278
|
+
root.runPublisher()
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
stderr: StdioCollector {
|
|
282
|
+
waitForEnd: true
|
|
283
|
+
onStreamFinished: if (text.trim() !== "") console.warn("cad0p.pi-usage", text.trim())
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function localPath(url) {
|
|
288
|
+
var text = String(url || "")
|
|
289
|
+
if (text.indexOf("file://") === 0) text = decodeURIComponent(text.slice(7))
|
|
290
|
+
return text.replace(/\/+$/, "")
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// The user's timer unit first, so a manual refresh runs with exactly the
|
|
294
|
+
// environment the periodic one does (PI_QUOTA_CLAUDE_SOURCE and friends);
|
|
295
|
+
// then the CLI of the package this plugin ships in; then piquota on PATH.
|
|
296
|
+
function publisherCommand() {
|
|
297
|
+
var script = [
|
|
298
|
+
"if command -v systemctl >/dev/null 2>&1 && systemctl --user cat piquota-omarchy.service >/dev/null 2>&1; then",
|
|
299
|
+
" exec systemctl --user start piquota-omarchy.service",
|
|
300
|
+
"fi",
|
|
301
|
+
"cli=\"$(readlink -f \"$1\" 2>/dev/null)/../../bin/piquota.js\"",
|
|
302
|
+
"if [ -f \"$cli\" ] && command -v node >/dev/null 2>&1; then exec node \"$cli\" omarchy; fi",
|
|
303
|
+
"if command -v piquota >/dev/null 2>&1; then exec piquota omarchy; fi",
|
|
304
|
+
"if [ -x \"$HOME/.local/bin/piquota\" ]; then exec \"$HOME/.local/bin/piquota\" omarchy; fi",
|
|
305
|
+
"exit 0"
|
|
306
|
+
].join("\n")
|
|
307
|
+
return ["bash", "-c", script, "pi-usage-refresh", localPath(pluginDir)]
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function runPublisher() {
|
|
311
|
+
if (publisherProcess.running) {
|
|
312
|
+
pendingPublisher = true
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
publisherProcess.command = publisherCommand()
|
|
316
|
+
publisherProcess.running = true
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// r, middle click, IPC refresh: everything, now.
|
|
320
|
+
function refreshAll() {
|
|
321
|
+
updateBuiltinOnBar()
|
|
322
|
+
runPublisher()
|
|
323
|
+
if (collectorsWanted) runCollectors("force")
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Opening the panel wants the numbers that go stale on the wire; Omarchy's
|
|
327
|
+
// collectors reuse their recent transcript scans in this mode.
|
|
328
|
+
function refreshOnOpen() {
|
|
329
|
+
updateBuiltinOnBar()
|
|
330
|
+
rescan()
|
|
331
|
+
if (collectorsWanted) runCollectors("limits")
|
|
332
|
+
}
|
|
333
|
+
}
|