maxpool 1.23.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -3423,6 +3423,33 @@ export class AccountManager {
3423
3423
  * (never the unified or scopedWeekly fields) so a provider reading can't reach
3424
3424
  * the OAuth quota gates.
3425
3425
  */
3426
+ /** Store the latest Claude reset-grant listing (prober piggyback). */
3427
+ applyResetGrants(accountIndex, listing) {
3428
+ const account = this.accounts[accountIndex];
3429
+ if (!account || !listing) return;
3430
+ if (listing.error) { account.resetGrants = null; return; }
3431
+ account.resetGrants = {
3432
+ grants: listing.grants || [],
3433
+ nextGrantId: listing.nextGrantId ?? null,
3434
+ cooldownUntil: listing.cooldownUntil ?? null,
3435
+ eligible: listing.eligible !== false,
3436
+ checkedAt: Date.now(),
3437
+ };
3438
+ }
3439
+
3440
+ /** Store the latest reset-card listing for an account (prober piggyback).
3441
+ * Cards are display/decision state only — routing never reads them. */
3442
+ applyResetCards(accountIndex, cards) {
3443
+ const account = this.accounts[accountIndex];
3444
+ if (!account || !cards) return;
3445
+ if (cards.error) { account.resetCards = null; return; } // never half-write
3446
+ account.resetCards = {
3447
+ fiveHour: cards.fiveHour || [],
3448
+ weekly: cards.weekly || [],
3449
+ checkedAt: Date.now(),
3450
+ };
3451
+ }
3452
+
3426
3453
  applyProviderUsage(accountIndex, usage) {
3427
3454
  const account = this.accounts[accountIndex];
3428
3455
  if (!account || !usage) return;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Claude banked usage-limit resets (internal program `cedar_ember`; the
3
+ * session-only 1/week program is `juniper_tide` — deliberately NOT auto-claimed
4
+ * here: it costs a scarce weekly slot for the least valuable window).
5
+ *
6
+ * Endpoints (same shape Claude Code itself uses; verified against the 2.1.285
7
+ * binary and third-party implementations, 2026-09-30):
8
+ * GET /api/oauth/usage?cedar_ember=1&skip_spend=1
9
+ * -> cedar_ember: { eligible, ineligible_reason, grants: [...],
10
+ * next_grant_id, cooldown_until, weekly_resets_at }
11
+ * Grant: { id, resets_total, resets_left, starts_at, ends_at, usable_now,
12
+ * paused, use_requires_limit }
13
+ * GET /api/oauth/profile -> organization uuids (organizations[].uuid)
14
+ * POST /api/organizations/{org}/reset_rate_limits
15
+ * { program:'cedar_ember', grant_id, request_id }
16
+ *
17
+ * CRITICAL: without a Claude Code User-Agent the usage endpoint answers
18
+ * eligible:false, ineligible_reason:"surface" for EVERY token — the grants exist
19
+ * but are invisible. Mirror the CLI's UA exactly.
20
+ *
21
+ * Envelope: claim returns HTTP 200 with success:false on failure; `already_used`
22
+ * counts as success (idempotent) — request_id is the client's idempotency key.
23
+ */
24
+ import { randomUUID } from 'node:crypto';
25
+
26
+ const USAGE_BASE = 'https://api.anthropic.com/api/oauth/usage';
27
+ const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
28
+ const CLAIM_PATH = '/api/organizations';
29
+
30
+ function cliUserAgent() {
31
+ // Match the installed CLI's version so server-side cli_version gating treats us
32
+ // like the real client. Falls back to a known-good recent version.
33
+ const v = process.env.MAXPOOL_CLAUDE_CLI_VERSION || '2.1.285';
34
+ return `claude-cli/${v} (external, cli)`;
35
+ }
36
+
37
+ function headers(accessToken) {
38
+ return {
39
+ 'Authorization': `Bearer ${accessToken}`,
40
+ 'User-Agent': cliUserAgent(),
41
+ 'Accept': 'application/json',
42
+ };
43
+ }
44
+
45
+ /** Parse an ISO/epoch-ish timestamp into ms, or null. */
46
+ function toMs(v) {
47
+ if (v == null) return null;
48
+ if (typeof v === 'number') return v > 1e12 ? v : v * 1e3; // s vs ms heuristics
49
+ const t = Date.parse(v);
50
+ return Number.isFinite(t) ? t : null;
51
+ }
52
+
53
+ /** Normalize a cedar_ember grant. Drops malformed entries (the CLI does the same:
54
+ * "ignoring a malformed grant"). Keeps expired ones OUT unless resets remain and
55
+ * ends_at is still ahead — expired grants are display-only noise. */
56
+ export function normalizeGrant(raw, now = Date.now()) {
57
+ if (!raw || typeof raw !== 'object') return null;
58
+ const g = raw.id != null ? raw : { ...raw, id: raw }; // tolerate id-less shapes
59
+ const resetsTotal = Number(g.resets_total ?? g.resetsTotal);
60
+ const resetsLeft = Number(g.resets_left ?? g.resetsLeft);
61
+ if (!Number.isFinite(resetsTotal) || resetsTotal < 1) return null;
62
+ // resets_left:0 = SPENT (live 2026-09-30: the opus55-launch grant on
63
+ // max@gomokka.com shows resets_total:1, resets_left:0, usable_now:false — already
64
+ // consumed at the Opus 5.5 launch). Keep it OUT of the claimable list; the LIST
65
+ // still surfaces it so the TUI can show the spent history if wanted.
66
+ if (!Number.isFinite(resetsLeft) || resetsLeft < 1) return null;
67
+ const startsAt = toMs(g.starts_at ?? g.startsAt);
68
+ const endsAt = toMs(g.ends_at ?? g.endsAt);
69
+ return {
70
+ id: g.id,
71
+ label: g.label || null,
72
+ resetsLeft, resetsTotal, startsAt, endsAt,
73
+ // clears is the money field: [five_hour, seven_day, seven_day_overage_included]
74
+ // means a grant clears BOTH windows (measured live). Default to assuming both
75
+ // when absent rather than none — the observed server always sends it.
76
+ clears: Array.isArray(g.clears) ? g.clears : ['five_hour', 'seven_day'],
77
+ usableNow: g.usable_now !== false && (resetsLeft >= 1),
78
+ paused: g.paused === true,
79
+ requiresLimit: g.use_requires_limit !== false, // most grants only fire at the wall
80
+ expired: endsAt != null && endsAt <= now,
81
+ };
82
+ }
83
+
84
+ /** List this account's banked resets. Zero-spend read (skip_spend=1).
85
+ * Returns { grants: [], nextGrantId, cooldownUntil, eligible, reason } | { error }. */
86
+ export async function listResetGrants(accessToken, { signal } = {}) {
87
+ if (!accessToken) return { error: 'no token' };
88
+ try {
89
+ const res = await fetch(`${USAGE_BASE}?cedar_ember=1&skip_spend=1`, {
90
+ headers: headers(accessToken), signal,
91
+ });
92
+ if (res.status === 401) return { error: 'unauthorized' };
93
+ if (!res.ok) return { error: `HTTP ${res.status}` };
94
+ const body = await res.json();
95
+ const ce = body?.cedar_ember;
96
+ if (!ce || typeof ce !== 'object') {
97
+ // Not an error: most accounts simply aren't in the program.
98
+ return { grants: [], nextGrantId: null, cooldownUntil: null, eligible: false, reason: 'not_offered' };
99
+ }
100
+ if (ce.eligible === false) {
101
+ return { grants: [], nextGrantId: null, cooldownUntil: toMs(ce.cooldown_until ?? ce.cooldownUntil),
102
+ eligible: false, reason: ce.ineligible_reason || 'ineligible' };
103
+ }
104
+ const now = Date.now();
105
+ const grants = (Array.isArray(ce.grants) ? ce.grants : []).map(g => normalizeGrant(g, now)).filter(Boolean);
106
+ return {
107
+ grants,
108
+ nextGrantId: ce.next_grant_id ?? ce.nextGrantId ?? null,
109
+ cooldownUntil: toMs(ce.cooldown_until ?? ce.cooldownUntil),
110
+ eligible: true,
111
+ reason: null,
112
+ };
113
+ } catch (err) {
114
+ return { error: err.message || String(err) };
115
+ }
116
+ }
117
+
118
+ /** Claim one grant. The CLI never retries a claim on the wire; our request_id is
119
+ * random per CALL (server treats already_used as success), so a caller retry is
120
+ * safe but the id is not reused across distinct decisions. Returns
121
+ * { ok:true } | { ok:false, error, code }. */
122
+ export async function claimResetGrant(accessToken, grantId, { signal, requestId } = {}) {
123
+ if (!accessToken || !grantId) return { ok: false, error: 'missing args' };
124
+ try {
125
+ const prof = await fetch(PROFILE_URL, { headers: headers(accessToken), signal });
126
+ if (!prof.ok) return { ok: false, error: `profile HTTP ${prof.status}` };
127
+ const orgs = (await prof.json())?.organizations;
128
+ const org = Array.isArray(orgs) && orgs.length ? orgs[0].uuid : null;
129
+ if (!org) return { ok: false, error: 'no oauth organization' };
130
+
131
+ const rid = requestId || randomUUID();
132
+ const res = await fetch(`https://api.anthropic.com${CLAIM_PATH}/${org}/reset_rate_limits`, {
133
+ method: 'POST',
134
+ headers: { ...headers(accessToken), 'Content-Type': 'application/json' },
135
+ body: JSON.stringify({ program: 'cedar_ember', grant_id: grantId, request_id: rid }),
136
+ signal,
137
+ });
138
+ const data = await res.json().catch(() => null);
139
+ // Success shapes observed: {success:true} or result:"already_used" (idempotent).
140
+ const okFlag = data?.success === true || data?.result === 'already_used';
141
+ if (okFlag) return { ok: true, grantId, alreadyUsed: data?.result === 'already_used' };
142
+ return { ok: false, error: data?.error?.message || JSON.stringify(data).slice(0, 200), code: data?.error?.type };
143
+ } catch (err) {
144
+ return { ok: false, error: err.message || String(err) };
145
+ }
146
+ }
package/src/prober.js CHANGED
@@ -8,6 +8,9 @@
8
8
  // This is the one sanctioned active-upstream feature; the proxy is otherwise passive.
9
9
 
10
10
  import { fetchUsage, fetchProviderUsage } from './oauth.js';
11
+ import { listResetCards, redeemResetCard } from './zai-reset-cards.js';
12
+ import { listResetGrants, claimResetGrant } from './claude-reset-grants.js';
13
+ import { decideZai, decideClaude } from './reset-policy.js';
11
14
 
12
15
  export class Prober {
13
16
  constructor(accountManager, { intervalMs = 0, probeFn = fetchUsage, providerProbeFn = fetchProviderUsage, timeoutMs = 10_000, log = console.log, usageGapMs = null } = {}) {
@@ -179,9 +182,56 @@ export class Prober {
179
182
  const usage = await this._withTimeout(this.providerProbeFn(account));
180
183
  if (!usage) return; // timed out — try again next cycle
181
184
  this.am.applyProviderUsage(account.index, usage);
185
+ // Reset cards piggyback on the same poll cycle (z.ai only; zero-spend read).
186
+ // Failures are swallowed on purpose: cards are an enhancement, never a probe
187
+ // health signal — a card-list 401 must not turn the quota bars stale.
188
+ // OWNER GATE (2026-09-30): disabled accounts are excluded from listing AND
189
+ // redemption entirely — a readable key on a disabled account (someone else
190
+ // uses it) is not authority to act.
191
+ if (account.enabled !== false && account.credential) {
192
+ if (account.provider === 'zai' && this.am.applyResetCards) {
193
+ try {
194
+ const cards = await this._withTimeout(listResetCards(account.credential));
195
+ if (cards) {
196
+ this.am.applyResetCards(account.index, cards);
197
+ const act = decideZai(account, cards);
198
+ if (act) await this._redeemZaiCard(account, act);
199
+ }
200
+ } catch { /* cards are best-effort */ }
201
+ }
202
+ }
182
203
  } catch { /* best-effort; never let a probe throw */ }
183
204
  }
184
205
 
206
+ /** Execute a z.ai card redeem decided by the policy. Logs before/after. */
207
+ async _redeemZaiCard(account, act) {
208
+ try {
209
+ const q = account.quota || {};
210
+ this.log(`[reset-card] ${account.name}: redeeming ${act.resetType} card ${act.card.recordId} (${act.reason}; ses=${q.providerSes ?? '?'} wk=${q.providerWk ?? '?'})`);
211
+ const r = await this._withTimeout(redeemResetCard(account.credential, act.card.recordId, act.resetType));
212
+ if (r?.ok) {
213
+ this.log(`[reset-card] ${account.name}: card ${act.card.recordId} redeemed`);
214
+ if (this.am.applyResetCards) {
215
+ const fresh = await this._withTimeout(listResetCards(account.credential));
216
+ if (fresh) this.am.applyResetCards(account.index, fresh);
217
+ }
218
+ } else {
219
+ this.log(`[reset-card] ${account.name}: redeem FAILED card ${act.card.recordId}: ${r?.error}`);
220
+ }
221
+ } catch (e) { this.log(`[reset-card] ${account.name}: redeem error ${e.message}`); }
222
+ }
223
+
224
+ /** Execute a Claude cedar_ember claim decided by the policy. */
225
+ async _claimClaudeGrant(account, act) {
226
+ try {
227
+ const q = account.quota || {};
228
+ this.log(`[reset-grant] ${account.name}: claiming grant ${act.grant.id} (${act.reason}; ses=${q.unified5h ?? '?'} wk=${q.unified7d ?? '?'})`);
229
+ const r = await this._withTimeout(claimResetGrant(account.credential, act.grant.id));
230
+ if (r?.ok) this.log(`[reset-grant] ${account.name}: grant ${act.grant.id} claimed${r.alreadyUsed ? ' (already used — idempotent)' : ''}`);
231
+ else this.log(`[reset-grant] ${account.name}: claim FAILED ${act.grant.id}: ${r?.error}`);
232
+ } catch (e) { this.log(`[reset-grant] ${account.name}: claim error ${e.message}`); }
233
+ }
234
+
185
235
  /** Probe one OAUTH account. Returns {ok, status} so probeAll can pace/back-off
186
236
  * and so a persistent failure is RECORDED (surfaced in the TUI/status) rather
187
237
  * than silently swallowed — a swallowed failing probe is what let a stale
@@ -193,6 +243,18 @@ export class Prober {
193
243
  // on 2solarmax@ between 2026-09-18 and 09-20.
194
244
  if (account.subscriptionGone) return { ok: false, status: 403 };
195
245
  try {
246
+ // Claude banked resets (cedar_ember) piggyback on the oauth poll — same
247
+ // OWNER GATE as z.ai cards: a DISABLED account is never listed or claimed.
248
+ try {
249
+ if (account.enabled !== false && account.credential && this.am.applyResetGrants) {
250
+ const listing = await this._withTimeout(listResetGrants(account.credential));
251
+ if (listing) {
252
+ this.am.applyResetGrants(account.index, listing);
253
+ const act = decideClaude(account, listing);
254
+ if (act) await this._claimClaudeGrant(account, act);
255
+ }
256
+ }
257
+ } catch { /* grants are best-effort; never break the quota probe */ }
196
258
  await this.am.ensureTokenFresh(account.index);
197
259
  let usage = await this._withTimeout(this.probeFn(account.credential));
198
260
  if (usage?.status === 401) {
@@ -0,0 +1,121 @@
1
+ /**
2
+ * WHEN to redeem a limit-reset: the deliberate decision layer.
3
+ *
4
+ * Two value sources, one policy:
5
+ * - z.ai reset cards: { fiveHour:[{recordId,expiresAt,expired}], weekly:[...] }
6
+ * - Claude cedar_ember grants: { grants:[{id,resetsLeft,endsAt,usableNow,paused,
7
+ * requiresLimit,clears}], nextGrantId, cooldownUntil, eligible }
8
+ *
9
+ * Rules (owner, 2026-09-30 — "account not just for exhaustion, but also the time
10
+ * left until expiry of the reset"):
11
+ *
12
+ * R0 ENABLED GATE — the caller must never even ask about a disabled account. The
13
+ * policy asserts it too: a disabled account's cards/grants are someone else's.
14
+ * (privacy@gomokka.com: owner uses it elsewhere; readable key is not authority.)
15
+ *
16
+ * R1 AT-THE-WALL — redeem when the window the reset clears is effectively spent:
17
+ * weekly >= 0.90, or (no weekly-clearing reset and 5h >= 0.95). Wall, not 100%:
18
+ * routing benches an account before the bar reads full.
19
+ *
20
+ * R2 DYING VALUE — a reset expiring soon is worth MORE than waiting: redeem when
21
+ * expiry < 24h AND the account has consumed enough that the refill buys real
22
+ * capacity (utilization > 0.30 for weekly-clearing; > 0.50 for 5h-only). At
23
+ * expiry the reset is worth zero, so "save it for a worse moment" is the
24
+ * false economy — the worse moment must occur BEFORE expiry or never.
25
+ *
26
+ * R3 EXPIRED — never redeem an expired reset (idempotent keys protect retries,
27
+ * but a redeem against an expired card is just a guaranteed API error).
28
+ *
29
+ * R4 ORDER — prefer the reset that clears the MOST (weekly clears both windows);
30
+ * among equals, the soonest-expiring first. Never spend a weekly-clearing
31
+ * reset when a 5h-only one would do — EXCEPT under R2 with <24h left on the
32
+ * weekly reset and no 5h reset at all.
33
+ *
34
+ * R5 PACE — at most ONE redeem per account per evaluation. The caller invokes the
35
+ * policy each poll cycle; the deterministic z.ai requestId makes an accidental
36
+ * double-fire harmless, but the policy still returns a single action.
37
+ */
38
+ export const POLICY = {
39
+ wallWeekly: 0.90, // weekly at 90% = at the wall
40
+ wallSes: 0.95, // 5h at 95% = at the wall
41
+ dyingWindowMs: 24 * 3600 * 1000, // redeem-don't-save horizon
42
+ dyingMinUtilWeekly: 0.30, // below this a dying weekly refill buys too little
43
+ dyingMinUtilSes: 0.50, // 5h refills are cheap capacity; demand more waste first
44
+ };
45
+
46
+ function msLeft(t, now) { return t == null ? Infinity : t - now; }
47
+
48
+ /** Decide the z.ai action. Returns {kind:'card', card, resetType} | null. */
49
+ export function decideZai(account, cards, now = Date.now()) {
50
+ if (account?.enabled === false) return null; // R0
51
+ if (!cards || cards.error) return null;
52
+ const q = account.quota || {};
53
+ const ses = q.providerSes ?? null;
54
+ const wk = q.weeklyAbsent ? null : (q.providerWk ?? null);
55
+ const wkUtil = wk; const sesUtil = ses;
56
+
57
+ const weeklyCards = (cards.weekly || []).filter(c => !c.expired && c.recordId != null);
58
+ const sesCards = (cards.fiveHour || []).filter(c => !c.expired && c.recordId != null);
59
+ // R4: soonest expiry first inside each class.
60
+ weeklyCards.sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity));
61
+ sesCards.sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity));
62
+
63
+ const weeklyCard = weeklyCards[0] || null;
64
+ const sesCard = sesCards[0] || null;
65
+
66
+ // --- weekly card: clears BOTH windows (z.ai docs; measured: glm1 100%->0% both)
67
+ if (weeklyCard) {
68
+ const worst = Math.max(wkUtil ?? 0, sesUtil ?? 0);
69
+ const atWall = wkUtil != null && wkUtil >= POLICY.wallWeekly;
70
+ const dying = msLeft(weeklyCard.expiresAt, now) < POLICY.dyingWindowMs
71
+ && worst >= POLICY.dyingMinUtilWeekly;
72
+ if (atWall || dying) {
73
+ return { kind: 'card', card: weeklyCard, resetType: 'WEEK',
74
+ reason: atWall ? 'weekly at wall' : 'dying value' };
75
+ }
76
+ }
77
+
78
+ // --- 5h card: clears only the session window
79
+ if (sesCard) {
80
+ const atWall = sesUtil != null && sesUtil >= POLICY.wallSes
81
+ && (weeklyCard == null); // R4: a weekly card can do this job AND more
82
+ const dying = msLeft(sesCard.expiresAt, now) < POLICY.dyingWindowMs
83
+ && (sesUtil ?? 0) >= POLICY.dyingMinUtilSes;
84
+ if (atWall || dying) {
85
+ return { kind: 'card', card: sesCard, resetType: 'FIVE_HOUR',
86
+ reason: atWall ? 'session at wall' : 'dying value' };
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /** Decide the Claude action. Returns {kind:'grant', grant} | null. */
93
+ export function decideClaude(account, listing, now = Date.now()) {
94
+ if (account?.enabled === false) return null; // R0
95
+ if (!listing || listing.error || listing.eligible === false) return null;
96
+ if (listing.cooldownUntil != null && listing.cooldownUntil > now) return null;
97
+ const grants = (listing.grants || [])
98
+ .filter(g => g.usableNow && !g.paused && !g.expired)
99
+ .sort((a, b) => (a.endsAt ?? Infinity) - (b.endsAt ?? Infinity));
100
+ // The server names the next grant it would spend; honoring it avoids surprises.
101
+ const chosen = grants.find(g => listing.nextGrantId == null || g.id === listing.nextGrantId) || grants[0];
102
+ if (!chosen) return null;
103
+
104
+ const q = account.quota || {};
105
+ const wkUtil = q.unified7d ?? null;
106
+ const sesUtil = q.unified5h ?? null;
107
+ const clearsWk = chosen.clears?.includes('seven_day');
108
+
109
+ let atWall = false;
110
+ if (clearsWk) atWall = wkUtil != null && wkUtil >= POLICY.wallWeekly;
111
+ else atWall = sesUtil != null && sesUtil >= POLICY.wallSes;
112
+
113
+ // use_requires_limit: some grants may only fire AT the wall (server-enforced).
114
+ const dying = msLeft(chosen.endsAt, now) < POLICY.dyingWindowMs
115
+ && Math.max(wkUtil ?? 0, sesUtil ?? 0) >= (clearsWk ? POLICY.dyingMinUtilWeekly : POLICY.dyingMinUtilSes);
116
+ // A requires-limit grant cannot be redeemed pre-wall even for dying value.
117
+ if (atWall || (dying && !chosen.requiresLimit)) {
118
+ return { kind: 'grant', grant: chosen, reason: atWall ? 'at wall' : 'dying value' };
119
+ }
120
+ return null;
121
+ }
package/src/tui.js CHANGED
@@ -255,6 +255,30 @@ function settingsTags(am, a) {
255
255
  }
256
256
  const capTag = capText(a, capBenched(am, a), am);
257
257
  if (capTag) tags.push(capTag);
258
+ // A switched-off account whose credentials ALSO died keeps "disabled" in the status
259
+ // column (so the disabled inventory stays readable at a glance) and carries the second
260
+ // problem here. Without this the row would hide the fact that re-enabling it later
261
+ // yields a broken account — the 2026-08-10 report that 8 disabled accounts were all
262
+ // sitting on dead credentials.
263
+ if (a?.enabled === false) {
264
+ if (a.refreshDead) tags.push(yellow('needs login'));
265
+ else if (a.subscriptionGone) tags.push(red('no sub'));
266
+ return tags; // disabled: never show card inventory either (owner gate)
267
+ }
268
+ // Available limit resets — the pool redeems these automatically per policy
269
+ // (reset-policy.js). Shown so the owner can see banked value before it expires.
270
+ const rc = a?.resetCards;
271
+ if (rc) {
272
+ const live5 = (rc.fiveHour || []).filter(c => !c.expired).length;
273
+ const liveW = (rc.weekly || []).filter(c => !c.expired).length;
274
+ if (liveW) tags.push(green(`reset wk×${liveW}`));
275
+ if (live5) tags.push(green(`reset 5h×${live5}`));
276
+ }
277
+ const rg = a?.resetGrants;
278
+ if (rg?.eligible) {
279
+ const live = (rg.grants || []).filter(g => g.usableNow && !g.expired).length;
280
+ if (live) tags.push(green(`reset×${live}`));
281
+ }
258
282
  return tags;
259
283
  }
260
284
 
@@ -2084,7 +2108,9 @@ export class TUI {
2084
2108
  if (a.refreshDead) effectiveStatus = a.enabled === false ? 'disabled-reauth' : 'reauth';
2085
2109
  // Subscription gone (org-disabled 403): a DISTINCT state from reauth — re-logging in
2086
2110
  // will NOT fix it until the subscription is re-purchased. Says the actionable thing.
2087
- else if (a.subscriptionGone) effectiveStatus = 'no sub';
2111
+ // Same precedence rule as reauth above: when the account is ALSO switched off,
2112
+ // "disabled" stays the headline so the disabled inventory remains readable.
2113
+ else if (a.subscriptionGone) effectiveStatus = a.enabled === false ? 'disabled-nosub' : 'no sub';
2088
2114
  switch (effectiveStatus) {
2089
2115
  case 'active': status = isCur ? green('active') : 'active'; break;
2090
2116
  case 'reauth': status = yellow('reauth'); break;
@@ -2093,9 +2119,19 @@ export class TUI {
2093
2119
  case 'waiting': status = yellow('waiting'); break;
2094
2120
  case 'paused': status = yellow('paused'); break;
2095
2121
  case 'disabled': status = red('✕ disabled'); break;
2096
- // Disabled AND needs re-login — both facts matter: it won't serve because you
2097
- // switched it off, and it CAN'T serve until you log in again.
2098
- case 'disabled-reauth': status = red('✕ reauth'); break;
2122
+ // Disabled AND needs re-login — both facts matter, and DISABLED LEADS.
2123
+ // The owner's question that drove this (2026-09-30): "when I disable the account
2124
+ // and then the token expires, does it show disabled or reauth? It needs to
2125
+ // continue to show disabled otherwise I'm confused as to which ones I have
2126
+ // disabled." Showing only '✕ reauth' made a deliberately-off account
2127
+ // indistinguishable from a live one with dead credentials, so the disabled
2128
+ // inventory could not be read off the screen at all. So the STATUS column always
2129
+ // says 'disabled' for an account you switched off, and the reason it ALSO cannot
2130
+ // serve rides as its own tag ("needs login" / "no sub") — collapsing both into one
2131
+ // '+' marker would lose the distinction between "log in again" (fixable) and
2132
+ // "subscription gone" (not fixable by logging in).
2133
+ case 'disabled-reauth':
2134
+ case 'disabled-nosub': status = red('✕ disabled'); break;
2099
2135
  case 'no sub': status = red('✕ no sub'); break;
2100
2136
  case 'throttled': {
2101
2137
  // A transient auto-recovering cooldown — show the remaining time (from
@@ -0,0 +1,118 @@
1
+ /**
2
+ * z.ai Quota Reset Cards — list + redeem, via the same API key the quota poller
3
+ * already uses. Zero browser, zero captcha (the exact pain that motivated this:
4
+ * ~6 provider accounts, each a manual web login with an aggressive captcha to
5
+ * check for a card that usually isn't there).
6
+ *
7
+ * Endpoints (verified live 2026-09-30):
8
+ * GET /api/biz/customer-package-reset/list?targetType=PERSONAL
9
+ * -> data.fiveHourResets[] / weekResets[], each {recordId, grantType,
10
+ * expireTime 'YYYY-MM-DD HH:mm:ss' (UTC), available}
11
+ * POST /api/biz/customer-package-reset/use
12
+ * body {recordId, targetType:'PERSONAL', resetType:'WEEK'|'FIVE_HOUR',
13
+ * requestId} -> envelope; data echoes the recordId on success
14
+ *
15
+ * Semantics measured live:
16
+ * - a WEEKLY card ALSO refills the 5h window (z.ai docs) — never spend a weekly
17
+ * card on a 5h wall.
18
+ * - `requestId` is the idempotency key: a retry with the SAME id cannot burn a
19
+ * second card. We derive it deterministically from recordId so any retry of a
20
+ * failed redeem is automatically safe.
21
+ * - the envelope returns HTTP 200 with success:false on failure — always check
22
+ * the envelope, never the status code.
23
+ * - an unavailable/expired/foreign card returns code 400 "The specified reset
24
+ * attempt is unavailable. Refresh and try again".
25
+ * - invalid resetType vocabulary yields 400 "Unsupported reset type: X" (safe
26
+ * probe that cannot spend anything).
27
+ */
28
+ import { createHash } from 'node:crypto';
29
+
30
+ const BASE = 'https://api.z.ai/api/biz/customer-package-reset';
31
+ const LIST_URL = `${BASE}/list?targetType=PERSONAL`;
32
+ const USE_URL = `${BASE}/use`;
33
+
34
+ /** 'YYYY-MM-DD HH:mm:ss' (z.ai has no timezone suffix; it is UTC) -> epoch ms. */
35
+ export function parseCardExpiry(s) {
36
+ if (!s || typeof s !== 'string') return null;
37
+ const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
38
+ if (!m) return null;
39
+ return Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
40
+ }
41
+
42
+ /** Normalize a raw card array -> [{recordId, expiresAt, expired}], available only. */
43
+ function normalizeCards(arr, now = Date.now()) {
44
+ const out = [];
45
+ for (const c of (Array.isArray(arr) ? arr : [])) {
46
+ if (!c || c.available !== true) continue;
47
+ const recordId = Number(c.recordId);
48
+ if (!Number.isFinite(recordId)) continue;
49
+ const expiresAt = parseCardExpiry(c.expireTime);
50
+ out.push({ recordId, expiresAt, expired: expiresAt != null && expiresAt <= now });
51
+ }
52
+ // Soonest-expiring first — that's the one to spend first.
53
+ out.sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity));
54
+ return out;
55
+ }
56
+
57
+ /** List this account's available reset cards. Zero-spend read.
58
+ * Returns { fiveHour: [], weekly: [] } | { error }. */
59
+ export async function listResetCards(token, { signal } = {}) {
60
+ if (!token) return { error: 'no token' };
61
+ try {
62
+ const res = await fetch(LIST_URL, {
63
+ headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' },
64
+ signal,
65
+ });
66
+ if (res.status === 401) return { error: 'unauthorized' };
67
+ const data = await res.json();
68
+ if (data?.code !== 200 || !data?.data) {
69
+ return { error: `bad_envelope${data?.code != null ? ' code=' + data.code : ''}` };
70
+ }
71
+ const now = Date.now();
72
+ return {
73
+ fiveHour: normalizeCards(data.data.fiveHourResets, now),
74
+ weekly: normalizeCards(data.data.weekResets, now),
75
+ source: 'zai',
76
+ };
77
+ } catch (err) {
78
+ return { error: err.message || String(err) };
79
+ }
80
+ }
81
+
82
+ /** Deterministic requestId for a card: retries of the SAME redeem are idempotent
83
+ * server-side, so a network blip mid-redeem can never double-spend. */
84
+ function requestIdFor(recordId) {
85
+ const h = createHash('sha256').update(`maxpool-zai-card-${recordId}`).digest('hex').slice(0, 24);
86
+ return `maxpool-${h}`;
87
+ }
88
+
89
+ /** Redeem one card. NEVER retries on the wire here (the idempotency key makes a
90
+ * caller-level retry safe). Returns { ok:true, recordId } | { ok:false, error, code }. */
91
+ export async function redeemResetCard(token, recordId, resetType, { signal } = {}) {
92
+ if (resetType !== 'WEEK' && resetType !== 'FIVE_HOUR') {
93
+ return { ok: false, error: `invalid resetType ${resetType}` };
94
+ }
95
+ if (!Number.isFinite(recordId)) return { ok: false, error: 'invalid recordId' };
96
+ try {
97
+ const res = await fetch(USE_URL, {
98
+ method: 'POST',
99
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({
101
+ recordId, targetType: 'PERSONAL', resetType,
102
+ requestId: requestIdFor(recordId),
103
+ }),
104
+ signal,
105
+ });
106
+ const data = await res.json().catch(() => null);
107
+ if (data?.success === true && (data.code === 200 || data.code === 0)) {
108
+ return { ok: true, recordId, data: data.data ?? null };
109
+ }
110
+ return {
111
+ ok: false,
112
+ error: data?.msg || `envelope code ${data?.code} (HTTP ${res.status})`,
113
+ code: data?.code ?? null,
114
+ };
115
+ } catch (err) {
116
+ return { ok: false, error: err.message || String(err) };
117
+ }
118
+ }