tokenmaxxing 1.124.0 → 1.126.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/DESIGN.md CHANGED
@@ -62,7 +62,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
62
62
  4. Write `respawn/<session-id>` (atomic temp+rename) naming the target, with `waitUntil` now for a plain move or the assigned reset when the pool is depleted. No credential is written.
63
63
 
64
64
  ### 3.3 Move and depleted-pool pause (supervisor)
65
- The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it and the old seat's record carries no live `enforcedUntil` (`compact: true` from the Stop and SessionStart hooks and from the supervisor's own seat watch, `false` from StopFailure: it runs `claude -p --resume <live-id> /compact` under the old seat's store, bounded at five minutes; the compaction counts only when a `compact_boundary` row lands in the transcript, because a refused compaction still exits 0, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <live-id>` (`--session-id <live-id>` when that id has no transcript yet, because `--resume` exits with `No conversation found` for it) with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The marker file is keyed by the launch id `<sid>`; the live id inside it is the session id the supervised process runs now, which `/clear` and an in-session `/resume` change. A hook adopts a differing stdin id as the live id (`current` in `sessions/<sid>.json`) only when its own process descends, through shell processes alone, from the presence record's pid; any other differing id is a nested session and writes nothing. The resumed process reads that store cold → runs on the target account, same conversation. The `--session-id` path is the exception: the conversation has no prior turn (a `/clear` with no turn since, or a move before the first turn), so it starts with no compaction and no first prompt, and in a stream-json session the relay replays the harness lines the killed child received: `user` lines and the `initialize` and session-state control requests in `REPLAYED_REQUESTS`; every other control request gets an error `control_response` on stdout, because a repeat could run its effect twice, and the `control_response` answers to the killed child's own requests are dropped. A first launch with `--resume <id>` takes the same path when the supervisor holds `sessions/<id>.json` for that id and no project directory holds its transcript. On the `--resume` path the relaunch submits a first prompt that names the move (and the compaction, when it landed). After a Stop move the previous turn had finished, so the prompt asks Claude to end the turn without restating it, and to relaunch any background Bash task, Monitor, or Workflow run that turn waited on, because the move ended it. After any other move the prompt asks Claude to continue the task, says the restart ended every background Bash task, Monitor, and Workflow run the session started, and asks Claude to relaunch what the task still needs; a SessionStart move is in that group because a prompt sent inside the marker poll window can already have started on the old seat. The conversation goes on without a keystroke: in a terminal session it is the positional prompt after the session id; in a stream-json session (`--input-format stream-json`, where the binary ignores a positional prompt) the supervisor owns stdin, relays the harness's lines to the child through a pipe, and writes the prompt as the first `user` line of the new process. In a terminal session the supervisor's own notices go to stderr. In a stream-json session (`--output-format stream-json`, read from the effective flags like the relay) there is no terminal and no statusline, so the supervisor writes each notice (the compaction and its failure, the move, the depleted-pool wait with its resume time, the resume) to stdout as a `system` line of subtype `informational` and level `warning`, the shape Claude Code emits for its own print-mode notices, which the harness renders in the conversation while the stream stays parseable; the per-second countdown ticks stay terminal-only, and the supervisor writes only while no child is running. The `SessionStart` hook (every source) re-runs the decision before the first turn and writes the same marker if the seat is already over its bar or the pool is depleted. The supervisor also watches its own seat every ten seconds while the child runs (account record plus tee, the same bars) and writes the marker itself when the seat is at or over a bar, so a move never depends on a hook firing: a harness session between turns whose background workflow burns the window, or a subagent refusal that stamped the wall, is moved within seconds. SIGTERM to the supervisor is forwarded to the child and the supervisor exits after it, so a harness that stops the wrapper (the Agent SDK does) never leaves an orphaned claude on the old seat. A marker whose `launchedAt` does not match the running child is discarded, and after a Ctrl-C on the countdown a later marker whose `waitUntil` falls inside the skipped wait is ignored. A marker that fails to parse is never deleted and never treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path. In a stream-json session that fatal, the wrap-depth abort, the wrapper-chain abort, and a failed presence write also go to stdout as the same `informational`/`warning` line.
65
+ The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it and the old seat's record carries no live `enforcedUntil` (`compact: true` from the Stop and SessionStart hooks and from the supervisor's own seat watch, `false` from StopFailure: it runs `claude -p --resume <live-id> /compact` under the old seat's store, bounded at five minutes; the compaction counts only when a `compact_boundary` row lands in the transcript, because a refused compaction still exits 0, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <live-id>` (`--session-id <live-id>` when that id has no transcript yet, because `--resume` exits with `No conversation found` for it) with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The marker file is keyed by the launch id `<sid>`; the live id inside it is the session id the supervised process runs now, which `/clear` and an in-session `/resume` change. A hook adopts a differing stdin id as the live id (`current` in `sessions/<sid>.json`) only when its own process descends, through shell processes alone, from the presence record's pid; any other differing id is a nested session and writes nothing. The resumed process reads that store cold → runs on the target account, same conversation. The `--session-id` path is the exception: the conversation has no prior turn (a `/clear` with no turn since, or a move before the first turn), so it starts with no compaction and no first prompt, and in a stream-json session the relay replays the harness lines the killed child received: `user` lines and the `initialize` and session-state control requests in `REPLAYED_REQUESTS`; every other control request gets an error `control_response` on stdout, because a repeat could run its effect twice, and the `control_response` answers to the killed child's own requests are dropped. A first launch with `--resume <id>` takes the same path when the supervisor holds `sessions/<id>.json` for that id and no project directory holds its transcript. On the `--resume` path the relaunch submits a first prompt that names the move (and the compaction, when it landed). After a Stop move the previous turn had finished, so the prompt asks Claude to end the turn without restating it, and to relaunch any background Bash task, Monitor, or Workflow run that turn waited on, because the move ended it. After any other move the prompt asks Claude to continue the task, says the restart ended every background Bash task, Monitor, and Workflow run the session started, and asks Claude to relaunch what the task still needs; a SessionStart move is in that group because a prompt sent inside the marker poll window can already have started on the old seat. The conversation goes on without a keystroke: in a terminal session it is the positional prompt after the session id; in a stream-json session (`--input-format stream-json`, where the binary ignores a positional prompt) the supervisor owns stdin, relays the harness's lines to the child through a pipe, sends the new process the latest `initialize` and session-state control requests the harness sent, because the Agent SDK sends `initialize` once per query and it carries the appended system prompt, the hooks, and the SDK MCP servers, and then writes the prompt as the first `user` line. In a terminal session the supervisor's own notices go to stderr. In a stream-json session (`--output-format stream-json`, read from the effective flags like the relay) there is no terminal and no statusline, so the supervisor writes each notice (the compaction and its failure, the move, the depleted-pool wait with its resume time, the resume) to stdout as a `system` line of subtype `informational` and level `warning`, the shape Claude Code emits for its own print-mode notices, which the harness renders in the conversation while the stream stays parseable; the per-second countdown ticks stay terminal-only, and the supervisor writes only while no child is running. The `SessionStart` hook (every source) re-runs the decision before the first turn and writes the same marker if the seat is already over its bar or the pool is depleted. The supervisor also watches its own seat every ten seconds while the child runs (account record plus tee, the same bars) and writes the marker itself when the seat is at or over a bar, so a move never depends on a hook firing: a harness session between turns whose background workflow burns the window, or a subagent refusal that stamped the wall, is moved within seconds. SIGTERM to the supervisor is forwarded to the child and the supervisor exits after it, so a harness that stops the wrapper (the Agent SDK does) never leaves an orphaned claude on the old seat. A marker whose `launchedAt` does not match the running child is discarded, and after a Ctrl-C on the countdown a later marker whose `waitUntil` falls inside the skipped wait is ignored. A marker that fails to parse is never deleted and never treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path. In a stream-json session that fatal, the wrap-depth abort, the wrapper-chain abort, and a failed presence write also go to stdout as the same `informational`/`warning` line.
66
66
 
67
67
  ### 3.4 Placement (supervisor, under the pool lock)
68
68
  1. Fold every account's tee into the index.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.124.0",
3
+ "version": "1.126.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/check.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { claude } from "../lib/claude.ts";
2
- import { evaluateAndMaybeSwap } from "../lib/decide.ts";
2
+ import { codex } from "../lib/codex.ts";
3
+ import { evaluateAndMaybeSwap, useBankedResets } from "../lib/decide.ts";
3
4
  import { sampleOldest } from "../lib/sample.ts";
4
5
  import { pruneStaleSessions } from "../lib/sessions.ts";
5
6
  import { loadConfig } from "../lib/state.ts";
@@ -13,6 +14,7 @@ export async function cmdCheck(json = false): Promise<number> {
13
14
  try {
14
15
  pruneStaleSessions(now);
15
16
  d = await evaluateAndMaybeSwap(claude, now);
17
+ await useBankedResets(codex, loadConfig(), now);
16
18
  await sampleOldest(loadConfig());
17
19
  await maybeAutoUpdate();
18
20
  } catch (e) {
package/src/cli/status.ts CHANGED
@@ -6,7 +6,7 @@ import { opencodeGo } from "../lib/opencodego.ts";
6
6
  import { loadAccounts, loadConfig, saveAccounts } from "../lib/state.ts";
7
7
  import { withLock } from "../lib/lock.ts";
8
8
  import { codexPool, grokPool, opencodeGoPool } from "../lib/paths.ts";
9
- import { barFor, earliestReset, gatedWindows, landWindows, isExhausted, isSessionWindow, limitWindows, liveUsed, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
9
+ import { barFor, earliestReset, gatedWindows, landGrant, landWindows, isExhausted, isSessionWindow, limitWindows, liveUsed, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
10
10
  import type { Provider, SampleReport } from "../lib/provider.ts";
11
11
  import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
12
12
  import type { Account, AccountThresholds, Bars, Config, Thresholds, Window } from "../lib/types.ts";
@@ -121,6 +121,11 @@ async function collect(p: Provider, cfg: Config, now: number, cached: boolean):
121
121
  if (a.tier != null) stored.tier = a.tier;
122
122
  dirty = true;
123
123
  }
124
+ const report = reports.get(a.id);
125
+ if (report?.ok === true && report.source === "probe" && a.bankedResetAt != null) {
126
+ landGrant(stored, a.bankedReset, a.bankedResetAt);
127
+ dirty = true;
128
+ }
124
129
  }
125
130
  if (dirty) saveAccounts(p.pool, fresh);
126
131
  idx = fresh;
@@ -345,6 +345,7 @@ function controlErrorLine(requestId: string): string {
345
345
  class StdinRelay {
346
346
  private sink: FileSink | null = null;
347
347
  private queue: string[] = [];
348
+ private state = new Map<string, string>();
348
349
  private replayable: string[] = [];
349
350
  private dropped: string[] = [];
350
351
  private pendingTranscript: string | null = null;
@@ -364,7 +365,7 @@ class StdinRelay {
364
365
  this.replayable = [];
365
366
  this.dropped = [];
366
367
  this.pendingTranscript = pendingTranscript;
367
- const lines = first === null ? this.queue : [first, ...this.queue];
368
+ const lines = [...this.state.values(), ...(first === null ? [] : [first]), ...this.queue];
368
369
  this.queue = [];
369
370
  for (const line of lines) this.forward(line);
370
371
  if (this.ended) this.close();
@@ -382,7 +383,7 @@ class StdinRelay {
382
383
  return dropped;
383
384
  }
384
385
 
385
- private keep(line: string): void {
386
+ private keep(line: string, control: boolean, request: { request_id: string } | null): void {
386
387
  if (this.pendingTranscript === null) return;
387
388
  if (existsSync(this.pendingTranscript)) {
388
389
  this.pendingTranscript = null;
@@ -390,10 +391,8 @@ class StdinRelay {
390
391
  this.dropped = [];
391
392
  return;
392
393
  }
393
- const parsed = RelayLineSchema.safeParse(JsonTextSchema.safeParse(line).data);
394
- if (!parsed.success) this.replayable.push(line);
395
- else if (parsed.data.type === "control_request" && REPLAYED_REQUESTS.has(parsed.data.request.subtype)) this.replayable.push(line);
396
- else if (parsed.data.type === "control_request") this.dropped.push(parsed.data.request_id);
394
+ if (request !== null) this.dropped.push(request.request_id);
395
+ else if (!control) this.replayable.push(line);
397
396
  }
398
397
 
399
398
  private async pump(): Promise<void> {
@@ -401,18 +400,22 @@ class StdinRelay {
401
400
  }
402
401
 
403
402
  private forward(line: string): void {
403
+ const parsed = RelayLineSchema.safeParse(JsonTextSchema.safeParse(line).data);
404
+ const request = parsed.success && parsed.data.type === "control_request" ? parsed.data : null;
405
+ const setup = request !== null && REPLAYED_REQUESTS.has(request.request.subtype);
406
+ if (setup) this.state.set(request.request.subtype, line);
404
407
  if (this.sink !== null) {
405
408
  try {
406
409
  this.sink.write(line);
407
410
  this.sink.flush();
408
- this.keep(line);
411
+ if (!setup) this.keep(line, parsed.success, request);
409
412
  return;
410
413
  } catch (e) {
411
414
  log("supervisor.relay_write_failed", { err: errorMessage(e) });
412
415
  this.sink = null;
413
416
  }
414
417
  }
415
- this.queue.push(line);
418
+ if (!setup) this.queue.push(line);
416
419
  }
417
420
 
418
421
  private close(): void {
package/src/lib/claude.ts CHANGED
@@ -9,14 +9,14 @@ import { withLock } from "./lock.ts";
9
9
  import { errorMessage, log } from "./log.ts";
10
10
  import { claudeTierLabel, describeIdentity, fetchTokenIdentity, isDeadCredential, InvalidGrantError } from "./oauth.ts";
11
11
  import { claudePool, env, paths, seatFromEnv, storeDirFor } from "./paths.ts";
12
- import { MAX_WAITERS_PER_ACCOUNT, landWindows, pickBest, pickEarliestReset, pickWaitTarget, thresholdBars, type PickCtx } from "./picker.ts";
12
+ import { MAX_WAITERS_PER_ACCOUNT, pickBest, pickEarliestReset, pickWaitTarget, thresholdBars, type PickCtx } from "./picker.ts";
13
13
  import { deletePiStore } from "./piauth.ts";
14
14
  import { livingPresences, writePresence } from "./presence.ts";
15
- import { StoreUnusableError, type Observation, type Provider, type SampleReport, type SeatBorrow } from "./provider.ts";
16
- import { claimSample, foldTee, readyToSample, runSample, sampleAccountUsage, sampleDue, sampleIntervalMs, teeObservation, usageBlockedUntil } from "./sample.ts";
15
+ import { StoreUnusableError, type Observation, type Provider, type ResetClaim, type SampleReport, type SeatBorrow } from "./provider.ts";
16
+ import { claimSample, foldTee, landRead, readyToSample, runSample, sampleAccountUsage, sampleDue, sampleIntervalMs, teeObservation, usageBlockedUntil } from "./sample.ts";
17
17
  import { clearUsageSnapshot, liveWaitClaims, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, readJsonFile, saveAccounts, type Harvest } from "./state.ts";
18
18
  import { saveTermios, restoreTermios } from "./tty.ts";
19
- import { fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredEnv, windowsOf } from "./usage.ts";
19
+ import { claimResetGrant, fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredEnv, windowsOf } from "./usage.ts";
20
20
  import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config, type ModelInfo } from "./types.ts";
21
21
  import { c } from "../cli/render.ts";
22
22
 
@@ -48,9 +48,7 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
48
48
  if (account.needsReauth) a.needsReauth = true;
49
49
  if (outcome.ok) {
50
50
  a.probeFails = 0;
51
- if (a.lastUsageAt == null || startedAt > a.lastUsageAt) {
52
- landWindows(a, mergeWindows(windowsOf(outcome.usage, startedAt), a.windows), startedAt, thresholdBars(cfg));
53
- }
51
+ landRead(a, outcome.usage, startedAt, thresholdBars(cfg));
54
52
  } else {
55
53
  a.probeFails = (a.probeFails ?? 0) + 1;
56
54
  if (outcome.retryAt != null) a.usageRetryAt = outcome.retryAt;
@@ -87,14 +85,14 @@ async function samplePool(accounts: Account[], _liveId: string | null, now: numb
87
85
  return;
88
86
  }
89
87
  a.lastProbeAt = now;
88
+ const startedAt = Date.now();
90
89
  const outcome = await runSample(a, ready);
91
90
  if (!outcome.ok) {
92
91
  if (outcome.retryAt != null) a.usageRetryAt = outcome.retryAt;
93
92
  reports.set(a.id, { ok: false, reason: outcome.reason });
94
93
  return;
95
94
  }
96
- const at = Date.now();
97
- landWindows(a, mergeWindows(windowsOf(outcome.usage, at), a.windows), at, bars);
95
+ landRead(a, outcome.usage, startedAt, bars);
98
96
  reports.set(a.id, { ok: true, source: "probe" });
99
97
  }),
100
98
  );
@@ -122,6 +120,15 @@ async function prepareMove(target: Account): Promise<void> {
122
120
  log("move.prepared", { account: target.id.slice(0, 8), label: target.label });
123
121
  }
124
122
 
123
+ async function useBankedReset(a: Account, signal: AbortSignal): Promise<ResetClaim> {
124
+ const grant = a.bankedReset?.grant;
125
+ const organizationUuid = a.oauthAccount?.organizationUuid;
126
+ if (grant == null || organizationUuid == null) return { reset: false, detail: "no grant id or organization on the record" };
127
+ const creds = await readStore(a.id);
128
+ if (creds == null || isDeadCredential(creds) || creds.expiresAt <= Date.now()) return { reset: false, detail: "no unexpired access token in the store" };
129
+ return claimResetGrant({ accessToken: creds.accessToken, organizationUuid, grant }, signal);
130
+ }
131
+
125
132
  async function storeUsable(a: Account): Promise<boolean> {
126
133
  try {
127
134
  const creds = await readStore(a.id);
@@ -339,6 +346,7 @@ export const claude: Provider = {
339
346
  samplePool,
340
347
  mergeWindows,
341
348
  swap: prepareMove,
349
+ useBankedReset,
342
350
  classifySwapError: (e) => (e instanceof InvalidGrantError ? "dead-grant" : e instanceof StoreUnusableError ? "skip" : "fatal"),
343
351
  removeCredentials,
344
352
  storeUsable,
@@ -85,11 +85,15 @@ export function resolveRealBin(input: { name: string; key: BinKey }): string {
85
85
  throw new Error(`could not locate the real \`${input.name}\` binary (set ${input.key} in config.json)`);
86
86
  }
87
87
 
88
- export function verifyRealBin(input: { bin: string; name: string; versionOk: (versionOutput: string) => boolean }): string | null {
88
+ function spawnVersion(bin: string) {
89
89
  const env = { ...process.env, [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH), TOKENMAXXING_PROBE: "1" };
90
+ return Bun.spawnSync([bin, "--version"], { env, stdout: "pipe", stderr: "pipe", timeout: 15_000, killSignal: "SIGKILL" });
91
+ }
92
+
93
+ export function verifyRealBin(input: { bin: string; name: string; versionOk: (versionOutput: string) => boolean }): string | null {
90
94
  let p;
91
95
  try {
92
- p = Bun.spawnSync([input.bin, "--version"], { env, stdout: "pipe", stderr: "pipe", timeout: 15_000, killSignal: "SIGKILL" });
96
+ p = spawnVersion(input.bin);
93
97
  } catch (e) {
94
98
  return errorMessage(e);
95
99
  }
@@ -115,6 +119,17 @@ function isBareVersion(out: string): boolean {
115
119
 
116
120
  export const PI_BIN = { name: "pi", key: "piBin", versionOk: isBareVersion } as const;
117
121
 
122
+ let userAgent: string | undefined;
123
+
124
+ export function claudeUserAgent(): string {
125
+ if (userAgent != null) return userAgent;
126
+ const bin = resolveRealBin(CLAUDE_BIN);
127
+ const version = spawnVersion(bin).stdout.toString().trim().split(" ")[0] ?? "";
128
+ if (!isBareVersion(version)) throw new Error(`\`${bin} --version\` printed no version`);
129
+ userAgent = `claude-cli/${version} (external, cli)`;
130
+ return userAgent;
131
+ }
132
+
118
133
  export function resolveVerifiedClaude(): string {
119
134
  const candidates: string[] = [];
120
135
  try {
package/src/lib/codex.ts CHANGED
@@ -8,12 +8,12 @@ import { codexIdentityOf, codexStoreUsable, deleteCodexStoreAuth, ensureCodexSto
8
8
  import { CodexInvalidGrantError, CodexRefreshFailedError, refreshCodexAuth } from "./codexoauth.ts";
9
9
  import { deletePiStore } from "./piauth.ts";
10
10
  import { PI_PRESENCE_PREFIX, livingPresences, seatCounts, writePresence } from "./presence.ts";
11
- import { CodexUsageReadError, codexLimitLabel, fetchCodexUsage } from "./codexusage.ts";
11
+ import { CodexUsageReadError, codexLimitLabel, consumeCodexReset, fetchCodexUsage } from "./codexusage.ts";
12
12
  import { codexSupervisorLink, ensurePathInRc, installCodexSupervisor, managedShellRcSkipLines, shellRcPath } from "./install.ts";
13
13
  import { withLock } from "./lock.ts";
14
14
  import { errorMessage, log } from "./log.ts";
15
15
  import { codexPaths, codexPool, codexSeatFromEnv, optionalEnv } from "./paths.ts";
16
- import { isExhausted, onCredits, pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
16
+ import { isExhausted, landGrant, onCredits, pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
17
17
  import { StoreUnusableError, type Observation, type Provider, type SampleReport, type SeatBorrow } from "./provider.ts";
18
18
  import { loadAccounts, loadConfig, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
19
19
  import { restoreTermios, saveTermios } from "./tty.ts";
@@ -34,6 +34,7 @@ function applyUsage(account: Account, usage: CodexUsage, at: number): void {
34
34
  account.windows = usage.windows;
35
35
  account.lastUsageAt = at;
36
36
  account.hasCredits = usage.hasCredits ?? undefined;
37
+ landGrant(account, usage.bankedReset, at);
37
38
  if (usage.email != null) account.email = usage.email;
38
39
  if (usage.planType != null) account.tier = usage.planType;
39
40
  }
@@ -300,6 +301,10 @@ export const codex: Provider = {
300
301
  samplePool,
301
302
  mergeWindows: (next) => next,
302
303
  swap: prepareMove,
304
+ useBankedReset: async (a, signal) => {
305
+ const fresh = await freshCodexAuth(a, Date.now(), false);
306
+ return fresh.ok ? consumeCodexReset(fresh.auth, signal) : { reset: false, detail: fresh.reason };
307
+ },
303
308
  classifySwapError: (e) => (e instanceof CodexInvalidGrantError ? "dead-grant" : e instanceof StoreUnusableError ? "skip" : "fatal"),
304
309
  removeCredentials: async (a) => {
305
310
  deleteCodexStoreAuth(a.id);
@@ -1,9 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { http, safeErrorDetail } from "./http.ts";
3
- import { errorMessage } from "./log.ts";
3
+ import { errorMessage, log } from "./log.ts";
4
4
  import { env } from "./paths.ts";
5
- import { EpochSecondsSchema, JsonTextSchema, type CodexAuthJson, type CodexUsage, type Window } from "./types.ts";
5
+ import { EpochSecondsSchema, JsonTextSchema, type BankedReset, type CodexAuthJson, type CodexUsage, type Window } from "./types.ts";
6
6
  import { codexIdentityOf } from "./codexauth.ts";
7
+ import type { ResetClaim } from "./provider.ts";
7
8
  import { familyTokens } from "./usage.ts";
8
9
 
9
10
  export const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
@@ -33,6 +34,7 @@ const WireUsageSchema = z.looseObject({
33
34
  plan_type: z.string().nullish(),
34
35
  rate_limit: WireRateLimitSchema.nullish(),
35
36
  credits: z.looseObject({ has_credits: z.boolean().nullish() }).nullish(),
37
+ rate_limit_reset_credits: z.unknown().optional(),
36
38
  additional_rate_limits: z
37
39
  .array(z.looseObject({ limit_name: z.string(), rate_limit: WireRateLimitSchema.nullish() }))
38
40
  .nullish(),
@@ -53,18 +55,19 @@ function toWindows(rateLimit: z.infer<typeof WireRateLimitSchema> | null | undef
53
55
  return out;
54
56
  }
55
57
 
58
+ function codexHeaders(auth: CodexAuthJson): Record<string, string> {
59
+ return {
60
+ Authorization: `Bearer ${auth.tokens.access_token}`,
61
+ "ChatGPT-Account-Id": codexIdentityOf({ auth }).accountId,
62
+ "User-Agent": "codex-cli",
63
+ };
64
+ }
65
+
56
66
  export async function fetchCodexUsage(input: { auth: CodexAuthJson; at: number }): Promise<CodexUsage> {
57
67
  const { auth, at } = input;
58
- const identity = codexIdentityOf({ auth });
59
68
  let res: Response;
60
69
  try {
61
- res = await http.get(USAGE_URL, {
62
- headers: {
63
- Authorization: `Bearer ${auth.tokens.access_token}`,
64
- "ChatGPT-Account-Id": identity.accountId,
65
- "User-Agent": "codex-cli",
66
- },
67
- });
70
+ res = await http.get(USAGE_URL, { headers: codexHeaders(auth) });
68
71
  } catch (e) {
69
72
  throw new CodexUsageReadError(`endpoint unreachable: ${errorMessage(e)}`);
70
73
  }
@@ -83,6 +86,7 @@ export async function fetchCodexUsage(input: { auth: CodexAuthJson; at: number }
83
86
  email: wire.email ?? null,
84
87
  planType: wire.plan_type ?? null,
85
88
  hasCredits: wire.credits?.has_credits ?? null,
89
+ bankedReset: resetCredits(wire.rate_limit_reset_credits),
86
90
  windows: [
87
91
  ...toWindows(wire.rate_limit, null, at),
88
92
  ...(wire.additional_rate_limits ?? []).flatMap((row) => toWindows(row.rate_limit, row.limit_name, at)),
@@ -90,6 +94,33 @@ export async function fetchCodexUsage(input: { auth: CodexAuthJson; at: number }
90
94
  };
91
95
  }
92
96
 
97
+ const ResetCreditsSchema = z.looseObject({ available_count: z.number() });
98
+
99
+ function resetCredits(raw: unknown): BankedReset | undefined {
100
+ if (raw == null) return undefined;
101
+ const block = ResetCreditsSchema.safeParse(raw);
102
+ if (!block.success) {
103
+ log("codexusage.reset_credits_unreadable", { issue: z.prettifyError(block.error).slice(0, 200) });
104
+ return undefined;
105
+ }
106
+ return block.data.available_count > 0 ? { grant: null } : undefined;
107
+ }
108
+
109
+ const RESET_URL = env("TOKENMAXXING_CODEX_RESET_URL", "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume");
110
+ const ConsumeResponseSchema = z.looseObject({ code: z.string() });
111
+
112
+ export async function consumeCodexReset(auth: CodexAuthJson, signal: AbortSignal): Promise<ResetClaim> {
113
+ const res = await http.post(RESET_URL, {
114
+ headers: codexHeaders(auth),
115
+ json: { redeem_request_id: crypto.randomUUID() },
116
+ signal,
117
+ });
118
+ const text = await res.text();
119
+ if (!res.ok) return { reset: false, detail: `HTTP ${res.status}: ${safeErrorDetail({ text })}` };
120
+ const code = ConsumeResponseSchema.safeParse(JsonTextSchema.safeParse(text).data).data?.code;
121
+ return { reset: code === "reset", detail: code ?? "unreadable body" };
122
+ }
123
+
93
124
  const LIMIT_LABEL_ABBREVIATIONS = new Map([["reserve", "rsrv"]]);
94
125
 
95
126
  export function codexLimitLabel(limitName: string): string {
package/src/lib/decide.ts CHANGED
@@ -2,11 +2,11 @@ import { countBy } from "es-toolkit";
2
2
  import { CLAUDE_COMPACT_KILL_MS } from "./compact.ts";
3
3
  import { withLock } from "./lock.ts";
4
4
  import { loadAccounts, loadConfig, liveWaitClaims, releaseWaitClaim, replaceWaitClaim, saveAccounts } from "./state.ts";
5
- import { MAX_WAITERS_PER_ACCOUNT, isExhausted, landWindows, limitWindows, liveUsed, nextWeeklyReset, onCredits, pickBest, pickWaitTarget, screensUntilReset, sessionWindow, thresholdBars, weeklyWindow, type PickCtx } from "./picker.ts";
5
+ import { MAX_WAITERS_PER_ACCOUNT, isExhausted, landWindows, limitWindows, liveUsed, nextWeeklyReset, onCredits, pickBest, pickWaitTarget, screensUntilReset, sessionWindow, thresholdBars, wantsBankedReset, weeklyWindow, type PickCtx } from "./picker.ts";
6
6
  import { familyTokens } from "./usage.ts";
7
7
  import { errorMessage, log } from "./log.ts";
8
- import type { Observation, Provider } from "./provider.ts";
9
- import type { Account, Bars, EnforcedLimit } from "./types.ts";
8
+ import type { Observation, Provider, ResetClaim } from "./provider.ts";
9
+ import type { Account, Bars, Config, EnforcedLimit } from "./types.ts";
10
10
 
11
11
  export type SwapDecision = { swapped: boolean; account: Account | null; reason: string; waitUntil?: number };
12
12
 
@@ -58,11 +58,58 @@ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): numb
58
58
  return limit.resetsAt ?? cachedReset ?? now + (span === "session" ? FIVE_HOURS_MS : WEEK_MS);
59
59
  }
60
60
 
61
+ async function foldLive(p: Provider, a: Account, cfg: Config, now: number, bars: Bars): Promise<Observation | null> {
62
+ const obs = await p.observeLive(a, cfg, now, { probe: false, perModel: false });
63
+ if (obs && (a.lastUsageAt == null || obs.at > a.lastUsageAt)) landWindows(a, p.mergeWindows(obs.windows, a.windows), obs.at, bars);
64
+ return obs;
65
+ }
66
+
67
+ const CLAIM_DEADLINE_MS = 25_000;
68
+ const CLAIM_HOLD_MS = 60_000;
69
+
70
+ export async function useBankedResets(p: Provider, cfg: Config, now: number): Promise<void> {
71
+ const bars = thresholdBars(cfg);
72
+ const wanted = async (accounts: Account[]): Promise<Account[]> => {
73
+ const held = accounts.filter((a) => a.bankedReset != null);
74
+ for (const a of held) await foldLive(p, a, cfg, now, bars);
75
+ return held.filter((a) => wantsBankedReset(a, bars, now));
76
+ };
77
+ if ((await wanted(loadAccounts(p.pool).accounts)).length === 0) return;
78
+ const due = await withLock(p.pool.lockFile, async () => {
79
+ const idx = loadAccounts(p.pool);
80
+ const picked = await wanted(idx.accounts);
81
+ const claims = picked.map((a) => ({ ...a }));
82
+ const until = Date.now() + CLAIM_HOLD_MS;
83
+ for (const a of picked) {
84
+ a.bankedReset = undefined;
85
+ a.bankedResetAt = until;
86
+ }
87
+ if (picked.length > 0) saveAccounts(p.pool, idx);
88
+ return claims;
89
+ });
90
+ const signal = AbortSignal.timeout(CLAIM_DEADLINE_MS);
91
+ await Promise.all(
92
+ due.map(async (a) => {
93
+ const claim = await p.useBankedReset(a, signal).catch((e: unknown): ResetClaim => ({ reset: false, detail: errorMessage(e) }));
94
+ log("decide.banked_reset", { account: a.id.slice(0, 8), reset: claim.reset, detail: claim.detail.slice(0, 200) });
95
+ if (!claim.reset) return;
96
+ await withLock(p.pool.lockFile, () => {
97
+ const idx = loadAccounts(p.pool);
98
+ const stored = idx.accounts.find((x) => x.id === a.id);
99
+ if (!stored) return;
100
+ landWindows(stored, p.mergeWindows([], stored.windows), Date.now(), bars);
101
+ saveAccounts(p.pool, idx);
102
+ });
103
+ }),
104
+ );
105
+ }
106
+
61
107
  export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, opts: EvalOpts = {}): Promise<SwapDecision> {
62
108
  const activeId = opts.seatId === undefined ? p.liveId() : opts.seatId;
63
109
  const waiterId = canRespawn ? opts.waiterId : undefined;
64
110
  const cfg = loadConfig();
65
111
  const bars = thresholdBars(cfg);
112
+ if (enforced == null) await useBankedResets(p, cfg, now);
66
113
  const stored0 = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
67
114
  const walled0 = stored0?.enforcedUntil != null && stored0.enforcedUntil > now;
68
115
  const gated = opts.sessionFamilies ?? p.gatedFamilies(cfg);
@@ -106,9 +153,8 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
106
153
 
107
154
  let obs2: Observation | null = null;
108
155
  for (const a of idx.accounts) {
109
- const obs = await p.observeLive(a, cfg, now, { probe: false, perModel: false });
156
+ const obs = await foldLive(p, a, cfg, now, bars);
110
157
  if (a === active) obs2 = obs;
111
- if (obs && (a.lastUsageAt == null || obs.at > a.lastUsageAt)) landWindows(a, p.mergeWindows(obs.windows, a.windows), obs.at, bars);
112
158
  }
113
159
  saveAccounts(p.pool, idx);
114
160
 
package/src/lib/picker.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { maxBy, minBy, sortBy } from "es-toolkit";
2
2
  import { familyTokens } from "./usage.ts";
3
- import type { Account, Bars, Config, Thresholds, Window } from "./types.ts";
3
+ import type { Account, BankedReset, Bars, Config, Thresholds, Window } from "./types.ts";
4
4
 
5
5
  export function thresholdBars(cfg: Config): Bars {
6
6
  const bars = (t: Thresholds): Thresholds => ({ session: t.session - cfg.policy.projectionMargin, weekly: t.weekly });
@@ -114,6 +114,20 @@ export function weeklyExpiry(a: Account, now: number): number {
114
114
  return nextWeeklyReset(weeklyWindow(a)?.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
115
115
  }
116
116
 
117
+ const BANKED_RESET_LEAD_MS = 3 * 24 * 60 * 60 * 1000;
118
+
119
+ export function wantsBankedReset(a: Account, bars: Bars, now: number): boolean {
120
+ const weekly = weeklyWindow(a);
121
+ const left = weeklyExpiry(a, now) - now;
122
+ return a.bankedReset != null && weekly != null && liveUsed(weekly, now) >= barFor(a, weekly, bars, now) && Number.isFinite(left) && left > BANKED_RESET_LEAD_MS;
123
+ }
124
+
125
+ export function landGrant(a: Account, grant: BankedReset | undefined, sentAt: number): void {
126
+ if (sentAt <= (a.bankedResetAt ?? 0)) return;
127
+ a.bankedReset = grant;
128
+ a.bankedResetAt = sentAt;
129
+ }
130
+
117
131
  export function earliestReset(a: Account, now: number): number {
118
132
  const session = sessionWindow(a)?.resetsAt;
119
133
  return Math.min(session != null && session > now ? session : Number.POSITIVE_INFINITY, weeklyExpiry(a, now));
@@ -8,6 +8,8 @@ export type SampleReport = { ok: true; source: "statusline" | "probe" | "cached"
8
8
 
9
9
  export type SwapFailure = "dead-grant" | "skip" | "fatal";
10
10
 
11
+ export type ResetClaim = { reset: boolean; detail: string };
12
+
11
13
  export type SeatBorrow = { store: string; id: string; reused: boolean } | { denied: string } | null;
12
14
 
13
15
  export class StoreUnusableError extends Error {
@@ -31,6 +33,7 @@ export type Provider = {
31
33
  samplePool(accounts: Account[], liveId: string | null, now: number): Promise<Map<string, SampleReport>>;
32
34
  mergeWindows(next: Window[], prev: Window[]): Window[];
33
35
  swap(target: Account): Promise<void>;
36
+ useBankedReset(account: Account, signal: AbortSignal): Promise<ResetClaim>;
34
37
  classifySwapError(e: unknown): SwapFailure;
35
38
  removeCredentials(account: Account): Promise<void>;
36
39
  storeUsable(account: Account): Promise<boolean>;
package/src/lib/sample.ts CHANGED
@@ -7,7 +7,7 @@ import { withLock } from "./lock.ts";
7
7
  import { errorMessage, log } from "./log.ts";
8
8
  import { claudeTierLabel, isDeadCredential } from "./oauth.ts";
9
9
  import { claudePool, paths, storeDirFor } from "./paths.ts";
10
- import { barFor, gatedWindows, landWindows, liveUsed, thresholdBars } from "./picker.ts";
10
+ import { barFor, gatedWindows, landGrant, landWindows, liveUsed, thresholdBars } from "./picker.ts";
11
11
  import { seatCounts } from "./presence.ts";
12
12
  import type { Observation } from "./provider.ts";
13
13
  import { loadAccounts, loadUsageSnapshot, saveAccounts } from "./state.ts";
@@ -60,6 +60,11 @@ export function teeObservation(account: Account): Observation | null {
60
60
  return { windows: mergeWindows(aggregate, account.windows), at };
61
61
  }
62
62
 
63
+ export function landRead(account: Account, usage: UsageWindows, at: number, thresholds: Bars): void {
64
+ if (account.lastUsageAt == null || at > account.lastUsageAt) landWindows(account, mergeWindows(windowsOf(usage, at), account.windows), at, thresholds);
65
+ landGrant(account, usage.bankedReset, at);
66
+ }
67
+
63
68
  export function foldTee(account: Account, thresholds: Bars): boolean {
64
69
  const observed = teeObservation(account);
65
70
  if (!observed || (account.lastUsageAt != null && observed.at <= account.lastUsageAt)) return false;
@@ -197,8 +202,8 @@ export async function sampleOldest(cfg: Config): Promise<void> {
197
202
  stored.usageRetryAt = outcome.retryAt;
198
203
  dirty = true;
199
204
  }
200
- if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
201
- landWindows(stored, mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows), startedAt, thresholdBars(cfg));
205
+ if (stored && outcome.ok) {
206
+ landRead(stored, outcome.usage, startedAt, thresholdBars(cfg));
202
207
  dirty = true;
203
208
  }
204
209
  if (dirty) saveAccounts(claudePool, idx);
@@ -110,6 +110,7 @@ export function statusOnlyProvider(spec: StatusOnlySpec): Provider {
110
110
  swap: async () => {
111
111
  throw new Error(`${spec.name} moves are not supported yet (status-only pool)`);
112
112
  },
113
+ useBankedReset: async () => ({ reset: false, detail: `${spec.name} has no banked reset` }),
113
114
  classifySwapError: () => "fatal",
114
115
  removeCredentials: async (a) => {
115
116
  rmSync(spec.storeDirFor(a.id), { recursive: true, force: true });
package/src/lib/types.ts CHANGED
@@ -36,7 +36,10 @@ const AggregateWindowsSchema = z.object({
36
36
  sevenDay: UsageWindowSchema,
37
37
  });
38
38
 
39
- export type UsageWindows = z.infer<typeof AggregateWindowsSchema> & { perModel: Record<string, UsageWindow> };
39
+ export const BankedResetSchema = z.object({ grant: z.string().nullable() });
40
+ export type BankedReset = z.infer<typeof BankedResetSchema>;
41
+
42
+ export type UsageWindows = z.infer<typeof AggregateWindowsSchema> & { perModel: Record<string, UsageWindow>; bankedReset?: BankedReset };
40
43
 
41
44
  export const WindowSchema = z.object({
42
45
  name: z.string().nullable(),
@@ -73,6 +76,8 @@ export const AccountSchema = z.object({
73
76
  enforcedUntil: z.number().optional(),
74
77
  needsReauth: z.boolean().optional(),
75
78
  hasCredits: z.boolean().optional(),
79
+ bankedReset: BankedResetSchema.optional(),
80
+ bankedResetAt: z.number().optional(),
76
81
  oauthAccount: OAuthAccountSchema.optional(),
77
82
  });
78
83
  export type Account = z.infer<typeof AccountSchema>;
@@ -252,6 +257,7 @@ export type CodexUsage = {
252
257
  email: string | null;
253
258
  planType: string | null;
254
259
  hasCredits: boolean | null;
260
+ bankedReset: BankedReset | undefined;
255
261
  windows: Window[];
256
262
  };
257
263
 
package/src/lib/usage.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { omit } from "es-toolkit";
2
2
  import { z } from "zod";
3
- import { http } from "./http.ts";
3
+ import { claudeUserAgent } from "./claudebin.ts";
4
+ import { http, safeErrorDetail } from "./http.ts";
4
5
  import { errorMessage, log } from "./log.ts";
5
6
  import { env } from "./paths.ts";
6
- import { EpochSecondsSchema, InstantSchema, JsonTextSchema, RateLimitsStdinSchema, type ModelInfo, type UsageWindow, type UsageWindows, type Window } from "./types.ts";
7
+ import type { ResetClaim } from "./provider.ts";
8
+ import { EpochSecondsSchema, InstantSchema, JsonTextSchema, RateLimitsStdinSchema, type BankedReset, type ModelInfo, type UsageWindow, type UsageWindows, type Window } from "./types.ts";
7
9
 
8
10
  const win = (w: { used_percentage: number; resets_at?: number | null }): UsageWindow => ({
9
11
  usedPercentage: w.used_percentage,
@@ -198,12 +200,32 @@ const UsageScopedLimitSchema = z.looseObject({
198
200
  resets_at: ResetsAtSchema,
199
201
  scope: z.looseObject({ model: z.looseObject({ display_name: z.string() }) }),
200
202
  });
203
+ const ResetGrantSchema = z.looseObject({ id: z.string(), resets_left: z.number(), clears: z.array(z.string()), paused: z.boolean(), usable_now: z.boolean() });
204
+ const CedarEmberSchema = z.looseObject({ next_grant_id: z.string().nullish(), grants: z.array(z.looseObject({ id: z.string() })) });
201
205
  const UsageResponseSchema = z.looseObject({
202
206
  five_hour: UsageLimitSchema.nullish(),
203
207
  seven_day: UsageLimitSchema.nullish(),
204
208
  limits: z.array(z.unknown()).nullish(),
209
+ cedar_ember: z.unknown().optional(),
205
210
  });
206
211
 
212
+ function unreadableGrant(error: z.ZodError): undefined {
213
+ log("usage.cedar_ember_unreadable", { issue: z.prettifyError(error).slice(0, 200) });
214
+ return undefined;
215
+ }
216
+
217
+ function weeklyGrant(raw: unknown): BankedReset | undefined {
218
+ if (raw == null) return undefined;
219
+ const block = CedarEmberSchema.safeParse(raw);
220
+ if (!block.success) return unreadableGrant(block.error);
221
+ const named = block.data.grants.find((g) => g.id === block.data.next_grant_id);
222
+ if (named == null) return undefined;
223
+ const next = ResetGrantSchema.safeParse(named);
224
+ if (!next.success) return unreadableGrant(next.error);
225
+ const g = next.data;
226
+ return g.usable_now && !g.paused && g.resets_left > 0 && g.clears.includes("seven_day") ? { grant: g.id } : undefined;
227
+ }
228
+
207
229
  function scopedRows(limits: unknown[]): Record<string, UsageWindow> {
208
230
  const perModel: Record<string, UsageWindow> = {};
209
231
  for (const raw of limits) {
@@ -223,7 +245,7 @@ export async function fetchUsageDirect(accessToken: string): Promise<UsageRead>
223
245
  try {
224
246
  res = await http.get(USAGE_URL, {
225
247
  searchParams: { at_wall: 1, skip_spend: 1 },
226
- headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
248
+ headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": claudeUserAgent() },
227
249
  signal: AbortSignal.timeout(USAGE_DEADLINE_MS),
228
250
  });
229
251
  } catch (e) {
@@ -241,5 +263,23 @@ export async function fetchUsageDirect(accessToken: string): Promise<UsageRead>
241
263
  return { ok: false, retryAt: null };
242
264
  }
243
265
  const win = (w: z.infer<typeof UsageLimitSchema>): UsageWindow => ({ usedPercentage: w.utilization, resetsAt: w.resets_at ?? null });
244
- return { ok: true, usage: { fiveHour: win(parsed.data.five_hour), sevenDay: win(parsed.data.seven_day), perModel: scopedRows(parsed.data.limits ?? []) } };
266
+ return {
267
+ ok: true,
268
+ usage: { fiveHour: win(parsed.data.five_hour), sevenDay: win(parsed.data.seven_day), perModel: scopedRows(parsed.data.limits ?? []), bankedReset: weeklyGrant(parsed.data.cedar_ember) },
269
+ };
270
+ }
271
+
272
+ const ClaimResponseSchema = z.looseObject({ result: z.string(), reason: z.string().nullish() });
273
+
274
+ export async function claimResetGrant(input: { accessToken: string; organizationUuid: string; grant: string }, signal: AbortSignal): Promise<ResetClaim> {
275
+ const res = await http.post(new URL(`/api/organizations/${input.organizationUuid}/reset_rate_limits`, USAGE_URL), {
276
+ headers: { Authorization: `Bearer ${input.accessToken}`, "anthropic-beta": "oauth-2025-04-20", "User-Agent": claudeUserAgent() },
277
+ json: { program: "cedar_ember", grant_id: input.grant, request_id: crypto.randomUUID() },
278
+ signal,
279
+ });
280
+ const text = await res.text();
281
+ if (!res.ok) return { reset: false, detail: `HTTP ${res.status}: ${safeErrorDetail({ text })}` };
282
+ const claim = ClaimResponseSchema.safeParse(JsonTextSchema.safeParse(text).data).data;
283
+ if (claim == null) return { reset: false, detail: "unreadable body" };
284
+ return { reset: claim.result === "reset", detail: claim.reason != null ? `${claim.result}: ${claim.reason}` : claim.result };
245
285
  }