tokenmaxxing 1.18.0 → 1.19.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
@@ -85,6 +85,8 @@ The decision engages at `five_hour >= 80%` (policy.greedySessionFloor): from the
85
85
 
86
86
  **Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches on the Fable cap; a Sonnet session rides the aggregate. Both layers apply the per-model gate: a burnt Fable cap screens an account out of a Layer 1 switch, and a Fable cap at the wall screens it out of the Layer 2 squeeze too.
87
87
 
88
+ **Banked resets (opt-in).** Both CLIs can reset a hit limit on the same account: Claude Code's `/limit-reset` (the `juniper_tide` server program, claimed by `POST /api/organizations/<org>/reset_rate_limits`, once a week, and the reset window still counts toward the weekly limit; the slash command is interactive-only, so tokenmaxxing makes the claim call itself with the live credential) and Codex's banked reset (`POST .../wham/rate-limit-reset-credits/consume` with an idempotency key; it refreshes the 5h and weekly windows together). `policy.preferToUseBankedReset` (a provider list, default `[]`) makes the hard path spend one before swapping, because a swap cold-starts the per-org prompt cache while a reset keeps the seat. Claude holds between the session rung and the wall only while the account still has weekly room for one more session window at its measured rate (`sessionWindowWeeklyCost`, learned from the tee within one window; unmeasured swaps), claims at the wall, and on `reset` respawns the failed turn in place. Codex rides to the wall while the usage endpoint reports a credit, consumes one, re-samples, and never restarts. Detail: `docs/switching.mdx` §Banked resets, `src/lib/bankedreset.ts`, `src/lib/codexreset.ts`.
89
+
88
90
  ---
89
91
 
90
92
  ## 6. Honest papercuts
package/README.md CHANGED
@@ -101,12 +101,13 @@ The **target** is chosen greedily off each account's cached windows: among usabl
101
101
  "greedySessionFloor": 80,
102
102
  "switchModels": ["fable"],
103
103
  "usagePollTtlMs": 90000,
104
- "checkIntervalMs": 60000
104
+ "checkIntervalMs": 60000,
105
+ "preferToUseBankedReset": []
105
106
  }
106
107
  }
107
108
  ```
108
109
 
109
- `thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer and which every headroom band is a multiple of, one tick near a bar up to five with plenty of headroom (a depleted wait and the post-swap cooldown sleep to their own deadlines instead) - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
110
+ `thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer and which every headroom band is a multiple of, one tick near a bar up to five with plenty of headroom (a depleted wait and the post-swap cooldown sleep to their own deadlines instead) - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick; `preferToUseBankedReset` lists the providers (`claude`, `codex`) that spend a banked limit reset (Claude Code's weekly `/limit-reset`, Codex's banked reset) on the same account before swapping, which keeps the prompt cache a swap would cold-start - on Claude the seat holds past the session rung only while the account has weekly room for one more session window at its measured rate, and claims the reset at the wall.
110
111
 
111
112
  State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
112
113
 
@@ -148,7 +149,7 @@ codex # use codex as always
148
149
 
149
150
  Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, so **a restart is the switch**. The installed Stop hook runs the same greedy pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, weekly aggregate and per-model caps alike); when it swaps, the supervisor relaunches `codex resume <session-id>` on the fresh account with the transcript intact. `tokenmaxxing switch --codex [sel]` does it manually, `status`/`watch`/`ls` show both pools.
150
151
 
151
- Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and codex has no cross-process lock on `auth.json`, so tokenmaxxing serializes all of its own credential writes behind its own lock and swaps only at idle turn boundaries.
152
+ Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and codex has no cross-process lock on `auth.json`, so tokenmaxxing serializes all of its own credential writes behind its own lock and swaps only at idle turn boundaries. With `codex` in `policy.preferToUseBankedReset`, a seat at its limit consumes a banked reset credit (which refreshes both windows) and keeps running instead of restarting onto another account.
152
153
 
153
154
  ## Honest limitations
154
155
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "tokenmaxxing",
4
- "version": "1.18.0",
4
+ "version": "1.19.0",
5
5
  "description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
6
6
  "author": {
7
7
  "name": "anaclumos",
@@ -12,6 +12,7 @@ description: Explain and apply tokenmaxxing switch policy (greedy vs hard path,
12
12
  - **HARD path**: a screening bar crossed. Swap to best usable target; if none, Layer 2 wall logic (Claude only).
13
13
  - **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
14
14
  - **Effective bars**: `effectiveBars(cfg, pool)` = the active rung of the 5h ladder (`thresholds.session`, default `[90]`, a single rung: the lowest rung some pooled account, the current one included, still clears) and the weekly bar, each minus `policy.projectionMargin`. Trigger and screening must share these bars or swaps ping-pong. Codex reads `terminalBars(cfg)`, the top rung only. The check cadence is capped one band per rung climbed, and every band is a multiple of the tick `policy.checkIntervalMs` (default 60000).
15
+ - **Banked reset**: opt-in via `policy.preferToUseBankedReset` (a provider list, default `[]`), hard path only. Claude: the seat holds between the session rung and the wall while a reset is believed available and the weekly windows have room for one more measured session window (`sessionWindowWeeklyCost`; unmeasured swaps), claims the `/limit-reset` server call at the wall, and keeps the seat on `reset` (the failed turn is retriggered in place). Codex: rides to the wall while a reset credit exists, consumes one, no restart. The greedy path never resets.
15
16
 
16
17
  ## Layer 2 (Claude only)
17
18
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  - `docs/content/docs/switching.mdx`
4
4
  - `src/lib/decide.ts`, `src/lib/picker.ts`
5
+ - `src/lib/bankedreset.ts`, `src/lib/codexreset.ts` (banked limit resets)
5
6
  - `.memory/switch-policy-pace-pressure.md`
6
7
 
7
- Default screening bars: session ladder [90] (a single rung; add lower rungs such as [50, 80, 95] to hand off earlier) with the active rung resolved per pool, weekly 98. Engagement floor 80. Wall defaults 100. Cooldown 45s on the automatic path after a swap. Check tick `policy.checkIntervalMs` (default 60000, minimum 10000); `init` writes it into the timer unit. Check cadence ceiling per rung: five ticks, three, two, then the one-tick floor, with the headroom bands at the same multiples (40 points sleeps five ticks, 20 three, 8 two, less every tick).
8
+ Default screening bars: session ladder [90] (a single rung; add lower rungs such as [50, 80, 95] to hand off earlier) with the active rung resolved per pool, weekly 98. Engagement floor 80. Wall defaults 100. Cooldown 45s on the automatic path after a swap. Check tick `policy.checkIntervalMs` (default 60000, minimum 10000); `init` writes it into the timer unit. Check cadence ceiling per rung: five ticks, three, two, then the one-tick floor, with the headroom bands at the same multiples (40 points sleeps five ticks, 20 three, 8 two, less every tick). Banked resets off by default (`policy.preferToUseBankedReset: []`); Claude claims once a week per account at the wall, Codex consumes one credit at the wall.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/check.ts CHANGED
@@ -30,6 +30,7 @@ export async function cmdCheck(args: string[] = [], json = false): Promise<numbe
30
30
  ok: true,
31
31
  due: true,
32
32
  swapped: d.swapped,
33
+ reset: d.reset === true,
33
34
  account: d.account?.label ?? null,
34
35
  reason: d.reason,
35
36
  waitUntil: d.waitUntil ?? null,
@@ -40,6 +41,8 @@ export async function cmdCheck(args: string[] = [], json = false): Promise<numbe
40
41
  const next = c.dim(`next in ${Math.round(delayMs / 1000)}s`);
41
42
  if (d.swapped && d.account) {
42
43
  console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)} ${next}`);
44
+ } else if (d.reset && d.account) {
45
+ console.log(`${c.green("↻")} reset the session limit on ${c.bold(d.account.label)} ${next}`);
43
46
  } else if (d.waitUntil !== undefined && d.account) {
44
47
  console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`) + ` ${next}`);
45
48
  } else {
package/src/cli/config.ts CHANGED
@@ -21,6 +21,7 @@ export const KNOWN_KEYS = [
21
21
  "policy.usagePollTtlMs",
22
22
  "policy.maxWaitMs",
23
23
  "policy.checkIntervalMs",
24
+ "policy.preferToUseBankedReset",
24
25
  ] as const;
25
26
 
26
27
  const TIMER_REWRITE_NOTE = "note: run `tokenmaxxing init` to rewrite the periodic timer at the new tick";
package/src/cli/status.ts CHANGED
@@ -12,7 +12,7 @@ import { isCodexExhausted } from "../lib/codexpick.ts";
12
12
  import { codexLimitLabel, isSessionWindow } from "../lib/codexusage.ts";
13
13
  import { bar, c, claudeTierLabel, count, emitJson, fmtAgo } from "./render.ts";
14
14
  import { gatedFamilies, type FullUsage } from "../lib/usage.ts";
15
- import { ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow } from "../lib/types.ts";
15
+ import { BankedResetRecordSchema, ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow } from "../lib/types.ts";
16
16
 
17
17
  const SampleReportSchema = z.discriminatedUnion("ok", [
18
18
  z.object({ ok: z.literal(true), source: z.enum(["statusline", "probe"]) }),
@@ -35,6 +35,9 @@ const ClaudeStatusAccountSchema = z.object({
35
35
  pingError: z.string().nullable(),
36
36
  pingRejected: z.boolean(),
37
37
  pinged: z.boolean(),
38
+ bankedReset: BankedResetRecordSchema.nullable(),
39
+ sessionWindowWeeklyCost: z.number().nullable(),
40
+ sessionWindowFamilyCosts: z.record(z.string(), z.number()).nullable(),
38
41
  });
39
42
  type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
40
43
 
@@ -56,6 +59,7 @@ const CodexStatusAccountSchema = z.object({
56
59
  .nullable(),
57
60
  usageAt: z.number().nullable(),
58
61
  sample: SampleReportSchema,
62
+ resetCredits: z.number().int().nullable(),
59
63
  });
60
64
  type CodexStatusAccount = z.infer<typeof CodexStatusAccountSchema>;
61
65
 
@@ -153,7 +157,8 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
153
157
  samples.set(a.accountUuid, { outcome, viaTee });
154
158
  if (!outcome.ok) return;
155
159
  a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
156
- a.lastUsageAt = viaTee && teeAt != null ? teeAt : Date.now();
160
+ a.lastUsageAt = viaTee && teeAt != null ? (live?.sampledAt ?? teeAt) : Date.now();
161
+ if (viaTee && live?.sessionWindowWeeklyCost != null) a.sessionWindowWeeklyCost = live.sessionWindowWeeklyCost;
157
162
  if (Object.keys(outcome.usage.perModel).length > 0) {
158
163
  a.lastPerModel = outcome.usage.perModel;
159
164
  a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
@@ -173,6 +178,7 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
173
178
  const families = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
174
179
  const bars = effectiveBars(cfg, { accounts: idx.accounts, now, switchFamilies: families });
175
180
  const liveAccount = readOAuthAccount()?.accountUuid ?? null;
181
+ const liveModelUsage = loadModelUsage();
176
182
  const ordered = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
177
183
  const accounts = ordered.map((a): ClaudeStatusAccount => {
178
184
  const sampled = samples.get(a.accountUuid) ?? { outcome: { ok: false, reason: "not sampled" }, viaTee: false };
@@ -195,6 +201,9 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
195
201
  pingError: sampled.outcome.pingError ?? null,
196
202
  pingRejected: sampled.outcome.pingRejected === true,
197
203
  pinged: pings.has(a.accountUuid) && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
204
+ bankedReset: a.bankedReset ?? null,
205
+ sessionWindowWeeklyCost: a.sessionWindowWeeklyCost ?? null,
206
+ sessionWindowFamilyCosts: liveModelUsage && liveModelUsage.account === a.accountUuid ? (liveModelUsage.familyCosts ?? null) : null,
198
207
  };
199
208
  });
200
209
  return {
@@ -222,7 +231,7 @@ async function collectCodex(input: { cfg: Config; now: number }): Promise<Status
222
231
  const outcome = await sampleCodexAccount({ account, liveAccountId: liveId, now });
223
232
  outcomes.set(account.accountId, outcome);
224
233
  if (outcome.ok) {
225
- account.lastUsage = { aggregate: outcome.usage.aggregate, perLimit: outcome.usage.perLimit };
234
+ account.lastUsage = { aggregate: outcome.usage.aggregate, perLimit: outcome.usage.perLimit, resetCredits: outcome.usage.resetCredits, reachedType: outcome.usage.reachedType };
226
235
  account.lastUsageAt = Date.now();
227
236
  if (outcome.usage.email != null) account.email = outcome.usage.email;
228
237
  if (outcome.usage.planType != null) account.planType = outcome.usage.planType;
@@ -263,6 +272,7 @@ async function collectCodex(input: { cfg: Config; now: number }): Promise<Status
263
272
  : null,
264
273
  usageAt: account.lastUsageAt ?? null,
265
274
  sample: outcome.ok ? { ok: true, source: "probe" } : { ok: false, reason: outcome.reason },
275
+ resetCredits: usage?.resetCredits ?? null,
266
276
  };
267
277
  });
268
278
  return { bars, accounts };
package/src/cli/switch.ts CHANGED
@@ -2,7 +2,7 @@ import { withLock } from "../lib/lock.ts";
2
2
  import { paths } from "../lib/paths.ts";
3
3
  import { loadAccounts, loadConfig, loadUsage } from "../lib/state.ts";
4
4
  import { readOAuthAccount } from "../lib/claudejson.ts";
5
- import { performSwap } from "../lib/swap.ts";
5
+ import { isSkippableSwapError, performSwap } from "../lib/swap.ts";
6
6
  import { currentWins, effectiveBars, pickBest, pickEarliestReset, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
7
7
  import { InvalidGrantError } from "../lib/oauth.ts";
8
8
  import { gatedFamilies } from "../lib/usage.ts";
@@ -66,14 +66,16 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
66
66
  currentAccountUuid: null,
67
67
  switchFamilies,
68
68
  });
69
+ const rejected = new Set<string>();
69
70
  while (true) {
70
71
  const cur = loadAccounts();
71
72
  const everyone = everyoneIn(cur.accounts);
73
+ const pool = cur.accounts.filter((a) => !rejected.has(a.accountUuid));
72
74
  const active =
73
75
  (claimed != null ? cur.accounts.find((a) => a.accountUuid === claimed) : null) ??
74
76
  cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
75
77
  null;
76
- if (active != null && currentWins(active, cur.accounts, everyone)) {
78
+ if (active != null && currentWins(active, pool, everyone)) {
77
79
  if (drifted) return swapTo(active, "drift-reconciled");
78
80
  const expiry = weeklyExpiry(active, now);
79
81
  const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
@@ -85,60 +87,83 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
85
87
  });
86
88
  return 0;
87
89
  }
88
- const best = pickBest(cur.accounts, { ...everyone, currentAccountUuid: active?.accountUuid ?? null });
90
+ const best = pickBest(pool, { ...everyone, currentAccountUuid: active?.accountUuid ?? null });
89
91
  if (!best) break;
90
92
  try {
91
93
  await performSwap(best);
92
94
  } catch (e) {
95
+ rejected.add(best.accountUuid);
93
96
  if (e instanceof InvalidGrantError) {
94
97
  deadGrants.push(best.label);
95
98
  if (!json) console.error(c.red(deadGrantMessage(best)));
96
99
  continue;
97
100
  }
101
+ if (isSkippableSwapError(e)) {
102
+ if (!json) console.error(c.yellow(`${best.label}: ${e instanceof Error ? e.message : String(e)} - skipped for this run`));
103
+ continue;
104
+ }
98
105
  throw e;
99
106
  }
100
107
  emit(`${c.green("↻")} switched to ${c.bold(best.label)}`, { switched: true, account: best.label, reason: "best" });
101
108
  return 0;
102
109
  }
103
110
 
104
- const fresh = loadAccounts();
105
- const earliest = pickEarliestReset(fresh.accounts, everyoneIn(fresh.accounts));
106
- const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
107
- if (!earliest) {
108
- if (reauth.length > 0) {
109
- return fail(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`, {
110
- paint: c.yellow,
111
- extra: { reauthNeeded: reauth },
111
+ while (true) {
112
+ const fresh = loadAccounts();
113
+ const pool = fresh.accounts.filter((a) => !rejected.has(a.accountUuid));
114
+ const earliest = pickEarliestReset(pool, everyoneIn(fresh.accounts));
115
+ const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
116
+ if (!earliest) {
117
+ if (reauth.length > 0) {
118
+ return fail(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`, {
119
+ paint: c.yellow,
120
+ extra: { reauthNeeded: reauth },
121
+ });
122
+ }
123
+ const freshActive = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid) ?? null;
124
+ if (drifted && freshActive) return swapTo(freshActive, "drift-reconciled");
125
+ emit(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"), {
126
+ switched: false,
127
+ account: freshActive?.label ?? null,
128
+ reason: "unknown-resets",
129
+ });
130
+ return 0;
131
+ }
132
+ const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
133
+ const availableAt = earliest.availableAt > now ? earliest.availableAt : null;
134
+ if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
135
+ const msg = availableAt == null
136
+ ? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
137
+ : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(availableAt, now)})${reauthNote}`;
138
+ emit(c.yellow(msg), {
139
+ switched: false,
140
+ account: earliest.account.label,
141
+ reason: availableAt == null ? "no-target" : "all-at-limit",
142
+ availableAt,
143
+ reauthNeeded: reauth,
112
144
  });
145
+ return 0;
146
+ }
147
+ try {
148
+ await performSwap(earliest.account);
149
+ } catch (e) {
150
+ rejected.add(earliest.account.accountUuid);
151
+ if (e instanceof InvalidGrantError) {
152
+ deadGrants.push(earliest.account.label);
153
+ if (!json) console.error(c.red(deadGrantMessage(earliest.account)));
154
+ continue;
155
+ }
156
+ if (isSkippableSwapError(e)) {
157
+ if (!json) console.error(c.yellow(`${earliest.account.label}: ${e instanceof Error ? e.message : String(e)} - skipped for this run`));
158
+ continue;
159
+ }
160
+ throw e;
161
+ }
162
+ emit(`${c.green("↻")} switched to ${c.bold(earliest.account.label)}`, { switched: true, account: earliest.account.label, reason: "earliest-reset", availableAt, reauthNeeded: reauth });
163
+ if (availableAt != null && !json) {
164
+ console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(availableAt, now)})${reauthNote}`));
113
165
  }
114
- const freshActive = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid) ?? null;
115
- if (drifted && freshActive) return swapTo(freshActive, "drift-reconciled");
116
- emit(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"), {
117
- switched: false,
118
- account: freshActive?.label ?? null,
119
- reason: "unknown-resets",
120
- });
121
- return 0;
122
- }
123
- const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
124
- const availableAt = earliest.availableAt > now ? earliest.availableAt : null;
125
- if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
126
- const msg = availableAt == null
127
- ? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
128
- : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(availableAt, now)})${reauthNote}`;
129
- emit(c.yellow(msg), {
130
- switched: false,
131
- account: earliest.account.label,
132
- reason: availableAt == null ? "no-target" : "all-at-limit",
133
- availableAt,
134
- reauthNeeded: reauth,
135
- });
136
166
  return 0;
137
167
  }
138
- const code = await swapTo(earliest.account, "earliest-reset", { availableAt, reauthNeeded: reauth });
139
- if (code === 0 && availableAt != null && !json) {
140
- console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(availableAt, now)})${reauthNote}`));
141
- }
142
- return code;
143
168
  });
144
169
  }
@@ -99,6 +99,7 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
99
99
  }
100
100
  if (await promoteReconcile({ supervisorId, sessionId })) return;
101
101
  const decision = await evaluateAndMaybeSwapCodex({});
102
+ if (decision.reset && decision.account) log("codexstop.reset", { account: decision.account.accountId.slice(0, 8) });
102
103
  if (decision.swapped && decision.account) {
103
104
  mkdirSync(codexPaths.respawnDir, { recursive: true });
104
105
  const payload = CodexRespawnMarkerSchema.parse({
@@ -4,12 +4,13 @@ import { paths } from "../lib/paths.ts";
4
4
  import { writeFileAtomic } from "../lib/atomic.ts";
5
5
  import { readOAuthAccount } from "../lib/claudejson.ts";
6
6
  import { enforcedWindowMs, evaluateAndMaybeSwap, postSwapProof, recordEnforcedLimit } from "../lib/decide.ts";
7
- import { loadConfig, loadLastSwapAt } from "../lib/state.ts";
7
+ import { loadConfig, loadLastSeatChangeAt, loadLastSwapAt } from "../lib/state.ts";
8
8
  import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail } from "../lib/usage.ts";
9
9
  import { RespawnMarkerSchema, type EnforcedLimit } from "../lib/types.ts";
10
10
  import { log } from "../lib/log.ts";
11
11
 
12
12
  export const RETRIGGER_PROMPT = "Continue where the previous turn left off; it was interrupted by a usage limit and tokenmaxxing switched accounts.";
13
+ export const RETRIGGER_RESET_PROMPT = "Continue where the previous turn left off; it was interrupted by the session limit and tokenmaxxing reset that limit on the same account.";
13
14
 
14
15
  const StopFailureStdin = z.looseObject({
15
16
  session_id: z.uuid().optional().catch(undefined),
@@ -52,11 +53,11 @@ export async function runStopFailureHook(): Promise<number> {
52
53
 
53
54
  let enforced: EnforcedLimit | null = null;
54
55
  if (limit && found && account) {
55
- if (postSwapProof({ swapAt: loadLastSwapAt(), launchedAt, errorAt: found.errorAt, now })) {
56
+ if (postSwapProof({ swapAt: limit.kind === "session" ? loadLastSeatChangeAt() : loadLastSwapAt(), launchedAt, errorAt: found.errorAt, now })) {
56
57
  const stamp = await recordEnforcedLimit({ limit, account, now });
57
58
  log("stopfailure.enforced", { kind: limit.kind, family: limit.kind === "model" ? limit.family : undefined, outcome: stamp.outcome, resetsAt: stamp.resetsAt, subagent: !mainLoop });
58
59
  if (stamp.outcome !== "account-moved") {
59
- enforced = { account, family: limit.kind === "model" ? limit.family : null, resetsAt: stamp.resetsAt, windowMs: enforcedWindowMs(limit) };
60
+ enforced = { account, kind: limit.kind, family: limit.kind === "model" ? limit.family : null, resetsAt: stamp.resetsAt, windowMs: enforcedWindowMs(limit) };
60
61
  }
61
62
  } else {
62
63
  log("stopfailure.unproven", { kind: limit.kind });
@@ -71,18 +72,20 @@ export async function runStopFailureHook(): Promise<number> {
71
72
  }
72
73
 
73
74
  const decision = await evaluateAndMaybeSwap(now, canPause && enforced != null, enforced);
74
- if (enforced && canPause && pinnedSid && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
75
+ const reset = decision.reset === true;
76
+ if (enforced && canPause && pinnedSid && decision.account && (decision.swapped || decision.waitUntil !== undefined || reset)) {
75
77
  const marker = join(paths.respawnDir, pinnedSid);
76
78
  const payload = RespawnMarkerSchema.parse({
77
79
  account: decision.account.label,
78
80
  ts: Date.now(),
79
81
  waitUntil: decision.waitUntil ?? now,
80
82
  sessionId: stdinSid ?? pinnedSid,
81
- prompt: RETRIGGER_PROMPT,
83
+ prompt: reset ? RETRIGGER_RESET_PROMPT : RETRIGGER_PROMPT,
82
84
  ...(launchedAt != null ? { launchedAt } : {}),
85
+ ...(reset ? { reset } : {}),
83
86
  });
84
87
  writeFileAtomic(marker, JSON.stringify(payload));
85
- log("stopfailure.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8), account: decision.account.accountUuid.slice(0, 8), waitUntil: payload.waitUntil });
88
+ log("stopfailure.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8), account: decision.account.accountUuid.slice(0, 8), waitUntil: payload.waitUntil, reset });
86
89
  } else {
87
90
  log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
88
91
  }
@@ -25,6 +25,7 @@ export async function runStopHook(): Promise<number> {
25
25
  try {
26
26
  const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null;
27
27
  const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
28
+ if (decision.reset && decision.account) log("stop.reset", { account: decision.account.accountUuid.slice(0, 8) });
28
29
  if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
29
30
  log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
30
31
  if (decision.waitUntil !== undefined && canPause && pinnedSid) {
@@ -286,7 +286,8 @@ export async function runSupervisor(argv: string[]): Promise<number> {
286
286
  respawns++;
287
287
  if (m.waitUntil > Date.now()) {
288
288
  if (await countdownWait(m.account, m.waitUntil)) overriddenUntil = m.waitUntil;
289
- } else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
289
+ } else if (m.reset) process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: reset the session limit on ${m.account} - resuming...\x1b[0m\n`);
290
+ else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
290
291
  saveSessionFlags(m.sessionId, persistable, process.cwd());
291
292
  launchArgs = ["--resume", m.sessionId, ...persistable, ...(m.prompt ? ["--", m.prompt] : [])];
292
293
  continue;