tokenmaxxing 1.34.0 → 1.38.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.
Files changed (54) hide show
  1. package/DESIGN.md +3 -5
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +23 -40
  5. package/src/cli/auth.ts +38 -52
  6. package/src/cli/check.ts +2 -1
  7. package/src/cli/doctor.ts +13 -13
  8. package/src/cli/init.ts +29 -126
  9. package/src/cli/rename.ts +11 -45
  10. package/src/cli/render.ts +0 -7
  11. package/src/cli/rm.ts +24 -39
  12. package/src/cli/setuptoken.ts +16 -16
  13. package/src/cli/status.ts +95 -238
  14. package/src/cli/switch.ts +54 -46
  15. package/src/entries/codexstophook.ts +23 -23
  16. package/src/entries/codexsupervisor.ts +3 -3
  17. package/src/entries/sessionstart.ts +5 -3
  18. package/src/entries/statusline.ts +17 -19
  19. package/src/entries/stopfailurehook.ts +9 -7
  20. package/src/entries/stophook.ts +5 -4
  21. package/src/entries/subagentstatusline.ts +2 -7
  22. package/src/lib/atomic.ts +2 -8
  23. package/src/lib/claude.ts +320 -0
  24. package/src/lib/claudelock.ts +2 -3
  25. package/src/lib/codex.ts +345 -0
  26. package/src/lib/codexauth.ts +9 -7
  27. package/src/lib/codexoauth.ts +5 -11
  28. package/src/lib/codexpresence.ts +3 -19
  29. package/src/lib/codexusage.ts +17 -41
  30. package/src/lib/credstore.ts +3 -7
  31. package/src/lib/decide.ts +86 -174
  32. package/src/lib/http.ts +3 -6
  33. package/src/lib/install.ts +4 -7
  34. package/src/lib/oauth.ts +14 -12
  35. package/src/lib/paths.ts +15 -7
  36. package/src/lib/picker.ts +56 -37
  37. package/src/lib/proc.ts +4 -4
  38. package/src/lib/provider.ts +34 -0
  39. package/src/lib/sample.ts +33 -33
  40. package/src/lib/state.ts +51 -18
  41. package/src/lib/swap.ts +41 -38
  42. package/src/lib/types.ts +31 -51
  43. package/src/lib/usage.ts +28 -20
  44. package/src/main.ts +20 -20
  45. package/src/cli/codexadd.ts +0 -89
  46. package/src/cli/codexinit.ts +0 -120
  47. package/src/cli/codexrm.ts +0 -36
  48. package/src/cli/codexswitch.ts +0 -113
  49. package/src/cli/onboard.ts +0 -92
  50. package/src/lib/codexdecide.ts +0 -157
  51. package/src/lib/codexpick.ts +0 -80
  52. package/src/lib/codexsample.ts +0 -48
  53. package/src/lib/codexstate.ts +0 -22
  54. package/src/lib/codexswap.ts +0 -70
package/src/lib/state.ts CHANGED
@@ -1,16 +1,19 @@
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 } from "./paths.ts";
4
+ import { paths, realClaudeBinFromEnv, realCodexBinFromEnv, type PoolPaths } from "./paths.ts";
5
5
  import { writeFileAtomic } from "./atomic.ts";
6
6
  import {
7
7
  AccountsIndexSchema,
8
8
  ConfigSchema,
9
+ ErrnoSchema,
9
10
  LastSwapSchema,
10
11
  UsageStateSchema,
12
+ type Account,
11
13
  type AccountsIndex,
12
14
  type Config,
13
15
  type UsageState,
16
+ type Window,
14
17
  } from "./types.ts";
15
18
 
16
19
  export function loadConfig(): Config {
@@ -44,25 +47,56 @@ export function pinBinOverride(input: { key: "claudeBin" | "codexBin"; bin: stri
44
47
  writeFileAtomic(paths.configJson, JSON.stringify(raw, null, 2) + "\n");
45
48
  }
46
49
 
47
- const emptyIndex = (): AccountsIndex => ({ version: 1, activeAccountUuid: null, accounts: [] });
50
+ const emptyIndex = (): AccountsIndex => ({ version: 2, activeId: null, accounts: [] });
48
51
 
49
- export function loadAccounts(): AccountsIndex {
50
- if (!existsSync(paths.accountsJson)) return emptyIndex();
52
+ export function loadAccounts(pool: PoolPaths): AccountsIndex {
53
+ if (!existsSync(pool.accountsJson)) return emptyIndex();
51
54
  let json: unknown;
52
55
  try {
53
- json = JSON.parse(readFileSync(paths.accountsJson, "utf8"));
56
+ json = JSON.parse(readFileSync(pool.accountsJson, "utf8"));
54
57
  } catch {
55
- throw new Error(`${paths.accountsJson} is corrupt (unparsable JSON) - refusing to treat a damaged pool as empty; repair or remove the file`);
58
+ throw new Error(`${pool.accountsJson} is corrupt (unparsable JSON) - refusing to treat a damaged pool as empty; repair or remove the file`);
56
59
  }
57
60
  const parsed = AccountsIndexSchema.safeParse(json);
58
61
  if (!parsed.success) {
59
- throw new Error(`${paths.accountsJson} does not match the accounts schema - refusing to treat a damaged pool as empty; repair or remove the file`);
62
+ throw new Error(`${pool.accountsJson} does not match the accounts schema - refusing to treat a damaged pool as empty; repair or remove the file`);
60
63
  }
61
64
  return parsed.data;
62
65
  }
63
66
 
64
- export function saveAccounts(idx: AccountsIndex): void {
65
- writeFileAtomic(paths.accountsJson, JSON.stringify(AccountsIndexSchema.parse(idx), null, 2) + "\n");
67
+ export function saveAccounts(pool: PoolPaths, idx: AccountsIndex): void {
68
+ writeFileAtomic(pool.accountsJson, JSON.stringify(AccountsIndexSchema.parse(idx), null, 2) + "\n");
69
+ }
70
+
71
+ export type Harvest = {
72
+ id: string;
73
+ email: string | null;
74
+ tier: string | null;
75
+ oauthAccount?: Account["oauthAccount"];
76
+ sample: { windows: Window[]; at: number } | null;
77
+ park: () => Promise<void>;
78
+ };
79
+
80
+ export function upsertAccount(
81
+ idx: AccountsIndex,
82
+ h: Harvest,
83
+ mergeWindows: (next: Window[], prev: Window[]) => Window[],
84
+ ): Account {
85
+ const existing = idx.accounts.find((a) => a.id === h.id);
86
+ const fresh: Account = {
87
+ id: h.id,
88
+ label: existing?.label ?? h.email ?? h.id.slice(0, 8),
89
+ email: h.email,
90
+ tier: h.tier,
91
+ addedAt: existing?.addedAt ?? new Date().toISOString(),
92
+ windows: h.sample ? mergeWindows(h.sample.windows, existing?.windows ?? []) : (existing?.windows ?? []),
93
+ lastUsageAt: h.sample ? h.sample.at : existing?.lastUsageAt,
94
+ needsReauth: false,
95
+ ...(h.oauthAccount ? { oauthAccount: h.oauthAccount } : {}),
96
+ };
97
+ if (existing) Object.assign(existing, fresh);
98
+ else idx.accounts.push(fresh);
99
+ return existing ?? fresh;
66
100
  }
67
101
 
68
102
  export function loadUsageSnapshot(): { state: UsageState; at: number } | null {
@@ -91,22 +125,22 @@ export function clearUsageSnapshots(): void {
91
125
  rmSync(paths.usageJson, { force: true });
92
126
  }
93
127
 
94
- export function loadLastSwapAt(): number | null {
95
- if (!existsSync(paths.lastSwapJson)) return null;
128
+ export function loadLastSwapAt(pool: PoolPaths): number | null {
129
+ if (!existsSync(pool.lastSwapJson)) return null;
96
130
  let json: unknown;
97
131
  try {
98
- json = JSON.parse(readFileSync(paths.lastSwapJson, "utf8"));
132
+ json = JSON.parse(readFileSync(pool.lastSwapJson, "utf8"));
99
133
  } catch {
100
- throw new Error(`${paths.lastSwapJson} is corrupt (unparsable JSON) - refusing to treat a damaged swap clock as never-swapped; repair or remove the file`);
134
+ throw new Error(`${pool.lastSwapJson} is corrupt (unparsable JSON) - refusing to treat a damaged swap clock as never-swapped; repair or remove the file`);
101
135
  }
102
136
  return LastSwapSchema.parse(json).ts;
103
137
  }
104
138
 
105
- export function saveLastSwapAt(ts: number): void {
106
- writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
139
+ export function saveLastSwapAt(pool: PoolPaths, ts: number): void {
140
+ writeFileAtomic(pool.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
107
141
  }
108
142
 
109
- const DepletedWaitSchema = z.object({ waitUntil: z.number(), accountUuid: z.string(), ts: z.number() });
143
+ const DepletedWaitSchema = z.object({ waitUntil: z.number(), id: z.string(), ts: z.number() });
110
144
  export type DepletedWait = z.infer<typeof DepletedWaitSchema>;
111
145
 
112
146
  export function loadDepletedWait(): DepletedWait | null {
@@ -148,8 +182,7 @@ export function writeUsage(input: UsageState, opts: { stamp?: boolean } = {}): b
148
182
  try {
149
183
  utimesSync(paths.usageJson, new Date(next.ts), new Date(next.ts));
150
184
  } catch (e) {
151
- const errno = z.object({ code: z.string() }).safeParse(e);
152
- if (!errno.success || errno.data.code !== "ENOENT") throw e;
185
+ if (ErrnoSchema.safeParse(e).data?.code !== "ENOENT") throw e;
153
186
  }
154
187
  return false;
155
188
  }
package/src/lib/swap.ts CHANGED
@@ -3,6 +3,7 @@ import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, merge
3
3
  import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
4
4
  import { swapOAuthAccount } from "./claudejson.ts";
5
5
  import { withClaudeRefreshLock } from "./claudelock.ts";
6
+ import { claudePool, credItemFor } from "./paths.ts";
6
7
  import { log } from "./log.ts";
7
8
  import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
8
9
 
@@ -11,7 +12,9 @@ function parseBlob(raw: string) {
11
12
  }
12
13
 
13
14
  export async function performSwap(target: Account): Promise<void> {
14
- const idx = loadAccounts();
15
+ const idx = loadAccounts(claudePool);
16
+ const oauthAccount = target.oauthAccount;
17
+ if (!oauthAccount) throw new Error(`${target.label} has no oauthAccount on its record - re-add it with \`tokenmaxxing auth ${target.label}\``);
15
18
 
16
19
  const preLive = await readItem(liveTarget());
17
20
  let liveOwner: Account | null = null;
@@ -49,31 +52,31 @@ export async function performSwap(target: Account): Promise<void> {
49
52
  } catch (e) {
50
53
  throw new Error(`cannot resolve the live credential's owner (${e instanceof Error ? e.message : String(e)}) - refusing to swap over it; the next check retries`);
51
54
  }
52
- liveOwner = idx.accounts.find((a) => a.accountUuid === identity.accountUuid) ?? null;
55
+ liveOwner = idx.accounts.find((a) => a.id === identity.accountUuid) ?? null;
53
56
  if (!liveOwner) {
54
57
  throw new Error(
55
58
  `live credential belongs to ${describeIdentity(identity)}, which is not in the pool - refusing to swap over it; import it first with \`tokenmaxxing add\``,
56
59
  );
57
60
  }
58
- if (liveOwner.accountUuid !== idx.activeAccountUuid) {
61
+ if (liveOwner.id !== idx.activeId) {
59
62
  log("swap.harvest_drift", {
60
- labeled: idx.activeAccountUuid?.slice(0, 8) ?? null,
61
- actual: liveOwner.accountUuid.slice(0, 8),
63
+ labeled: idx.activeId?.slice(0, 8) ?? null,
64
+ actual: liveOwner.id.slice(0, 8),
62
65
  });
63
66
  }
64
67
  }
65
68
  }
66
69
 
67
- const selfSwap = liveOwner != null && liveOwner.accountUuid === target.accountUuid;
70
+ const selfSwap = liveOwner != null && liveOwner.id === target.id;
68
71
  let fresh: OAuthCreds | null = null;
69
72
  if (!selfSwap) {
70
- const parkedRaw = await readItem(parkedTarget(target.keychainItem));
71
- if (!parkedRaw) throw new Error(`no parked credential for ${target.email}`);
73
+ const parkedRaw = await readItem(parkedTarget(credItemFor(target.id)));
74
+ if (!parkedRaw) throw new Error(`no parked credential for ${target.label}`);
72
75
  const parked = parseBlob(parkedRaw).claudeAiOauth;
73
76
  const markDead = (detail: string): InvalidGrantError => {
74
- const t = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
75
- if (t) { t.needsReauth = true; saveAccounts(idx); }
76
- log("swap.invalid_grant", { account: target.accountUuid.slice(0, 8), detail });
77
+ const t = idx.accounts.find((a) => a.id === target.id);
78
+ if (t) { t.needsReauth = true; saveAccounts(claudePool, idx); }
79
+ log("swap.invalid_grant", { account: target.id.slice(0, 8), detail });
77
80
  return new InvalidGrantError(detail);
78
81
  };
79
82
  if (isDeadCredential(parked)) throw markDead("parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`");
@@ -82,14 +85,14 @@ export async function performSwap(target: Account): Promise<void> {
82
85
  parkedOwner = await fetchTokenIdentity(parked.accessToken);
83
86
  } catch (e) {
84
87
  if (!(e instanceof IdentityUnavailableError && e.status === 401)) throw e;
85
- log("swap.parked_token_stale", { account: target.accountUuid.slice(0, 8) });
88
+ log("swap.parked_token_stale", { account: target.id.slice(0, 8) });
86
89
  }
87
- if (parkedOwner != null && parkedOwner.accountUuid !== target.accountUuid) {
88
- const owner = idx.accounts.find((a) => a.accountUuid === parkedOwner.accountUuid) ?? null;
90
+ if (parkedOwner != null && parkedOwner.accountUuid !== target.id) {
91
+ const owner = idx.accounts.find((a) => a.id === parkedOwner.accountUuid) ?? null;
89
92
  let kept = "was left in place";
90
- if (owner != null && owner.accountUuid !== liveOwner?.accountUuid) {
91
- await writeItem(parkedTarget(owner.keychainItem), JSON.stringify({ claudeAiOauth: parked }));
92
- log("swap.parked_relocated", { account: owner.accountUuid.slice(0, 8) });
93
+ if (owner != null && owner.id !== liveOwner?.id) {
94
+ await writeItem(parkedTarget(credItemFor(owner.id)), JSON.stringify({ claudeAiOauth: parked }));
95
+ log("swap.parked_relocated", { account: owner.id.slice(0, 8) });
93
96
  kept = "was copied to that account's slot";
94
97
  }
95
98
  throw markDead(`parked credential belongs to ${describeIdentity(parkedOwner)} - refusing to spend another account's grant; the pair ${kept}; re-auth with \`tokenmaxxing auth ${target.label}\``);
@@ -99,7 +102,7 @@ export async function performSwap(target: Account): Promise<void> {
99
102
  return await refreshCredential(parked);
100
103
  } catch (e) {
101
104
  if (e instanceof InvalidGrantError) throw markDead(e.detail);
102
- if (e instanceof RefreshRejectedError) log("swap.refresh_rejected", { account: target.accountUuid.slice(0, 8), status: e.status, detail: e.detail });
105
+ if (e instanceof RefreshRejectedError) log("swap.refresh_rejected", { account: target.id.slice(0, 8), status: e.status, detail: e.detail });
103
106
  throw e;
104
107
  }
105
108
  };
@@ -112,18 +115,18 @@ export async function performSwap(target: Account): Promise<void> {
112
115
  try {
113
116
  freshOwner = await fetchTokenIdentity(rotated.accessToken);
114
117
  } catch (e) {
115
- await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: rotated }));
118
+ await writeItem(parkedTarget(credItemFor(target.id)), JSON.stringify({ claudeAiOauth: rotated }));
116
119
  throw new Error(`refreshed the parked credential but cannot verify its owner (${e instanceof Error ? e.message : String(e)}) - kept the rotated token parked; the next check retries`);
117
120
  }
118
- if (freshOwner.accountUuid !== target.accountUuid) {
119
- const owner = idx.accounts.find((a) => a.accountUuid === freshOwner.accountUuid) ?? null;
120
- const kept = await keepRotatedPair({ fresh: rotated, owner, fallback: target, liveOwnerUuid: liveOwner?.accountUuid ?? null, expectedLiveToken, lock });
121
+ if (freshOwner.accountUuid !== target.id) {
122
+ const owner = idx.accounts.find((a) => a.id === freshOwner.accountUuid) ?? null;
123
+ const kept = await keepRotatedPair({ fresh: rotated, owner, fallback: target, liveOwnerId: liveOwner?.id ?? null, expectedLiveToken, lock });
121
124
  throw markDead(`parked credential belongs to ${describeIdentity(freshOwner)}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth ${target.label}\``);
122
125
  }
123
126
  return rotated;
124
127
  });
125
128
  }
126
- await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
129
+ await writeItem(parkedTarget(credItemFor(target.id)), JSON.stringify({ claudeAiOauth: fresh }));
127
130
  }
128
131
 
129
132
  await withClaudeRefreshLock(async (lock) => {
@@ -136,44 +139,44 @@ export async function performSwap(target: Account): Promise<void> {
136
139
  }
137
140
 
138
141
  if (liveOwner && currentLive) {
139
- await writeItem(parkedTarget(liveOwner.keychainItem), claudeAiOauthOnly(currentLive));
140
- log("swap.harvest", { account: liveOwner.accountUuid.slice(0, 8) });
142
+ await writeItem(parkedTarget(credItemFor(liveOwner.id)), claudeAiOauthOnly(currentLive));
143
+ log("swap.harvest", { account: liveOwner.id.slice(0, 8) });
141
144
  }
142
145
 
143
146
  if (fresh != null) {
144
147
  await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
145
148
  }
146
- swapOAuthAccount(target.oauthAccount);
147
- idx.activeAccountUuid = target.accountUuid;
148
- const t2 = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
149
+ swapOAuthAccount(oauthAccount);
150
+ idx.activeId = target.id;
151
+ const t2 = idx.accounts.find((a) => a.id === target.id);
149
152
  if (t2) { t2.needsReauth = false; }
150
- saveAccounts(idx);
153
+ saveAccounts(claudePool, idx);
151
154
  clearUsageSnapshots();
152
155
  clearDepletedWait();
153
- saveLastSwapAt(Date.now());
156
+ saveLastSwapAt(claudePool, Date.now());
154
157
  });
155
- log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
158
+ log("swap.done", { account: target.id.slice(0, 8), label: target.label });
156
159
  }
157
160
 
158
161
  export async function keepRotatedPair(input: {
159
162
  fresh: OAuthCreds;
160
163
  owner: Account | null;
161
164
  fallback: Account;
162
- liveOwnerUuid: string | null;
165
+ liveOwnerId: string | null;
163
166
  expectedLiveToken: string | null;
164
167
  lock: { compromised: () => boolean };
165
168
  }): Promise<string> {
166
169
  const { fresh, owner } = input;
167
170
  if (owner == null) {
168
- await writeItem(parkedTarget(input.fallback.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
169
- log("swap.rotated_kept_in_stamped_slot", { account: input.fallback.accountUuid.slice(0, 8) });
171
+ await writeItem(parkedTarget(credItemFor(input.fallback.id)), JSON.stringify({ claudeAiOauth: fresh }));
172
+ log("swap.rotated_kept_in_stamped_slot", { account: input.fallback.id.slice(0, 8) });
170
173
  return "was kept in this account's stamped slot because its owner is not in the pool";
171
174
  }
172
175
  const park = async (): Promise<void> => {
173
- await writeItem(parkedTarget(owner.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
174
- log("swap.rotated_parked_rescued", { account: owner.accountUuid.slice(0, 8) });
176
+ await writeItem(parkedTarget(credItemFor(owner.id)), JSON.stringify({ claudeAiOauth: fresh }));
177
+ log("swap.rotated_parked_rescued", { account: owner.id.slice(0, 8) });
175
178
  };
176
- if (input.liveOwnerUuid !== owner.accountUuid) {
179
+ if (input.liveOwnerId !== owner.id) {
177
180
  await park();
178
181
  return "was parked under that account";
179
182
  }
@@ -182,7 +185,7 @@ export async function keepRotatedPair(input: {
182
185
  if (input.lock.compromised()) throw new Error("refresh lock compromised");
183
186
  if (currentLive == null || parseBlob(currentLive).claudeAiOauth.accessToken !== input.expectedLiveToken) throw new Error("live credential changed while unlocked");
184
187
  await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
185
- log("swap.rotated_live_rescued", { account: owner.accountUuid.slice(0, 8) });
188
+ log("swap.rotated_live_rescued", { account: owner.id.slice(0, 8) });
186
189
  return "was installed into the live store so that account's sessions continue";
187
190
  } catch (e) {
188
191
  await park();
package/src/lib/types.ts CHANGED
@@ -38,10 +38,18 @@ const AggregateWindowsSchema = z.object({
38
38
 
39
39
  export const UsageWindowsSchema = AggregateWindowsSchema.extend({
40
40
  perModel: z.record(z.string(), UsageWindowSchema).default({}),
41
- rowsAt: z.number().optional(),
42
41
  });
43
42
  export type UsageWindows = z.infer<typeof UsageWindowsSchema>;
44
43
 
44
+ export const WindowSchema = z.object({
45
+ name: z.string().nullable(),
46
+ usedPercentage: z.number(),
47
+ resetsAt: z.number().nullable(),
48
+ windowSeconds: z.number().nullable(),
49
+ sampledAt: z.number(),
50
+ });
51
+ export type Window = z.infer<typeof WindowSchema>;
52
+
45
53
  const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
46
54
  export type ModelInfo = z.infer<typeof ModelInfoSchema>;
47
55
 
@@ -54,26 +62,23 @@ export const UsageStateSchema = AggregateWindowsSchema.extend({
54
62
  export type UsageState = z.infer<typeof UsageStateSchema>;
55
63
 
56
64
  export const AccountSchema = z.object({
57
- accountUuid: z.string(),
58
- email: z.string(),
59
- organizationUuid: z.string(),
65
+ id: z.string(),
60
66
  label: z.string(),
61
- keychainItem: z.string(),
62
- oauthAccount: OAuthAccountSchema,
67
+ email: z.string().nullable(),
68
+ tier: z.string().nullable(),
63
69
  addedAt: z.string(),
64
- lastUsage: UsageWindowsSchema.optional(),
70
+ windows: z.array(WindowSchema).default([]),
65
71
  lastUsageAt: z.number().optional(),
66
72
  lastProbeAt: z.number().optional(),
67
73
  enforcedUntil: z.number().optional(),
68
74
  needsReauth: z.boolean().optional(),
69
- subscriptionType: z.string().optional(),
70
- rateLimitTier: z.string().optional(),
75
+ oauthAccount: OAuthAccountSchema.optional(),
71
76
  });
72
77
  export type Account = z.infer<typeof AccountSchema>;
73
78
 
74
79
  export const AccountsIndexSchema = z.object({
75
- version: z.literal(1),
76
- activeAccountUuid: z.string().nullable(),
80
+ version: z.literal(2),
81
+ activeId: z.string().nullable(),
77
82
  accounts: z.array(AccountSchema).default([]),
78
83
  });
79
84
 
@@ -216,53 +221,14 @@ export const CodexAuthJsonSchema = z.looseObject({
216
221
  });
217
222
  export type CodexAuthJson = z.infer<typeof CodexAuthJsonSchema>;
218
223
 
219
- const CodexWindowSchema = z.object({
220
- usedPercentage: z.number(),
221
- resetsAt: z.number().nullable(),
222
- windowSeconds: z.number().nullable(),
223
- });
224
- export type CodexWindow = z.infer<typeof CodexWindowSchema>;
225
-
226
224
  export const CodexUsageSchema = z.object({
227
225
  accountId: z.string(),
228
226
  email: z.string().nullable(),
229
227
  planType: z.string().nullable(),
230
- aggregate: z.array(CodexWindowSchema),
231
- perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
228
+ windows: z.array(WindowSchema),
232
229
  });
233
230
  export type CodexUsage = z.infer<typeof CodexUsageSchema>;
234
231
 
235
- const BareFileNameSchema = z
236
- .string()
237
- .refine((s) => s.length > 0 && s !== "." && s !== ".." && !s.includes("/") && !s.includes("\\"), {
238
- message: "credFile must be a bare file name, not a path",
239
- });
240
-
241
- export const CodexAccountSchema = z.object({
242
- accountId: z.string(),
243
- email: z.string().nullable(),
244
- label: z.string(),
245
- planType: z.string().nullable(),
246
- credFile: BareFileNameSchema,
247
- addedAt: z.string(),
248
- needsReauth: z.boolean().optional(),
249
- lastUsage: z
250
- .object({
251
- aggregate: z.array(CodexWindowSchema),
252
- perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
253
- })
254
- .optional(),
255
- lastUsageAt: z.number().optional(),
256
- });
257
- export type CodexAccount = z.infer<typeof CodexAccountSchema>;
258
-
259
- export const CodexAccountsIndexSchema = z.object({
260
- version: z.literal(1),
261
- activeAccountId: z.string().nullable(),
262
- accounts: z.array(CodexAccountSchema).default([]),
263
- });
264
- export type CodexAccountsIndex = z.infer<typeof CodexAccountsIndexSchema>;
265
-
266
232
  export const CodexStopStdinSchema = z.looseObject({
267
233
  session_id: z.string().optional(),
268
234
  hook_event_name: z.string().optional(),
@@ -278,3 +244,17 @@ export const CodexReconcileMarkerSchema = z.object({
278
244
  accountId: z.string(),
279
245
  ts: z.number(),
280
246
  });
247
+
248
+ export const ErrnoSchema = z.object({ code: z.string() });
249
+
250
+ export const JsonTextSchema = z.codec(z.string(), z.unknown(), {
251
+ decode: (text, ctx) => {
252
+ try {
253
+ return JSON.parse(text);
254
+ } catch {
255
+ ctx.issues.push({ code: "invalid_format", format: "json", input: text });
256
+ return z.NEVER;
257
+ }
258
+ },
259
+ encode: (value) => JSON.stringify(value),
260
+ });
package/src/lib/usage.ts CHANGED
@@ -3,7 +3,7 @@ import { delay } from "es-toolkit";
3
3
  import { z } from "zod";
4
4
  import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
5
5
  import { log } from "./log.ts";
6
- import { RateLimitsStdinSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
6
+ import { JsonTextSchema, RateLimitsStdinSchema, type ModelInfo, type UsageWindow, type UsageWindows, type Window } from "./types.ts";
7
7
 
8
8
  export function normalizeResetsAt(v: unknown): number | null {
9
9
  const num = z.number().finite().safeParse(v);
@@ -239,9 +239,22 @@ export function parseUsageText(text: string, now = Date.now()): UsageWindows | n
239
239
  return { fiveHour, sevenDay, perModel };
240
240
  }
241
241
 
242
- export function keepRows(next: UsageWindows, prev: UsageWindows | undefined, at: number): UsageWindows {
243
- if (Object.keys(next.perModel).length > 0 && (prev?.rowsAt == null || prev.rowsAt <= at)) return { ...next, rowsAt: at };
244
- return { ...next, perModel: prev?.perModel ?? {}, ...(prev?.rowsAt != null ? { rowsAt: prev.rowsAt } : {}) };
242
+ const FIVE_HOURS_S = 5 * 3600;
243
+ const WEEK_S = 7 * 24 * 3600;
244
+
245
+ export function windowsOf(u: UsageWindows, at: number): Window[] {
246
+ return [
247
+ { name: null, ...u.fiveHour, windowSeconds: FIVE_HOURS_S, sampledAt: at },
248
+ { name: null, ...u.sevenDay, windowSeconds: WEEK_S, sampledAt: at },
249
+ ...Object.entries(u.perModel).map(([name, w]) => ({ name, ...w, windowSeconds: WEEK_S, sampledAt: at })),
250
+ ];
251
+ }
252
+
253
+ export function mergeWindows(next: Window[], prev: Window[]): Window[] {
254
+ const named = (ws: Window[]) => ws.filter((w) => w.name != null);
255
+ const newest = (ws: Window[]) => Math.max(0, ...ws.map((w) => w.sampledAt));
256
+ const rows = named(next).length > 0 && newest(named(prev)) <= newest(named(next)) ? named(next) : named(prev);
257
+ return [...next.filter((w) => w.name == null), ...rows];
245
258
  }
246
259
 
247
260
  export const CRED_ENV_OVERRIDES = [
@@ -266,21 +279,16 @@ const SpawnResultSchema = z.object({
266
279
  type SpawnResult = z.infer<typeof SpawnResultSchema>;
267
280
 
268
281
  async function spawnClaudeBounded(cmd: string[], env: Record<string, string>): Promise<SpawnResult | null> {
269
- const p = Bun.spawn(cmd, { env, stdout: "pipe", stderr: "pipe" });
270
- const killer = setTimeout(() => p.kill("SIGKILL"), PROBE_KILL_MS);
271
- try {
272
- const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
273
- const settled = await Promise.race([
274
- reads,
275
- p.exited.then(() => delay(PIPE_GRACE_MS)).then(() => null),
276
- ]);
277
- if (settled === null) return null;
278
- const [stdout, stderr] = settled;
279
- await p.exited;
280
- return { exitCode: p.exitCode, stdout, stderr };
281
- } finally {
282
- clearTimeout(killer);
283
- }
282
+ const p = Bun.spawn(cmd, { env, stdout: "pipe", stderr: "pipe", timeout: PROBE_KILL_MS, killSignal: "SIGKILL" });
283
+ const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
284
+ const settled = await Promise.race([
285
+ reads,
286
+ p.exited.then(() => delay(PIPE_GRACE_MS)).then(() => null),
287
+ ]);
288
+ if (settled === null) return null;
289
+ const [stdout, stderr] = settled;
290
+ await p.exited;
291
+ return { exitCode: p.exitCode, stdout, stderr };
284
292
  }
285
293
 
286
294
  async function probeUsageOnce(env: Record<string, string>, now: number): Promise<UsageWindows | null> {
@@ -301,7 +309,7 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
301
309
  return null;
302
310
  }
303
311
 
304
- const j = z.object({ result: z.string() }).safeParse((() => { try { return JSON.parse(out); } catch { return null; } })());
312
+ const j = z.object({ result: z.string() }).safeParse(JsonTextSchema.safeParse(out).data);
305
313
  const text = j.success ? j.data.result : out;
306
314
  const full = parseUsageText(text, now);
307
315
  if (!full) {
package/src/main.ts CHANGED
@@ -7,18 +7,16 @@ import { runSubagentStatusline } from "./entries/subagentstatusline.ts";
7
7
  import { runStopHook } from "./entries/stophook.ts";
8
8
  import { runStopFailureHook } from "./entries/stopfailurehook.ts";
9
9
  import { runSessionStart } from "./entries/sessionstart.ts";
10
+ import { runCodexSupervisor } from "./entries/codexsupervisor.ts";
11
+ import { runCodexStopHook } from "./entries/codexstophook.ts";
12
+ import { claude } from "./lib/claude.ts";
13
+ import { codex } from "./lib/codex.ts";
10
14
  import { cmdInit } from "./cli/init.ts";
11
15
  import { cmdAdd } from "./cli/add.ts";
12
16
  import { cmdAuth } from "./cli/auth.ts";
13
- import { cmdCodexAdd } from "./cli/codexadd.ts";
14
- import { cmdCodexInit } from "./cli/codexinit.ts";
15
- import { cmdCodexSwitch } from "./cli/codexswitch.ts";
16
- import { runCodexSupervisor } from "./entries/codexsupervisor.ts";
17
- import { runCodexStopHook } from "./entries/codexstophook.ts";
18
17
  import { cmdStatus } from "./cli/status.ts";
19
18
  import { cmdDoctor } from "./cli/doctor.ts";
20
19
  import { cmdRm } from "./cli/rm.ts";
21
- import { cmdCodexRm } from "./cli/codexrm.ts";
22
20
  import { cmdRename } from "./cli/rename.ts";
23
21
  import { cmdSwitch } from "./cli/switch.ts";
24
22
  import { cmdCheck } from "./cli/check.ts";
@@ -31,7 +29,9 @@ import { c, emitError } from "./cli/render.ts";
31
29
 
32
30
  const JSON_FLAG = "--json";
33
31
  const CACHED_FLAG = "--cached";
32
+ const CODEX_FLAG = "--codex";
34
33
  const JSON_COMMANDS = new Set(["status", "config", "check", "switch"]);
34
+ const CODEX_COMMANDS = new Set(["init", "add", "auth", "switch", "rm", "rename"]);
35
35
 
36
36
  function printHelp(): void {
37
37
  console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
@@ -43,7 +43,7 @@ function printHelp(): void {
43
43
  ${c.cyan("tokenmaxxing init --codex")} same for codex: import login, install codex supervisor + Stop hook (trust it via /hooks)
44
44
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
45
45
  ${c.cyan("tokenmaxxing add --codex")} register an additional codex account (isolated login)
46
- ${c.cyan("tokenmaxxing auth")} [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every needs-reauth account, one by one)
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)
47
47
  ${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
48
48
  ${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
49
49
  ${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)
@@ -80,7 +80,8 @@ async function main(): Promise<number> {
80
80
  jsonMode = argv.includes(JSON_FLAG);
81
81
  const json = jsonMode;
82
82
  const cached = argv.includes(CACHED_FLAG);
83
- const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG);
83
+ const provider = argv.includes(CODEX_FLAG) ? codex : claude;
84
+ const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG && a !== CODEX_FLAG);
84
85
  const sub = args[0];
85
86
 
86
87
  if (cached && sub != null && sub !== "status") {
@@ -88,6 +89,11 @@ async function main(): Promise<number> {
88
89
  return 2;
89
90
  }
90
91
 
92
+ if (provider === codex && (sub == null || !CODEX_COMMANDS.has(sub))) {
93
+ emitError({ json, message: `${CODEX_FLAG} applies to ${[...CODEX_COMMANDS].join(", ")}, not ${sub ?? "status"}` });
94
+ return 2;
95
+ }
96
+
91
97
  if (json && sub != null && !JSON_COMMANDS.has(sub)) {
92
98
  emitError({ json, message: `${sub} has no ${JSON_FLAG} form (${JSON_FLAG} applies to ${[...JSON_COMMANDS].join(", ")})` });
93
99
  return 2;
@@ -120,10 +126,7 @@ async function main(): Promise<number> {
120
126
  }
121
127
  return cmdStatus({ json, cached });
122
128
  }
123
- case "switch": {
124
- const rest = args.slice(1).filter((a) => a !== "--codex");
125
- return args.includes("--codex") ? cmdCodexSwitch(rest[0], json) : cmdSwitch(rest[0], json);
126
- }
129
+ case "switch": return cmdSwitch(provider, args[1], json);
127
130
  case "check": {
128
131
  if (args.length > 1) {
129
132
  emitError({ json, message: `unknown check option: ${args[1]} (check takes no options; the timer runs a plain check every tick)` });
@@ -132,15 +135,12 @@ async function main(): Promise<number> {
132
135
  return cmdCheck(json);
133
136
  }
134
137
  case "config": return cmdConfig(args.slice(1), json);
135
- case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
136
- case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
137
- case "auth": return cmdAuth(args.slice(1));
138
+ case "init": return cmdInit(provider);
139
+ case "add": return cmdAdd(provider);
140
+ case "auth": return cmdAuth(provider, args.slice(1));
138
141
  case "doctor": return cmdDoctor();
139
- case "rm": {
140
- const rest = args.slice(1).filter((a) => a !== "--codex");
141
- return args.includes("--codex") ? cmdCodexRm(rest[0]) : cmdRm(rest[0]);
142
- }
143
- case "rename": return cmdRename(args.slice(1));
142
+ case "rm": return cmdRm(provider, args[1]);
143
+ case "rename": return cmdRename(provider, args.slice(1));
144
144
  case "setup-token": return cmdSetupToken(args.slice(1));
145
145
  case "cursor": {
146
146
  if (args[1] === "init") return cmdCursorInit(args.slice(2));