@songsid/agend 2.1.2-beta.8 → 2.1.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.
Files changed (174) hide show
  1. package/dist/access-path.js +15 -6
  2. package/dist/access-path.js.map +1 -1
  3. package/dist/agent-cli-instructions.md +1 -0
  4. package/dist/agent-cli.js +14 -0
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.d.ts +2 -0
  7. package/dist/agent-endpoint.js +25 -1
  8. package/dist/agent-endpoint.js.map +1 -1
  9. package/dist/backend/antigravity.d.ts +20 -0
  10. package/dist/backend/antigravity.js +51 -4
  11. package/dist/backend/antigravity.js.map +1 -1
  12. package/dist/backend/claude-code.d.ts +59 -0
  13. package/dist/backend/claude-code.js +67 -1
  14. package/dist/backend/claude-code.js.map +1 -1
  15. package/dist/backend/codex.d.ts +26 -0
  16. package/dist/backend/codex.js +100 -0
  17. package/dist/backend/codex.js.map +1 -1
  18. package/dist/backend/grok.d.ts +25 -0
  19. package/dist/backend/grok.js +51 -0
  20. package/dist/backend/grok.js.map +1 -1
  21. package/dist/backend/kiro.d.ts +23 -0
  22. package/dist/backend/kiro.js +120 -2
  23. package/dist/backend/kiro.js.map +1 -1
  24. package/dist/backend/types.d.ts +58 -0
  25. package/dist/backend/types.js +13 -1
  26. package/dist/backend/types.js.map +1 -1
  27. package/dist/channel/adapters/discord.d.ts +15 -0
  28. package/dist/channel/adapters/discord.js +120 -8
  29. package/dist/channel/adapters/discord.js.map +1 -1
  30. package/dist/channel/adapters/telegram.d.ts +21 -0
  31. package/dist/channel/adapters/telegram.js +86 -0
  32. package/dist/channel/adapters/telegram.js.map +1 -1
  33. package/dist/channel/ipc-bridge.d.ts +9 -1
  34. package/dist/channel/ipc-bridge.js +12 -3
  35. package/dist/channel/ipc-bridge.js.map +1 -1
  36. package/dist/channel/ipc-timeouts.d.ts +46 -0
  37. package/dist/channel/ipc-timeouts.js +65 -0
  38. package/dist/channel/ipc-timeouts.js.map +1 -0
  39. package/dist/channel/mcp-server.js +25 -14
  40. package/dist/channel/mcp-server.js.map +1 -1
  41. package/dist/channel/mcp-tools.js +30 -1
  42. package/dist/channel/mcp-tools.js.map +1 -1
  43. package/dist/channel/reconnect-backoff.d.ts +17 -0
  44. package/dist/channel/reconnect-backoff.js +21 -0
  45. package/dist/channel/reconnect-backoff.js.map +1 -0
  46. package/dist/channel/types.d.ts +29 -0
  47. package/dist/classic-channel-manager.js +1 -5
  48. package/dist/classic-channel-manager.js.map +1 -1
  49. package/dist/cli.js +242 -91
  50. package/dist/cli.js.map +1 -1
  51. package/dist/completion.d.ts +27 -0
  52. package/dist/completion.js +121 -0
  53. package/dist/completion.js.map +1 -0
  54. package/dist/config-validator.js +21 -0
  55. package/dist/config-validator.js.map +1 -1
  56. package/dist/config.js +2 -0
  57. package/dist/config.js.map +1 -1
  58. package/dist/cost-guard.d.ts +3 -1
  59. package/dist/cost-guard.js +3 -1
  60. package/dist/cost-guard.js.map +1 -1
  61. package/dist/daemon.d.ts +299 -7
  62. package/dist/daemon.js +1152 -311
  63. package/dist/daemon.js.map +1 -1
  64. package/dist/event-log.d.ts +31 -0
  65. package/dist/event-log.js +96 -0
  66. package/dist/event-log.js.map +1 -1
  67. package/dist/fleet-context.d.ts +14 -0
  68. package/dist/fleet-lock.d.ts +28 -0
  69. package/dist/fleet-lock.js +130 -0
  70. package/dist/fleet-lock.js.map +1 -0
  71. package/dist/fleet-manager.d.ts +348 -6
  72. package/dist/fleet-manager.js +1860 -262
  73. package/dist/fleet-manager.js.map +1 -1
  74. package/dist/general-instance.d.ts +9 -0
  75. package/dist/general-instance.js +11 -0
  76. package/dist/general-instance.js.map +1 -0
  77. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +22 -0
  78. package/dist/general-knowledge/skills/model-discovery/SKILL.md +14 -23
  79. package/dist/general-knowledge/skills/session-management/SKILL.md +15 -34
  80. package/dist/hang-detector.d.ts +19 -13
  81. package/dist/hang-detector.js +19 -49
  82. package/dist/hang-detector.js.map +1 -1
  83. package/dist/instance-lifecycle.d.ts +58 -2
  84. package/dist/instance-lifecycle.js +231 -46
  85. package/dist/instance-lifecycle.js.map +1 -1
  86. package/dist/locale.js +7 -1
  87. package/dist/locale.js.map +1 -1
  88. package/dist/logger.js +14 -0
  89. package/dist/logger.js.map +1 -1
  90. package/dist/mcp-liveness.d.ts +21 -0
  91. package/dist/mcp-liveness.js +27 -0
  92. package/dist/mcp-liveness.js.map +1 -0
  93. package/dist/outbound-handlers.d.ts +9 -0
  94. package/dist/outbound-handlers.js +194 -31
  95. package/dist/outbound-handlers.js.map +1 -1
  96. package/dist/outbound-schemas.d.ts +11 -3
  97. package/dist/outbound-schemas.js +16 -2
  98. package/dist/outbound-schemas.js.map +1 -1
  99. package/dist/pane-write-lock.d.ts +48 -0
  100. package/dist/pane-write-lock.js +73 -0
  101. package/dist/pane-write-lock.js.map +1 -0
  102. package/dist/process-memory.d.ts +31 -0
  103. package/dist/process-memory.js +79 -0
  104. package/dist/process-memory.js.map +1 -0
  105. package/dist/quickstart.js +17 -16
  106. package/dist/quickstart.js.map +1 -1
  107. package/dist/reply-dedup.d.ts +41 -0
  108. package/dist/reply-dedup.js +0 -0
  109. package/dist/reply-dedup.js.map +1 -0
  110. package/dist/restart-progress.d.ts +41 -0
  111. package/dist/restart-progress.js +97 -0
  112. package/dist/restart-progress.js.map +1 -0
  113. package/dist/scheduler/db.js +3 -0
  114. package/dist/scheduler/db.js.map +1 -1
  115. package/dist/sd-notify.d.ts +27 -0
  116. package/dist/sd-notify.js +33 -1
  117. package/dist/sd-notify.js.map +1 -1
  118. package/dist/secret-file.d.ts +33 -0
  119. package/dist/secret-file.js +36 -0
  120. package/dist/secret-file.js.map +1 -0
  121. package/dist/service-installer.d.ts +28 -0
  122. package/dist/service-installer.js +72 -1
  123. package/dist/service-installer.js.map +1 -1
  124. package/dist/settings-api.js +5 -3
  125. package/dist/settings-api.js.map +1 -1
  126. package/dist/setup-wizard.js +9 -7
  127. package/dist/setup-wizard.js.map +1 -1
  128. package/dist/tmux-control.d.ts +58 -5
  129. package/dist/tmux-control.js +102 -14
  130. package/dist/tmux-control.js.map +1 -1
  131. package/dist/tmux-manager.d.ts +31 -1
  132. package/dist/tmux-manager.js +40 -11
  133. package/dist/tmux-manager.js.map +1 -1
  134. package/dist/topic-commands.d.ts +49 -9
  135. package/dist/topic-commands.js +270 -109
  136. package/dist/topic-commands.js.map +1 -1
  137. package/dist/tui-glyphs.d.ts +5 -1
  138. package/dist/tui-glyphs.js +6 -2
  139. package/dist/tui-glyphs.js.map +1 -1
  140. package/dist/types.d.ts +19 -2
  141. package/dist/ui/settings.html +1 -1
  142. package/dist/ui/view.html +123 -2
  143. package/dist/update-marker.d.ts +15 -0
  144. package/dist/update-marker.js +69 -0
  145. package/dist/update-marker.js.map +1 -0
  146. package/dist/usage/format-rich.d.ts +11 -0
  147. package/dist/usage/format-rich.js +121 -0
  148. package/dist/usage/format-rich.js.map +1 -0
  149. package/dist/usage/provider-alerts.d.ts +33 -0
  150. package/dist/usage/provider-alerts.js +57 -0
  151. package/dist/usage/provider-alerts.js.map +1 -0
  152. package/dist/usage/providers.d.ts +130 -0
  153. package/dist/usage/providers.js +1063 -0
  154. package/dist/usage/providers.js.map +1 -0
  155. package/dist/usage/statusline-usage.d.ts +20 -0
  156. package/dist/usage/statusline-usage.js +120 -0
  157. package/dist/usage/statusline-usage.js.map +1 -0
  158. package/dist/usage/usage-api.d.ts +50 -0
  159. package/dist/usage/usage-api.js +197 -0
  160. package/dist/usage/usage-api.js.map +1 -0
  161. package/dist/view-api.js +8 -3
  162. package/dist/view-api.js.map +1 -1
  163. package/dist/web-api.js +7 -3
  164. package/dist/web-api.js.map +1 -1
  165. package/dist/web-auth.d.ts +6 -0
  166. package/dist/web-auth.js +54 -0
  167. package/dist/web-auth.js.map +1 -0
  168. package/package.json +4 -1
  169. package/dist/channel/tool-tracker.d.ts +0 -13
  170. package/dist/channel/tool-tracker.js +0 -58
  171. package/dist/channel/tool-tracker.js.map +0 -1
  172. package/dist/daemon-entry.d.ts +0 -1
  173. package/dist/daemon-entry.js +0 -30
  174. package/dist/daemon-entry.js.map +0 -1
@@ -0,0 +1,1063 @@
1
+ /**
2
+ * AI subscription usage providers — read the credentials the CLI backends
3
+ * already saved on this machine and fetch live plan usage from each vendor's
4
+ * own usage endpoint. Powers `GET /api/ai-usage` (usage-api.ts) and the
5
+ * Usage panel on /view.
6
+ *
7
+ * Vendored from ai-usage-board (https://github.com/songsid/ai-usage-board, MIT),
8
+ * whose provider protocol logic is in turn ported from OpenUsage
9
+ * (https://github.com/robinebers/openusage, MIT, © Robin Ebers). See LICENSE.md
10
+ * in this directory. Not affiliated with or endorsed by OpenUsage.
11
+ *
12
+ * Token policy:
13
+ * - Claude/Codex: read-only, never refreshed — those vendors rotate refresh
14
+ * tokens, and refreshing behind the CLI's back could invalidate its login.
15
+ * Their credential files stay fresh as long as the CLIs are used (which, in
16
+ * a running fleet, they are).
17
+ * - Grok: access tokens expire within hours, so we refresh exactly like the
18
+ * Grok CLI does and persist rotated tokens back to auth.json — refusing to
19
+ * refresh at all when the file isn't writable, rather than risk the login.
20
+ */
21
+ import { readFile, writeFile, access, constants } from "node:fs/promises";
22
+ import { readFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { homedir } from "node:os";
25
+ import { getProviderRateLimit } from "./provider-alerts.js";
26
+ import { readStatuslineRateLimits, } from "./statusline-usage.js";
27
+ import Database from "better-sqlite3";
28
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
29
+ const SESSION_MS = 5 * 60 * 60 * 1000;
30
+ function jwtExpiryMs(token) {
31
+ try {
32
+ const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
33
+ return typeof payload.exp === "number" ? payload.exp * 1000 : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ // ── Claude ───────────────────────────────────────────────────────────────────
40
+ const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
41
+ function claudeResetIso(value) {
42
+ if (typeof value === "string" && value.trim()) {
43
+ const d = new Date(value);
44
+ if (!Number.isNaN(d.getTime()))
45
+ return d.toISOString();
46
+ }
47
+ if (typeof value === "number" && Number.isFinite(value)) {
48
+ const ms = Math.abs(value) < 1e10 ? value * 1000 : value;
49
+ return new Date(ms).toISOString();
50
+ }
51
+ return null;
52
+ }
53
+ /**
54
+ * Shell rc files an interactive login would have exported the token into.
55
+ *
56
+ * `claude setup-token` writes NO file — verified: there is no
57
+ * ~/.config/claude-code, and the binary only ever reads
58
+ * CLAUDE_CODE_OAUTH_TOKEN from the environment. It prints the token and the
59
+ * user exports it, typically into a shell rc. A systemd daemon inherits none of
60
+ * that, which is why the token was invisible to the fleet.
61
+ *
62
+ * Reading these is bounded and defensible: same machine, same user, the user's
63
+ * own token, and only one specific variable is extracted. It is a fallback —
64
+ * anything already in the environment or the credentials file wins.
65
+ */
66
+ const SHELL_RC_FILES = [".bashrc", ".bash_profile", ".zshrc", ".profile"];
67
+ /** `CLAUDE_CODE_OAUTH_TOKEN` as exported in a shell rc file, or null. */
68
+ export function tokenFromShellRc(read, home = homedir(), files = SHELL_RC_FILES) {
69
+ // Last assignment wins, matching how a shell would evaluate the file.
70
+ const pattern = /^[ \t]*(?:export[ \t]+)?CLAUDE_CODE_OAUTH_TOKEN[ \t]*=[ \t]*["']?([^"'\s#]+)/gm;
71
+ for (const file of files) {
72
+ let content;
73
+ try {
74
+ content = read(join(home, file));
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ let found = null;
80
+ for (const m of content.matchAll(pattern))
81
+ found = m[1];
82
+ // A literal shell expansion ("$OTHER_VAR") is not a token; skip rather than
83
+ // send a dollar sign to Anthropic as a bearer.
84
+ if (found && !found.startsWith("$"))
85
+ return found;
86
+ }
87
+ return null;
88
+ }
89
+ /**
90
+ * Where the Claude OAuth bearer comes from, in preference order:
91
+ *
92
+ * 1. `~/.claude/.credentials.json` (`claudeAiOauth.accessToken`) when present and
93
+ * unexpired — the interactive `/login` flow. Carries plan metadata.
94
+ * 2. `CLAUDE_CODE_OAUTH_TOKEN` — the long-lived (annual) token minted by
95
+ * `claude setup-token`. Users on this flow often have NO credentials file at
96
+ * all (or a stale one from an old login), which is why /usage showed them
97
+ * "not logged in" / "token expired" while their CLI worked fine. Same token
98
+ * family (`sk-ant-oat…`), same usage endpoint; it just carries no plan
99
+ * metadata, so plan shows unknown.
100
+ * 3. The same variable as exported in a shell rc file. A systemd-launched daemon
101
+ * inherits none of the user's interactive shell, so a `setup-token` login was
102
+ * invisible to /usage unless the user duplicated it into ~/.agend/.env.
103
+ * 4. An expired file token with no other source stays an explicit error.
104
+ * 5. `ANTHROPIC_API_KEY` alone is named in the hint but NOT used: console API
105
+ * keys are pay-per-token and have no subscription usage to query — the OAuth
106
+ * endpoint rejects them, and pretending otherwise would render a misleading
107
+ * error instead of an accurate "log in" hint.
108
+ */
109
+ export function resolveClaudeAuth(file, env = process.env,
110
+ // Injectable so tests never depend on the developer's own ~/.bashrc.
111
+ rcLookup = readShellRcToken) {
112
+ let plan = null;
113
+ if (file?.subscriptionType?.trim()) {
114
+ const subscriptionType = file.subscriptionType.trim().toLowerCase();
115
+ const rateLimitTier = file.rateLimitTier?.trim().toLowerCase() ?? "";
116
+ if (subscriptionType === "team") {
117
+ // Anthropic reuses the Max rate-limit tier identifier for Team Premium,
118
+ // but the plan multiplier is 6.25x rather than the identifier's 5x.
119
+ plan = rateLimitTier.includes("max")
120
+ ? "Team Premium (6.25x)"
121
+ : "Team Standard (1.25x)";
122
+ }
123
+ else if (subscriptionType === "max") {
124
+ const tier = rateLimitTier.match(/(?:max_)?(\d+)x/);
125
+ plan = tier ? `Max (${tier[1]}x)` : "Max";
126
+ }
127
+ else if (subscriptionType === "pro") {
128
+ plan = "Pro";
129
+ }
130
+ else {
131
+ plan = subscriptionType.replace(/^./, c => c.toUpperCase());
132
+ }
133
+ }
134
+ const fileFresh = !!file?.accessToken && !(file.expiresAt && file.expiresAt < Date.now());
135
+ if (file?.accessToken && fileFresh)
136
+ return { token: file.accessToken.trim(), plan };
137
+ const envToken = env.CLAUDE_CODE_OAUTH_TOKEN?.trim();
138
+ if (envToken)
139
+ return { token: envToken, plan };
140
+ // Last resort: the shell rc the user exported it into. A systemd daemon does
141
+ // not inherit that, so without this the fleet cannot see a setup-token login
142
+ // unless the user duplicates it into ~/.agend/.env.
143
+ const rcToken = rcLookup();
144
+ if (rcToken)
145
+ return { token: rcToken, plan };
146
+ if (file?.accessToken) {
147
+ // AgEnD runs claude instances itself, and each run refreshes this file — so
148
+ // this usually clears on its own rather than needing a human.
149
+ return {
150
+ error: "Access token expired — it refreshes the next time any claude instance runs.",
151
+ plan,
152
+ };
153
+ }
154
+ return null;
155
+ }
156
+ /** Cached so /usage does not stat four rc files on every call. */
157
+ let shellRcTokenCache = null;
158
+ function readShellRcToken() {
159
+ if (shellRcTokenCache && Date.now() - shellRcTokenCache.at < 5 * 60_000) {
160
+ return shellRcTokenCache.token;
161
+ }
162
+ const token = tokenFromShellRc(path => readFileSync(path, "utf-8"));
163
+ shellRcTokenCache = { token, at: Date.now() };
164
+ return token;
165
+ }
166
+ /**
167
+ * Render a statusline reading as provider metrics.
168
+ *
169
+ * Kept deliberately close to the API path's shape so the panel looks the same
170
+ * wherever the numbers came from — the hint is what tells the difference, and
171
+ * it names the source because a number with no provenance invites "is this
172
+ * even live?".
173
+ */
174
+ export function statuslineUsage(reading, plan, now = Date.now()) {
175
+ const metrics = [];
176
+ const add = (w, label, windowMs) => {
177
+ if (!w)
178
+ return;
179
+ metrics.push({
180
+ label,
181
+ type: "percent",
182
+ used: w.usedPercent,
183
+ resetsAt: w.resetsAtMs !== null ? new Date(w.resetsAtMs).toISOString() : null,
184
+ windowMs,
185
+ });
186
+ };
187
+ add(reading.fiveHour, "Session", SESSION_MS);
188
+ add(reading.sevenDay, "Weekly", WEEK_MS);
189
+ const ageMin = Math.round((now - reading.observedAtMs) / 60_000);
190
+ const age = ageMin <= 0 ? "just now" : `${ageMin}m ago`;
191
+ return {
192
+ status: "ok",
193
+ plan,
194
+ // Say what is missing as well as where it came from: someone comparing this
195
+ // against claude.ai should not have to guess why per-model rows vanished.
196
+ hint: `from ${reading.instance}'s statusline, ${age} — no API call; plan and per-model limits need a CLI login`,
197
+ metrics,
198
+ };
199
+ }
200
+ /** Exported for tests — the statusline/API preference order is the subject. */
201
+ export async function fetchClaudeUsage() {
202
+ const home = process.env.CLAUDE_HOME || join(homedir(), ".claude");
203
+ let file;
204
+ try {
205
+ file = JSON.parse(await readFile(join(home, ".credentials.json"), "utf8")).claudeAiOauth ?? null;
206
+ }
207
+ catch {
208
+ file = null;
209
+ }
210
+ // Read before anything else: it costs one stat per instance, needs no token,
211
+ // and it is what keeps the panel useful when the API path cannot run at all.
212
+ const statusline = readStatuslineRateLimits();
213
+ const auth = resolveClaudeAuth(file);
214
+ if (auth === null) {
215
+ // No token anywhere — but the CLI itself is logged in somehow (including an
216
+ // API key, which the usage endpoint rejects outright), and it wrote its
217
+ // limits down. This is the case the whole statusline source exists for.
218
+ if (statusline)
219
+ return statuslineUsage(statusline, null);
220
+ const hint = process.env.ANTHROPIC_API_KEY
221
+ ? "API-key login has no subscription usage. Use `claude /login` or set CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`)."
222
+ : "Log in with the Claude Code CLI (`claude`).";
223
+ return { status: "no-credentials", hint, metrics: [] };
224
+ }
225
+ if ("error" in auth) {
226
+ if (statusline)
227
+ return statuslineUsage(statusline, auth.plan);
228
+ return { status: "error", plan: auth.plan, error: auth.error, metrics: [] };
229
+ }
230
+ const plan = auth.plan;
231
+ /** Every API failure degrades to the statusline rather than to an error row. */
232
+ const orStatusline = (failure) => statusline ? statuslineUsage(statusline, plan) : failure;
233
+ let res;
234
+ try {
235
+ res = await fetch(CLAUDE_USAGE_URL, {
236
+ headers: {
237
+ Authorization: `Bearer ${auth.token}`,
238
+ Accept: "application/json",
239
+ "Content-Type": "application/json",
240
+ "anthropic-beta": "oauth-2025-04-20",
241
+ "User-Agent": "claude-code/2.1.69",
242
+ },
243
+ signal: AbortSignal.timeout(10_000),
244
+ });
245
+ }
246
+ catch {
247
+ return orStatusline({ status: "error", plan, error: "Could not reach api.anthropic.com.", metrics: [] });
248
+ }
249
+ if (res.status === 401 || res.status === 403) {
250
+ return orStatusline({ status: "error", plan, error: "Token rejected. Run `claude` once to refresh the login.", metrics: [] });
251
+ }
252
+ if (res.status === 429) {
253
+ return orStatusline({ status: "error", plan, error: "Rate limited by Anthropic — try again later.", metrics: [] });
254
+ }
255
+ if (!res.ok)
256
+ return orStatusline({ status: "error", plan, error: `Usage request failed (HTTP ${res.status}).`, metrics: [] });
257
+ let body;
258
+ try {
259
+ body = await res.json();
260
+ }
261
+ catch {
262
+ return orStatusline({ status: "error", plan, error: "Invalid response from usage endpoint.", metrics: [] });
263
+ }
264
+ const metrics = [];
265
+ const window = (obj, label, windowMs) => {
266
+ const o = obj;
267
+ if (typeof o?.utilization !== "number")
268
+ return;
269
+ metrics.push({ label, type: "percent", used: o.utilization, resetsAt: claudeResetIso(o.resets_at), windowMs });
270
+ };
271
+ window(body.five_hour, "Session", SESSION_MS);
272
+ window(body.seven_day, "Weekly", WEEK_MS);
273
+ window(body.seven_day_sonnet, "Sonnet (weekly)", WEEK_MS);
274
+ // Per-model weekly windows live in the `limits` array (kind: weekly_scoped).
275
+ // `limits` also carries which window is currently BINDING (`is_active`) and a
276
+ // severity the top-level windows do not expose. Probed live on a Team account:
277
+ // the scoped weekly (26%) was active while the plain weekly (15%) was not —
278
+ // i.e. the number that will actually stop you is not always the biggest one.
279
+ const activeKinds = new Set();
280
+ for (const entry of Array.isArray(body.limits) ? body.limits : []) {
281
+ const e = entry;
282
+ if (e?.is_active === true && typeof e.kind === "string")
283
+ activeKinds.add(e.kind);
284
+ if (e?.kind !== "weekly_scoped")
285
+ continue;
286
+ const model = e.scope?.model?.display_name;
287
+ if (!model || typeof e.percent !== "number")
288
+ continue;
289
+ metrics.push({
290
+ label: `${model} (weekly)`,
291
+ type: "percent",
292
+ used: e.percent,
293
+ resetsAt: claudeResetIso(e.resets_at),
294
+ windowMs: WEEK_MS,
295
+ ...(e.is_active === true ? { note: "binding" } : {}),
296
+ ...(typeof e.severity === "string" && e.severity !== "normal" ? { note: `binding · ${e.severity}` } : {}),
297
+ });
298
+ }
299
+ // Mark the two plain windows too, using the same source.
300
+ for (const m of metrics) {
301
+ if (m.note)
302
+ continue;
303
+ if (m.label === "Session" && activeKinds.has("session"))
304
+ m.note = "binding";
305
+ if (m.label === "Weekly" && activeKinds.has("weekly_all"))
306
+ m.note = "binding";
307
+ }
308
+ const extra = body.extra_usage;
309
+ if (extra?.is_enabled === true && typeof extra.used_credits === "number") {
310
+ const used = extra.used_credits / 100;
311
+ const limit = typeof extra.monthly_limit === "number" && extra.monthly_limit > 0 ? extra.monthly_limit / 100 : null;
312
+ if (limit)
313
+ metrics.push({ label: "Extra usage", type: "dollars", used, limit });
314
+ else if (used > 0)
315
+ metrics.push({ label: "Extra usage", type: "dollars", used });
316
+ }
317
+ // A 200 with nothing in it is not better than the file on disk.
318
+ if (metrics.length === 0 && statusline)
319
+ return statuslineUsage(statusline, plan);
320
+ return { status: "ok", plan, metrics };
321
+ }
322
+ // ── Antigravity ──────────────────────────────────────────────────────────────
323
+ //
324
+ // Cloud Code quota API, verified live against this machine's real agy login
325
+ // (2026-08-02): refresh the Google OAuth token, then POST
326
+ // `retrieveUserQuotaSummary`. The flow and the installed-app client pair come
327
+ // from OpenUsage's Antigravity provider; for installed-app OAuth clients Google
328
+ // does not treat the "secret" as confidential (it ships inside every copy of
329
+ // the app), so embedding it is the accepted norm, not a leaked key. It is
330
+ // required for the refresh-token grant.
331
+ const AGY_TOKEN_FILE = ["antigravity-cli", "antigravity-oauth-token"];
332
+ const AGY_CLOUDCODE_URLS = [
333
+ "https://daily-cloudcode-pa.googleapis.com",
334
+ "https://cloudcode-pa.googleapis.com",
335
+ ];
336
+ /**
337
+ * The Google installed-app OAuth client pair, READ FROM THE INSTALLED agy
338
+ * BINARY rather than committed here.
339
+ *
340
+ * Installed-app clients embed their "secret" in every copy, so Google does not
341
+ * treat it as confidential — but that does not make it ours to publish, and
342
+ * GitHub's push protection agrees. Scanning the binary we are already
343
+ * authenticating on behalf of keeps the credential where its owner put it: if
344
+ * agy is not installed, we simply cannot refresh, which is the correct answer
345
+ * anyway. ~180MB scans in well under a second and the result is cached.
346
+ */
347
+ /**
348
+ * Refresh the Google OAuth token — only when the operator has supplied the
349
+ * installed-app client pair.
350
+ *
351
+ * agy stores a Google OAuth token that expires hourly and refreshes it whenever
352
+ * agy itself runs. Refreshing it ourselves needs the app's installed-app client
353
+ * pair, and there is no honest way for AgEnD to obtain it: it is not recoverable
354
+ * from the shipped binary (the strings that look like it are a different
355
+ * client), and hard-coding the published pair would mean committing a
356
+ * credential to a public repo — which GitHub push protection blocks, correctly.
357
+ *
358
+ * So it is opt-in. Without it, an expired token yields a clear "run `agy` once"
359
+ * hint rather than a broken panel — and in the case this provider actually
360
+ * exists for (a fleet with a live agy instance), agy is running and keeping its
361
+ * own token fresh, so the refresh is rarely needed at all.
362
+ */
363
+ function agyOAuthClient(env = process.env) {
364
+ const id = env.AGY_OAUTH_CLIENT_ID?.trim();
365
+ const secret = env.AGY_OAUTH_CLIENT_SECRET?.trim();
366
+ return id && secret ? { id, secret } : null;
367
+ }
368
+ /** Refreshed bearer, cached until shortly before expiry. The CLI's token file is
369
+ * never written back — it belongs to agy; refresh tokens are reusable. */
370
+ let agyTokenCache = null;
371
+ /**
372
+ * Pool the summary's buckets into at most four metrics.
373
+ *
374
+ * Two payload shapes exist. Older accounts report pooled ids (`gemini-5h`,
375
+ * `gemini-weekly`, `3p-5h`, `3p-weekly`), which map 1:1. This machine's live
376
+ * response instead lists PER-MODEL buckets (`gemini-3.6-flash-high`,
377
+ * `claude-sonnet-4-6`, `gpt-oss-120b-medium`, …) — those aggregate into a
378
+ * Gemini pool and an everything-else pool, keeping each pool's WORST remaining
379
+ * fraction, because eleven per-model meters is noise and the binding constraint
380
+ * is the one that runs out first.
381
+ */
382
+ export function agyPoolMetrics(buckets) {
383
+ const POOLED = {
384
+ "gemini-5h": "Gemini (session)", "gemini-weekly": "Gemini (weekly)",
385
+ "3p-5h": "Claude & others (session)", "3p-weekly": "Claude & others (weekly)",
386
+ };
387
+ const direct = [];
388
+ const pools = new Map();
389
+ for (const b of buckets) {
390
+ if (!b.bucketId || typeof b.remainingFraction !== "number" || !Number.isFinite(b.remainingFraction))
391
+ continue;
392
+ const reset = b.resetTime ?? null;
393
+ const pooledLabel = POOLED[b.bucketId];
394
+ if (pooledLabel) {
395
+ direct.push({ label: pooledLabel, type: "percent", used: (1 - b.remainingFraction) * 100, resetsAt: reset });
396
+ continue;
397
+ }
398
+ const pool = b.bucketId.startsWith("gemini") ? "Gemini" : "Claude & others";
399
+ const existing = pools.get(pool);
400
+ if (!existing) {
401
+ pools.set(pool, { fraction: b.remainingFraction, resetsAt: reset, models: 1 });
402
+ continue;
403
+ }
404
+ // Count every model in the pool; track the worst separately. Replacing the
405
+ // whole entry on a new worst would restart the count at that bucket.
406
+ existing.models += 1;
407
+ if (b.remainingFraction < existing.fraction) {
408
+ existing.fraction = b.remainingFraction;
409
+ existing.resetsAt = reset;
410
+ }
411
+ }
412
+ if (direct.length)
413
+ return direct;
414
+ return [...pools.entries()].map(([label, p]) => ({
415
+ label,
416
+ type: "percent",
417
+ used: (1 - p.fraction) * 100,
418
+ resetsAt: p.resetsAt,
419
+ // Which number this is: the highest usage across the pool's models. Without
420
+ // it a flat 0% right after a weekly rollover is indistinguishable from a
421
+ // broken reading — which is exactly how it was reported.
422
+ note: `busiest of ${p.models} model${p.models === 1 ? "" : "s"}`,
423
+ }));
424
+ }
425
+ /**
426
+ * agy's token file, in either shape.
427
+ *
428
+ * This machine writes it nested (`{ token: {...}, id_token, auth_method }`),
429
+ * which is what the provider was built against. Flat (`{ access_token, ... }`)
430
+ * is accepted too — it costs one `??` and other installs or future agy versions
431
+ * may differ. `expiry_date` (epoch ms, the shape ~/.gemini/oauth_creds.json
432
+ * uses) is read alongside `expiry` (RFC3339) for the same reason.
433
+ */
434
+ export function readAgyToken(file) {
435
+ const f = (file ?? {});
436
+ const nested = f.token;
437
+ return nested?.access_token || nested?.refresh_token ? nested : f;
438
+ }
439
+ /** RFC3339 `expiry` or epoch-ms `expiry_date`, whichever the file carries. */
440
+ function agyExpiryMs(t) {
441
+ if (typeof t.expiry_date === "number" && Number.isFinite(t.expiry_date))
442
+ return t.expiry_date;
443
+ return t.expiry ? new Date(t.expiry).getTime() : 0;
444
+ }
445
+ async function agyAccessToken() {
446
+ const home = process.env.GEMINI_HOME || join(homedir(), ".gemini");
447
+ let raw;
448
+ try {
449
+ raw = JSON.parse(await readFile(join(home, ...AGY_TOKEN_FILE), "utf8"));
450
+ }
451
+ catch {
452
+ return null; // no agy login on this machine
453
+ }
454
+ const t = readAgyToken(raw);
455
+ const expiryMs = agyExpiryMs(t);
456
+ if (t.access_token && expiryMs > Date.now() + 60_000)
457
+ return { token: t.access_token };
458
+ if (agyTokenCache && agyTokenCache.until > Date.now())
459
+ return { token: agyTokenCache.token };
460
+ const client = agyOAuthClient();
461
+ if (!t.refresh_token || !client) {
462
+ if (!t.access_token)
463
+ return null;
464
+ return {
465
+ error: client
466
+ ? "Access token expired and no refresh token is stored. Run `agy` once."
467
+ : "Access token expired. Run `agy` once to refresh it (or set AGY_OAUTH_CLIENT_ID/SECRET to refresh automatically).",
468
+ };
469
+ }
470
+ try {
471
+ const res = await fetch("https://oauth2.googleapis.com/token", {
472
+ method: "POST",
473
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
474
+ body: new URLSearchParams({
475
+ client_id: client.id,
476
+ client_secret: client.secret,
477
+ refresh_token: t.refresh_token,
478
+ grant_type: "refresh_token",
479
+ }),
480
+ signal: AbortSignal.timeout(15_000),
481
+ });
482
+ if (!res.ok)
483
+ return { error: "Google sign-in expired. Run `agy` once to log in again." };
484
+ const body = await res.json();
485
+ if (!body.access_token)
486
+ return { error: "Google token refresh returned no token." };
487
+ agyTokenCache = { token: body.access_token, until: Date.now() + Math.max(60, (body.expires_in ?? 3600) - 120) * 1000 };
488
+ return { token: body.access_token };
489
+ }
490
+ catch {
491
+ return { error: "Could not reach oauth2.googleapis.com." };
492
+ }
493
+ }
494
+ async function fetchAntigravityUsage() {
495
+ // A CLI-observed rate limit outranks anything the quota API says. The bucket
496
+ // summary cannot see the account-level individual cap: verified live, it
497
+ // reported every bucket 0% used in the same minute the CLI was refusing work
498
+ // with "Individual quota reached". Showing those buckets would be the exact
499
+ // misleading 🟢 0% this overlay exists to correct — so the row goes red and
500
+ // the buckets are withheld until the cap lifts.
501
+ const capAlert = getProviderRateLimit("antigravity");
502
+ if (capAlert) {
503
+ const hoursLeft = Math.max(1, Math.round((capAlert.resetsAtMs - Date.now()) / 3_600_000));
504
+ const resetText = hoursLeft >= 48 ? `${Math.round(hoursLeft / 24)}d` : `${hoursLeft}h`;
505
+ return {
506
+ status: "error",
507
+ error: `Individual cap reached — CLI is rate limited (resets in ~${resetText}). `
508
+ + "The per-model pool quotas are a separate counter and may still read 0% used.",
509
+ metrics: [],
510
+ };
511
+ }
512
+ const auth = await agyAccessToken();
513
+ if (auth === null) {
514
+ return { status: "no-credentials", hint: "Log in with the Antigravity CLI (`agy`).", metrics: [] };
515
+ }
516
+ if ("error" in auth)
517
+ return { status: "error", error: auth.error, metrics: [] };
518
+ for (const base of AGY_CLOUDCODE_URLS) {
519
+ let res;
520
+ try {
521
+ res = await fetch(`${base}/v1internal:retrieveUserQuotaSummary`, {
522
+ method: "POST",
523
+ headers: {
524
+ Authorization: `Bearer ${auth.token}`,
525
+ "Content-Type": "application/json",
526
+ "User-Agent": "antigravity",
527
+ },
528
+ body: "{}",
529
+ signal: AbortSignal.timeout(15_000),
530
+ });
531
+ }
532
+ catch {
533
+ continue;
534
+ }
535
+ if (res.status === 401 || res.status === 403) {
536
+ return { status: "error", error: "Token rejected. Run `agy` once to refresh the login.", metrics: [] };
537
+ }
538
+ if (!res.ok)
539
+ continue; // try the other base
540
+ let body;
541
+ try {
542
+ body = await res.json();
543
+ }
544
+ catch {
545
+ continue;
546
+ }
547
+ const buckets = (body.groups ?? []).flatMap(g => g.buckets ?? []);
548
+ const metrics = agyPoolMetrics(buckets);
549
+ return metrics.length
550
+ ? { status: "ok", plan: null, metrics }
551
+ : { status: "ok", plan: null, hint: "No quota information reported for this account.", metrics: [] };
552
+ }
553
+ return { status: "error", error: "Could not reach the Cloud Code quota endpoints.", metrics: [] };
554
+ }
555
+ // ── Codex ────────────────────────────────────────────────────────────────────
556
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
557
+ const CREDIT_USD_RATE = 0.04;
558
+ function codexResetIso(win, nowMs) {
559
+ if (typeof win.reset_at === "number" && Number.isFinite(win.reset_at))
560
+ return new Date(win.reset_at * 1000).toISOString();
561
+ if (typeof win.reset_after_seconds === "number" && Number.isFinite(win.reset_after_seconds)) {
562
+ return new Date(nowMs + win.reset_after_seconds * 1000).toISOString();
563
+ }
564
+ return null;
565
+ }
566
+ // Codex normally puts the 5-hour window in primary and weekly in secondary, but
567
+ // a window's own limit_window_seconds wins when present. Some plans (e.g. Team)
568
+ // report a monthly window — label by actual duration, never by slot.
569
+ function codexWindows(rateLimit, labels, nowMs) {
570
+ const candidates = [
571
+ { win: rateLimit?.primary_window, fallback: "session" },
572
+ { win: rateLimit?.secondary_window, fallback: "weekly" },
573
+ ].filter(c => c.win && typeof c.win === "object");
574
+ const metrics = [];
575
+ for (const { win, fallback } of candidates) {
576
+ if (!win || typeof win.used_percent !== "number")
577
+ continue;
578
+ const explicitMs = typeof win.limit_window_seconds === "number" && Number.isFinite(win.limit_window_seconds)
579
+ ? win.limit_window_seconds * 1000 : null;
580
+ const windowMs = explicitMs ?? (fallback === "session" ? SESSION_MS : WEEK_MS);
581
+ let label;
582
+ if (windowMs === SESSION_MS)
583
+ label = labels.session;
584
+ else if (windowMs === WEEK_MS)
585
+ label = labels.weekly;
586
+ else if (windowMs >= 27 * 864e5 && windowMs <= 32 * 864e5)
587
+ label = labels.monthly;
588
+ else if (explicitMs)
589
+ label = labels.other(Math.round(windowMs / 864e5));
590
+ else
591
+ label = fallback === "session" ? labels.session : labels.weekly;
592
+ metrics.push({ label, type: "percent", used: win.used_percent, resetsAt: codexResetIso(win, nowMs), windowMs });
593
+ }
594
+ return metrics;
595
+ }
596
+ function codexPlan(planType) {
597
+ if (typeof planType !== "string" || !planType.trim())
598
+ return null;
599
+ switch (planType.trim().toLowerCase()) {
600
+ case "prolite": return "Pro 5x";
601
+ case "pro": return "Pro 20x";
602
+ default: return planType.trim().split("_").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
603
+ }
604
+ }
605
+ async function fetchCodexUsage() {
606
+ const home = process.env.CODEX_HOME || join(homedir(), ".codex");
607
+ let auth;
608
+ try {
609
+ auth = JSON.parse(await readFile(join(home, "auth.json"), "utf8"));
610
+ }
611
+ catch {
612
+ return { status: "no-credentials", hint: "Log in with the Codex CLI (`codex`).", metrics: [] };
613
+ }
614
+ const accessToken = auth?.tokens?.access_token;
615
+ if (!accessToken) {
616
+ return { status: "no-credentials", hint: "auth.json has no OAuth login (API-key-only auth cannot read usage).", metrics: [] };
617
+ }
618
+ const exp = jwtExpiryMs(accessToken);
619
+ if (exp && exp < Date.now()) {
620
+ return { status: "error", error: "Access token expired. Run `codex` once to refresh it.", metrics: [] };
621
+ }
622
+ const headers = {
623
+ Authorization: `Bearer ${accessToken}`,
624
+ Accept: "application/json",
625
+ "User-Agent": "agend-usage",
626
+ };
627
+ if (auth.tokens?.account_id)
628
+ headers["ChatGPT-Account-Id"] = auth.tokens.account_id;
629
+ let res;
630
+ try {
631
+ res = await fetch(CODEX_USAGE_URL, { headers, signal: AbortSignal.timeout(10_000) });
632
+ }
633
+ catch {
634
+ return { status: "error", error: "Could not reach chatgpt.com.", metrics: [] };
635
+ }
636
+ if (res.status === 401 || res.status === 403) {
637
+ return { status: "error", error: "Token rejected. Run `codex` once to refresh the login.", metrics: [] };
638
+ }
639
+ if (!res.ok)
640
+ return { status: "error", error: `Usage request failed (HTTP ${res.status}).`, metrics: [] };
641
+ let body;
642
+ try {
643
+ body = await res.json();
644
+ }
645
+ catch {
646
+ return { status: "error", error: "Invalid response from usage endpoint.", metrics: [] };
647
+ }
648
+ const nowMs = Date.now();
649
+ const metrics = codexWindows(body.rate_limit, {
650
+ session: "Session", weekly: "Weekly", monthly: "Monthly", other: d => `${d}-day window`,
651
+ }, nowMs);
652
+ // Model-specific limits (e.g. Spark) ride in additional_rate_limits, same window shape.
653
+ for (const entry of Array.isArray(body.additional_rate_limits) ? body.additional_rate_limits : []) {
654
+ const e = entry;
655
+ if (!e || typeof e !== "object" || !e.rate_limit)
656
+ continue;
657
+ const rawName = e.limit_name || e.metered_feature || "Model limit";
658
+ metrics.push(...codexWindows(e.rate_limit, {
659
+ session: rawName, weekly: `${rawName} (weekly)`, monthly: `${rawName} (monthly)`, other: d => `${rawName} (${d}d)`,
660
+ }, nowMs));
661
+ }
662
+ const resets = body.rate_limit_reset_credits;
663
+ if (typeof resets?.available_count === "number" && resets.available_count >= 0) {
664
+ metrics.push({ label: "Rate limit resets", type: "count", value: Math.floor(resets.available_count), unit: "available" });
665
+ }
666
+ const credits = body.credits;
667
+ let remaining = null;
668
+ if (typeof credits?.balance === "number")
669
+ remaining = credits.balance;
670
+ else if (credits?.has_credits === false)
671
+ remaining = 0;
672
+ if (remaining !== null) {
673
+ const count = Math.max(0, Math.floor(remaining));
674
+ metrics.push({ label: "Credits", type: "count", value: count, unit: "credits", note: `≈ $${(count * CREDIT_USD_RATE).toFixed(2)}` });
675
+ }
676
+ return { status: "ok", plan: codexPlan(body.plan_type), metrics };
677
+ }
678
+ // ── Grok ─────────────────────────────────────────────────────────────────────
679
+ const GROK_CREDITS_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
680
+ const GROK_SETTINGS_URL = "https://cli-chat-proxy.grok.com/v1/settings";
681
+ const GROK_REFRESH_URL = "https://auth.x.ai/oauth2/token";
682
+ const GROK_DEFAULT_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
683
+ const GROK_WEEKLY_PERIOD = "USAGE_PERIOD_TYPE_WEEKLY";
684
+ const GROK_REFRESH_BUFFER_MS = 5 * 60 * 1000;
685
+ function grokEntryExpiryMs(entry, token) {
686
+ const fromJwt = jwtExpiryMs(token);
687
+ if (fromJwt)
688
+ return fromJwt;
689
+ const raw = entry.expires_at || entry.expires;
690
+ if (typeof raw === "string") {
691
+ const ms = new Date(raw).getTime();
692
+ if (!Number.isNaN(ms))
693
+ return ms;
694
+ }
695
+ return null;
696
+ }
697
+ async function grokRefreshAndPersist(file, auth, entryKey, entry) {
698
+ // Refuse to refresh unless the rotated tokens can be written back — a rotated
699
+ // refresh token that only lives in our memory would break `grok` itself.
700
+ try {
701
+ await access(file, constants.W_OK);
702
+ }
703
+ catch {
704
+ return { error: "Token expired and auth.json is read-only. Run `grok` once to refresh it." };
705
+ }
706
+ const refreshToken = (entry.refresh_token || entry.refresh || "").trim();
707
+ if (!refreshToken)
708
+ return { error: "Token expired and no refresh token found. Run `grok login`." };
709
+ const clientId = entry.oidc_client_id?.trim()
710
+ || entryKey.split("::").pop()?.trim()
711
+ || GROK_DEFAULT_CLIENT_ID;
712
+ let res;
713
+ try {
714
+ res = await fetch(GROK_REFRESH_URL, {
715
+ method: "POST",
716
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
717
+ body: new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, refresh_token: refreshToken }),
718
+ signal: AbortSignal.timeout(15_000),
719
+ });
720
+ }
721
+ catch {
722
+ return { error: "Could not reach auth.x.ai to refresh the token." };
723
+ }
724
+ if (!res.ok)
725
+ return { error: "Grok session expired. Run `grok login` again." };
726
+ let body;
727
+ try {
728
+ body = await res.json();
729
+ }
730
+ catch {
731
+ return { error: "Invalid refresh response from auth.x.ai." };
732
+ }
733
+ if (!body.access_token)
734
+ return { error: "Grok session expired. Run `grok login` again." };
735
+ // Update only this entry's fields, preserving everything else in the file
736
+ // (other accounts' entries included).
737
+ const updated = { ...(auth[entryKey] ?? {}) };
738
+ updated.key = body.access_token;
739
+ if (body.refresh_token)
740
+ updated.refresh_token = body.refresh_token;
741
+ if (body.id_token)
742
+ updated.id_token = body.id_token;
743
+ if (typeof body.expires_in === "number")
744
+ updated.expires_at = new Date(Date.now() + body.expires_in * 1000).toISOString();
745
+ try {
746
+ await writeFile(file, JSON.stringify({ ...auth, [entryKey]: updated }, null, 2));
747
+ }
748
+ catch {
749
+ return { error: "Refreshed but could not write auth.json — refusing to continue without persisting. Run `grok` once instead." };
750
+ }
751
+ return { token: body.access_token };
752
+ }
753
+ function grokHeaders(token) {
754
+ return {
755
+ Authorization: `Bearer ${token.trim()}`,
756
+ "X-XAI-Token-Auth": "xai-grok-cli",
757
+ Accept: "application/json",
758
+ "User-Agent": "agend-usage",
759
+ };
760
+ }
761
+ async function fetchGrokUsage() {
762
+ const home = process.env.GROK_HOME || join(homedir(), ".grok");
763
+ const file = join(home, "auth.json");
764
+ let auth;
765
+ try {
766
+ auth = JSON.parse(await readFile(file, "utf8"));
767
+ }
768
+ catch {
769
+ return { status: "no-credentials", hint: "Log in with the Grok CLI (`grok login`).", metrics: [] };
770
+ }
771
+ const entries = Object.entries(auth).filter(([, e]) => typeof e?.key === "string" && e.key.trim());
772
+ if (!entries.length)
773
+ return { status: "no-credentials", hint: "auth.json has no usable login. Run `grok login`.", metrics: [] };
774
+ const [entryKey, entry] = entries[0];
775
+ let token = entry.key.trim();
776
+ const expiry = grokEntryExpiryMs(entry, token);
777
+ if (expiry && expiry - Date.now() <= GROK_REFRESH_BUFFER_MS) {
778
+ const refreshed = await grokRefreshAndPersist(file, auth, entryKey, entry);
779
+ if (refreshed.error)
780
+ return { status: "error", error: refreshed.error, metrics: [] };
781
+ token = refreshed.token;
782
+ }
783
+ const fetchBoth = async (tok) => {
784
+ const [credits, settings] = await Promise.all([
785
+ fetch(GROK_CREDITS_URL, { headers: grokHeaders(tok), signal: AbortSignal.timeout(10_000) }),
786
+ fetch(GROK_SETTINGS_URL, { headers: grokHeaders(tok), signal: AbortSignal.timeout(10_000) }).catch(() => null),
787
+ ]);
788
+ return { credits, settings };
789
+ };
790
+ let credits, settings;
791
+ try {
792
+ ({ credits, settings } = await fetchBoth(token));
793
+ if (credits.status === 401 || credits.status === 403) {
794
+ const refreshed = await grokRefreshAndPersist(file, auth, entryKey, entry);
795
+ if (refreshed.error)
796
+ return { status: "error", error: refreshed.error, metrics: [] };
797
+ token = refreshed.token;
798
+ ({ credits, settings } = await fetchBoth(token));
799
+ }
800
+ }
801
+ catch {
802
+ return { status: "error", error: "Could not reach cli-chat-proxy.grok.com.", metrics: [] };
803
+ }
804
+ if (credits.status === 401 || credits.status === 403) {
805
+ return { status: "error", error: "Grok session expired. Run `grok login` again.", metrics: [] };
806
+ }
807
+ if (!credits.ok)
808
+ return { status: "error", error: `Billing request failed (HTTP ${credits.status}).`, metrics: [] };
809
+ let body;
810
+ try {
811
+ body = await credits.json();
812
+ }
813
+ catch {
814
+ return { status: "error", error: "Invalid response from billing endpoint.", metrics: [] };
815
+ }
816
+ const config = body?.config;
817
+ const period = config?.currentPeriod;
818
+ if (!config || !period?.type)
819
+ return { status: "error", error: "Grok billing response changed.", metrics: [] };
820
+ const metrics = [];
821
+ if (period.type === GROK_WEEKLY_PERIOD) {
822
+ // proto-JSON drops zero-valued fields: an absent creditUsagePercent means 0%.
823
+ const pct = typeof config.creditUsagePercent === "number" ? config.creditUsagePercent : 0;
824
+ const start = new Date(period.start ?? "").getTime();
825
+ const end = new Date(period.end ?? "").getTime();
826
+ metrics.push({
827
+ label: "Weekly limit",
828
+ type: "percent",
829
+ used: Math.min(100, Math.max(0, pct)),
830
+ resetsAt: Number.isNaN(end) ? null : new Date(end).toISOString(),
831
+ windowMs: Number.isNaN(end - start) ? null : end - start,
832
+ });
833
+ }
834
+ const cap = typeof config.onDemandCap?.val === "number" ? config.onDemandCap.val : 0;
835
+ metrics.push({ label: "Pay as you go", type: "text", value: cap > 0 ? `${cap} cap` : "Disabled" });
836
+ let plan = null;
837
+ if (settings?.ok) {
838
+ try {
839
+ const s = await settings.json();
840
+ if (typeof s.subscription_tier_display === "string" && s.subscription_tier_display.trim()) {
841
+ plan = s.subscription_tier_display.trim();
842
+ }
843
+ }
844
+ catch { /* plan is optional */ }
845
+ }
846
+ return { status: "ok", plan, metrics };
847
+ }
848
+ // ── Kiro (Amazon Q Developer) ────────────────────────────────────────────────
849
+ // Original research, no OpenUsage upstream: the Kiro CLI stores its login in
850
+ // data.sqlite3 `auth_kv` (social or Identity Center token) and usage rides on
851
+ // the CodeWhisperer GetUsageLimits API — the exact call the CLI itself makes.
852
+ // Read-only: the CLI refreshes its own tokens.
853
+ const KIRO_TARGET = "AmazonCodeWhispererService.GetUsageLimits";
854
+ export function kiroAuthKind(token) {
855
+ return typeof token.start_url === "string" && token.start_url.trim().length > 0
856
+ ? "q-developer-pro"
857
+ : "builder-id";
858
+ }
859
+ /**
860
+ * `notInstalled` means there is no kiro-cli data directory at all — the only
861
+ * case where the provider should vanish from the panel. `missing` means kiro-cli
862
+ * IS installed but no login we recognise is stored: a real state the user needs
863
+ * to see, not a reason to hide the row.
864
+ */
865
+ function readKiroToken() {
866
+ const home = process.env.KIRO_CLI_HOME || join(homedir(), ".local", "share", "kiro-cli");
867
+ let db;
868
+ try {
869
+ db = new Database(join(home, "data.sqlite3"), { readonly: true, fileMustExist: true });
870
+ }
871
+ catch {
872
+ return { notInstalled: true };
873
+ }
874
+ try {
875
+ // Read every token-shaped row rather than a fixed pair of keys: kiro names
876
+ // its auth rows per login type (`kirocli:social:token`,
877
+ // `codewhisperer:odic:token`, …), and an unlisted one used to read as
878
+ // "not logged in" — which then removed Kiro from the panel entirely.
879
+ const rows = db.prepare("SELECT key, value FROM auth_kv WHERE key LIKE '%:token'").all();
880
+ const parse = (raw) => {
881
+ if (!raw)
882
+ return null;
883
+ try {
884
+ const t = JSON.parse(raw);
885
+ return t.access_token ? t : null;
886
+ }
887
+ catch {
888
+ return null;
889
+ }
890
+ };
891
+ const candidates = rows
892
+ .map(r => parse(r.value))
893
+ .filter((t) => t !== null);
894
+ if (candidates.length === 0)
895
+ return { missing: true };
896
+ // Switching login type leaves the old token behind, so "first key wins"
897
+ // could report a stale account. Prefer a token that has not expired, then
898
+ // the one that expires latest.
899
+ const expiry = (t) => (t.expires_at ? new Date(t.expires_at).getTime() : 0);
900
+ const now = Date.now();
901
+ const live = candidates.filter(t => expiry(t) > now);
902
+ const token = (live.length ? live : candidates).sort((a, b) => expiry(b) - expiry(a))[0];
903
+ return { token, kind: kiroAuthKind(token) };
904
+ }
905
+ catch {
906
+ return { missing: true };
907
+ }
908
+ finally {
909
+ try {
910
+ db.close();
911
+ }
912
+ catch { /* best-effort */ }
913
+ }
914
+ }
915
+ function kiroTitleCase(s) {
916
+ return s.toLowerCase().replace(/(^|\s)\S/g, c => c.toUpperCase());
917
+ }
918
+ function kiroEpochIso(sec) {
919
+ return typeof sec === "number" && Number.isFinite(sec) && sec > 0
920
+ ? new Date(sec * 1000).toISOString()
921
+ : null;
922
+ }
923
+ /** Exported for tests: the Q-Pro and expired paths return without any network call. */
924
+ export async function fetchKiroUsage() {
925
+ const { token, kind, missing, notInstalled } = readKiroToken();
926
+ if (notInstalled) {
927
+ // The one case that should disappear from the panel: kiro-cli isn't here.
928
+ return { status: "no-credentials", hint: "Log in with the Kiro CLI (`kiro-cli`).", metrics: [] };
929
+ }
930
+ if (missing || !token) {
931
+ // Installed but no readable login. Previously indistinguishable from
932
+ // not-installed, so the whole Kiro row vanished instead of saying this.
933
+ return { status: "ok", plan: "Kiro", hint: "Signed out — run `kiro-cli` to log in.", metrics: [] };
934
+ }
935
+ const expiresAt = token.expires_at ? new Date(token.expires_at).getTime() : null;
936
+ if (expiresAt && expiresAt < Date.now()) {
937
+ // Not an error state: kiro-cli refreshes its own login on next use, and the
938
+ // panel showing a red failure for a routine token rollover is just noise.
939
+ // Still `ok` rather than no-credentials, so the row stays on screen with its
940
+ // explanation instead of the provider silently disappearing.
941
+ return { status: "ok", plan: "Kiro", hint: "Token refreshing — try again in a moment.", metrics: [] };
942
+ }
943
+ if (kind === "q-developer-pro") {
944
+ // Seat-licensed subscription: GetUsageLimits describes Builder-ID credit
945
+ // buckets, not this, so there is nothing to meter and no call to make.
946
+ return {
947
+ status: "ok",
948
+ plan: "Q Developer Pro",
949
+ hint: "Subscription — no credit usage to report.",
950
+ metrics: [],
951
+ };
952
+ }
953
+ const region = token.region
954
+ || (typeof token.profile_arn === "string" ? token.profile_arn.split(":")[3] : "")
955
+ || "us-east-1";
956
+ let res;
957
+ try {
958
+ res = await fetch(`https://codewhisperer.${region}.amazonaws.com/`, {
959
+ method: "POST",
960
+ headers: {
961
+ "Content-Type": "application/x-amz-json-1.0",
962
+ "X-Amz-Target": KIRO_TARGET,
963
+ Authorization: `Bearer ${token.access_token}`,
964
+ },
965
+ body: JSON.stringify(token.profile_arn ? { profileArn: token.profile_arn } : {}),
966
+ signal: AbortSignal.timeout(15_000),
967
+ });
968
+ }
969
+ catch {
970
+ return { status: "error", error: `Could not reach codewhisperer.${region}.amazonaws.com.`, metrics: [] };
971
+ }
972
+ if (res.status === 401 || res.status === 403) {
973
+ return { status: "error", error: "Token rejected. Use `kiro-cli` once to refresh the login.", metrics: [] };
974
+ }
975
+ if (!res.ok)
976
+ return { status: "error", error: `Usage request failed (HTTP ${res.status}).`, metrics: [] };
977
+ let body;
978
+ try {
979
+ body = await res.json();
980
+ }
981
+ catch {
982
+ return { status: "error", error: "Invalid response from GetUsageLimits.", metrics: [] };
983
+ }
984
+ const plan = typeof body.subscriptionInfo?.subscriptionTitle === "string"
985
+ ? kiroTitleCase(body.subscriptionInfo.subscriptionTitle)
986
+ : null;
987
+ const metrics = [];
988
+ for (const ub of Array.isArray(body.usageBreakdownList) ? body.usageBreakdownList : []) {
989
+ const unit = (ub.displayNamePlural || ub.displayName || "credits").toLowerCase();
990
+ const used = ub.currentUsageWithPrecision ?? ub.currentUsage ?? null;
991
+ const limit = ub.usageLimitWithPrecision ?? ub.usageLimit ?? null;
992
+ if (used !== null && limit !== null && limit > 0) {
993
+ metrics.push({
994
+ label: `${ub.displayNamePlural || ub.displayName || "Usage"} (monthly)`,
995
+ type: "percent",
996
+ used: Math.min(100, (used / limit) * 100),
997
+ resetsAt: kiroEpochIso(ub.nextDateReset ?? body.nextDateReset),
998
+ note: `${+used.toFixed(1)} / ${+limit.toFixed(0)} ${unit}`,
999
+ });
1000
+ }
1001
+ // Bonus / gift credits (redeemed promo codes), aggregated across active codes.
1002
+ const bonuses = (ub.bonuses ?? []).filter(b => b?.status === "ACTIVE");
1003
+ if (bonuses.length) {
1004
+ const bUsed = bonuses.reduce((s, b) => s + (b.currentUsage ?? 0), 0);
1005
+ const bLimit = bonuses.reduce((s, b) => s + (b.usageLimit ?? 0), 0);
1006
+ const expiries = bonuses.map(b => b.expiresAt).filter((e) => typeof e === "number" && e > 0);
1007
+ if (bLimit > 0) {
1008
+ metrics.push({
1009
+ label: "Bonus credits",
1010
+ type: "percent",
1011
+ used: Math.min(100, (bUsed / bLimit) * 100),
1012
+ resetsAt: expiries.length ? kiroEpochIso(Math.min(...expiries)) : null,
1013
+ note: `${+bUsed.toFixed(1)} / ${+bLimit.toFixed(0)} ${unit} · ${bonuses.length} codes`,
1014
+ });
1015
+ }
1016
+ }
1017
+ const overages = ub.currentOveragesWithPrecision ?? ub.currentOverages ?? 0;
1018
+ if (overages > 0 && typeof ub.overageCharges === "number") {
1019
+ metrics.push({ label: "Overage charges", type: "dollars", used: ub.overageCharges });
1020
+ }
1021
+ }
1022
+ const overageStatus = body.overageConfiguration?.overageStatus;
1023
+ if (typeof overageStatus === "string") {
1024
+ metrics.push({ label: "Pay-per-use", type: "text", value: kiroTitleCase(overageStatus.replace(/_/g, " ")) });
1025
+ }
1026
+ if (metrics.length === 0) {
1027
+ // Amazon Q logins whose account type reports no credit buckets (#398's
1028
+ // sibling case): an OK answer with nothing in it must say so, not render an
1029
+ // empty row that reads like a failure.
1030
+ return { status: "ok", plan: plan ?? "Amazon Q", hint: "No quota information available for this account type.", metrics: [] };
1031
+ }
1032
+ return { status: "ok", plan, metrics };
1033
+ }
1034
+ // ── Registry ─────────────────────────────────────────────────────────────────
1035
+ const PROVIDERS = [
1036
+ { id: "claude", name: "Claude", fetch: fetchClaudeUsage },
1037
+ { id: "antigravity", name: "Antigravity", fetch: fetchAntigravityUsage },
1038
+ { id: "codex", name: "Codex", fetch: fetchCodexUsage },
1039
+ { id: "grok", name: "Grok", fetch: fetchGrokUsage },
1040
+ { id: "kiro", name: "Kiro", fetch: fetchKiroUsage },
1041
+ ];
1042
+ /** Fetch every provider in parallel. Providers without local credentials are
1043
+ * skipped (per the panel's contract: absent CLIs simply don't show). */
1044
+ export async function fetchAllUsage() {
1045
+ const results = await Promise.all(PROVIDERS.map(async (p) => {
1046
+ try {
1047
+ return { id: p.id, name: p.name, ...(await p.fetch()) };
1048
+ }
1049
+ catch (err) {
1050
+ return { id: p.id, name: p.name, status: "error", error: String(err?.message ?? err), metrics: [] };
1051
+ }
1052
+ }));
1053
+ return {
1054
+ fetchedAt: new Date().toISOString(),
1055
+ // `no-credentials` now means exactly "this CLI is not set up on this
1056
+ // machine" — those are hidden. Anything a user could act on (signed out,
1057
+ // token rolled over, no quota for this account type) is reported as `ok`
1058
+ // with a hint by its provider, so it stays on screen. A provider that
1059
+ // vanishes silently is the failure this filter used to cause.
1060
+ providers: results.filter(r => r.status !== "no-credentials"),
1061
+ };
1062
+ }
1063
+ //# sourceMappingURL=providers.js.map