tokenmaxxing 1.57.0 → 1.59.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 +6 -6
  2. package/README.md +3 -3
  3. package/package.json +1 -1
  4. package/src/cli/auth.ts +6 -17
  5. package/src/cli/check.ts +2 -2
  6. package/src/cli/cloudrun.ts +12 -19
  7. package/src/cli/config.ts +2 -1
  8. package/src/cli/doctor.ts +5 -4
  9. package/src/cli/init.ts +0 -1
  10. package/src/cli/render.ts +24 -9
  11. package/src/cli/rm.ts +0 -4
  12. package/src/cli/status.ts +37 -55
  13. package/src/entries/codexstophook.ts +11 -24
  14. package/src/entries/codexsupervisor.ts +30 -72
  15. package/src/entries/statusline.ts +17 -26
  16. package/src/entries/stopfailurehook.ts +4 -5
  17. package/src/entries/stophook.ts +12 -10
  18. package/src/entries/subagentstatusline.ts +4 -10
  19. package/src/entries/supervisor.ts +71 -113
  20. package/src/lib/claude.ts +18 -19
  21. package/src/lib/claudebin.ts +3 -2
  22. package/src/lib/claudelock.ts +2 -2
  23. package/src/lib/cloud.ts +4 -8
  24. package/src/lib/codex.ts +3 -3
  25. package/src/lib/codexauth.ts +2 -11
  26. package/src/lib/codexbin.ts +2 -1
  27. package/src/lib/codexoauth.ts +3 -8
  28. package/src/lib/codexusage.ts +5 -5
  29. package/src/lib/compact.ts +5 -18
  30. package/src/lib/credstore.ts +1 -6
  31. package/src/lib/decide.ts +12 -29
  32. package/src/lib/grok.ts +38 -152
  33. package/src/lib/http.ts +1 -2
  34. package/src/lib/install.ts +20 -38
  35. package/src/lib/keychain.ts +1 -4
  36. package/src/lib/log.ts +5 -5
  37. package/src/lib/oauth.ts +6 -5
  38. package/src/lib/opencodego.ts +31 -140
  39. package/src/lib/paths.ts +6 -22
  40. package/src/lib/picker.ts +9 -20
  41. package/src/lib/presence.ts +4 -5
  42. package/src/lib/proc.ts +22 -0
  43. package/src/lib/provider.ts +1 -1
  44. package/src/lib/sample.ts +25 -24
  45. package/src/lib/sessions.ts +5 -6
  46. package/src/lib/settings.ts +1 -8
  47. package/src/lib/state.ts +6 -29
  48. package/src/lib/statusonly.ts +143 -0
  49. package/src/lib/supervise.ts +111 -0
  50. package/src/lib/types.ts +27 -36
  51. package/src/lib/update.ts +6 -7
  52. package/src/lib/usage.ts +45 -188
  53. package/src/main.ts +5 -5
  54. package/src/entries/sessionstart.ts +0 -33
@@ -1,12 +1,12 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { writeFileAtomic } from "./atomic.ts";
6
6
  import { opencodeGoAuthJsonFor, opencodeGoPaths, opencodeGoPool, opencodeGoStoreDirFor } from "./paths.ts";
7
- import type { Observation, Provider, SampleReport } from "./provider.ts";
8
- import { loadConfig, pinBinOverride, type Harvest } from "./state.ts";
9
- import type { Account } from "./types.ts";
7
+ import type { Provider } from "./provider.ts";
8
+ import { type Harvest } from "./state.ts";
9
+ import { statusOnlyProvider, type AuthEntry } from "./statusonly.ts";
10
10
  import { c } from "../cli/render.ts";
11
11
 
12
12
  const OpencodeAuthEntrySchema = z.looseObject({ type: z.string(), key: z.string().optional() });
@@ -20,77 +20,12 @@ function liveAuthPath(): string {
20
20
  return join(dataHome(), "opencode", "auth.json");
21
21
  }
22
22
 
23
- function parseAuthFile(path: string): { provider: string; key: string } | null {
24
- let raw: unknown;
25
- try {
26
- raw = JSON.parse(readFileSync(path, "utf8"));
27
- } catch {
28
- return null;
29
- }
30
- const map = OpencodeAuthFileSchema.safeParse(raw);
31
- if (!map.success) return null;
32
- const entry = OpencodeAuthEntrySchema.safeParse(map.data["opencode-go"]);
33
- if (!entry.success || entry.data.type !== "api" || !entry.data.key) return null;
34
- return { provider: "opencode-go", key: entry.data.key };
35
- }
36
-
37
23
  function idOfKey(key: string): string {
38
24
  const h = new Bun.CryptoHasher("sha256");
39
25
  h.update(`opencode-go:${key}`);
40
26
  return `opencode-go-${h.digest("hex")}`;
41
27
  }
42
28
 
43
- function resolveRealOpencode(): string {
44
- const cfg = loadConfig();
45
- if (cfg.opencodeBin) {
46
- if (!existsSync(cfg.opencodeBin)) throw new Error(`configured opencodeBin does not exist: ${cfg.opencodeBin} - fix config.json`);
47
- return cfg.opencodeBin;
48
- }
49
- for (const d of (process.env.PATH ?? "").split(":")) {
50
- if (!d) continue;
51
- const cand = join(d, "opencode");
52
- try {
53
- if (existsSync(cand)) return cand;
54
- } catch {
55
- continue;
56
- }
57
- }
58
- throw new Error("could not locate the real `opencode` binary (set opencodeBin in config.json)");
59
- }
60
-
61
- function liveId(): string | null {
62
- return null;
63
- }
64
-
65
- function presence(): Map<string, number> {
66
- return new Map();
67
- }
68
-
69
- async function observeLive(account: Account): Promise<Observation | null> {
70
- return account.lastUsageAt != null ? { windows: account.windows, at: account.lastUsageAt } : null;
71
- }
72
-
73
- async function samplePool(accounts: Account[]): Promise<Map<string, SampleReport>> {
74
- const reports = new Map<string, SampleReport>();
75
- for (const a of accounts) {
76
- reports.set(a.id, (await storeUsable(a)) ? { ok: true, source: "probe" } : { ok: false, reason: "no usable credential in this account's store - run `tokenmaxxing auth --opencode-go`" });
77
- }
78
- return reports;
79
- }
80
-
81
- async function storeUsable(a: Account): Promise<boolean> {
82
- try {
83
- const found = parseAuthFile(opencodeGoAuthJsonFor(a.id));
84
- return found != null && idOfKey(found.key) === a.id;
85
- } catch {
86
- return false;
87
- }
88
- }
89
-
90
- async function removeCredentials(a: Account): Promise<void> {
91
- rmSync(opencodeGoStoreDirFor(a.id), { recursive: true, force: true });
92
- }
93
-
94
29
  function harvestOf(key: string): Harvest {
95
30
  const id = idOfKey(key);
96
31
  return {
@@ -105,82 +40,38 @@ function harvestOf(key: string): Harvest {
105
40
  };
106
41
  }
107
42
 
108
- async function login(): Promise<Harvest | null> {
109
- const real = resolveRealOpencode();
110
- const onboardDir = opencodeGoPaths.onboardDir;
111
- rmSync(onboardDir, { recursive: true, force: true });
112
- mkdirSync(onboardDir, { recursive: true });
113
- const p = Bun.spawn([real, "providers", "login", "-p", "opencode-go"], {
114
- stdin: "inherit",
115
- stdout: "inherit",
116
- stderr: "inherit",
117
- env: { ...process.env, XDG_DATA_HOME: onboardDir, TOKENMAXXING_PROBE: "1" },
118
- });
119
- await p.exited;
43
+ function readAuth(path: string): AuthEntry[] {
44
+ let raw: unknown;
120
45
  try {
121
- if (p.exitCode !== 0) {
122
- console.error(c.red("no opencode-go login landed in the isolated home - nothing added."));
123
- return null;
124
- }
125
- const found = parseAuthFile(join(onboardDir, "opencode", "auth.json"));
126
- if (!found) {
127
- console.error(c.red("no opencode-go login landed in the isolated home - nothing added."));
128
- return null;
129
- }
130
- return harvestOf(found.key);
131
- } finally {
132
- rmSync(onboardDir, { recursive: true, force: true });
133
- }
134
- }
135
-
136
- async function importLive(): Promise<Harvest | null> {
137
- console.log(c.cyan("Pooling your opencode-go API key - your existing opencode auth stays as it is."));
138
- console.log();
139
- const found = parseAuthFile(liveAuthPath());
140
- if (found) {
141
- console.log(c.dim(`found an opencode-go credential in ${liveAuthPath()} - pooling it; use \`tokenmaxxing add --opencode-go\` for more keys.`));
142
- return harvestOf(found.key);
143
- }
144
- return login();
145
- }
146
-
147
- function preflight(): void {
148
- const real = resolveRealOpencode();
149
- const p = Bun.spawnSync([real, "--version"], { stdout: "pipe", stderr: "pipe", timeout: 15_000 });
150
- const out = (p.stdout?.toString() ?? "").trim();
151
- if (p.exitCode !== 0 || out === "") {
152
- throw new Error(`opencode binary failed verification: ${real}`);
46
+ raw = JSON.parse(readFileSync(path, "utf8"));
47
+ } catch {
48
+ return [];
153
49
  }
154
- pinBinOverride({ key: "opencodeBin", bin: real });
155
- }
156
-
157
- function install(): void {
158
- console.log(c.dim("opencode-go pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled keys."));
50
+ const map = OpencodeAuthFileSchema.safeParse(raw);
51
+ if (!map.success) return [];
52
+ const entry = OpencodeAuthEntrySchema.safeParse(map.data["opencode-go"]);
53
+ if (!entry.success || entry.data.type !== "api" || !entry.data.key) return [];
54
+ return [{ id: idOfKey(entry.data.key), usable: true, harvest: harvestOf(entry.data.key) }];
159
55
  }
160
56
 
161
- export const opencodeGo: Provider = {
57
+ export const opencodeGo: Provider = statusOnlyProvider({
162
58
  name: "opencode-go",
163
59
  flag: " --opencode-go",
164
60
  pool: opencodeGoPool,
165
- seats: "live",
166
- waitsWhenDepleted: false,
167
- statusOnly: true,
168
- liveId,
169
- presence,
170
- gatedFamilies: () => null,
171
- observeLive,
172
- samplePool,
173
- mergeWindows: (next) => next,
174
- swap: async () => {
175
- throw new Error("opencode-go moves are not supported yet (status-only pool)");
176
- },
177
- classifySwapError: () => "fatal",
178
- removeCredentials,
179
- storeUsable,
180
- login,
181
- importLive,
182
- preflight,
183
- install,
61
+ binName: "opencode",
62
+ binKey: "opencodeBin",
63
+ storeDirFor: opencodeGoStoreDirFor,
64
+ authJsonFor: opencodeGoAuthJsonFor,
65
+ onboardDir: opencodeGoPaths.onboardDir,
66
+ homeEnv: "XDG_DATA_HOME",
67
+ loginArgs: ["providers", "login", "-p", "opencode-go"],
68
+ onboardAuthRel: join("opencode", "auth.json"),
69
+ liveAuthPath,
70
+ readAuth,
71
+ liveId: () => null,
72
+ versionOk: (out) => out.trim() !== "",
73
+ importIntro: "Pooling your opencode-go API key - your existing opencode auth stays as it is.",
74
+ foundLive: () => `found an opencode-go credential in ${liveAuthPath()} - pooling it; use \`tokenmaxxing add --opencode-go\` for more keys.`,
75
+ installNotice: "opencode-go pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled keys.",
184
76
  loginStep: () => `Paste the opencode-go API key from ${c.bold("opencode.ai/zen")} when opencode asks for it.`,
185
- windowLabel: (name) => name.toLowerCase(),
186
- };
77
+ });
package/src/lib/paths.ts CHANGED
@@ -6,8 +6,12 @@ const HOME = homedir();
6
6
 
7
7
  const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
8
8
 
9
+ export function optionalEnv(name: string, source: Record<string, string | undefined> = process.env): string | undefined {
10
+ return EnvOverrideSchema.parse(source[name]);
11
+ }
12
+
9
13
  export function env(name: string, fallback: string): string {
10
- return EnvOverrideSchema.parse(process.env[name]) ?? fallback;
14
+ return optionalEnv(name) ?? fallback;
11
15
  }
12
16
 
13
17
  const TM_HOME = env("TOKENMAXXING_HOME", join(HOME, ".config", "tokenmaxxing"));
@@ -42,29 +46,25 @@ export const paths = {
42
46
 
43
47
  export const claudePool = {
44
48
  accountsJson: join(TM_HOME, "accounts.json"),
45
- lastSwapJson: null,
46
49
  lockFile: join(TM_HOME, "lock"),
47
50
  } as const;
48
51
 
49
52
  export const codexPool = {
50
53
  accountsJson: join(TM_HOME, "codex-accounts.json"),
51
- lastSwapJson: null,
52
54
  lockFile: join(TM_HOME, "codex-lock"),
53
55
  } as const;
54
56
 
55
57
  export const grokPool = {
56
58
  accountsJson: join(TM_HOME, "grok-accounts.json"),
57
- lastSwapJson: null,
58
59
  lockFile: join(TM_HOME, "grok-lock"),
59
60
  } as const;
60
61
 
61
62
  export const opencodeGoPool = {
62
63
  accountsJson: join(TM_HOME, "opencode-go-accounts.json"),
63
- lastSwapJson: null,
64
64
  lockFile: join(TM_HOME, "opencode-go-lock"),
65
65
  } as const;
66
66
 
67
- export type PoolPaths = { accountsJson: string; lastSwapJson: string | null; lockFile: string };
67
+ export type PoolPaths = { accountsJson: string; lockFile: string };
68
68
 
69
69
  const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
70
70
 
@@ -159,20 +159,4 @@ export function namespacedCredService(configDirRaw: string): string {
159
159
  return `Claude Code-credentials-${h.digest("hex").slice(0, 8)}`;
160
160
  }
161
161
 
162
- export function realClaudeBinFromEnv(): string | undefined {
163
- return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CLAUDE_BIN);
164
- }
165
-
166
- export function realCodexBinFromEnv(): string | undefined {
167
- return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_BIN);
168
- }
169
-
170
- export function realGrokBinFromEnv(): string | undefined {
171
- return EnvOverrideSchema.parse(process.env.TOKENMAXXING_GROK_BIN);
172
- }
173
-
174
- export function realOpencodeBinFromEnv(): string | undefined {
175
- return EnvOverrideSchema.parse(process.env.TOKENMAXXING_OPENCODE_BIN);
176
- }
177
-
178
162
  export { HOME };
package/src/lib/picker.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { maxBy, minBy, sortBy } from "es-toolkit";
2
- import { z } from "zod";
3
2
  import { familyTokens } from "./usage.ts";
4
- import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresholds, type Window } from "./types.ts";
3
+ import type { Account, Config, Thresholds, Window } from "./types.ts";
5
4
 
6
5
  export function thresholdBars(cfg: Config): Thresholds {
7
6
  return {
@@ -10,14 +9,13 @@ export function thresholdBars(cfg: Config): Thresholds {
10
9
  };
11
10
  }
12
11
 
13
- const PickCtxSchema = z.object({
14
- now: z.number(),
15
- thresholds: ThresholdsSchema,
16
- currentId: z.string().nullable(),
17
- families: z.array(z.string()).nullable(),
18
- seats: z.map(z.string(), z.number()).nullable(),
19
- });
20
- export type PickCtx = z.infer<typeof PickCtxSchema>;
12
+ export type PickCtx = {
13
+ now: number;
14
+ thresholds: Thresholds;
15
+ currentId: string | null;
16
+ families: string[] | null;
17
+ seats: Map<string, number> | null;
18
+ };
21
19
 
22
20
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
23
21
  const SESSION_WINDOW_MAX_S = 6 * 3600;
@@ -123,21 +121,12 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
123
121
  return sortBy(usable, swapPreference(ctx))[0] ?? null;
124
122
  }
125
123
 
126
- export function currentWins(active: Account | null, accounts: Account[], ctx: PickCtx, margin = 1): boolean {
127
- if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
128
- const best = pickBest(accounts, { ...ctx, currentId: null });
129
- if (best == null || best.id === active.id) return true;
130
- if (margin > 1) return pacePressure(best, ctx) <= pacePressure(active, ctx) * margin;
131
- return swapPreference(ctx).every((k) => k(active) === k(best));
132
- }
133
-
134
124
  export function usableAt(a: Account, ctx: PickCtx): number {
135
125
  const blocking = blockingUntil(a, ctx).filter((t) => t > ctx.now);
136
126
  return blocking.length ? Math.max(...blocking) : ctx.now;
137
127
  }
138
128
 
139
- const EarliestResetSchema = z.object({ account: AccountSchema, availableAt: z.number() });
140
- export type EarliestReset = z.infer<typeof EarliestResetSchema>;
129
+ export type EarliestReset = { account: Account; availableAt: number };
141
130
 
142
131
  export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestReset | null {
143
132
  const mapped = accounts
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { writeFileAtomic } from "./atomic.ts";
5
5
  import { pidExists, pidStartTime } from "./proc.ts";
6
+ import { ErrnoSchema } from "./types.ts";
6
7
 
7
8
  const PresenceSchema = z.object({
8
9
  accountId: z.string(),
@@ -21,8 +22,7 @@ export function clearPresence(input: { dir: string; id: string }): void {
21
22
  rmSync(join(input.dir, input.id), { force: true });
22
23
  }
23
24
 
24
- const LivingPresenceSchema = z.object({ id: z.string(), accountId: z.string() });
25
- export type LivingPresence = z.infer<typeof LivingPresenceSchema>;
25
+ export type LivingPresence = { id: string; accountId: string };
26
26
 
27
27
  export function livingPresences(dir: string): LivingPresence[] {
28
28
  const living: LivingPresence[] = [];
@@ -33,8 +33,7 @@ export function livingPresences(dir: string): LivingPresence[] {
33
33
  try {
34
34
  raw = readFileSync(file, "utf8");
35
35
  } catch (e) {
36
- const errno = z.object({ code: z.string() }).safeParse(e);
37
- if (errno.success && errno.data.code === "ENOENT") continue;
36
+ if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") continue;
38
37
  throw e;
39
38
  }
40
39
  const parsed = PresenceSchema.safeParse((() => {
@@ -55,7 +54,7 @@ export function livingPresences(dir: string): LivingPresence[] {
55
54
  rmSync(file, { force: true });
56
55
  continue;
57
56
  }
58
- living.push(LivingPresenceSchema.parse({ id: name, accountId: parsed.data.accountId }));
57
+ living.push({ id: name, accountId: parsed.data.accountId });
59
58
  }
60
59
  return living;
61
60
  }
package/src/lib/proc.ts CHANGED
@@ -7,6 +7,28 @@ export function pidStartTime(pid: number): string | null {
7
7
  return lstart === "" ? null : lstart;
8
8
  }
9
9
 
10
+ export async function readStdin(): Promise<string> {
11
+ const chunks: Uint8Array[] = [];
12
+ for await (const chunk of Bun.stdin.stream()) chunks.push(chunk);
13
+ return Buffer.concat(chunks).toString("utf8");
14
+ }
15
+
16
+ export async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
17
+ const decoder = new TextDecoder();
18
+ let pending = "";
19
+ for await (const chunk of stream) {
20
+ pending += decoder.decode(chunk, { stream: true });
21
+ let nl = pending.indexOf("\n");
22
+ while (nl !== -1) {
23
+ yield pending.slice(0, nl);
24
+ pending = pending.slice(nl + 1);
25
+ nl = pending.indexOf("\n");
26
+ }
27
+ }
28
+ pending += decoder.decode();
29
+ if (pending.length > 0) yield pending;
30
+ }
31
+
10
32
  export function pidExists(pid: number): boolean {
11
33
  try {
12
34
  process.kill(pid, 0);
@@ -18,7 +18,7 @@ export type Provider = {
18
18
  liveId(): string | null;
19
19
  presence(): Map<string, number>;
20
20
  gatedFamilies(cfg: Config): string[] | null;
21
- observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null>;
21
+ observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null>;
22
22
  samplePool(accounts: Account[], liveId: string | null, now: number): Promise<Map<string, SampleReport>>;
23
23
  mergeWindows(next: Window[], prev: Window[]): Window[];
24
24
  swap(target: Account): Promise<void>;
package/src/lib/sample.ts CHANGED
@@ -1,29 +1,20 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { z } from "zod";
4
3
  import { writeFileAtomic } from "./atomic.ts";
5
4
  import { readStore } from "./credstore.ts";
6
5
  import { withLock } from "./lock.ts";
7
- import { log } from "./log.ts";
6
+ import { errorMessage, log } from "./log.ts";
8
7
  import { claudeTierLabel, isAccessTokenExpiring, isDeadCredential } from "./oauth.ts";
9
8
  import { claudePool, paths, sampleDirFor, storeDirFor } from "./paths.ts";
10
9
  import { seatCounts } from "./presence.ts";
11
10
  import type { Observation } from "./provider.ts";
12
11
  import { loadAccounts, loadUsageSnapshot, saveAccounts } from "./state.ts";
13
- import { fetchUsageDirect, mergeWindows, probeUsage, windowsOf } from "./usage.ts";
14
- import { UsageWindowsSchema, type Account, type Config } from "./types.ts";
12
+ import { fetchUsageDirect, mergeWindows, refreshViaProbe, windowsOf } from "./usage.ts";
13
+ import type { Account, Config, UsageWindows } from "./types.ts";
15
14
 
16
- const SampleOutcomeSchema = z.discriminatedUnion("ok", [
17
- z.object({ ok: z.literal(true), usage: UsageWindowsSchema, via: z.enum(["get", "probe"]) }),
18
- z.object({ ok: z.literal(false), reason: z.string() }),
19
- ]);
20
- export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
15
+ export type SampleOutcome = { ok: true; usage: UsageWindows; via: "get" | "probe" } | { ok: false; reason: string };
21
16
 
22
- const PreparedSampleSchema = z.discriminatedUnion("ok", [
23
- z.object({ ok: z.literal(true), token: z.string().nullable() }),
24
- z.object({ ok: z.literal(false), reason: z.string() }),
25
- ]);
26
- type PreparedSample = z.infer<typeof PreparedSampleSchema>;
17
+ type PreparedSample = { ok: true; token: string | null } | { ok: false; reason: string };
27
18
 
28
19
  export function teeObservation(account: Account): Observation | null {
29
20
  const stored = account.lastUsageAt != null ? { windows: account.windows, at: account.lastUsageAt } : null;
@@ -48,7 +39,7 @@ export async function prepareSample(account: Account): Promise<PreparedSample> {
48
39
  try {
49
40
  creds = await readStore(account.id);
50
41
  } catch (e) {
51
- return { ok: false, reason: `store credential unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
42
+ return { ok: false, reason: `store credential unreadable (${errorMessage(e).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
52
43
  }
53
44
  if (!creds) return { ok: false, reason: "no credential in this account's store - run `tokenmaxxing auth`" };
54
45
  if (isDeadCredential(creds)) {
@@ -60,25 +51,29 @@ export async function prepareSample(account: Account): Promise<PreparedSample> {
60
51
  return { ok: true, token: isAccessTokenExpiring(creds) ? null : creds.accessToken };
61
52
  }
62
53
 
63
- async function runProbe(account: Account, suffix: string, retries: number | undefined): Promise<SampleOutcome> {
54
+ async function refreshedToken(account: Account, suffix: string): Promise<string | null> {
64
55
  const dir = sampleDirFor(account.id, suffix);
65
56
  mkdirSync(dir, { recursive: true });
66
57
  writeFileAtomic(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
67
- const usage = await probeUsage({ configDir: dir, store: storeDirFor(account.id) }, Date.now(), { retries });
68
- return usage ? { ok: true, usage, via: "probe" } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
58
+ if (!(await refreshViaProbe({ configDir: dir, store: storeDirFor(account.id) }))) return null;
59
+ const prepared = await prepareSample(account);
60
+ return prepared.ok ? prepared.token : null;
69
61
  }
70
62
 
71
- export async function runSample(account: Account, token: string | null, opts: { retries?: number; suffix?: string }): Promise<SampleOutcome> {
63
+ export async function runSample(account: Account, token: string | null, suffix = ""): Promise<SampleOutcome> {
72
64
  if (token) {
73
65
  const usage = await fetchUsageDirect(token);
74
66
  if (usage) return { ok: true, usage, via: "get" };
75
67
  }
76
- return runProbe(account, opts.suffix ?? "", opts.retries);
68
+ const fresh = await refreshedToken(account, suffix);
69
+ if (!fresh) return { ok: false, reason: "the `/usage` probe left no usable access token in the store (see log)" };
70
+ const usage = await fetchUsageDirect(fresh);
71
+ return usage ? { ok: true, usage, via: "probe" } : { ok: false, reason: "usage read failed after the `/usage` probe (see log)" };
77
72
  }
78
73
 
79
- export async function sampleAccountUsage(account: Account, opts: { retries?: number } = {}): Promise<SampleOutcome> {
74
+ export async function sampleAccountUsage(account: Account): Promise<SampleOutcome> {
80
75
  const prepared = await prepareSample(account);
81
- return prepared.ok ? runSample(account, prepared.token, opts) : prepared;
76
+ return prepared.ok ? runSample(account, prepared.token) : prepared;
82
77
  }
83
78
 
84
79
  const SAMPLE_BATCH = 3;
@@ -119,15 +114,21 @@ export async function sampleOldest(cfg: Config): Promise<void> {
119
114
  await Promise.all(
120
115
  reserved.map(async ({ account, token }) => {
121
116
  const startedAt = Date.now();
122
- const outcome = await runSample(account, token, { retries: 0, suffix: "-tick" });
117
+ const outcome = await runSample(account, token, "-tick");
123
118
  await withLock(claudePool.lockFile, () => {
124
119
  const idx = loadAccounts(claudePool);
125
120
  const stored = idx.accounts.find((a) => a.id === account.id);
121
+ let dirty = false;
122
+ if (stored && account.needsReauth === true && stored.needsReauth !== true) {
123
+ stored.needsReauth = true;
124
+ dirty = true;
125
+ }
126
126
  if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
127
127
  stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
128
128
  stored.lastUsageAt = startedAt;
129
- saveAccounts(claudePool, idx);
129
+ dirty = true;
130
130
  }
131
+ if (dirty) saveAccounts(claudePool, idx);
131
132
  log(outcome.ok ? "sample.ok" : "sample.failed", {
132
133
  account: account.id.slice(0, 8),
133
134
  ...(outcome.ok ? { via: outcome.via } : { reason: outcome.reason.slice(0, 200) }),
@@ -3,7 +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
+ import type { RespawnMarkerSchema } from "./types.ts";
7
7
 
8
8
  const SessionSchema = z.object({ flags: z.array(z.string()), cwd: z.string() });
9
9
 
@@ -36,8 +36,7 @@ export function pruneStaleSessions(now: number): void {
36
36
  }
37
37
  }
38
38
 
39
- const SupervisedSessionSchema = z.object({ sid: z.string().min(1), launchedAt: z.number().finite().nullable() });
40
- export type SupervisedSession = z.infer<typeof SupervisedSessionSchema>;
39
+ export type SupervisedSession = { sid: string; launchedAt: number | null };
41
40
 
42
41
  const LaunchedAtSchema = z.coerce.number().finite().optional().catch(undefined);
43
42
 
@@ -45,18 +44,18 @@ export function supervisedSession(env: Record<string, string | undefined> = proc
45
44
  if (env.TOKENMAXXING_SUPERVISED !== "1") return null;
46
45
  const sid = env.TOKENMAXXING_SESSION_ID;
47
46
  if (sid == null || sid === "") return null;
48
- return SupervisedSessionSchema.parse({ sid, launchedAt: LaunchedAtSchema.parse(env.TOKENMAXXING_LAUNCHED_AT) ?? null });
47
+ return { sid, launchedAt: LaunchedAtSchema.parse(env.TOKENMAXXING_LAUNCHED_AT) ?? null };
49
48
  }
50
49
 
51
50
  export function writeRespawnMarker(input: { session: SupervisedSession; sessionId: string; accountId: string; waitUntil: number; compact: boolean }): void {
52
51
  mkdirSync(paths.respawnDir, { recursive: true });
53
- const payload = RespawnMarkerSchema.parse({
52
+ const payload: z.infer<typeof RespawnMarkerSchema> = {
54
53
  accountId: input.accountId,
55
54
  ts: Date.now(),
56
55
  waitUntil: input.waitUntil,
57
56
  sessionId: input.sessionId,
58
57
  compact: input.compact,
59
58
  ...(input.session.launchedAt != null ? { launchedAt: input.session.launchedAt } : {}),
60
- });
59
+ };
61
60
  writeFileAtomic(join(paths.respawnDir, input.session.sid), JSON.stringify(payload));
62
61
  }
@@ -112,14 +112,7 @@ export function uninstallSettings(): void {
112
112
  writeSettings(s);
113
113
  }
114
114
 
115
- const SettingsCheckSchema = z.object({
116
- statusLineOk: z.boolean(),
117
- subagentStatusLineOk: z.boolean(),
118
- stopOk: z.boolean(),
119
- stopFailureOk: z.boolean(),
120
- sessionStartOk: z.boolean(),
121
- });
122
- export type SettingsCheck = z.infer<typeof SettingsCheckSchema>;
115
+ export type SettingsCheck = Record<"statusLineOk" | "subagentStatusLineOk" | "stopOk" | "stopFailureOk" | "sessionStartOk", boolean>;
123
116
 
124
117
  export function checkSettings(): SettingsCheck {
125
118
  const s = readSettings();
package/src/lib/state.ts CHANGED
@@ -1,13 +1,12 @@
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, realGrokBinFromEnv, realOpencodeBinFromEnv, usageJsonFor, type PoolPaths } from "./paths.ts";
4
+ import { optionalEnv, paths, usageJsonFor, type PoolPaths } from "./paths.ts";
5
5
  import { writeFileAtomic } from "./atomic.ts";
6
6
  import {
7
7
  AccountsIndexSchema,
8
8
  ConfigSchema,
9
9
  ErrnoSchema,
10
- LastSwapSchema,
11
10
  UsageStateSchema,
12
11
  type Account,
13
12
  type AccountsIndex,
@@ -31,14 +30,10 @@ export function loadConfig(): Config {
31
30
  throw new Error(`${paths.configJson} is invalid (${fields}) - fix or remove the offending values`);
32
31
  }
33
32
  const cfg = parsed.data;
34
- const envBin = realClaudeBinFromEnv();
35
- if (envBin) cfg.claudeBin = envBin;
36
- const envCodexBin = realCodexBinFromEnv();
37
- if (envCodexBin) cfg.codexBin = envCodexBin;
38
- const envGrokBin = realGrokBinFromEnv();
39
- if (envGrokBin) cfg.grokBin = envGrokBin;
40
- const envOpencodeBin = realOpencodeBinFromEnv();
41
- if (envOpencodeBin) cfg.opencodeBin = envOpencodeBin;
33
+ cfg.claudeBin = optionalEnv("TOKENMAXXING_CLAUDE_BIN") ?? cfg.claudeBin;
34
+ cfg.codexBin = optionalEnv("TOKENMAXXING_CODEX_BIN") ?? cfg.codexBin;
35
+ cfg.grokBin = optionalEnv("TOKENMAXXING_GROK_BIN") ?? cfg.grokBin;
36
+ cfg.opencodeBin = optionalEnv("TOKENMAXXING_OPENCODE_BIN") ?? cfg.opencodeBin;
42
37
  return cfg;
43
38
  }
44
39
 
@@ -51,7 +46,7 @@ export function pinBinOverride(input: { key: "claudeBin" | "codexBin" | "grokBin
51
46
  writeFileAtomic(paths.configJson, JSON.stringify(raw, null, 2) + "\n");
52
47
  }
53
48
 
54
- const emptyIndex = (): AccountsIndex => ({ version: 2, activeId: null, accounts: [] });
49
+ const emptyIndex = (): AccountsIndex => ({ version: 2, accounts: [] });
55
50
 
56
51
  export function loadAccounts(pool: PoolPaths): AccountsIndex {
57
52
  if (!existsSync(pool.accountsJson)) return emptyIndex();
@@ -127,24 +122,6 @@ export function clearUsageSnapshot(accountId: string): void {
127
122
  rmSync(usageJsonFor(accountId), { force: true });
128
123
  }
129
124
 
130
- export function loadLastSwapAt(pool: PoolPaths): number | null {
131
- if (pool.lastSwapJson == null || !existsSync(pool.lastSwapJson)) return null;
132
- let json: unknown;
133
- try {
134
- json = JSON.parse(readFileSync(pool.lastSwapJson, "utf8"));
135
- } catch {
136
- throw new Error(`${pool.lastSwapJson} is corrupt (unparsable JSON) - refusing to treat a damaged swap clock as never-swapped; repair or remove the file`);
137
- }
138
- return LastSwapSchema.parse(json).ts;
139
- }
140
-
141
- export function saveLastSwapAt(pool: PoolPaths, ts: number): void {
142
- if (pool.lastSwapJson == null) throw new Error("this pool keeps no swap clock");
143
- writeFileAtomic(pool.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
144
- }
145
-
146
- export const POST_SWAP_COOLDOWN_MS = 45_000;
147
-
148
125
  const USAGE_TS_REFRESH_MS = 10 * 60_000;
149
126
  const SAMPLED_AT_REFRESH_MS = 30_000;
150
127