@songsid/agend 2.1.4-beta.37 → 2.1.4-beta.39

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 (45) hide show
  1. package/dist/backend/codex.js +13 -0
  2. package/dist/backend/codex.js.map +1 -1
  3. package/dist/channel/mcp-server.js +11 -3
  4. package/dist/channel/mcp-server.js.map +1 -1
  5. package/dist/cli.js +14 -14
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config-validator.js +6 -0
  8. package/dist/config-validator.js.map +1 -1
  9. package/dist/config.d.ts +2 -0
  10. package/dist/config.js +3 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/cross-instance-envelope.d.ts +6 -0
  13. package/dist/cross-instance-envelope.js +30 -0
  14. package/dist/cross-instance-envelope.js.map +1 -0
  15. package/dist/daemon.d.ts +73 -0
  16. package/dist/daemon.js +229 -54
  17. package/dist/daemon.js.map +1 -1
  18. package/dist/fleet-manager.js +33 -0
  19. package/dist/fleet-manager.js.map +1 -1
  20. package/dist/locale.js +168 -0
  21. package/dist/locale.js.map +1 -1
  22. package/dist/outbound-handlers.js +57 -13
  23. package/dist/outbound-handlers.js.map +1 -1
  24. package/dist/outbound-schemas.d.ts +2 -1
  25. package/dist/outbound-schemas.js +2 -0
  26. package/dist/outbound-schemas.js.map +1 -1
  27. package/dist/tmux-manager.d.ts +7 -0
  28. package/dist/tmux-manager.js +62 -14
  29. package/dist/tmux-manager.js.map +1 -1
  30. package/dist/types.d.ts +2 -0
  31. package/dist/ui/view.html +124 -15
  32. package/dist/usage/format-rich.js +24 -23
  33. package/dist/usage/format-rich.js.map +1 -1
  34. package/dist/usage/i18n-keys.d.ts +7 -0
  35. package/dist/usage/i18n-keys.js +34 -0
  36. package/dist/usage/i18n-keys.js.map +1 -0
  37. package/dist/usage/i18n.d.ts +5 -0
  38. package/dist/usage/i18n.js +27 -0
  39. package/dist/usage/i18n.js.map +1 -0
  40. package/dist/usage/providers.d.ts +12 -0
  41. package/dist/usage/providers.js +132 -73
  42. package/dist/usage/providers.js.map +1 -1
  43. package/dist/usage/usage-api.js +22 -21
  44. package/dist/usage/usage-api.js.map +1 -1
  45. package/package.json +1 -1
@@ -25,6 +25,7 @@ import { homedir } from "node:os";
25
25
  import { getProviderRateLimit } from "./provider-alerts.js";
26
26
  import { readStatuslineRateLimits, } from "./statusline-usage.js";
27
27
  import Database from "better-sqlite3";
28
+ const i18n = (key, ...args) => args.length ? { key, args } : { key };
28
29
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
29
30
  const SESSION_MS = 5 * 60 * 60 * 1000;
30
31
  function jwtExpiryMs(token) {
@@ -173,19 +174,20 @@ function readShellRcToken() {
173
174
  */
174
175
  export function statuslineUsage(reading, plan, now = Date.now()) {
175
176
  const metrics = [];
176
- const add = (w, label, windowMs) => {
177
+ const add = (w, label, labelKey, windowMs) => {
177
178
  if (!w)
178
179
  return;
179
180
  metrics.push({
180
181
  label,
182
+ labelI18n: i18n(labelKey),
181
183
  type: "percent",
182
184
  used: w.usedPercent,
183
185
  resetsAt: w.resetsAtMs !== null ? new Date(w.resetsAtMs).toISOString() : null,
184
186
  windowMs,
185
187
  });
186
188
  };
187
- add(reading.fiveHour, "Session", SESSION_MS);
188
- add(reading.sevenDay, "Weekly", WEEK_MS);
189
+ add(reading.fiveHour, "Session", "usage.metric.session", SESSION_MS);
190
+ add(reading.sevenDay, "Weekly", "usage.metric.weekly", WEEK_MS);
189
191
  const ageMin = Math.round((now - reading.observedAtMs) / 60_000);
190
192
  const age = ageMin <= 0 ? "just now" : `${ageMin}m ago`;
191
193
  return {
@@ -194,6 +196,7 @@ export function statuslineUsage(reading, plan, now = Date.now()) {
194
196
  // Say what is missing as well as where it came from: someone comparing this
195
197
  // against claude.ai should not have to guess why per-model rows vanished.
196
198
  hint: `from ${reading.instance}'s statusline, ${age} — no API call; plan and per-model limits need a CLI login`,
199
+ hintI18n: i18n(ageMin <= 0 ? "usage.hint.statusline_now" : "usage.hint.statusline_minutes", reading.instance, ageMin),
197
200
  metrics,
198
201
  };
199
202
  }
@@ -237,12 +240,19 @@ export async function fetchClaudeUsage() {
237
240
  const hint = process.env.ANTHROPIC_API_KEY
238
241
  ? "API-key login has no subscription usage. Use `claude /login` or set CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`)."
239
242
  : "Log in with the Claude Code CLI (`claude`).";
240
- return { status: "no-credentials", hint, metrics: [] };
243
+ return {
244
+ status: "no-credentials",
245
+ hint,
246
+ hintI18n: process.env.ANTHROPIC_API_KEY
247
+ ? i18n("usage.hint.claude_api_key")
248
+ : i18n("usage.hint.login_claude"),
249
+ metrics: [],
250
+ };
241
251
  }
242
252
  if ("error" in auth) {
243
253
  if (statusline)
244
254
  return statuslineUsage(statusline, auth.plan);
245
- return { status: "error", plan: auth.plan, error: auth.error, metrics: [] };
255
+ return { status: "error", plan: auth.plan, error: auth.error, errorI18n: i18n("usage.error.claude_token_expired"), metrics: [] };
246
256
  }
247
257
  const plan = auth.plan;
248
258
  /** Every API failure degrades to the statusline rather than to an error row. */
@@ -261,33 +271,39 @@ export async function fetchClaudeUsage() {
261
271
  });
262
272
  }
263
273
  catch {
264
- return orStatusline({ status: "error", plan, error: "Could not reach api.anthropic.com.", metrics: [] });
274
+ return orStatusline({ status: "error", plan, error: "Could not reach api.anthropic.com.", errorI18n: i18n("usage.error.unreachable", "api.anthropic.com"), metrics: [] });
265
275
  }
266
276
  if (res.status === 401 || res.status === 403) {
267
- return orStatusline({ status: "error", plan, error: "Token rejected. Run `claude` once to refresh the login.", metrics: [] });
277
+ return orStatusline({ status: "error", plan, error: "Token rejected. Run `claude` once to refresh the login.", errorI18n: i18n("usage.error.token_rejected", "claude"), metrics: [] });
268
278
  }
269
279
  if (res.status === 429) {
270
- return orStatusline({ status: "error", plan, error: "Rate limited by Anthropic — try again later.", metrics: [] });
280
+ return orStatusline({ status: "error", plan, error: "Rate limited by Anthropic — try again later.", errorI18n: i18n("usage.error.rate_limited", "Anthropic"), metrics: [] });
271
281
  }
272
282
  if (!res.ok)
273
- return orStatusline({ status: "error", plan, error: `Usage request failed (HTTP ${res.status}).`, metrics: [] });
283
+ return orStatusline({ status: "error", plan, error: `Usage request failed (HTTP ${res.status}).`, errorI18n: i18n("usage.error.http", res.status), metrics: [] });
274
284
  let body;
275
285
  try {
276
286
  body = await res.json();
277
287
  }
278
288
  catch {
279
- return orStatusline({ status: "error", plan, error: "Invalid response from usage endpoint.", metrics: [] });
289
+ return orStatusline({ status: "error", plan, error: "Invalid response from usage endpoint.", errorI18n: i18n("usage.error.invalid_response"), metrics: [] });
280
290
  }
281
291
  const metrics = [];
282
- const window = (obj, label, windowMs) => {
292
+ const window = (obj, label, labelKey, windowMs) => {
283
293
  const o = obj;
284
294
  if (typeof o?.utilization !== "number")
285
295
  return;
286
- metrics.push({ label, type: "percent", used: o.utilization, resetsAt: claudeResetIso(o.resets_at), windowMs });
296
+ metrics.push({ label, labelI18n: i18n(labelKey), type: "percent", used: o.utilization, resetsAt: claudeResetIso(o.resets_at), windowMs });
287
297
  };
288
- window(body.five_hour, "Session", SESSION_MS);
289
- window(body.seven_day, "Weekly", WEEK_MS);
290
- window(body.seven_day_sonnet, "Sonnet (weekly)", WEEK_MS);
298
+ window(body.five_hour, "Session", "usage.metric.session", SESSION_MS);
299
+ window(body.seven_day, "Weekly", "usage.metric.weekly", WEEK_MS);
300
+ const sonnet = body.seven_day_sonnet;
301
+ if (typeof sonnet?.utilization === "number") {
302
+ metrics.push({
303
+ label: "Sonnet (weekly)", labelI18n: i18n("usage.metric.named_weekly", "Sonnet"),
304
+ type: "percent", used: sonnet.utilization, resetsAt: claudeResetIso(sonnet.resets_at), windowMs: WEEK_MS,
305
+ });
306
+ }
291
307
  // Per-model weekly windows live in the `limits` array (kind: weekly_scoped).
292
308
  // `limits` also carries which window is currently BINDING (`is_active`) and a
293
309
  // severity the top-level windows do not expose. Probed live on a Team account:
@@ -305,31 +321,38 @@ export async function fetchClaudeUsage() {
305
321
  continue;
306
322
  metrics.push({
307
323
  label: `${model} (weekly)`,
324
+ labelI18n: i18n("usage.metric.named_weekly", model),
308
325
  type: "percent",
309
326
  used: e.percent,
310
327
  resetsAt: claudeResetIso(e.resets_at),
311
328
  windowMs: WEEK_MS,
312
- ...(e.is_active === true ? { note: "binding" } : {}),
313
- ...(typeof e.severity === "string" && e.severity !== "normal" ? { note: `binding · ${e.severity}` } : {}),
329
+ ...(e.is_active === true ? { note: "binding", noteI18n: i18n("usage.note.binding") } : {}),
330
+ ...(typeof e.severity === "string" && e.severity !== "normal"
331
+ ? { note: `binding · ${e.severity}`, noteI18n: i18n("usage.note.binding_severity", e.severity) }
332
+ : {}),
314
333
  });
315
334
  }
316
335
  // Mark the two plain windows too, using the same source.
317
336
  for (const m of metrics) {
318
337
  if (m.note)
319
338
  continue;
320
- if (m.label === "Session" && activeKinds.has("session"))
339
+ if (m.label === "Session" && activeKinds.has("session")) {
321
340
  m.note = "binding";
322
- if (m.label === "Weekly" && activeKinds.has("weekly_all"))
341
+ m.noteI18n = i18n("usage.note.binding");
342
+ }
343
+ if (m.label === "Weekly" && activeKinds.has("weekly_all")) {
323
344
  m.note = "binding";
345
+ m.noteI18n = i18n("usage.note.binding");
346
+ }
324
347
  }
325
348
  const extra = body.extra_usage;
326
349
  if (extra?.is_enabled === true && typeof extra.used_credits === "number") {
327
350
  const used = extra.used_credits / 100;
328
351
  const limit = typeof extra.monthly_limit === "number" && extra.monthly_limit > 0 ? extra.monthly_limit / 100 : null;
329
352
  if (limit)
330
- metrics.push({ label: "Extra usage", type: "dollars", used, limit });
353
+ metrics.push({ label: "Extra usage", labelI18n: i18n("usage.metric.extra_usage"), type: "dollars", used, limit });
331
354
  else if (used > 0)
332
- metrics.push({ label: "Extra usage", type: "dollars", used });
355
+ metrics.push({ label: "Extra usage", labelI18n: i18n("usage.metric.extra_usage"), type: "dollars", used });
333
356
  }
334
357
  // A 200 with nothing in it is not better than the file on disk.
335
358
  if (metrics.length === 0 && statusline)
@@ -398,8 +421,10 @@ let agyTokenCache = null;
398
421
  */
399
422
  export function agyPoolMetrics(buckets) {
400
423
  const POOLED = {
401
- "gemini-5h": "Gemini (session)", "gemini-weekly": "Gemini (weekly)",
402
- "3p-5h": "Claude & others (session)", "3p-weekly": "Claude & others (weekly)",
424
+ "gemini-5h": { text: "Gemini (session)", i18n: i18n("usage.metric.named_session", "Gemini") },
425
+ "gemini-weekly": { text: "Gemini (weekly)", i18n: i18n("usage.metric.named_weekly", "Gemini") },
426
+ "3p-5h": { text: "Claude & others (session)", i18n: i18n("usage.metric.agy_claude_others_session") },
427
+ "3p-weekly": { text: "Claude & others (weekly)", i18n: i18n("usage.metric.agy_claude_others_weekly") },
403
428
  };
404
429
  const direct = [];
405
430
  const pools = new Map();
@@ -409,7 +434,7 @@ export function agyPoolMetrics(buckets) {
409
434
  const reset = b.resetTime ?? null;
410
435
  const pooledLabel = POOLED[b.bucketId];
411
436
  if (pooledLabel) {
412
- direct.push({ label: pooledLabel, type: "percent", used: (1 - b.remainingFraction) * 100, resetsAt: reset });
437
+ direct.push({ label: pooledLabel.text, labelI18n: pooledLabel.i18n, type: "percent", used: (1 - b.remainingFraction) * 100, resetsAt: reset });
413
438
  continue;
414
439
  }
415
440
  const pool = b.bucketId.startsWith("gemini") ? "Gemini" : "Claude & others";
@@ -430,6 +455,9 @@ export function agyPoolMetrics(buckets) {
430
455
  return direct;
431
456
  return [...pools.entries()].map(([label, p]) => ({
432
457
  label,
458
+ labelI18n: label === "Claude & others"
459
+ ? i18n("usage.metric.agy_claude_others")
460
+ : i18n("usage.metric.named", label),
433
461
  type: "percent",
434
462
  used: (1 - p.fraction) * 100,
435
463
  resetsAt: p.resetsAt,
@@ -437,6 +465,7 @@ export function agyPoolMetrics(buckets) {
437
465
  // it a flat 0% right after a weekly rollover is indistinguishable from a
438
466
  // broken reading — which is exactly how it was reported.
439
467
  note: `busiest of ${p.models} model${p.models === 1 ? "" : "s"}`,
468
+ noteI18n: i18n(p.models === 1 ? "usage.note.busiest_model" : "usage.note.busiest_models", p.models),
440
469
  }));
441
470
  }
442
471
  /**
@@ -482,6 +511,9 @@ async function agyAccessToken() {
482
511
  error: client
483
512
  ? "Access token expired and no refresh token is stored. Run `agy` once."
484
513
  : "Access token expired. Run `agy` once to refresh it (or set AGY_OAUTH_CLIENT_ID/SECRET to refresh automatically).",
514
+ errorI18n: client
515
+ ? i18n("usage.error.agy_no_refresh")
516
+ : i18n("usage.error.agy_token_expired"),
485
517
  };
486
518
  }
487
519
  try {
@@ -497,15 +529,15 @@ async function agyAccessToken() {
497
529
  signal: AbortSignal.timeout(15_000),
498
530
  });
499
531
  if (!res.ok)
500
- return { error: "Google sign-in expired. Run `agy` once to log in again." };
532
+ return { error: "Google sign-in expired. Run `agy` once to log in again.", errorI18n: i18n("usage.error.agy_login_expired") };
501
533
  const body = await res.json();
502
534
  if (!body.access_token)
503
- return { error: "Google token refresh returned no token." };
535
+ return { error: "Google token refresh returned no token.", errorI18n: i18n("usage.error.agy_refresh_empty") };
504
536
  agyTokenCache = { token: body.access_token, until: Date.now() + Math.max(60, (body.expires_in ?? 3600) - 120) * 1000 };
505
537
  return { token: body.access_token };
506
538
  }
507
539
  catch {
508
- return { error: "Could not reach oauth2.googleapis.com." };
540
+ return { error: "Could not reach oauth2.googleapis.com.", errorI18n: i18n("usage.error.unreachable", "oauth2.googleapis.com") };
509
541
  }
510
542
  }
511
543
  async function fetchAntigravityUsage() {
@@ -523,15 +555,16 @@ async function fetchAntigravityUsage() {
523
555
  status: "error",
524
556
  error: `Individual cap reached — CLI is rate limited (resets in ~${resetText}). `
525
557
  + "The per-model pool quotas are a separate counter and may still read 0% used.",
558
+ errorI18n: i18n("usage.error.agy_cap", resetText),
526
559
  metrics: [],
527
560
  };
528
561
  }
529
562
  const auth = await agyAccessToken();
530
563
  if (auth === null) {
531
- return { status: "no-credentials", hint: "Log in with the Antigravity CLI (`agy`).", metrics: [] };
564
+ return { status: "no-credentials", hint: "Log in with the Antigravity CLI (`agy`).", hintI18n: i18n("usage.hint.login_agy"), metrics: [] };
532
565
  }
533
566
  if ("error" in auth)
534
- return { status: "error", error: auth.error, metrics: [] };
567
+ return { status: "error", error: auth.error, errorI18n: auth.errorI18n, metrics: [] };
535
568
  for (const base of AGY_CLOUDCODE_URLS) {
536
569
  let res;
537
570
  try {
@@ -550,7 +583,7 @@ async function fetchAntigravityUsage() {
550
583
  continue;
551
584
  }
552
585
  if (res.status === 401 || res.status === 403) {
553
- return { status: "error", error: "Token rejected. Run `agy` once to refresh the login.", metrics: [] };
586
+ return { status: "error", error: "Token rejected. Run `agy` once to refresh the login.", errorI18n: i18n("usage.error.token_rejected", "agy"), metrics: [] };
554
587
  }
555
588
  if (!res.ok)
556
589
  continue; // try the other base
@@ -565,9 +598,9 @@ async function fetchAntigravityUsage() {
565
598
  const metrics = agyPoolMetrics(buckets);
566
599
  return metrics.length
567
600
  ? { status: "ok", plan: null, metrics }
568
- : { status: "ok", plan: null, hint: "No quota information reported for this account.", metrics: [] };
601
+ : { status: "ok", plan: null, hint: "No quota information reported for this account.", hintI18n: i18n("usage.hint.no_quota"), metrics: [] };
569
602
  }
570
- return { status: "error", error: "Could not reach the Cloud Code quota endpoints.", metrics: [] };
603
+ return { status: "error", error: "Could not reach the Cloud Code quota endpoints.", errorI18n: i18n("usage.error.unreachable", "Cloud Code quota endpoints"), metrics: [] };
571
604
  }
572
605
  // ── Codex ────────────────────────────────────────────────────────────────────
573
606
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
@@ -606,7 +639,7 @@ function codexWindows(rateLimit, labels, nowMs) {
606
639
  label = labels.other(Math.round(windowMs / 864e5));
607
640
  else
608
641
  label = fallback === "session" ? labels.session : labels.weekly;
609
- metrics.push({ label, type: "percent", used: win.used_percent, resetsAt: codexResetIso(win, nowMs), windowMs });
642
+ metrics.push({ label: label.text, labelI18n: label.i18n, type: "percent", used: win.used_percent, resetsAt: codexResetIso(win, nowMs), windowMs });
610
643
  }
611
644
  return metrics;
612
645
  }
@@ -626,15 +659,15 @@ export async function fetchCodexUsage() {
626
659
  auth = JSON.parse(await readFile(join(home, "auth.json"), "utf8"));
627
660
  }
628
661
  catch {
629
- return { status: "no-credentials", hint: "Log in with the Codex CLI (`codex`).", metrics: [] };
662
+ return { status: "no-credentials", hint: "Log in with the Codex CLI (`codex`).", hintI18n: i18n("usage.hint.login_codex"), metrics: [] };
630
663
  }
631
664
  const accessToken = auth?.tokens?.access_token;
632
665
  if (!accessToken) {
633
- return { status: "no-credentials", hint: "auth.json has no OAuth login (API-key-only auth cannot read usage).", metrics: [] };
666
+ return { status: "no-credentials", hint: "auth.json has no OAuth login (API-key-only auth cannot read usage).", hintI18n: i18n("usage.hint.codex_no_oauth"), metrics: [] };
634
667
  }
635
668
  const exp = jwtExpiryMs(accessToken);
636
669
  if (exp && exp < Date.now()) {
637
- return { status: "error", error: "Access token expired. Run `codex` once to refresh it.", metrics: [] };
670
+ return { status: "error", error: "Access token expired. Run `codex` once to refresh it.", errorI18n: i18n("usage.error.codex_token_expired"), metrics: [] };
638
671
  }
639
672
  const headers = {
640
673
  Authorization: `Bearer ${accessToken}`,
@@ -648,23 +681,26 @@ export async function fetchCodexUsage() {
648
681
  res = await fetch(CODEX_USAGE_URL, { headers, signal: AbortSignal.timeout(10_000) });
649
682
  }
650
683
  catch {
651
- return { status: "error", error: "Could not reach chatgpt.com.", metrics: [] };
684
+ return { status: "error", error: "Could not reach chatgpt.com.", errorI18n: i18n("usage.error.unreachable", "chatgpt.com"), metrics: [] };
652
685
  }
653
686
  if (res.status === 401 || res.status === 403) {
654
- return { status: "error", error: "Token rejected. Run `codex` once to refresh the login.", metrics: [] };
687
+ return { status: "error", error: "Token rejected. Run `codex` once to refresh the login.", errorI18n: i18n("usage.error.token_rejected", "codex"), metrics: [] };
655
688
  }
656
689
  if (!res.ok)
657
- return { status: "error", error: `Usage request failed (HTTP ${res.status}).`, metrics: [] };
690
+ return { status: "error", error: `Usage request failed (HTTP ${res.status}).`, errorI18n: i18n("usage.error.http", res.status), metrics: [] };
658
691
  let body;
659
692
  try {
660
693
  body = await res.json();
661
694
  }
662
695
  catch {
663
- return { status: "error", error: "Invalid response from usage endpoint.", metrics: [] };
696
+ return { status: "error", error: "Invalid response from usage endpoint.", errorI18n: i18n("usage.error.invalid_response"), metrics: [] };
664
697
  }
665
698
  const nowMs = Date.now();
666
699
  const metrics = codexWindows(body.rate_limit, {
667
- session: "Session", weekly: "Weekly", monthly: "Monthly", other: d => `${d}-day window`,
700
+ session: { text: "Session", i18n: i18n("usage.metric.session") },
701
+ weekly: { text: "Weekly", i18n: i18n("usage.metric.weekly") },
702
+ monthly: { text: "Monthly", i18n: i18n("usage.metric.monthly") },
703
+ other: d => ({ text: `${d}-day window`, i18n: i18n("usage.metric.days_window", d) }),
668
704
  }, nowMs);
669
705
  // Model-specific limits (e.g. Spark) ride in additional_rate_limits, same window shape.
670
706
  for (const entry of Array.isArray(body.additional_rate_limits) ? body.additional_rate_limits : []) {
@@ -673,12 +709,19 @@ export async function fetchCodexUsage() {
673
709
  continue;
674
710
  const rawName = e.limit_name || e.metered_feature || "Model limit";
675
711
  metrics.push(...codexWindows(e.rate_limit, {
676
- session: rawName, weekly: `${rawName} (weekly)`, monthly: `${rawName} (monthly)`, other: d => `${rawName} (${d}d)`,
712
+ session: { text: rawName, i18n: i18n("usage.metric.named", rawName) },
713
+ weekly: { text: `${rawName} (weekly)`, i18n: i18n("usage.metric.named_weekly", rawName) },
714
+ monthly: { text: `${rawName} (monthly)`, i18n: i18n("usage.metric.named_monthly", rawName) },
715
+ other: d => ({ text: `${rawName} (${d}d)`, i18n: i18n("usage.metric.named_days", rawName, d) }),
677
716
  }, nowMs));
678
717
  }
679
718
  const resets = body.rate_limit_reset_credits;
680
719
  if (typeof resets?.available_count === "number" && resets.available_count >= 0) {
681
- metrics.push({ label: "Rate limit resets", type: "count", value: Math.floor(resets.available_count), unit: "available" });
720
+ metrics.push({
721
+ label: "Rate limit resets", labelI18n: i18n("usage.metric.rate_limit_resets"),
722
+ type: "count", value: Math.floor(resets.available_count),
723
+ unit: "available", unitI18n: i18n("usage.unit.available"),
724
+ });
682
725
  }
683
726
  const credits = body.credits;
684
727
  let remaining = null;
@@ -688,7 +731,10 @@ export async function fetchCodexUsage() {
688
731
  remaining = 0;
689
732
  if (remaining !== null) {
690
733
  const count = Math.max(0, Math.floor(remaining));
691
- metrics.push({ label: "Credits", type: "count", value: count, unit: "credits", note: `≈ $${(count * CREDIT_USD_RATE).toFixed(2)}` });
734
+ metrics.push({
735
+ label: "Credits", labelI18n: i18n("usage.metric.credits"), type: "count", value: count,
736
+ unit: "credits", unitI18n: i18n("usage.unit.credits"), note: `≈ $${(count * CREDIT_USD_RATE).toFixed(2)}`,
737
+ });
692
738
  }
693
739
  return { status: "ok", plan: codexPlan(body.plan_type), metrics };
694
740
  }
@@ -718,11 +764,11 @@ async function grokRefreshAndPersist(file, auth, entryKey, entry) {
718
764
  await access(file, constants.W_OK);
719
765
  }
720
766
  catch {
721
- return { error: "Token expired and auth.json is read-only. Run `grok` once to refresh it." };
767
+ return { error: "Token expired and auth.json is read-only. Run `grok` once to refresh it.", errorI18n: i18n("usage.error.grok_auth_readonly") };
722
768
  }
723
769
  const refreshToken = (entry.refresh_token || entry.refresh || "").trim();
724
770
  if (!refreshToken)
725
- return { error: "Token expired and no refresh token found. Run `grok login`." };
771
+ return { error: "Token expired and no refresh token found. Run `grok login`.", errorI18n: i18n("usage.error.grok_no_refresh") };
726
772
  const clientId = entry.oidc_client_id?.trim()
727
773
  || entryKey.split("::").pop()?.trim()
728
774
  || GROK_DEFAULT_CLIENT_ID;
@@ -736,19 +782,19 @@ async function grokRefreshAndPersist(file, auth, entryKey, entry) {
736
782
  });
737
783
  }
738
784
  catch {
739
- return { error: "Could not reach auth.x.ai to refresh the token." };
785
+ return { error: "Could not reach auth.x.ai to refresh the token.", errorI18n: i18n("usage.error.grok_refresh_unreachable") };
740
786
  }
741
787
  if (!res.ok)
742
- return { error: "Grok session expired. Run `grok login` again." };
788
+ return { error: "Grok session expired. Run `grok login` again.", errorI18n: i18n("usage.error.grok_expired") };
743
789
  let body;
744
790
  try {
745
791
  body = await res.json();
746
792
  }
747
793
  catch {
748
- return { error: "Invalid refresh response from auth.x.ai." };
794
+ return { error: "Invalid refresh response from auth.x.ai.", errorI18n: i18n("usage.error.grok_invalid_refresh") };
749
795
  }
750
796
  if (!body.access_token)
751
- return { error: "Grok session expired. Run `grok login` again." };
797
+ return { error: "Grok session expired. Run `grok login` again.", errorI18n: i18n("usage.error.grok_expired") };
752
798
  // Update only this entry's fields, preserving everything else in the file
753
799
  // (other accounts' entries included).
754
800
  const updated = { ...(auth[entryKey] ?? {}) };
@@ -763,7 +809,7 @@ async function grokRefreshAndPersist(file, auth, entryKey, entry) {
763
809
  await writeFile(file, JSON.stringify({ ...auth, [entryKey]: updated }, null, 2));
764
810
  }
765
811
  catch {
766
- return { error: "Refreshed but could not write auth.json — refusing to continue without persisting. Run `grok` once instead." };
812
+ return { error: "Refreshed but could not write auth.json — refusing to continue without persisting. Run `grok` once instead.", errorI18n: i18n("usage.error.grok_persist") };
767
813
  }
768
814
  return { token: body.access_token };
769
815
  }
@@ -783,18 +829,18 @@ async function fetchGrokUsage() {
783
829
  auth = JSON.parse(await readFile(file, "utf8"));
784
830
  }
785
831
  catch {
786
- return { status: "no-credentials", hint: "Log in with the Grok CLI (`grok login`).", metrics: [] };
832
+ return { status: "no-credentials", hint: "Log in with the Grok CLI (`grok login`).", hintI18n: i18n("usage.hint.login_grok"), metrics: [] };
787
833
  }
788
834
  const entries = Object.entries(auth).filter(([, e]) => typeof e?.key === "string" && e.key.trim());
789
835
  if (!entries.length)
790
- return { status: "no-credentials", hint: "auth.json has no usable login. Run `grok login`.", metrics: [] };
836
+ return { status: "no-credentials", hint: "auth.json has no usable login. Run `grok login`.", hintI18n: i18n("usage.hint.grok_no_login"), metrics: [] };
791
837
  const [entryKey, entry] = entries[0];
792
838
  let token = entry.key.trim();
793
839
  const expiry = grokEntryExpiryMs(entry, token);
794
840
  if (expiry && expiry - Date.now() <= GROK_REFRESH_BUFFER_MS) {
795
841
  const refreshed = await grokRefreshAndPersist(file, auth, entryKey, entry);
796
842
  if (refreshed.error)
797
- return { status: "error", error: refreshed.error, metrics: [] };
843
+ return { status: "error", error: refreshed.error, errorI18n: refreshed.errorI18n, metrics: [] };
798
844
  token = refreshed.token;
799
845
  }
800
846
  const fetchBoth = async (tok) => {
@@ -810,30 +856,30 @@ async function fetchGrokUsage() {
810
856
  if (credits.status === 401 || credits.status === 403) {
811
857
  const refreshed = await grokRefreshAndPersist(file, auth, entryKey, entry);
812
858
  if (refreshed.error)
813
- return { status: "error", error: refreshed.error, metrics: [] };
859
+ return { status: "error", error: refreshed.error, errorI18n: refreshed.errorI18n, metrics: [] };
814
860
  token = refreshed.token;
815
861
  ({ credits, settings } = await fetchBoth(token));
816
862
  }
817
863
  }
818
864
  catch {
819
- return { status: "error", error: "Could not reach cli-chat-proxy.grok.com.", metrics: [] };
865
+ return { status: "error", error: "Could not reach cli-chat-proxy.grok.com.", errorI18n: i18n("usage.error.unreachable", "cli-chat-proxy.grok.com"), metrics: [] };
820
866
  }
821
867
  if (credits.status === 401 || credits.status === 403) {
822
- return { status: "error", error: "Grok session expired. Run `grok login` again.", metrics: [] };
868
+ return { status: "error", error: "Grok session expired. Run `grok login` again.", errorI18n: i18n("usage.error.grok_expired"), metrics: [] };
823
869
  }
824
870
  if (!credits.ok)
825
- return { status: "error", error: `Billing request failed (HTTP ${credits.status}).`, metrics: [] };
871
+ return { status: "error", error: `Billing request failed (HTTP ${credits.status}).`, errorI18n: i18n("usage.error.billing_http", credits.status), metrics: [] };
826
872
  let body;
827
873
  try {
828
874
  body = await credits.json();
829
875
  }
830
876
  catch {
831
- return { status: "error", error: "Invalid response from billing endpoint.", metrics: [] };
877
+ return { status: "error", error: "Invalid response from billing endpoint.", errorI18n: i18n("usage.error.invalid_billing_response"), metrics: [] };
832
878
  }
833
879
  const config = body?.config;
834
880
  const period = config?.currentPeriod;
835
881
  if (!config || !period?.type)
836
- return { status: "error", error: "Grok billing response changed.", metrics: [] };
882
+ return { status: "error", error: "Grok billing response changed.", errorI18n: i18n("usage.error.grok_response_changed"), metrics: [] };
837
883
  const metrics = [];
838
884
  if (period.type === GROK_WEEKLY_PERIOD) {
839
885
  // proto-JSON drops zero-valued fields: an absent creditUsagePercent means 0%.
@@ -842,6 +888,7 @@ async function fetchGrokUsage() {
842
888
  const end = new Date(period.end ?? "").getTime();
843
889
  metrics.push({
844
890
  label: "Weekly limit",
891
+ labelI18n: i18n("usage.metric.weekly_limit"),
845
892
  type: "percent",
846
893
  used: Math.min(100, Math.max(0, pct)),
847
894
  resetsAt: Number.isNaN(end) ? null : new Date(end).toISOString(),
@@ -849,7 +896,11 @@ async function fetchGrokUsage() {
849
896
  });
850
897
  }
851
898
  const cap = typeof config.onDemandCap?.val === "number" ? config.onDemandCap.val : 0;
852
- metrics.push({ label: "Pay as you go", type: "text", value: cap > 0 ? `${cap} cap` : "Disabled" });
899
+ metrics.push({
900
+ label: "Pay as you go", labelI18n: i18n("usage.metric.pay_as_you_go"), type: "text",
901
+ value: cap > 0 ? `${cap} cap` : "Disabled",
902
+ valueI18n: cap > 0 ? i18n("usage.value.cap", cap) : i18n("usage.value.disabled"),
903
+ });
853
904
  let plan = null;
854
905
  if (settings?.ok) {
855
906
  try {
@@ -942,12 +993,12 @@ export async function fetchKiroUsage() {
942
993
  const { token, kind, missing, notInstalled } = readKiroToken();
943
994
  if (notInstalled) {
944
995
  // The one case that should disappear from the panel: kiro-cli isn't here.
945
- return { status: "no-credentials", hint: "Log in with the Kiro CLI (`kiro-cli`).", metrics: [] };
996
+ return { status: "no-credentials", hint: "Log in with the Kiro CLI (`kiro-cli`).", hintI18n: i18n("usage.hint.login_kiro"), metrics: [] };
946
997
  }
947
998
  if (missing || !token) {
948
999
  // Installed but no readable login. Previously indistinguishable from
949
1000
  // not-installed, so the whole Kiro row vanished instead of saying this.
950
- return { status: "ok", plan: "Kiro", hint: "Signed out — run `kiro-cli` to log in.", metrics: [] };
1001
+ return { status: "ok", plan: "Kiro", hint: "Signed out — run `kiro-cli` to log in.", hintI18n: i18n("usage.hint.kiro_signed_out"), metrics: [] };
951
1002
  }
952
1003
  const expiresAt = token.expires_at ? new Date(token.expires_at).getTime() : null;
953
1004
  if (expiresAt && expiresAt < Date.now()) {
@@ -955,7 +1006,7 @@ export async function fetchKiroUsage() {
955
1006
  // panel showing a red failure for a routine token rollover is just noise.
956
1007
  // Still `ok` rather than no-credentials, so the row stays on screen with its
957
1008
  // explanation instead of the provider silently disappearing.
958
- return { status: "ok", plan: "Kiro", hint: "Token refreshing — try again in a moment.", metrics: [] };
1009
+ return { status: "ok", plan: "Kiro", hint: "Token refreshing — try again in a moment.", hintI18n: i18n("usage.hint.token_refreshing"), metrics: [] };
959
1010
  }
960
1011
  if (kind === "q-developer-pro") {
961
1012
  // Seat-licensed subscription: GetUsageLimits describes Builder-ID credit
@@ -964,6 +1015,7 @@ export async function fetchKiroUsage() {
964
1015
  status: "ok",
965
1016
  plan: "Q Developer Pro",
966
1017
  hint: "Subscription — no credit usage to report.",
1018
+ hintI18n: i18n("usage.hint.subscription_no_credit"),
967
1019
  metrics: [],
968
1020
  };
969
1021
  }
@@ -984,35 +1036,40 @@ export async function fetchKiroUsage() {
984
1036
  });
985
1037
  }
986
1038
  catch {
987
- return { status: "error", error: `Could not reach codewhisperer.${region}.amazonaws.com.`, metrics: [] };
1039
+ return { status: "error", error: `Could not reach codewhisperer.${region}.amazonaws.com.`, errorI18n: i18n("usage.error.unreachable", `codewhisperer.${region}.amazonaws.com`), metrics: [] };
988
1040
  }
989
1041
  if (res.status === 401 || res.status === 403) {
990
- return { status: "error", error: "Token rejected. Use `kiro-cli` once to refresh the login.", metrics: [] };
1042
+ return { status: "error", error: "Token rejected. Use `kiro-cli` once to refresh the login.", errorI18n: i18n("usage.error.token_rejected", "kiro-cli"), metrics: [] };
991
1043
  }
992
1044
  if (!res.ok)
993
- return { status: "error", error: `Usage request failed (HTTP ${res.status}).`, metrics: [] };
1045
+ return { status: "error", error: `Usage request failed (HTTP ${res.status}).`, errorI18n: i18n("usage.error.http", res.status), metrics: [] };
994
1046
  let body;
995
1047
  try {
996
1048
  body = await res.json();
997
1049
  }
998
1050
  catch {
999
- return { status: "error", error: "Invalid response from GetUsageLimits.", metrics: [] };
1051
+ return { status: "error", error: "Invalid response from GetUsageLimits.", errorI18n: i18n("usage.error.invalid_kiro_response"), metrics: [] };
1000
1052
  }
1001
1053
  const plan = typeof body.subscriptionInfo?.subscriptionTitle === "string"
1002
1054
  ? kiroTitleCase(body.subscriptionInfo.subscriptionTitle)
1003
1055
  : null;
1004
1056
  const metrics = [];
1005
1057
  for (const ub of Array.isArray(body.usageBreakdownList) ? body.usageBreakdownList : []) {
1006
- const unit = (ub.displayNamePlural || ub.displayName || "credits").toLowerCase();
1058
+ const displayName = ub.displayNamePlural || ub.displayName;
1059
+ const unit = (displayName || "credits").toLowerCase();
1007
1060
  const used = ub.currentUsageWithPrecision ?? ub.currentUsage ?? null;
1008
1061
  const limit = ub.usageLimitWithPrecision ?? ub.usageLimit ?? null;
1009
1062
  if (used !== null && limit !== null && limit > 0) {
1010
1063
  metrics.push({
1011
- label: `${ub.displayNamePlural || ub.displayName || "Usage"} (monthly)`,
1064
+ label: `${displayName || "Usage"} (monthly)`,
1065
+ labelI18n: displayName
1066
+ ? i18n("usage.metric.named_monthly", displayName)
1067
+ : i18n("usage.metric.usage_monthly"),
1012
1068
  type: "percent",
1013
1069
  used: Math.min(100, (used / limit) * 100),
1014
1070
  resetsAt: kiroEpochIso(ub.nextDateReset ?? body.nextDateReset),
1015
1071
  note: `${+used.toFixed(1)} / ${+limit.toFixed(0)} ${unit}`,
1072
+ noteI18n: i18n("usage.note.used_limit_unit", +used.toFixed(1), +limit.toFixed(0), unit),
1016
1073
  });
1017
1074
  }
1018
1075
  // Bonus / gift credits (redeemed promo codes), aggregated across active codes.
@@ -1024,27 +1081,29 @@ export async function fetchKiroUsage() {
1024
1081
  if (bLimit > 0) {
1025
1082
  metrics.push({
1026
1083
  label: "Bonus credits",
1084
+ labelI18n: i18n("usage.metric.bonus_credits"),
1027
1085
  type: "percent",
1028
1086
  used: Math.min(100, (bUsed / bLimit) * 100),
1029
1087
  resetsAt: expiries.length ? kiroEpochIso(Math.min(...expiries)) : null,
1030
1088
  note: `${+bUsed.toFixed(1)} / ${+bLimit.toFixed(0)} ${unit} · ${bonuses.length} codes`,
1089
+ noteI18n: i18n("usage.note.bonus_codes", +bUsed.toFixed(1), +bLimit.toFixed(0), unit, bonuses.length),
1031
1090
  });
1032
1091
  }
1033
1092
  }
1034
1093
  const overages = ub.currentOveragesWithPrecision ?? ub.currentOverages ?? 0;
1035
1094
  if (overages > 0 && typeof ub.overageCharges === "number") {
1036
- metrics.push({ label: "Overage charges", type: "dollars", used: ub.overageCharges });
1095
+ metrics.push({ label: "Overage charges", labelI18n: i18n("usage.metric.overage_charges"), type: "dollars", used: ub.overageCharges });
1037
1096
  }
1038
1097
  }
1039
1098
  const overageStatus = body.overageConfiguration?.overageStatus;
1040
1099
  if (typeof overageStatus === "string") {
1041
- metrics.push({ label: "Pay-per-use", type: "text", value: kiroTitleCase(overageStatus.replace(/_/g, " ")) });
1100
+ metrics.push({ label: "Pay-per-use", labelI18n: i18n("usage.metric.pay_per_use"), type: "text", value: kiroTitleCase(overageStatus.replace(/_/g, " ")) });
1042
1101
  }
1043
1102
  if (metrics.length === 0) {
1044
1103
  // Amazon Q logins whose account type reports no credit buckets (#398's
1045
1104
  // sibling case): an OK answer with nothing in it must say so, not render an
1046
1105
  // empty row that reads like a failure.
1047
- return { status: "ok", plan: plan ?? "Amazon Q", hint: "No quota information available for this account type.", metrics: [] };
1106
+ return { status: "ok", plan: plan ?? "Amazon Q", hint: "No quota information available for this account type.", hintI18n: i18n("usage.hint.no_quota_account"), metrics: [] };
1048
1107
  }
1049
1108
  return { status: "ok", plan, metrics };
1050
1109
  }