tokenmaxxing 1.39.0 → 1.41.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/src/lib/sample.ts CHANGED
@@ -1,18 +1,17 @@
1
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { minBy } from "es-toolkit";
4
4
  import { z } from "zod";
5
- import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
6
- import { claudePool, credItemFor, paths } from "./paths.ts";
7
- import { withClaudeRefreshLock } from "./claudelock.ts";
5
+ import { writeFileAtomic } from "./atomic.ts";
6
+ import { readStore } from "./credstore.ts";
8
7
  import { withLock } from "./lock.ts";
9
- import { readOAuthAccount } from "./claudejson.ts";
10
- import { claudeTierLabel, refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
11
- import { mergeWindows, probeUsage, windowsOf } from "./usage.ts";
12
- import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadLastSwapAt, saveAccounts } from "./state.ts";
13
- import { keepRotatedPair } from "./swap.ts";
14
8
  import { log } from "./log.ts";
15
- import { CredentialBlobSchema, TokenIdentitySchema, UsageWindowsSchema, type Account, type Config, type OAuthCreds, type TokenIdentity } from "./types.ts";
9
+ import { claudeTierLabel, isDeadCredential } from "./oauth.ts";
10
+ import { claudePool, sampleDirFor, storeDirFor } from "./paths.ts";
11
+ import type { Observation } from "./provider.ts";
12
+ import { loadAccounts, loadUsageSnapshot, saveAccounts } from "./state.ts";
13
+ import { mergeWindows, probeUsage, windowsOf } from "./usage.ts";
14
+ import { UsageWindowsSchema, type Account, type Config } from "./types.ts";
16
15
 
17
16
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
18
17
  z.object({ ok: z.literal(true), usage: UsageWindowsSchema }),
@@ -20,288 +19,96 @@ const SampleOutcomeSchema = z.discriminatedUnion("ok", [
20
19
  ]);
21
20
  export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
22
21
 
23
- const IdentityCheckSchema = z.discriminatedUnion("status", [
24
- z.object({ status: z.literal("match") }),
25
- z.object({ status: z.literal("mismatch"), reason: z.string(), owner: TokenIdentitySchema }),
26
- z.object({ status: z.literal("unavailable"), reason: z.string(), stale: z.boolean() }),
22
+ const PreparedProbeSchema = z.discriminatedUnion("ok", [
23
+ z.object({ ok: z.literal(true), dir: z.string() }),
24
+ z.object({ ok: z.literal(false), reason: z.string() }),
27
25
  ]);
28
- type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
26
+ type PreparedProbe = z.infer<typeof PreparedProbeSchema>;
29
27
 
30
- async function checkIdentity(creds: OAuthCreds, account: Account, signal?: AbortSignal): Promise<IdentityCheck> {
31
- let identity: TokenIdentity;
32
- try {
33
- identity = await fetchTokenIdentity(creds.accessToken, signal);
34
- } catch (e) {
35
- return {
36
- status: "unavailable",
37
- reason: `credential identity check failed: ${e instanceof Error ? e.message : String(e)}`,
38
- stale: e instanceof IdentityUnavailableError && e.status === 401,
39
- };
40
- }
41
- if (identity.accountUuid === account.id) return { status: "match" };
42
- return { status: "mismatch", reason: `credential actually belongs to ${describeIdentity(identity)}`, owner: identity };
28
+ export function teeObservation(account: Account): Observation | null {
29
+ const stored = account.lastUsageAt != null ? { windows: account.windows, at: account.lastUsageAt } : null;
30
+ const snap = loadUsageSnapshot(account.id);
31
+ if (!snap) return stored;
32
+ const at = snap.state.sampledAt ?? snap.state.ts;
33
+ if (stored != null && at <= stored.at) return stored;
34
+ const aggregate = windowsOf({ fiveHour: snap.state.fiveHour, sevenDay: snap.state.sevenDay, perModel: {} }, at);
35
+ return { windows: mergeWindows(aggregate, account.windows), at };
43
36
  }
44
37
 
45
- function refreshTier(account: Account, creds: OAuthCreds): void {
46
- account.tier = claudeTierLabel(creds) ?? account.tier;
38
+ export function foldTee(account: Account): boolean {
39
+ const observed = teeObservation(account);
40
+ if (!observed || (account.lastUsageAt != null && observed.at <= account.lastUsageAt)) return false;
41
+ account.windows = observed.windows;
42
+ account.lastUsageAt = observed.at;
43
+ return true;
47
44
  }
48
45
 
49
- const ProbeFailureSchema = z.object({ ok: z.literal(false), reason: z.string() });
50
- type ProbeFailure = z.infer<typeof ProbeFailureSchema>;
51
- const PreparedProbeSchema = z.discriminatedUnion("ok", [z.object({ ok: z.literal(true), dir: z.string(), installed: z.string() }), ProbeFailureSchema]);
52
- type PreparedProbe = z.infer<typeof PreparedProbeSchema>;
53
-
54
- async function prepareParkedProbe(account: Account, dir: string, signal?: AbortSignal): Promise<PreparedProbe> {
55
- const backup = parkedTarget(credItemFor(account.id));
56
- const parkedRaw = await readItem(backup);
57
- if (!parkedRaw) return { ok: false, reason: "no parked credential - run `tokenmaxxing auth`" };
58
- if (!account.oauthAccount) return { ok: false, reason: "account record has no oauthAccount - run `tokenmaxxing auth`" };
59
-
60
- let creds: OAuthCreds;
46
+ export async function prepareProbe(account: Account, suffix = ""): Promise<PreparedProbe> {
47
+ let creds;
61
48
  try {
62
- creds = CredentialBlobSchema.parse(JSON.parse(parkedRaw)).claudeAiOauth;
49
+ creds = await readStore(account.id);
63
50
  } catch (e) {
64
- return { ok: false, reason: `parked credential unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
51
+ return { ok: false, reason: `store credential unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
65
52
  }
53
+ if (!creds) return { ok: false, reason: "no credential in this account's store - run `tokenmaxxing auth`" };
66
54
  if (isDeadCredential(creds)) {
67
55
  account.needsReauth = true;
68
- return { ok: false, reason: "parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`" };
69
- }
70
-
71
- const liveRaw = await readItem(liveTarget());
72
- let liveAccount: string | null = null;
73
- let liveToken: string | null = null;
74
- let liveRefresh: string | null = null;
75
- if (liveRaw != null) {
76
- let liveCreds: OAuthCreds;
77
- try {
78
- liveCreds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
79
- } catch (e) {
80
- return { ok: false, reason: `cannot read the live credential (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
81
- }
82
- if (!isDeadCredential(liveCreds)) {
83
- liveToken = liveCreds.accessToken;
84
- liveRefresh = liveCreds.refreshToken;
85
- try {
86
- liveAccount = (await fetchTokenIdentity(liveCreds.accessToken, signal)).accountUuid;
87
- } catch (e) {
88
- return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
89
- }
90
- if (liveAccount === account.id) {
91
- return { ok: false, reason: "this account holds the LIVE login (active label drifted) - run `tokenmaxxing switch` to reconcile" };
92
- }
93
- }
56
+ return { ok: false, reason: "the store's credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`" };
94
57
  }
95
-
96
- const relocate = async (owner: TokenIdentity): Promise<string> => {
97
- const pooled = loadAccounts(claudePool).accounts.find((a) => a.id === owner.accountUuid) ?? null;
98
- if (pooled == null || pooled.id === liveAccount) return "was left in place";
99
- await writeItem(parkedTarget(credItemFor(pooled.id)), JSON.stringify({ claudeAiOauth: creds }));
100
- log("swap.parked_relocated", { account: pooled.id.slice(0, 8) });
101
- return "was copied to that account's slot";
102
- };
103
-
104
- if (isAccessTokenExpiring(creds, 300_000)) {
105
- const owner = await checkIdentity(creds, account, signal);
106
- if (owner.status === "mismatch") {
107
- const kept = await relocate(owner.owner);
108
- account.needsReauth = true;
109
- return { ok: false, reason: `${owner.reason} - refusing to spend another account's grant; the pair ${kept}; re-auth with \`tokenmaxxing auth\`` };
110
- }
111
- if (owner.status === "unavailable" && !owner.stale) {
112
- return { ok: false, reason: `${owner.reason} - refusing to refresh a parked credential whose owner cannot be verified` };
113
- }
114
- const rotate = async (): Promise<{ fresh: OAuthCreds } | { failed: ProbeFailure }> => {
115
- try {
116
- return { fresh: await refreshCredential(creds, Date.now(), signal) };
117
- } catch (e) {
118
- if (e instanceof InvalidGrantError) {
119
- account.needsReauth = true;
120
- return { failed: { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing auth`" } };
121
- }
122
- return { failed: { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` } };
123
- }
124
- };
125
- let result: { fresh: OAuthCreds } | { failed: ProbeFailure };
126
- if (owner.status === "match") {
127
- result = await rotate();
128
- } else {
129
- if (liveRefresh != null && creds.refreshToken === liveRefresh) {
130
- return { ok: false, reason: "this slot holds a copy of the LIVE login's grant - refusing to rotate it from a parked slot; run `tokenmaxxing switch` to reconcile" };
131
- }
132
- try {
133
- signal?.throwIfAborted();
134
- result = await withClaudeRefreshLock(async (lock) => {
135
- const rotated = await rotate();
136
- if ("failed" in rotated) return rotated;
137
- const verified = await checkIdentity(rotated.fresh, account, signal);
138
- if (verified.status === "match") return rotated;
139
- if (verified.status === "unavailable") {
140
- await writeItem(backup, JSON.stringify({ claudeAiOauth: rotated.fresh }));
141
- return { failed: { ok: false, reason: `${verified.reason} - the rotated pair stays in this slot unverified until the next pass` } };
142
- }
143
- const trueOwner = loadAccounts(claudePool).accounts.find((a) => a.id === verified.owner.accountUuid) ?? null;
144
- const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account, liveOwnerId: liveAccount, expectedLiveToken: liveToken, lock });
145
- account.needsReauth = true;
146
- return { failed: { ok: false, reason: `${verified.reason}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth\`` } };
147
- });
148
- } catch (e) {
149
- return { ok: false, reason: `cannot arbitrate a stale parked credential: ${e instanceof Error ? e.message : String(e)}` };
150
- }
151
- }
152
- if ("failed" in result) return result.failed;
153
- creds = result.fresh;
154
- await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
155
- }
156
-
157
- const identity = await checkIdentity(creds, account, signal);
158
- if (identity.status === "mismatch") {
159
- const kept = await relocate(identity.owner);
160
- account.needsReauth = true;
161
- return { ok: false, reason: `${identity.reason} - this account's own credential is gone; the pair ${kept}; re-auth with \`tokenmaxxing auth\`` };
162
- }
163
- if (identity.status === "unavailable") {
164
- return { ok: false, reason: identity.reason };
165
- }
166
- refreshTier(account, creds);
167
-
168
- rmSync(dir, { recursive: true, force: true });
58
+ if (!account.oauthAccount) return { ok: false, reason: "account record has no oauthAccount - run `tokenmaxxing auth`" };
59
+ account.tier = claudeTierLabel(creds) ?? account.tier;
60
+ const dir = sampleDirFor(account.id, suffix);
169
61
  mkdirSync(dir, { recursive: true });
170
- const installed = JSON.stringify({ claudeAiOauth: creds });
171
- try {
172
- await writeItem(isolatedTarget(dir), installed);
173
- writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
174
- } catch (e) {
175
- await finishParkedProbe(account, { dir, installed });
176
- throw e;
177
- }
178
- return { ok: true, dir, installed };
62
+ writeFileAtomic(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
63
+ return { ok: true, dir };
179
64
  }
180
65
 
181
- async function runParkedProbe(dir: string, opts: { retries?: number }): Promise<SampleOutcome> {
182
- const usage = await probeUsage(dir, Date.now(), { retries: opts.retries });
66
+ export async function runProbe(account: Account, dir: string, opts: { retries?: number }): Promise<SampleOutcome> {
67
+ const usage = await probeUsage({ configDir: dir, store: storeDirFor(account.id) }, Date.now(), { retries: opts.retries });
183
68
  return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
184
69
  }
185
70
 
186
- async function finishParkedProbe(account: Account, prepared: { dir: string; installed: string }): Promise<void> {
187
- const isoTarget = isolatedTarget(prepared.dir);
188
- const backup = parkedTarget(credItemFor(account.id));
189
- try {
190
- const afterIso = await readItem(isoTarget);
191
- if (afterIso && afterIso !== prepared.installed) {
192
- const parkedNow = await readItem(backup);
193
- const installedToken = CredentialBlobSchema.parse(JSON.parse(prepared.installed)).claudeAiOauth.accessToken;
194
- const parkedToken = parkedNow == null ? null : CredentialBlobSchema.parse(JSON.parse(parkedNow)).claudeAiOauth.accessToken;
195
- if (parkedToken === installedToken) await writeItem(backup, claudeAiOauthOnly(afterIso));
196
- else log("sample.harvest_skipped", { account: account.id.slice(0, 8) });
197
- }
198
- } finally {
199
- await deleteItem(isoTarget);
200
- rmSync(prepared.dir, { recursive: true, force: true });
201
- }
202
- }
203
-
204
- export async function probeParkedUsage(account: Account): Promise<SampleOutcome> {
205
- const prepared = await prepareParkedProbe(account, join(paths.sampleDir, credItemFor(account.id)));
206
- if (!prepared.ok) return prepared;
207
- try {
208
- return await runParkedProbe(prepared.dir, {});
209
- } finally {
210
- await finishParkedProbe(account, prepared);
211
- }
71
+ export async function probeAccountUsage(account: Account): Promise<SampleOutcome> {
72
+ const prepared = await prepareProbe(account);
73
+ return prepared.ok ? runProbe(account, prepared.dir, {}) : prepared;
212
74
  }
213
75
 
214
- const PREPARE_DEADLINE_MS = 20_000;
215
-
216
- export async function sampleOldestParked(cfg: Config): Promise<void> {
76
+ export async function sampleOldest(cfg: Config): Promise<void> {
217
77
  const reserved = await withLock(claudePool.lockFile, async () => {
218
78
  const now = Date.now();
219
- const lastSwapAt = loadLastSwapAt(claudePool);
220
- if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) return null;
221
79
  const idx = loadAccounts(claudePool);
222
- const live = readOAuthAccount()?.accountUuid ?? null;
80
+ let dirty = false;
81
+ for (const a of idx.accounts) dirty = foldTee(a) || dirty;
223
82
  const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
224
- const stale = idx.accounts.filter((a) => a.id !== live && a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs);
83
+ const stale = idx.accounts.filter((a) => a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs);
225
84
  const target = minBy(stale, sampledAt);
226
- if (!target) return null;
85
+ if (!target) {
86
+ if (dirty) saveAccounts(claudePool, idx);
87
+ return null;
88
+ }
227
89
  target.lastProbeAt = now;
228
- const prepared = await prepareParkedProbe(target, join(paths.sampleDir, `${credItemFor(target.id)}-tick`), AbortSignal.timeout(PREPARE_DEADLINE_MS));
90
+ const prepared = await prepareProbe(target, "-tick");
229
91
  saveAccounts(claudePool, idx);
230
92
  if (!prepared.ok) {
231
- log("sample.parked_failed", { account: target.id.slice(0, 8), reason: prepared.reason.slice(0, 200) });
93
+ log("sample.failed", { account: target.id.slice(0, 8), reason: prepared.reason.slice(0, 200) });
232
94
  return null;
233
95
  }
234
- return { account: target, prepared };
96
+ return { account: target, dir: prepared.dir };
235
97
  });
236
98
  if (!reserved) return;
237
- const { account, prepared } = reserved;
238
99
  const startedAt = Date.now();
239
- let outcome: SampleOutcome | null = null;
240
- try {
241
- outcome = await runParkedProbe(prepared.dir, { retries: 0 });
242
- } finally {
243
- await withLock(claudePool.lockFile, async () => {
244
- await finishParkedProbe(account, prepared);
245
- if (outcome == null) return;
246
- const idx = loadAccounts(claudePool);
247
- const stored = idx.accounts.find((a) => a.id === account.id);
248
- if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
249
- stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
250
- stored.lastUsageAt = startedAt;
251
- saveAccounts(claudePool, idx);
252
- }
253
- log(outcome.ok ? "sample.parked_ok" : "sample.parked_failed", {
254
- account: account.id.slice(0, 8),
255
- ...(outcome.ok ? {} : { reason: outcome.reason.slice(0, 200) }),
256
- });
100
+ const outcome = await runProbe(reserved.account, reserved.dir, { retries: 0 });
101
+ await withLock(claudePool.lockFile, () => {
102
+ const idx = loadAccounts(claudePool);
103
+ const stored = idx.accounts.find((a) => a.id === reserved.account.id);
104
+ if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
105
+ stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
106
+ stored.lastUsageAt = startedAt;
107
+ saveAccounts(claudePool, idx);
108
+ }
109
+ log(outcome.ok ? "sample.ok" : "sample.failed", {
110
+ account: reserved.account.id.slice(0, 8),
111
+ ...(outcome.ok ? {} : { reason: outcome.reason.slice(0, 200) }),
257
112
  });
258
- }
259
- }
260
-
261
- export async function ensureLiveTokenFresh(): Promise<void> {
262
- const liveRaw = await readItem(liveTarget());
263
- if (!liveRaw) return;
264
- let creds: OAuthCreds;
265
- try {
266
- creds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
267
- } catch {
268
- return;
269
- }
270
- if (isDeadCredential(creds)) throw new InvalidGrantError("live credential was cleared after a failed refresh");
271
- if (!isAccessTokenExpiring(creds, 300_000)) return;
272
- await withClaudeRefreshLock(async (lock) => {
273
- const raw2 = await readItem(liveTarget());
274
- if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
275
- const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
276
- if (isDeadCredential(current)) throw new InvalidGrantError("live credential was cleared after a failed refresh");
277
- const next = isAccessTokenExpiring(current, 300_000) ? await refreshCredential(current) : current;
278
- if (next === current) return;
279
- if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
280
- await writeItem(liveTarget(), mergeIntoLive(raw2, next));
281
113
  });
282
114
  }
283
-
284
- export async function probeActiveUsage(account: Account): Promise<SampleOutcome> {
285
- try {
286
- await ensureLiveTokenFresh();
287
- } catch (e) {
288
- if (e instanceof InvalidGrantError) return { ok: false, reason: "live refresh token dead - run `claude` and `/login`" };
289
- return { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` };
290
- }
291
- const liveRaw = await readItem(liveTarget());
292
- if (!liveRaw) return { ok: false, reason: "no live credential - run `claude` and `/login`" };
293
- let creds: OAuthCreds;
294
- try {
295
- creds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
296
- } catch (e) {
297
- return { ok: false, reason: `live credential blob unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)})` };
298
- }
299
-
300
- const identity = await checkIdentity(creds, account);
301
- if (identity.status === "mismatch") return { ok: false, reason: `live ${identity.reason} - active label drifted; run \`tokenmaxxing switch\`` };
302
- if (identity.status === "unavailable") return { ok: false, reason: identity.reason };
303
- refreshTier(account, creds);
304
-
305
- const usage = await probeUsage();
306
- return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
307
- }
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { paths } from "./paths.ts";
5
5
  import { writeFileAtomic } from "./atomic.ts";
6
+ import { RespawnMarkerSchema } from "./types.ts";
6
7
 
7
8
  const SessionSchema = z.object({ flags: z.array(z.string()), cwd: z.string() });
8
9
 
@@ -34,3 +35,27 @@ export function pruneStaleSessions(now: number): void {
34
35
  }
35
36
  }
36
37
  }
38
+
39
+ const SupervisedSessionSchema = z.object({ sid: z.string().min(1), launchedAt: z.number().finite().nullable() });
40
+ export type SupervisedSession = z.infer<typeof SupervisedSessionSchema>;
41
+
42
+ const LaunchedAtSchema = z.coerce.number().finite().optional().catch(undefined);
43
+
44
+ export function supervisedSession(env: Record<string, string | undefined> = process.env): SupervisedSession | null {
45
+ if (env.TOKENMAXXING_SUPERVISED !== "1") return null;
46
+ const sid = env.TOKENMAXXING_SESSION_ID;
47
+ if (sid == null || sid === "") return null;
48
+ return SupervisedSessionSchema.parse({ sid, launchedAt: LaunchedAtSchema.parse(env.TOKENMAXXING_LAUNCHED_AT) ?? null });
49
+ }
50
+
51
+ export function writeRespawnMarker(input: { session: SupervisedSession; sessionId: string; accountId: string; waitUntil: number }): void {
52
+ mkdirSync(paths.respawnDir, { recursive: true });
53
+ const payload = RespawnMarkerSchema.parse({
54
+ accountId: input.accountId,
55
+ ts: Date.now(),
56
+ waitUntil: input.waitUntil,
57
+ sessionId: input.sessionId,
58
+ ...(input.session.launchedAt != null ? { launchedAt: input.session.launchedAt } : {}),
59
+ });
60
+ writeFileAtomic(join(paths.respawnDir, input.session.sid), JSON.stringify(payload));
61
+ }
package/src/lib/state.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
2
2
  import { isEqual } from "es-toolkit";
3
3
  import { z } from "zod";
4
- import { paths, realClaudeBinFromEnv, realCodexBinFromEnv, type PoolPaths } from "./paths.ts";
4
+ import { paths, realClaudeBinFromEnv, realCodexBinFromEnv, usageJsonFor, type PoolPaths } from "./paths.ts";
5
5
  import { writeFileAtomic } from "./atomic.ts";
6
6
  import {
7
7
  AccountsIndexSchema,
@@ -99,17 +99,19 @@ export function upsertAccount(
99
99
  return existing ?? fresh;
100
100
  }
101
101
 
102
- export function loadUsageSnapshot(): { state: UsageState; at: number } | null {
102
+ export type UsageSnapshot = { state: UsageState; at: number };
103
+
104
+ export function loadUsageSnapshot(accountId: string): UsageSnapshot | null {
103
105
  let fd: number;
104
106
  try {
105
- fd = openSync(paths.usageJson, "r");
107
+ fd = openSync(usageJsonFor(accountId), "r");
106
108
  } catch {
107
109
  return null;
108
110
  }
109
111
  try {
110
112
  const at = fstatSync(fd).mtimeMs;
111
113
  const parsed = UsageStateSchema.safeParse(JSON.parse(readFileSync(fd, "utf8")));
112
- return parsed.success ? { state: parsed.data, at } : null;
114
+ return parsed.success && parsed.data.account === accountId ? { state: parsed.data, at } : null;
113
115
  } catch {
114
116
  return null;
115
117
  } finally {
@@ -117,16 +119,12 @@ export function loadUsageSnapshot(): { state: UsageState; at: number } | null {
117
119
  }
118
120
  }
119
121
 
120
- export function loadUsage(): UsageState | null {
121
- return loadUsageSnapshot()?.state ?? null;
122
- }
123
-
124
- export function clearUsageSnapshots(): void {
125
- rmSync(paths.usageJson, { force: true });
122
+ export function clearUsageSnapshot(accountId: string): void {
123
+ rmSync(usageJsonFor(accountId), { force: true });
126
124
  }
127
125
 
128
126
  export function loadLastSwapAt(pool: PoolPaths): number | null {
129
- if (!existsSync(pool.lastSwapJson)) return null;
127
+ if (pool.lastSwapJson == null || !existsSync(pool.lastSwapJson)) return null;
130
128
  let json: unknown;
131
129
  try {
132
130
  json = JSON.parse(readFileSync(pool.lastSwapJson, "utf8"));
@@ -137,55 +135,32 @@ export function loadLastSwapAt(pool: PoolPaths): number | null {
137
135
  }
138
136
 
139
137
  export function saveLastSwapAt(pool: PoolPaths, ts: number): void {
138
+ if (pool.lastSwapJson == null) throw new Error("this pool keeps no swap clock");
140
139
  writeFileAtomic(pool.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
141
140
  }
142
141
 
143
- const DepletedWaitSchema = z.object({ waitUntil: z.number(), id: z.string(), ts: z.number() });
144
- export type DepletedWait = z.infer<typeof DepletedWaitSchema>;
145
-
146
- export function loadDepletedWait(): DepletedWait | null {
147
- if (!existsSync(paths.depletedJson)) return null;
148
- let json: unknown;
149
- try {
150
- json = JSON.parse(readFileSync(paths.depletedJson, "utf8"));
151
- } catch {
152
- throw new Error(`${paths.depletedJson} is corrupt (unparsable JSON) - repair or remove the file`);
153
- }
154
- return DepletedWaitSchema.parse(json);
155
- }
156
-
157
- export function saveDepletedWait(rec: DepletedWait): void {
158
- writeFileAtomic(paths.depletedJson, JSON.stringify(DepletedWaitSchema.parse(rec)));
159
- }
160
-
161
- export function clearDepletedWait(): void {
162
- rmSync(paths.depletedJson, { force: true });
163
- }
164
-
165
142
  export const POST_SWAP_COOLDOWN_MS = 45_000;
166
143
 
167
144
  const USAGE_TS_REFRESH_MS = 10 * 60_000;
168
145
  const SAMPLED_AT_REFRESH_MS = 30_000;
169
146
 
170
- export function writeUsage(input: UsageState, opts: { stamp?: boolean } = {}): boolean {
171
- const prev = loadUsage();
172
- const stamp = opts.stamp === true;
173
- const carriedSampleAt = prev != null && prev.account === input.account ? (prev.sampledAt ?? prev.ts) : undefined;
174
- const sampledAt = stamp ? (carriedSampleAt ?? input.sampledAt) : input.ts;
175
- const next: UsageState = { ...input, ...(sampledAt != null ? { sampledAt } : {}) };
147
+ export function writeUsage(input: UsageState): boolean {
148
+ const file = usageJsonFor(input.account);
149
+ const prev = loadUsageSnapshot(input.account)?.state ?? null;
150
+ const next: UsageState = { ...input, sampledAt: input.ts };
176
151
  if (
177
152
  prev &&
178
153
  isEqual({ ...prev, ts: 0, sampledAt: 0 }, { ...next, ts: 0, sampledAt: 0 }) &&
179
154
  next.ts - prev.ts < USAGE_TS_REFRESH_MS &&
180
- (stamp || next.ts - (prev.sampledAt ?? prev.ts) < SAMPLED_AT_REFRESH_MS)
155
+ next.ts - (prev.sampledAt ?? prev.ts) < SAMPLED_AT_REFRESH_MS
181
156
  ) {
182
157
  try {
183
- utimesSync(paths.usageJson, new Date(next.ts), new Date(next.ts));
158
+ utimesSync(file, new Date(next.ts), new Date(next.ts));
184
159
  } catch (e) {
185
160
  if (ErrnoSchema.safeParse(e).data?.code !== "ENOENT") throw e;
186
161
  }
187
162
  return false;
188
163
  }
189
- writeFileAtomic(paths.usageJson, JSON.stringify(next));
164
+ writeFileAtomic(file, JSON.stringify(next));
190
165
  return true;
191
166
  }
package/src/lib/types.ts CHANGED
@@ -54,7 +54,7 @@ const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
54
54
  export type ModelInfo = z.infer<typeof ModelInfoSchema>;
55
55
 
56
56
  export const UsageStateSchema = AggregateWindowsSchema.extend({
57
- account: z.string().nullable(),
57
+ account: z.string(),
58
58
  ts: z.number(),
59
59
  model: ModelInfoSchema.nullable().default(null),
60
60
  sampledAt: z.number().optional(),
@@ -130,7 +130,7 @@ export const ConfigSchema = z
130
130
  export type Config = z.infer<typeof ConfigSchema>;
131
131
 
132
132
  export const RespawnMarkerSchema = z.object({
133
- account: z.string(),
133
+ accountId: z.string(),
134
134
  ts: z.number(),
135
135
  waitUntil: z.number(),
136
136
  sessionId: z.string(),
@@ -186,15 +186,6 @@ export const SubagentStatusLineStdinSchema = z.looseObject({
186
186
  .catch(undefined),
187
187
  });
188
188
 
189
- export const RefreshResponseSchema = z.looseObject({
190
- access_token: z.string(),
191
- refresh_token: z.string().optional(),
192
- expires_in: z.number().optional(),
193
- refresh_token_expires_in: z.number().optional(),
194
- scope: z.string().optional(),
195
- token_type: z.string().optional(),
196
- });
197
-
198
189
  export const ProfileResponseSchema = z.looseObject({
199
190
  account: z.looseObject({ uuid: z.string(), email: z.string().nullish() }),
200
191
  organization: z.looseObject({ uuid: z.string(), name: z.string().nullish() }),
package/src/lib/usage.ts CHANGED
@@ -331,14 +331,17 @@ export function scrubCredentialEnv(env: Record<string, string>): Record<string,
331
331
  return scrubbed;
332
332
  }
333
333
 
334
- function probeEnv(configDir?: string): Record<string, string> {
334
+ export type ProbeTarget = { configDir: string; store?: string };
335
+
336
+ function probeEnv(target: ProbeTarget): Record<string, string> {
335
337
  const env = scrubCredentialEnv({ ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) });
336
- if (configDir) env.CLAUDE_CONFIG_DIR = configDir;
338
+ env.CLAUDE_CONFIG_DIR = target.configDir;
339
+ if (target.store) env.CLAUDE_SECURESTORAGE_CONFIG_DIR = target.store;
337
340
  return env;
338
341
  }
339
342
 
340
- export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number } = {}): Promise<UsageWindows | null> {
341
- const env = probeEnv(configDir);
343
+ export async function probeUsage(target: ProbeTarget, now = Date.now(), opts: { retries?: number } = {}): Promise<UsageWindows | null> {
344
+ const env = probeEnv(target);
342
345
 
343
346
  for (let attempt = 0; ; attempt++) {
344
347
  const full = await probeUsageOnce(env, now);
package/src/main.ts CHANGED
@@ -37,13 +37,12 @@ function printHelp(): void {
37
37
  console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
38
38
 
39
39
  ${c.cyan("tokenmaxxing")} show the pool with usage bars (alias of ${c.cyan("status")})
40
- ${c.cyan("tokenmaxxing switch")} [sel] switch to the best (or a specific) account; no-op when already on it
41
- ${c.cyan("tokenmaxxing check")} evaluate once, switch if over threshold (run by the periodic timer)
42
- ${c.cyan("tokenmaxxing init")} import the current account + install supervisor & hooks
40
+ ${c.cyan("tokenmaxxing check")} sample the account whose usage figure is oldest (run by the periodic timer)
41
+ ${c.cyan("tokenmaxxing init")} log in the first account (isolated) + install supervisor & hooks
43
42
  ${c.cyan("tokenmaxxing init --codex")} same for codex: import login, install codex supervisor + Stop hook (trust it via /hooks)
44
43
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
45
44
  ${c.cyan("tokenmaxxing add --codex")} register an additional codex account (isolated login)
46
- ${c.cyan("tokenmaxxing auth")} [--codex] [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every needs-reauth account, one by one)
45
+ ${c.cyan("tokenmaxxing auth")} [--codex] [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every account that is flagged or has no usable credential in its store, one by one)
47
46
  ${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
48
47
  ${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
49
48
  ${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)
@@ -98,18 +97,6 @@ async function main(): Promise<number> {
98
97
  emitError({ json, message: `${sub} has no ${JSON_FLAG} form (${JSON_FLAG} applies to ${[...JSON_COMMANDS].join(", ")})` });
99
98
  return 2;
100
99
  }
101
- if (!(sub != null && sub.startsWith("__")) && !process.env.TOKENMAXXING_PROBE) {
102
- const nonEmpty = (v: string | undefined) => (v != null && v !== "" ? v : null);
103
- const ambient = nonEmpty(process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR) ?? nonEmpty(process.env.CLAUDE_CONFIG_DIR);
104
- if (ambient != null) {
105
- emitError({
106
- json,
107
- message: `CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR is set (${ambient}): claude uses a namespaced credential store there that tokenmaxxing does not manage - unset it (or run from a clean shell) and retry.`,
108
- });
109
- return 1;
110
- }
111
- }
112
-
113
100
  switch (sub) {
114
101
  case "__statusline": return runStatusline();
115
102
  case "__subagent-statusline": return runSubagentStatusline();
@@ -126,7 +113,13 @@ async function main(): Promise<number> {
126
113
  }
127
114
  return cmdStatus({ json, cached });
128
115
  }
129
- case "switch": return cmdSwitch(provider, args[1], json);
116
+ case "switch": {
117
+ if (provider !== codex) {
118
+ emitError({ json, message: "Claude account placement is per session at launch; manual switching is available with `tokenmaxxing switch --codex`." });
119
+ return 2;
120
+ }
121
+ return cmdSwitch(args[1], json);
122
+ }
130
123
  case "check": {
131
124
  if (args.length > 1) {
132
125
  emitError({ json, message: `unknown check option: ${args[1]} (check takes no options; the timer runs a plain check every tick)` });
@@ -163,7 +156,7 @@ async function main(): Promise<number> {
163
156
  console.log(`removed ${removed.join(", ")}`);
164
157
  if (!out.timerDeactivated) console.log(c.yellow(`⚠ the check job may still be loaded - run: ${timerDeactivationHint()}`));
165
158
  if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
166
- console.log(`kept: accounts.json, config.json, and every parked credential (claude - macOS: keychain items, Linux: creds/; codex: codex-creds/) - remove accounts with \`xx rm\` to delete their credentials`);
159
+ console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-creds/) - remove accounts with \`xx rm\` to delete their credentials`);
167
160
  return 0;
168
161
  }
169
162
  case "help":