tokenmaxxing 1.38.0 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +70 -62
- package/README.md +30 -30
- package/package.json +2 -2
- package/src/cli/check.ts +4 -2
- package/src/cli/doctor.ts +19 -32
- package/src/cli/init.ts +2 -2
- package/src/cli/rm.ts +3 -12
- package/src/cli/setuptoken.ts +2 -2
- package/src/cli/status.ts +8 -4
- package/src/cli/switch.ts +12 -71
- package/src/entries/codexstophook.ts +2 -2
- package/src/entries/codexsupervisor.ts +3 -3
- package/src/entries/sessionstart.ts +9 -10
- package/src/entries/statusline.ts +5 -11
- package/src/entries/stopfailurehook.ts +12 -38
- package/src/entries/stophook.ts +8 -28
- package/src/entries/supervisor.ts +50 -10
- package/src/lib/claude.ts +122 -156
- package/src/lib/claudelock.ts +2 -3
- package/src/lib/codex.ts +12 -14
- package/src/lib/credstore.ts +10 -21
- package/src/lib/decide.ts +24 -35
- package/src/lib/oauth.ts +2 -68
- package/src/lib/paths.ts +23 -13
- package/src/lib/picker.ts +8 -0
- package/src/lib/presence.ts +67 -0
- package/src/lib/provider.ts +2 -4
- package/src/lib/sample.ts +66 -259
- package/src/lib/sessions.ts +25 -0
- package/src/lib/state.ts +17 -42
- package/src/lib/types.ts +2 -11
- package/src/lib/update.ts +133 -0
- package/src/lib/usage.ts +7 -4
- package/src/main.ts +10 -17
- package/src/lib/claudejson.ts +0 -30
- package/src/lib/codexpresence.ts +0 -64
- package/src/lib/swap.ts +0 -198
package/src/lib/credstore.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { writeFileAtomic } from "./atomic.ts";
|
|
5
5
|
import * as kc from "./keychain.ts";
|
|
6
|
-
import {
|
|
7
|
-
import { CredentialBlobSchema, ErrnoSchema } from "./types.ts";
|
|
6
|
+
import { keychain as kcNames, namespacedCredService, storeDirFor } from "./paths.ts";
|
|
7
|
+
import { CredentialBlobSchema, ErrnoSchema, type OAuthCreds } from "./types.ts";
|
|
8
8
|
|
|
9
9
|
const CredTargetSchema = z.discriminatedUnion("kind", [
|
|
10
10
|
z.object({ kind: z.literal("keychain"), service: z.string(), account: z.string() }),
|
|
@@ -14,8 +14,6 @@ export type CredTarget = z.infer<typeof CredTargetSchema>;
|
|
|
14
14
|
|
|
15
15
|
const darwin = process.platform === "darwin";
|
|
16
16
|
|
|
17
|
-
const BlobRecordSchema = z.record(z.string(), z.unknown());
|
|
18
|
-
|
|
19
17
|
export async function readItem(t: CredTarget): Promise<string | null> {
|
|
20
18
|
if (t.kind === "keychain") return kc.readItem(t);
|
|
21
19
|
try {
|
|
@@ -43,31 +41,22 @@ export async function deleteItem(t: CredTarget): Promise<boolean> {
|
|
|
43
41
|
}
|
|
44
42
|
}
|
|
45
43
|
|
|
46
|
-
export function
|
|
44
|
+
export function isolatedTarget(configDirRaw: string): CredTarget {
|
|
47
45
|
return darwin
|
|
48
|
-
? { kind: "keychain", service:
|
|
49
|
-
: { kind: "file", path: join(
|
|
46
|
+
? { kind: "keychain", service: namespacedCredService(configDirRaw), account: kcNames.account }
|
|
47
|
+
: { kind: "file", path: join(configDirRaw, ".credentials.json") };
|
|
50
48
|
}
|
|
51
49
|
|
|
52
|
-
export function
|
|
53
|
-
return
|
|
54
|
-
? { kind: "keychain", service: itemName, account: kcNames.account }
|
|
55
|
-
: { kind: "file", path: join(paths.credsDir, `${itemName}.json`) };
|
|
50
|
+
export function storeTarget(accountId: string): CredTarget {
|
|
51
|
+
return isolatedTarget(storeDirFor(accountId));
|
|
56
52
|
}
|
|
57
53
|
|
|
58
|
-
export function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
: { kind: "file", path: join(configDirRaw, ".credentials.json") };
|
|
54
|
+
export async function readStore(accountId: string): Promise<OAuthCreds | null> {
|
|
55
|
+
const raw = await readItem(storeTarget(accountId));
|
|
56
|
+
return raw == null ? null : CredentialBlobSchema.parse(JSON.parse(raw)).claudeAiOauth;
|
|
62
57
|
}
|
|
63
58
|
|
|
64
59
|
export function claudeAiOauthOnly(fullBlobRaw: string): string {
|
|
65
60
|
const b = CredentialBlobSchema.parse(JSON.parse(fullBlobRaw));
|
|
66
61
|
return JSON.stringify({ claudeAiOauth: b.claudeAiOauth });
|
|
67
62
|
}
|
|
68
|
-
|
|
69
|
-
export function mergeIntoLive(currentLiveRaw: string | null, freshClaudeAiOauth: unknown): string {
|
|
70
|
-
const base = currentLiveRaw == null ? {} : BlobRecordSchema.parse(JSON.parse(currentLiveRaw));
|
|
71
|
-
base["claudeAiOauth"] = freshClaudeAiOauth;
|
|
72
|
-
return JSON.stringify(base);
|
|
73
|
-
}
|
package/src/lib/decide.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { withLock } from "./lock.ts";
|
|
3
|
-
import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig,
|
|
3
|
+
import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadLastSwapAt, saveAccounts } from "./state.ts";
|
|
4
4
|
import { isExhausted, limitWindows, nextWeeklyReset, pickBest, pickEarliestReset, sessionWindow, thresholdBars, usableAt, weeklyWindow, type PickCtx } from "./picker.ts";
|
|
5
5
|
import { familyTokens } from "./usage.ts";
|
|
6
6
|
import { log } from "./log.ts";
|
|
@@ -43,12 +43,12 @@ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): numb
|
|
|
43
43
|
return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(),
|
|
46
|
+
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
|
|
47
47
|
const activeId = p.liveId();
|
|
48
48
|
|
|
49
49
|
const lastSwapAt = loadLastSwapAt(p.pool);
|
|
50
50
|
if (!enforced && lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
|
|
51
|
-
return
|
|
51
|
+
return { swapped: false, account: null, reason: "post-swap-cooldown" };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
const cfg = loadConfig();
|
|
@@ -58,11 +58,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
|
|
|
58
58
|
const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0 }) : null;
|
|
59
59
|
const stored = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
|
|
60
60
|
|
|
61
|
-
if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: p.gatedFamilies(cfg) })) {
|
|
62
|
-
if (!observed) {
|
|
63
|
-
const replay = depletedReplay(p, now);
|
|
64
|
-
if (replay) return replay;
|
|
65
|
-
}
|
|
61
|
+
if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: p.gatedFamilies(cfg), seats: null })) {
|
|
66
62
|
return { swapped: false, account: null, reason: "under-threshold-or-stale" };
|
|
67
63
|
}
|
|
68
64
|
|
|
@@ -89,12 +85,16 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
|
|
|
89
85
|
return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
|
|
90
86
|
}
|
|
91
87
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
active
|
|
96
|
-
|
|
88
|
+
let obs2: Observation | null = null;
|
|
89
|
+
for (const a of idx.accounts) {
|
|
90
|
+
const obs = await p.observeLive(a, cfg, now, { probe: false });
|
|
91
|
+
if (a === active) obs2 = obs;
|
|
92
|
+
if (obs && (a.lastUsageAt == null || obs.at > a.lastUsageAt)) {
|
|
93
|
+
a.windows = p.mergeWindows(obs.windows, a.windows);
|
|
94
|
+
a.lastUsageAt = obs.at;
|
|
95
|
+
}
|
|
97
96
|
}
|
|
97
|
+
saveAccounts(p.pool, idx);
|
|
98
98
|
|
|
99
99
|
const families = p.gatedFamilies(cfg);
|
|
100
100
|
const walled = active?.enforcedUntil != null && active.enforcedUntil > now;
|
|
@@ -102,18 +102,22 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
|
|
|
102
102
|
const screened = blindScreen && families != null ? cfg.policy.switchModels : families;
|
|
103
103
|
const family = enforced?.family ?? null;
|
|
104
104
|
const switchFamilies = screened == null ? null : family != null && !screened.includes(family) ? [...screened, family] : screened;
|
|
105
|
+
const present = p.presence();
|
|
106
|
+
const seats = p.seats === "shared" ? present : null;
|
|
105
107
|
|
|
106
|
-
const seatExhausted = active != null && isExhausted(active, { now, thresholds: bars, currentId: id2, families: switchFamilies });
|
|
107
|
-
if (!enforced2 && !isOver(active, obs2, { now, thresholds: bars, currentId: id2, families }) && !(enforced && seatExhausted)) {
|
|
108
|
-
return
|
|
108
|
+
const seatExhausted = active != null && isExhausted(active, { now, thresholds: bars, currentId: id2, families: switchFamilies, seats });
|
|
109
|
+
if (!enforced2 && !isOver(active, obs2, { now, thresholds: bars, currentId: id2, families, seats }) && !(enforced && seatExhausted)) {
|
|
110
|
+
return { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
111
|
+
}
|
|
112
|
+
if (p.seats === "shared" && !canRespawn) {
|
|
113
|
+
return { swapped: false, account: null, reason: "needs-respawn" };
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
const seatOf = (cur: { activeId: string | null; accounts: Account[] }): Account | null =>
|
|
112
117
|
cur.accounts.find((a) => a.id === id2) ?? cur.accounts.find((a) => a.id === cur.activeId) ?? null;
|
|
113
118
|
|
|
114
|
-
const present = p.presentIds();
|
|
115
119
|
const rejected = new Set<string>();
|
|
116
|
-
const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.id) && (a.id === id2 || !present.has(a.id)));
|
|
120
|
+
const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.id) && (p.seats === "shared" || a.id === id2 || !present.has(a.id)));
|
|
117
121
|
const skipOrThrow = (e: unknown, candidate: Account): void => {
|
|
118
122
|
if (p.classifySwapError(e) === "fatal") throw e;
|
|
119
123
|
rejected.add(candidate.id);
|
|
@@ -122,7 +126,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
|
|
|
122
126
|
while (true) {
|
|
123
127
|
const cur = loadAccounts(p.pool);
|
|
124
128
|
const seat = seatOf(cur);
|
|
125
|
-
const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies };
|
|
129
|
+
const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies, seats };
|
|
126
130
|
const best = pickBest(usable(cur.accounts), ctx);
|
|
127
131
|
if (!best) break;
|
|
128
132
|
try {
|
|
@@ -143,7 +147,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
|
|
|
143
147
|
while (true) {
|
|
144
148
|
const fresh = loadAccounts(p.pool);
|
|
145
149
|
const current = seatOf(fresh);
|
|
146
|
-
const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies };
|
|
150
|
+
const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies, seats };
|
|
147
151
|
const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
|
|
148
152
|
const other = pickEarliestReset(usable(fresh.accounts), ctx);
|
|
149
153
|
|
|
@@ -159,10 +163,6 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
|
|
|
159
163
|
}
|
|
160
164
|
|
|
161
165
|
const isCurrent = target.id === (current?.id ?? null);
|
|
162
|
-
if (!isCurrent && !anticipatory) {
|
|
163
|
-
log("decide.depleted_no_park", { account: target.id.slice(0, 8), waitUntil });
|
|
164
|
-
return { swapped: false, account: null, reason: "all-depleted", waitUntil };
|
|
165
|
-
}
|
|
166
166
|
if (!isCurrent) {
|
|
167
167
|
try {
|
|
168
168
|
await p.swap(target);
|
|
@@ -171,19 +171,8 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
|
|
|
171
171
|
continue;
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
-
saveDepletedWait({ waitUntil, id: target.id, ts: now });
|
|
175
174
|
log("decide.depleted_wait", { account: target.id.slice(0, 8), waitUntil });
|
|
176
175
|
return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
|
|
177
176
|
}
|
|
178
177
|
});
|
|
179
178
|
}
|
|
180
|
-
|
|
181
|
-
function depletedReplay(p: Provider, now: number): SwapDecision | null {
|
|
182
|
-
if (!p.waitsWhenDepleted) return null;
|
|
183
|
-
const rec = loadDepletedWait();
|
|
184
|
-
if (!rec || rec.waitUntil <= now) return null;
|
|
185
|
-
const account = loadAccounts(p.pool).accounts.find((a) => a.id === rec.id) ?? null;
|
|
186
|
-
if (!account) return null;
|
|
187
|
-
if (account.id !== p.liveId()) return null;
|
|
188
|
-
return { swapped: false, account, reason: "depleted-wait", waitUntil: rec.waitUntil };
|
|
189
|
-
}
|
package/src/lib/oauth.ts
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
|
-
import { http,
|
|
1
|
+
import { http, safeErrorDetail } from "./http.ts";
|
|
2
2
|
import { env } from "./paths.ts";
|
|
3
|
-
import { JsonTextSchema, ProfileResponseSchema,
|
|
3
|
+
import { JsonTextSchema, ProfileResponseSchema, TokenIdentitySchema, type OAuthCreds, type TokenIdentity } from "./types.ts";
|
|
4
4
|
|
|
5
|
-
const TOKEN_URL = env("TOKENMAXXING_OAUTH_TOKEN_URL", "https://platform.claude.com/v1/oauth/token");
|
|
6
|
-
const CLIENT_ID = env("TOKENMAXXING_OAUTH_CLIENT_ID", "9d1c250a-e61b-44d9-88ed-5944d1962f5e");
|
|
7
5
|
const PROFILE_URL = env("TOKENMAXXING_OAUTH_PROFILE_URL", "https://api.anthropic.com/api/oauth/profile");
|
|
8
6
|
|
|
9
|
-
const DEFAULT_SCOPES = [
|
|
10
|
-
"user:profile",
|
|
11
|
-
"user:inference",
|
|
12
|
-
"user:sessions:claude_code",
|
|
13
|
-
"user:mcp_servers",
|
|
14
|
-
"user:file_upload",
|
|
15
|
-
];
|
|
16
|
-
|
|
17
7
|
export class InvalidGrantError extends Error {
|
|
18
8
|
constructor(public readonly detail: string) {
|
|
19
9
|
super(`invalid_grant: ${detail}`);
|
|
@@ -21,13 +11,6 @@ export class InvalidGrantError extends Error {
|
|
|
21
11
|
}
|
|
22
12
|
}
|
|
23
13
|
|
|
24
|
-
export class RefreshRejectedError extends Error {
|
|
25
|
-
constructor(public readonly status: number, public readonly detail: string) {
|
|
26
|
-
super(`token refresh rejected (HTTP ${status}): ${detail}`);
|
|
27
|
-
this.name = "RefreshRejectedError";
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
14
|
export class IdentityUnavailableError extends Error {
|
|
32
15
|
constructor(public readonly status: number | null, public readonly detail: string) {
|
|
33
16
|
super(status == null ? `profile endpoint unreachable: ${detail}` : `identity check failed (HTTP ${status}): ${detail}`);
|
|
@@ -46,55 +29,6 @@ export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTie
|
|
|
46
29
|
return multiplier ? `${input.subscriptionType} ${multiplier}` : input.subscriptionType;
|
|
47
30
|
}
|
|
48
31
|
|
|
49
|
-
export async function refreshCredential(creds: OAuthCreds, now = Date.now(), signal?: AbortSignal): Promise<OAuthCreds> {
|
|
50
|
-
if (isDeadCredential(creds)) throw new InvalidGrantError("credential was cleared after a failed refresh");
|
|
51
|
-
const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
|
|
52
|
-
const body = {
|
|
53
|
-
grant_type: "refresh_token",
|
|
54
|
-
refresh_token: creds.refreshToken,
|
|
55
|
-
client_id: CLIENT_ID,
|
|
56
|
-
scope,
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
let res: Response;
|
|
60
|
-
try {
|
|
61
|
-
res = await http.post(TOKEN_URL, {
|
|
62
|
-
headers: { "Content-Type": "application/json" },
|
|
63
|
-
body: JSON.stringify(body),
|
|
64
|
-
signal,
|
|
65
|
-
});
|
|
66
|
-
} catch (e) {
|
|
67
|
-
throw new Error(`token endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const text = await res.text();
|
|
71
|
-
if (!res.ok) {
|
|
72
|
-
const detail = safeErrorDetail({ text });
|
|
73
|
-
if (res.status === 400 && oauthErrorCode({ text }) === "invalid_grant") throw new InvalidGrantError(detail);
|
|
74
|
-
if (res.status >= 400 && res.status < 500) throw new RefreshRejectedError(res.status, detail);
|
|
75
|
-
throw new Error(`token refresh failed (HTTP ${res.status}): ${detail}`);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const parsed = RefreshResponseSchema.safeParse(JsonTextSchema.safeParse(text).data);
|
|
79
|
-
if (!parsed.success) {
|
|
80
|
-
throw new Error(`token endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
|
|
81
|
-
}
|
|
82
|
-
const json = parsed.data;
|
|
83
|
-
|
|
84
|
-
const expiresIn = json.expires_in ?? 8 * 3600;
|
|
85
|
-
const refreshExpiresIn = json.refresh_token_expires_in;
|
|
86
|
-
|
|
87
|
-
return {
|
|
88
|
-
...creds,
|
|
89
|
-
accessToken: json.access_token,
|
|
90
|
-
refreshToken: json.refresh_token ?? creds.refreshToken,
|
|
91
|
-
expiresAt: now + expiresIn * 1000,
|
|
92
|
-
refreshTokenExpiresAt:
|
|
93
|
-
refreshExpiresIn != null ? now + refreshExpiresIn * 1000 : creds.refreshTokenExpiresAt,
|
|
94
|
-
scopes: json.scope ? json.scope.split(" ").filter(Boolean) : creds.scopes,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
32
|
export function isAccessTokenExpiring(creds: OAuthCreds, skewMs = 120_000, now = Date.now()): boolean {
|
|
99
33
|
return !creds.expiresAt || creds.expiresAt - now <= skewMs;
|
|
100
34
|
}
|
package/src/lib/paths.ts
CHANGED
|
@@ -15,22 +15,21 @@ const TM_HOME = env("TOKENMAXXING_HOME", join(HOME, ".config", "tokenmaxxing"));
|
|
|
15
15
|
export const paths = {
|
|
16
16
|
home: TM_HOME,
|
|
17
17
|
configJson: join(TM_HOME, "config.json"),
|
|
18
|
-
|
|
19
|
-
depletedJson: join(TM_HOME, "depleted.json"),
|
|
18
|
+
usageDir: join(TM_HOME, "usage"),
|
|
20
19
|
respawnDir: join(TM_HOME, "respawn"),
|
|
20
|
+
presenceDir: join(TM_HOME, "live"),
|
|
21
21
|
binDir: join(TM_HOME, "bin"),
|
|
22
22
|
supervisorLink: join(TM_HOME, "bin", "claude"),
|
|
23
23
|
logFile: join(TM_HOME, "tokenmaxxing.log"),
|
|
24
24
|
onboardDir: join(TM_HOME, "onboard"),
|
|
25
25
|
sampleDir: join(TM_HOME, "sample"),
|
|
26
|
-
|
|
26
|
+
storesDir: join(TM_HOME, "stores"),
|
|
27
27
|
setupTokensJson: join(TM_HOME, "setup-tokens.json"),
|
|
28
28
|
setupTokenDir: join(TM_HOME, "setup-token"),
|
|
29
29
|
cloudSessionsJson: join(TM_HOME, "cloud", "sessions.json"),
|
|
30
30
|
cloudWalledJson: join(TM_HOME, "cloud", "walled.json"),
|
|
31
31
|
cloudLockFile: join(TM_HOME, "cloud", "lock"),
|
|
32
32
|
|
|
33
|
-
claudeJson: env("TOKENMAXXING_CLAUDE_JSON", join(HOME, ".claude.json")),
|
|
34
33
|
claudeSettings: env(
|
|
35
34
|
"TOKENMAXXING_CLAUDE_SETTINGS",
|
|
36
35
|
join(env("CLAUDE_CONFIG_DIR", join(HOME, ".claude")), "settings.json"),
|
|
@@ -43,7 +42,7 @@ export const paths = {
|
|
|
43
42
|
|
|
44
43
|
export const claudePool = {
|
|
45
44
|
accountsJson: join(TM_HOME, "accounts.json"),
|
|
46
|
-
lastSwapJson:
|
|
45
|
+
lastSwapJson: null,
|
|
47
46
|
lockFile: join(TM_HOME, "lock"),
|
|
48
47
|
} as const;
|
|
49
48
|
|
|
@@ -53,7 +52,7 @@ export const codexPool = {
|
|
|
53
52
|
lockFile: join(TM_HOME, "codex-lock"),
|
|
54
53
|
} as const;
|
|
55
54
|
|
|
56
|
-
export type PoolPaths = { accountsJson: string; lastSwapJson: string; lockFile: string };
|
|
55
|
+
export type PoolPaths = { accountsJson: string; lastSwapJson: string | null; lockFile: string };
|
|
57
56
|
|
|
58
57
|
const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
|
|
59
58
|
|
|
@@ -73,18 +72,29 @@ export function codexCredItemFor(accountId: string): string {
|
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
export const keychain = {
|
|
76
|
-
service: env("TOKENMAXXING_KEYCHAIN_SERVICE", "Claude Code-credentials"),
|
|
77
75
|
account: env("TOKENMAXXING_KEYCHAIN_ACCOUNT", process.env.USER ?? "unknown"),
|
|
78
76
|
} as const;
|
|
79
77
|
|
|
80
|
-
export function
|
|
81
|
-
return
|
|
78
|
+
export function shortId(accountId: string): string {
|
|
79
|
+
return accountId.slice(0, 8);
|
|
82
80
|
}
|
|
83
81
|
|
|
84
|
-
export function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
export function storeDirFor(accountId: string): string {
|
|
83
|
+
return join(paths.storesDir, shortId(accountId));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function sampleDirFor(accountId: string, suffix = ""): string {
|
|
87
|
+
return join(paths.sampleDir, `${shortId(accountId)}${suffix}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function usageJsonFor(accountId: string): string {
|
|
91
|
+
return join(paths.usageDir, `${shortId(accountId)}.json`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function seatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
|
|
95
|
+
const store = env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
|
|
96
|
+
if (store == null || store === "") return null;
|
|
97
|
+
return accountIds.find((id) => storeDirFor(id) === store) ?? null;
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
export function namespacedCredService(configDirRaw: string): string {
|
package/src/lib/picker.ts
CHANGED
|
@@ -15,6 +15,7 @@ const PickCtxSchema = z.object({
|
|
|
15
15
|
thresholds: ThresholdsSchema,
|
|
16
16
|
currentId: z.string().nullable(),
|
|
17
17
|
families: z.array(z.string()).nullable(),
|
|
18
|
+
seats: z.map(z.string(), z.number()).nullable(),
|
|
18
19
|
});
|
|
19
20
|
export type PickCtx = z.infer<typeof PickCtxSchema>;
|
|
20
21
|
|
|
@@ -93,7 +94,14 @@ export function pacePressure(a: Account, now: number): number {
|
|
|
93
94
|
return Math.max(0, 100 - liveUsed(weekly, now)) / Math.max(1, reset - now);
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
export function seatHeadroom(a: Account, ctx: PickCtx): number {
|
|
98
|
+
const session = sessionWindow(a);
|
|
99
|
+
if (session == null) return Number.NEGATIVE_INFINITY;
|
|
100
|
+
return (ctx.thresholds.session - liveUsed(session, ctx.now)) / ((ctx.seats?.get(a.id) ?? 0) + 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
96
103
|
const swapPreference = (ctx: PickCtx) => [
|
|
104
|
+
...(ctx.seats == null ? [] : [(a: Account) => -seatHeadroom(a, ctx)]),
|
|
97
105
|
(a: Account) => -pacePressure(a, ctx.now),
|
|
98
106
|
(a: Account) => weeklyExpiry(a, ctx.now),
|
|
99
107
|
(a: Account) => weeklyWindow(a)?.usedPercentage ?? 101,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
5
|
+
import { pidExists, pidStartTime } from "./proc.ts";
|
|
6
|
+
|
|
7
|
+
const PresenceSchema = z.object({
|
|
8
|
+
accountId: z.string(),
|
|
9
|
+
pid: z.number(),
|
|
10
|
+
startedAt: z.string(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export function writePresence(input: { dir: string; id: string; accountId: string; pid: number }): void {
|
|
14
|
+
const startedAt = pidStartTime(input.pid);
|
|
15
|
+
if (startedAt == null) throw new Error(`could not read pid ${input.pid}'s start time (ps lstart) - refusing to write an unverifiable presence file`);
|
|
16
|
+
mkdirSync(input.dir, { recursive: true });
|
|
17
|
+
writeFileAtomic(join(input.dir, input.id), JSON.stringify(PresenceSchema.parse({ accountId: input.accountId, pid: input.pid, startedAt })));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function clearPresence(input: { dir: string; id: string }): void {
|
|
21
|
+
rmSync(join(input.dir, input.id), { force: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const LivingPresenceSchema = z.object({ id: z.string(), accountId: z.string() });
|
|
25
|
+
export type LivingPresence = z.infer<typeof LivingPresenceSchema>;
|
|
26
|
+
|
|
27
|
+
export function livingPresences(dir: string): LivingPresence[] {
|
|
28
|
+
const living: LivingPresence[] = [];
|
|
29
|
+
if (!existsSync(dir)) return living;
|
|
30
|
+
for (const name of readdirSync(dir)) {
|
|
31
|
+
const file = join(dir, name);
|
|
32
|
+
let raw: string;
|
|
33
|
+
try {
|
|
34
|
+
raw = readFileSync(file, "utf8");
|
|
35
|
+
} catch (e) {
|
|
36
|
+
const errno = z.object({ code: z.string() }).safeParse(e);
|
|
37
|
+
if (errno.success && errno.data.code === "ENOENT") continue;
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
const parsed = PresenceSchema.safeParse((() => {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(raw);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
})());
|
|
47
|
+
if (!parsed.success) {
|
|
48
|
+
throw new Error(`${file} is not a readable presence record - it may belong to a RUNNING session, refusing to treat it as absent; remove the file (or respawn that session) to proceed`);
|
|
49
|
+
}
|
|
50
|
+
const observed = pidStartTime(parsed.data.pid);
|
|
51
|
+
if (observed !== parsed.data.startedAt) {
|
|
52
|
+
if (observed == null && pidExists(parsed.data.pid)) {
|
|
53
|
+
throw new Error(`ps could not read the start time of live pid ${parsed.data.pid} (${file}) - refusing to clear a presence file that may guard a RUNNING session`);
|
|
54
|
+
}
|
|
55
|
+
rmSync(file, { force: true });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
living.push(LivingPresenceSchema.parse({ id: name, accountId: parsed.data.accountId }));
|
|
59
|
+
}
|
|
60
|
+
return living;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function seatCounts(dir: string): Map<string, number> {
|
|
64
|
+
const counts = new Map<string, number>();
|
|
65
|
+
for (const presence of livingPresences(dir)) counts.set(presence.accountId, (counts.get(presence.accountId) ?? 0) + 1);
|
|
66
|
+
return counts;
|
|
67
|
+
}
|
package/src/lib/provider.ts
CHANGED
|
@@ -12,12 +12,10 @@ export type Provider = {
|
|
|
12
12
|
name: "claude" | "codex";
|
|
13
13
|
flag: string;
|
|
14
14
|
pool: PoolPaths;
|
|
15
|
+
seats: "shared" | "live";
|
|
15
16
|
waitsWhenDepleted: boolean;
|
|
16
|
-
switchMargin: number;
|
|
17
|
-
switchNote: string;
|
|
18
17
|
liveId(): string | null;
|
|
19
|
-
|
|
20
|
-
presentIds(): Set<string>;
|
|
18
|
+
presence(): Map<string, number>;
|
|
21
19
|
gatedFamilies(cfg: Config): string[] | null;
|
|
22
20
|
observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null>;
|
|
23
21
|
samplePool(accounts: Account[], liveId: string | null, now: number): Promise<Map<string, SampleReport>>;
|