maxpool 1.23.0 → 1.24.1

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.1",
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
@@ -32,6 +32,8 @@ const vw = s => strip(s).length;
32
32
  const NAME_W = 20; // a.name.slice(0, NAME_W).padEnd(NAME_W) — fits a full email like 2solarmax@gmail.com (19)
33
33
  const PROVIDER_W = 9; // providerLabel(a).padEnd(PROVIDER_W) — fits "Anthropic"
34
34
  const STATUS_W = 13; // rpad(status, STATUS_W) — fits "throttled 59s"
35
+ const RESETS_W = 3; // 'Rst' column — banked limit resets (cards/grants) the
36
+ // pool can auto-redeem; '0' when none, count when some.
35
37
  const ROW_PREFIX = ' '; // ' ' + sel(1) + cur(1) + ' ' — 4 cols before the name
36
38
 
37
39
  // Human provider name for the accounts-table "Provider" column. account.provider
@@ -60,6 +62,7 @@ function acctHeader(W) {
60
62
  + 'Account'.padEnd(NAME_W) + ' '
61
63
  + 'Provider'.padEnd(PROVIDER_W) + ' '
62
64
  + 'Status'.padEnd(STATUS_W) + ' '
65
+ + 'Rst'.padEnd(RESETS_W) + ' '
63
66
  + quota;
64
67
  }
65
68
 
@@ -255,6 +258,16 @@ function settingsTags(am, a) {
255
258
  }
256
259
  const capTag = capText(a, capBenched(am, a), am);
257
260
  if (capTag) tags.push(capTag);
261
+ // A switched-off account whose credentials ALSO died keeps "disabled" in the status
262
+ // column (so the disabled inventory stays readable at a glance) and carries the second
263
+ // problem here. Without this the row would hide the fact that re-enabling it later
264
+ // yields a broken account — the 2026-08-10 report that 8 disabled accounts were all
265
+ // sitting on dead credentials.
266
+ if (a?.enabled === false) {
267
+ if (a.refreshDead) tags.push(yellow('needs login'));
268
+ else if (a.subscriptionGone) tags.push(red('no sub'));
269
+ return tags; // disabled: never show card inventory either (owner gate)
270
+ }
258
271
  return tags;
259
272
  }
260
273
 
@@ -2084,7 +2097,9 @@ export class TUI {
2084
2097
  if (a.refreshDead) effectiveStatus = a.enabled === false ? 'disabled-reauth' : 'reauth';
2085
2098
  // Subscription gone (org-disabled 403): a DISTINCT state from reauth — re-logging in
2086
2099
  // will NOT fix it until the subscription is re-purchased. Says the actionable thing.
2087
- else if (a.subscriptionGone) effectiveStatus = 'no sub';
2100
+ // Same precedence rule as reauth above: when the account is ALSO switched off,
2101
+ // "disabled" stays the headline so the disabled inventory remains readable.
2102
+ else if (a.subscriptionGone) effectiveStatus = a.enabled === false ? 'disabled-nosub' : 'no sub';
2088
2103
  switch (effectiveStatus) {
2089
2104
  case 'active': status = isCur ? green('active') : 'active'; break;
2090
2105
  case 'reauth': status = yellow('reauth'); break;
@@ -2093,9 +2108,19 @@ export class TUI {
2093
2108
  case 'waiting': status = yellow('waiting'); break;
2094
2109
  case 'paused': status = yellow('paused'); break;
2095
2110
  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;
2111
+ // Disabled AND needs re-login — both facts matter, and DISABLED LEADS.
2112
+ // The owner's question that drove this (2026-09-30): "when I disable the account
2113
+ // and then the token expires, does it show disabled or reauth? It needs to
2114
+ // continue to show disabled otherwise I'm confused as to which ones I have
2115
+ // disabled." Showing only '✕ reauth' made a deliberately-off account
2116
+ // indistinguishable from a live one with dead credentials, so the disabled
2117
+ // inventory could not be read off the screen at all. So the STATUS column always
2118
+ // says 'disabled' for an account you switched off, and the reason it ALSO cannot
2119
+ // serve rides as its own tag ("needs login" / "no sub") — collapsing both into one
2120
+ // '+' marker would lose the distinction between "log in again" (fixable) and
2121
+ // "subscription gone" (not fixable by logging in).
2122
+ case 'disabled-reauth':
2123
+ case 'disabled-nosub': status = red('✕ disabled'); break;
2099
2124
  case 'no sub': status = red('✕ no sub'); break;
2100
2125
  case 'throttled': {
2101
2126
  // A transient auto-recovering cooldown — show the remaining time (from
@@ -2111,8 +2136,25 @@ export class TUI {
2111
2136
  // Widened from 10 to fit "throttled 59s" so the quota bars stay column-aligned.
2112
2137
  status = rpad(status, STATUS_W);
2113
2138
 
2139
+ // Rst column — banked limit resets (owner request 2026-09-30: a separate
2140
+ // column showing 0 or the count). z.ai cards + Claude cedar_ember grants,
2141
+ // not-yet-expired, on ENABLED accounts only: a disabled account's resets are
2142
+ // not ours to spend, and showing a count there would imply otherwise.
2143
+ let resetsCell;
2144
+ {
2145
+ let n = 0;
2146
+ if (a.enabled !== false) {
2147
+ const rc = a.resetCards;
2148
+ if (rc) n += (rc.fiveHour || []).filter(c => !c.expired).length
2149
+ + (rc.weekly || []).filter(c => !c.expired).length;
2150
+ const rg = a.resetGrants;
2151
+ if (rg?.eligible) n += (rg.grants || []).filter(g => g.usableNow && !g.expired).length;
2152
+ }
2153
+ resetsCell = rpad(n > 0 ? green(String(n)) : gray('0'), RESETS_W);
2154
+ }
2155
+
2114
2156
  if (a.type === 'provider') {
2115
- return this._renderProviderAcct(sel, cur, name, type, status, a, bw, showBoth);
2157
+ return this._renderProviderAcct(sel, cur, name, type, status + ' ' + resetsCell, a, bw, showBoth);
2116
2158
  }
2117
2159
 
2118
2160
  // Quota ratios — prefer unified (Claude Max), fall back to standard (API key)
@@ -2135,7 +2177,7 @@ export class TUI {
2135
2177
  t2 = t1;
2136
2178
  }
2137
2179
 
2138
- let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
2180
+ let line = ` ${sel}${cur} ${name} ${type} ${status} ${resetsCell} ${l1} ${bar(r1, bw, t1)}`;
2139
2181
  if (showBoth) {
2140
2182
  // "no weekly cap on this plan" is a DIFFERENT state from "not read yet", and
2141
2183
  // both render as an empty bar. Say which, so a healthy uncapped account
@@ -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
+ }