@cad0p/pi-usage 0.0.0-stage → 0.11.2
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/LICENSE +21 -0
- package/README.md +509 -2
- package/bin/piquota.js +199 -0
- package/extensions/quota-panel.ts +963 -0
- package/package.json +59 -4
- package/src/auth/claude-code-auth.js +490 -0
- package/src/auth/jwt.js +84 -0
- package/src/auth/pi-auth.js +424 -0
- package/src/browser/cookies.js +557 -0
- package/src/browser/history.js +173 -0
- package/src/cache.js +224 -0
- package/src/cli/args.js +154 -0
- package/src/cli/commands/auth.js +216 -0
- package/src/cli/commands/gentle-pi.js +44 -0
- package/src/cli/commands/omarchy.js +75 -0
- package/src/cli/explain.js +99 -0
- package/src/cli/output.js +34 -0
- package/src/engine.js +404 -0
- package/src/exec.js +31 -0
- package/src/gentle-pi/rail-patch.js +335 -0
- package/src/http.js +182 -0
- package/src/model.js +332 -0
- package/src/omarchy/assets/antigravity.svg +3 -0
- package/src/omarchy/assets/claude.svg +1 -0
- package/src/omarchy/assets/codex.svg +1 -0
- package/src/omarchy/assets/github-actions.svg +3 -0
- package/src/omarchy/assets/opencode-go.svg +3 -0
- package/src/omarchy/publish.js +152 -0
- package/src/omarchy/record.js +462 -0
- package/src/opencode/dashboard.js +337 -0
- package/src/opencode/session.js +485 -0
- package/src/providers/antigravity-oauth.js +173 -0
- package/src/providers/antigravity.js +225 -0
- package/src/providers/backoff.js +146 -0
- package/src/providers/claude.js +194 -0
- package/src/providers/codex.js +151 -0
- package/src/providers/error-kind.js +128 -0
- package/src/providers/github-actions.js +379 -0
- package/src/providers/opencode-go.js +274 -0
- package/src/refresh.js +292 -0
- package/src/render/panel.js +411 -0
- package/src/render/theme.js +102 -0
- package/src/sticky.js +319 -0
package/src/model.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared quota model.
|
|
3
|
+
*
|
|
4
|
+
* Every provider normalizes its own API response into `QuotaWindow[]` so the
|
|
5
|
+
* terminal renderer, the Pi TUI extension and the Moshi adapter consume exactly
|
|
6
|
+
* one shape.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} QuotaWindow
|
|
11
|
+
* @property {string} id Stable id, e.g. "5h", "weekly", "monthly".
|
|
12
|
+
* @property {string} label Human label, e.g. "5h window".
|
|
13
|
+
* @property {number | null} usedPercent Percent consumed (0-100).
|
|
14
|
+
* @property {number | null} remainingPercent
|
|
15
|
+
* @property {string | null} resetsAt ISO timestamp.
|
|
16
|
+
* @property {number | null} resetsInSec Seconds until reset.
|
|
17
|
+
* @property {number | null} windowSeconds Length of the window when known.
|
|
18
|
+
* @property {string | null} note Provider-specific extra context.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} QuotaResult
|
|
23
|
+
* @property {string} family
|
|
24
|
+
* @property {string} label
|
|
25
|
+
* @property {string} account Display identity (email or short id).
|
|
26
|
+
* @property {string | null} [slot] Numbered sibling account ("2"), or null
|
|
27
|
+
* for the base entry of a family.
|
|
28
|
+
* @property {string | null} plan
|
|
29
|
+
* @property {QuotaWindow[]} windows
|
|
30
|
+
* @property {string | null} error Degradation message; null when healthy.
|
|
31
|
+
* @property {boolean} ok
|
|
32
|
+
* @property {string} updatedAt ISO timestamp.
|
|
33
|
+
* @property {string} source Credential source description.
|
|
34
|
+
* @property {"pi" | "claude-code"} [sourceKind] Which kind of store produced it.
|
|
35
|
+
* @property {string | null} expiresInMin Credential freshness, when known.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Clamp to the inclusive 0-100 percent range.
|
|
40
|
+
*
|
|
41
|
+
* A missing value stays missing: `Number(null)` is 0, and turning "no data" into
|
|
42
|
+
* "0% used" would render as a full bar.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} value
|
|
45
|
+
* @returns {number | null}
|
|
46
|
+
*/
|
|
47
|
+
export function clampPercent(value) {
|
|
48
|
+
if (typeof value !== "number" && typeof value !== "string") return null;
|
|
49
|
+
if (typeof value === "string" && value.trim() === "") return null;
|
|
50
|
+
const number = Number(value);
|
|
51
|
+
if (!Number.isFinite(number)) return null;
|
|
52
|
+
return Math.min(100, Math.max(0, number));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {unknown} value
|
|
57
|
+
* @returns {number | null}
|
|
58
|
+
*/
|
|
59
|
+
export function finiteNumber(value) {
|
|
60
|
+
if (typeof value !== "number" && typeof value !== "string") return null;
|
|
61
|
+
if (typeof value === "string" && value.trim() === "") return null;
|
|
62
|
+
const number = Number(value);
|
|
63
|
+
return Number.isFinite(number) ? number : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {unknown} value
|
|
68
|
+
* @returns {string | null}
|
|
69
|
+
*/
|
|
70
|
+
export function stringValue(value) {
|
|
71
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Epoch values below this are seconds, above are milliseconds. See the matching
|
|
76
|
+
* constant in auth/pi-auth.js.
|
|
77
|
+
*/
|
|
78
|
+
const SECONDS_EPOCH_CEILING = 1e11;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse a reset value into an ISO timestamp plus a countdown.
|
|
82
|
+
*
|
|
83
|
+
* Accepts ISO strings, epoch seconds/ms numbers and explicit second counts.
|
|
84
|
+
*
|
|
85
|
+
* @param {unknown} value
|
|
86
|
+
* @param {number} now
|
|
87
|
+
* @returns {{ resetsAt: string | null, resetsInSec: number | null }}
|
|
88
|
+
*/
|
|
89
|
+
export function parseReset(value, now = Date.now()) {
|
|
90
|
+
const numeric = finiteNumber(value);
|
|
91
|
+
if (numeric !== null) {
|
|
92
|
+
if (numeric > 1e9) {
|
|
93
|
+
const ms = numeric > SECONDS_EPOCH_CEILING ? numeric : numeric * 1000;
|
|
94
|
+
return { resetsAt: new Date(ms).toISOString(), resetsInSec: Math.max(0, Math.round((ms - now) / 1000)) };
|
|
95
|
+
}
|
|
96
|
+
if (numeric >= 0) {
|
|
97
|
+
return parseResetSeconds(numeric, now);
|
|
98
|
+
}
|
|
99
|
+
return { resetsAt: null, resetsInSec: null };
|
|
100
|
+
}
|
|
101
|
+
const text = stringValue(value);
|
|
102
|
+
if (text) {
|
|
103
|
+
if (/^\d+(\.\d+)?$/.test(text)) {
|
|
104
|
+
const num = Number(text);
|
|
105
|
+
if (num >= 0 && num <= 1e9) {
|
|
106
|
+
return parseResetSeconds(num, now);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const parsed = Date.parse(text);
|
|
110
|
+
if (!Number.isNaN(parsed)) {
|
|
111
|
+
return { resetsAt: new Date(parsed).toISOString(), resetsInSec: Math.max(0, Math.round((parsed - now) / 1000)) };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { resetsAt: null, resetsInSec: null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Derive a reset timestamp purely from a remaining-seconds count.
|
|
119
|
+
*
|
|
120
|
+
* @param {unknown} seconds
|
|
121
|
+
* @param {number} now
|
|
122
|
+
* @returns {{ resetsAt: string | null, resetsInSec: number | null }}
|
|
123
|
+
*/
|
|
124
|
+
export function parseResetSeconds(seconds, now = Date.now()) {
|
|
125
|
+
const numeric = finiteNumber(seconds);
|
|
126
|
+
if (numeric === null || numeric < 0) return { resetsAt: null, resetsInSec: null };
|
|
127
|
+
const rounded = Math.round(numeric);
|
|
128
|
+
return { resetsAt: new Date(now + rounded * 1000).toISOString(), resetsInSec: rounded };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build a window from a used-percent value.
|
|
133
|
+
*
|
|
134
|
+
* @param {{
|
|
135
|
+
* id: string,
|
|
136
|
+
* label: string,
|
|
137
|
+
* usedPercent?: unknown,
|
|
138
|
+
* remainingPercent?: unknown,
|
|
139
|
+
* resetsAt?: unknown,
|
|
140
|
+
* resetsInSec?: unknown,
|
|
141
|
+
* windowSeconds?: unknown,
|
|
142
|
+
* note?: string | null,
|
|
143
|
+
* now?: number,
|
|
144
|
+
* }} input
|
|
145
|
+
* @returns {QuotaWindow}
|
|
146
|
+
*/
|
|
147
|
+
export function buildWindow(input) {
|
|
148
|
+
const now = input.now ?? Date.now();
|
|
149
|
+
let used = clampPercent(input.usedPercent);
|
|
150
|
+
let remaining = clampPercent(input.remainingPercent);
|
|
151
|
+
if (used === null && remaining !== null) {
|
|
152
|
+
used = 100 - remaining;
|
|
153
|
+
} else if (remaining === null && used !== null) {
|
|
154
|
+
remaining = 100 - used;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let resets = { resetsAt: null, resetsInSec: null };
|
|
158
|
+
if (input.resetsAt !== undefined && input.resetsAt !== null) {
|
|
159
|
+
resets = parseReset(input.resetsAt, now);
|
|
160
|
+
} else if (input.resetsInSec !== undefined && input.resetsInSec !== null) {
|
|
161
|
+
resets = parseResetSeconds(input.resetsInSec, now);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
id: input.id,
|
|
166
|
+
label: input.label,
|
|
167
|
+
usedPercent: used,
|
|
168
|
+
remainingPercent: remaining,
|
|
169
|
+
resetsAt: resets.resetsAt,
|
|
170
|
+
resetsInSec: resets.resetsInSec,
|
|
171
|
+
windowSeconds: finiteNumber(input.windowSeconds),
|
|
172
|
+
note: input.note ?? null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Map a window length in seconds onto a canonical id/label pair.
|
|
178
|
+
*
|
|
179
|
+
* @param {number | null} seconds
|
|
180
|
+
* @returns {{ id: string, label: string }}
|
|
181
|
+
*/
|
|
182
|
+
export function windowFromSeconds(seconds) {
|
|
183
|
+
if (seconds === null) return { id: "window", label: "Window" };
|
|
184
|
+
if (seconds <= 6 * 3600) return { id: "5h", label: "5h window" };
|
|
185
|
+
if (seconds <= 8 * 86400) return { id: "weekly", label: "Weekly window" };
|
|
186
|
+
if (seconds <= 31 * 86400) return { id: "monthly", label: "Monthly window" };
|
|
187
|
+
return { id: "window", label: "Window" };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Map a provider-supplied window name onto a canonical id/label pair.
|
|
192
|
+
*
|
|
193
|
+
* @param {unknown} name
|
|
194
|
+
* @returns {{ id: string, label: string }}
|
|
195
|
+
*/
|
|
196
|
+
export function windowFromName(name) {
|
|
197
|
+
const text = (stringValue(name) ?? "").toLowerCase();
|
|
198
|
+
if (text.includes("week")) return { id: "weekly", label: "Weekly window" };
|
|
199
|
+
if (text.includes("month")) return { id: "monthly", label: "Monthly window" };
|
|
200
|
+
if (text.includes("day") && !text.includes("week")) return { id: "daily", label: "Daily window" };
|
|
201
|
+
if (text.includes("5h") || text.includes("five") || text.includes("session") || text.includes("hour")) {
|
|
202
|
+
return { id: "5h", label: "5h window" };
|
|
203
|
+
}
|
|
204
|
+
return { id: text || "window", label: text || "Window" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Format a second count as a short human duration, e.g. "3h 12m".
|
|
209
|
+
*
|
|
210
|
+
* @param {number | null} seconds
|
|
211
|
+
* @returns {string}
|
|
212
|
+
*/
|
|
213
|
+
export function humanDuration(seconds) {
|
|
214
|
+
if (seconds === null || !Number.isFinite(seconds)) return "unknown";
|
|
215
|
+
if (seconds <= 0) return "now";
|
|
216
|
+
const total = Math.round(seconds);
|
|
217
|
+
const days = Math.floor(total / 86400);
|
|
218
|
+
const hours = Math.floor((total % 86400) / 3600);
|
|
219
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
220
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
221
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
222
|
+
if (minutes > 0) return `${minutes}m`;
|
|
223
|
+
return `${total}s`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Format a reset countdown for a UI string.
|
|
228
|
+
*
|
|
229
|
+
* @param {number | null} seconds
|
|
230
|
+
* @returns {string}
|
|
231
|
+
*/
|
|
232
|
+
export function humanReset(seconds) {
|
|
233
|
+
if (seconds === null) return "reset unknown";
|
|
234
|
+
if (seconds <= 0) return "resetting now";
|
|
235
|
+
return `reset in ${humanDuration(seconds)}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Build a complete degraded result for one family.
|
|
240
|
+
*
|
|
241
|
+
* @param {{
|
|
242
|
+
* family: string,
|
|
243
|
+
* label: string,
|
|
244
|
+
* account?: string | null,
|
|
245
|
+
* plan?: string | null,
|
|
246
|
+
* error: string,
|
|
247
|
+
* source: string,
|
|
248
|
+
* now?: number,
|
|
249
|
+
* expiresInMin?: number | null,
|
|
250
|
+
* windows?: QuotaWindow[],
|
|
251
|
+
* }} input
|
|
252
|
+
* @returns {QuotaResult}
|
|
253
|
+
*/
|
|
254
|
+
export function degradedResult(input) {
|
|
255
|
+
const notConfigured = Boolean(
|
|
256
|
+
input.notConfigured ??
|
|
257
|
+
(typeof input.error === "string" && input.error.startsWith("no ") && input.error.includes("credential in the Pi store")),
|
|
258
|
+
);
|
|
259
|
+
return {
|
|
260
|
+
family: input.family,
|
|
261
|
+
label: input.label,
|
|
262
|
+
account: input.account ?? "unknown",
|
|
263
|
+
plan: input.plan ?? null,
|
|
264
|
+
windows: input.windows ?? [],
|
|
265
|
+
error: input.error,
|
|
266
|
+
ok: false,
|
|
267
|
+
notConfigured,
|
|
268
|
+
updatedAt: new Date(input.now ?? Date.now()).toISOString(),
|
|
269
|
+
source: input.source,
|
|
270
|
+
sourceKind: input.sourceKind,
|
|
271
|
+
expiresInMin: input.expiresInMin ?? null,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Pick a display identity without leaking full secrets.
|
|
277
|
+
*
|
|
278
|
+
* @param {{ email?: string | null, accountId?: string | null, projectId?: string | null, identity?: string }} credential
|
|
279
|
+
* @returns {string}
|
|
280
|
+
*/
|
|
281
|
+
export function displayIdentity(credential) {
|
|
282
|
+
if (credential.email) return credential.email;
|
|
283
|
+
if (credential.accountId) return `${credential.accountId.slice(0, 8)}…`;
|
|
284
|
+
if (credential.projectId) return `project ${credential.projectId.slice(0, 8)}…`;
|
|
285
|
+
return credential.identity ? `id ${credential.identity.slice(0, 8)}…` : "unknown";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Rank a window by how soon it constrains you: 0 = shortest (5h/session),
|
|
290
|
+
* then daily, weekly, monthly, and finally anything unrecognized.
|
|
291
|
+
*
|
|
292
|
+
* @param {QuotaWindow} window
|
|
293
|
+
* @returns {number}
|
|
294
|
+
*/
|
|
295
|
+
export function windowRank(window) {
|
|
296
|
+
const seconds = finiteNumber(window?.windowSeconds);
|
|
297
|
+
if (seconds !== null && seconds > 0) {
|
|
298
|
+
if (seconds <= 6 * 3600) return 0;
|
|
299
|
+
if (seconds <= 36 * 3600) return 1;
|
|
300
|
+
if (seconds <= 8 * 86400) return 2;
|
|
301
|
+
if (seconds <= 31 * 86400) return 3;
|
|
302
|
+
}
|
|
303
|
+
const id = String(window?.id ?? "").toLowerCase();
|
|
304
|
+
if (id.includes("5h") || id.includes("session") || id.includes("hour") || id.includes("rolling")) return 0;
|
|
305
|
+
if (id.includes("daily") || id.includes("day")) return 1;
|
|
306
|
+
if (id.includes("weekly") || id.includes("week") || id.includes("7d")) return 2;
|
|
307
|
+
if (id.includes("monthly") || id.includes("month") || id.includes("30d")) return 3;
|
|
308
|
+
return 4;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The window that represents a provider at a glance.
|
|
313
|
+
*
|
|
314
|
+
* Shortest first, because that is the limit you hit first: a 5h window at 3%
|
|
315
|
+
* used tells you far more about "can I keep working" than a weekly window at
|
|
316
|
+
* 69%. Ties inside the same rank go to the tightest one, so Antigravity's two
|
|
317
|
+
* 5h groups resolve to whichever is more consumed.
|
|
318
|
+
*
|
|
319
|
+
* @param {QuotaWindow[]} windows
|
|
320
|
+
* @returns {QuotaWindow | null}
|
|
321
|
+
*/
|
|
322
|
+
export function selectPrimaryWindow(windows) {
|
|
323
|
+
const list = Array.isArray(windows) ? windows : [];
|
|
324
|
+
const scored = list.filter((window) => window?.remainingPercent !== null && window?.remainingPercent !== undefined);
|
|
325
|
+
if (scored.length === 0) return list[0] ?? null;
|
|
326
|
+
return scored.reduce((best, window) => {
|
|
327
|
+
const bestRank = windowRank(best);
|
|
328
|
+
const rank = windowRank(window);
|
|
329
|
+
if (rank !== bestRank) return rank < bestRank ? window : best;
|
|
330
|
+
return /** @type {number} */ (window.remainingPercent) < /** @type {number} */ (best.remainingPercent) ? window : best;
|
|
331
|
+
});
|
|
332
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="257" preserveAspectRatio="xMidYMid" viewBox="0 0 256 257"><path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="#fff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/></svg>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg viewBox="0 0 98 96" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#2088FF"/>
|
|
3
|
+
</svg>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic writer for Omarchy agent usage records.
|
|
3
|
+
*
|
|
4
|
+
* This is the one place piQuota writes outside its own cache and state
|
|
5
|
+
* directories, and it is opt-in (`piquota omarchy`). It only ever creates or
|
|
6
|
+
* removes files named `pi-<family>.json` in Omarchy's usage directory, so
|
|
7
|
+
* Omarchy's own `claude.json` / `codex.json` records are never touched.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
import { isPiRecordId, RECORD_PREFIX } from "./record.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `PI_QUOTA_OMARCHY_DIR` wins; otherwise Omarchy's own location under
|
|
19
|
+
* `$XDG_STATE_HOME` (default `~/.local/state`).
|
|
20
|
+
*
|
|
21
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string }} [options]
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function resolveOmarchyDir(options = {}) {
|
|
25
|
+
const env = options.env ?? process.env;
|
|
26
|
+
if (env.PI_QUOTA_OMARCHY_DIR) return env.PI_QUOTA_OMARCHY_DIR;
|
|
27
|
+
const state = env.XDG_STATE_HOME || join(options.home ?? homedir(), ".local", "state");
|
|
28
|
+
return join(state, "omarchy", "agents", "usage");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} dir
|
|
33
|
+
* @param {string} id
|
|
34
|
+
* @param {Record<string, unknown>} record
|
|
35
|
+
* @returns {string} The final path.
|
|
36
|
+
*/
|
|
37
|
+
function writeRecord(dir, id, record) {
|
|
38
|
+
const target = join(dir, `${id}.json`);
|
|
39
|
+
const content = `${JSON.stringify(record)}\n`;
|
|
40
|
+
if (existsSync(target)) {
|
|
41
|
+
try {
|
|
42
|
+
if (readFileSync(target, "utf-8") === content) {
|
|
43
|
+
return target;
|
|
44
|
+
}
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
// The temp name does not end in `.json`, so Omarchy's `*.json` watcher never
|
|
48
|
+
// sees a half-written file.
|
|
49
|
+
const temp = join(dir, `.${id}.${process.pid}.tmp`);
|
|
50
|
+
try {
|
|
51
|
+
writeFileSync(temp, content, { mode: 0o644 });
|
|
52
|
+
renameSync(temp, target);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
rmSync(temp, { force: true });
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
return target;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Copy a file only if the target does not exist or its content differs.
|
|
62
|
+
* This is critical when writing to directories watched by inotify, such as
|
|
63
|
+
* Omarchy's plugins directory, where redundant writes trigger full shell reloads.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} srcPath
|
|
66
|
+
* @param {string} dstPath
|
|
67
|
+
* @returns {boolean} Whether the file was written.
|
|
68
|
+
*/
|
|
69
|
+
function copyIfChanged(srcPath, dstPath) {
|
|
70
|
+
try {
|
|
71
|
+
if (existsSync(dstPath)) {
|
|
72
|
+
const srcBuf = readFileSync(srcPath);
|
|
73
|
+
const dstBuf = readFileSync(dstPath);
|
|
74
|
+
if (srcBuf.equals(dstBuf)) return false;
|
|
75
|
+
}
|
|
76
|
+
copyFileSync(srcPath, dstPath);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Synchronize provider SVG icons into Omarchy plugin assets directories if present.
|
|
85
|
+
*
|
|
86
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string }} [options]
|
|
87
|
+
* @returns {string[]} Paths of written assets.
|
|
88
|
+
*/
|
|
89
|
+
export function syncOmarchyAssets(options = {}) {
|
|
90
|
+
const env = options.env ?? process.env;
|
|
91
|
+
const home = options.home ?? (env.HOME || homedir());
|
|
92
|
+
const pluginsDir = join(home, ".config", "omarchy", "plugins");
|
|
93
|
+
if (!existsSync(pluginsDir)) return [];
|
|
94
|
+
|
|
95
|
+
const assetsSrc = fileURLToPath(new URL("assets", import.meta.url));
|
|
96
|
+
if (!existsSync(assetsSrc)) return [];
|
|
97
|
+
|
|
98
|
+
const copied = [];
|
|
99
|
+
try {
|
|
100
|
+
for (const plugin of readdirSync(pluginsDir)) {
|
|
101
|
+
const pluginDir = join(pluginsDir, plugin);
|
|
102
|
+
const manifestPath = join(pluginDir, "manifest.json");
|
|
103
|
+
if (!existsSync(manifestPath)) continue;
|
|
104
|
+
const targetAssetsDir = join(pluginDir, "assets");
|
|
105
|
+
if (!existsSync(targetAssetsDir)) {
|
|
106
|
+
mkdirSync(targetAssetsDir, { recursive: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const file of readdirSync(assetsSrc)) {
|
|
110
|
+
if (!file.endsWith(".svg")) continue;
|
|
111
|
+
const srcPath = join(assetsSrc, file);
|
|
112
|
+
const dstPath = join(targetAssetsDir, file);
|
|
113
|
+
const piDstPath = join(targetAssetsDir, `pi-${file}`);
|
|
114
|
+
if (copyIfChanged(srcPath, dstPath)) copied.push(dstPath);
|
|
115
|
+
if (copyIfChanged(srcPath, piDstPath)) copied.push(piDstPath);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch {}
|
|
119
|
+
return copied;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Write the given records and remove stale `pi-*.json` files whose provider is
|
|
124
|
+
* no longer present.
|
|
125
|
+
*
|
|
126
|
+
* @param {Array<{ id: string, record: Record<string, unknown> }>} records
|
|
127
|
+
* @param {{ dir?: string, env?: Record<string, string | undefined>, home?: string }} [options]
|
|
128
|
+
* @returns {{ dir: string, written: string[], removed: string[] }}
|
|
129
|
+
*/
|
|
130
|
+
export function publishRecords(records, options = {}) {
|
|
131
|
+
const dir = options.dir ?? resolveOmarchyDir(options);
|
|
132
|
+
mkdirSync(dir, { recursive: true });
|
|
133
|
+
|
|
134
|
+
const written = [];
|
|
135
|
+
for (const { id, record } of records) {
|
|
136
|
+
if (!isPiRecordId(id)) continue;
|
|
137
|
+
written.push(writeRecord(dir, id, record));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const keep = new Set(records.map(({ id }) => `${id}.json`));
|
|
141
|
+
const removed = [];
|
|
142
|
+
for (const name of readdirSync(dir)) {
|
|
143
|
+
if (!name.startsWith(RECORD_PREFIX) || !name.endsWith(".json")) continue;
|
|
144
|
+
if (!isPiRecordId(name.slice(0, -".json".length)) || keep.has(name)) continue;
|
|
145
|
+
rmSync(join(dir, name), { force: true });
|
|
146
|
+
removed.push(join(dir, name));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
syncOmarchyAssets(options);
|
|
150
|
+
|
|
151
|
+
return { dir, written, removed };
|
|
152
|
+
}
|