tokenmaxxing 0.3.1 → 0.4.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/package.json +1 -1
- package/src/cli/add.ts +24 -19
- package/src/cli/check.ts +29 -0
- package/src/cli/doctor.ts +2 -1
- package/src/cli/init.ts +8 -1
- package/src/cli/rename.ts +17 -12
- package/src/cli/rm.ts +20 -15
- package/src/cli/status.ts +13 -11
- package/src/entries/statusline.ts +8 -2
- package/src/entries/supervisor.ts +3 -0
- package/src/lib/decide.ts +45 -13
- package/src/lib/install.ts +128 -0
- package/src/lib/paths.ts +5 -0
- package/src/lib/state.ts +25 -1
- package/src/lib/swap.ts +5 -1
- package/src/lib/types.ts +5 -0
- package/src/lib/usage.ts +13 -3
- package/src/main.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/add.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { readItem, writeItem, deleteItem, parkedTarget, isolatedTarget, claudeAi
|
|
|
11
11
|
import { resolveRealClaude } from "../lib/claudebin.ts";
|
|
12
12
|
import { probeUsage } from "../lib/usage.ts";
|
|
13
13
|
import { saveTermios, restoreTermios } from "../lib/tty.ts";
|
|
14
|
+
import { withLock } from "../lib/lock.ts";
|
|
14
15
|
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
15
16
|
import { credItemFor, paths } from "../lib/paths.ts";
|
|
16
17
|
import { CredentialBlobSchema, OAuthAccountSchema, type Account } from "../lib/types.ts";
|
|
@@ -101,29 +102,33 @@ export async function cmdAdd(): Promise<number> {
|
|
|
101
102
|
const keychainItem = credItemFor(uuid);
|
|
102
103
|
await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw)); // park a small backup
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
accountUuid
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
105
|
+
// under the flock: a concurrent swap's index write must not be clobbered.
|
|
106
|
+
const { account, poolSize } = await withLock(paths.lockFile, async () => {
|
|
107
|
+
const idx = loadAccounts();
|
|
108
|
+
const existing = idx.accounts.find((a) => a.accountUuid === uuid);
|
|
109
|
+
const fresh: Account = {
|
|
110
|
+
accountUuid: uuid,
|
|
111
|
+
email: oauthAccount.emailAddress,
|
|
112
|
+
organizationUuid: oauthAccount.organizationUuid,
|
|
113
|
+
label: existing?.label ?? oauthAccount.emailAddress,
|
|
114
|
+
keychainItem,
|
|
115
|
+
oauthAccount,
|
|
116
|
+
addedAt: existing?.addedAt ?? new Date().toISOString(),
|
|
117
|
+
subscriptionType: blob.claudeAiOauth.subscriptionType,
|
|
118
|
+
needsReauth: false,
|
|
119
|
+
lastUsage: sampled ? { fiveHour: sampled.session, sevenDay: sampled.weekAll } : existing?.lastUsage,
|
|
120
|
+
lastPerModel: sampled && Object.keys(sampled.perModel).length > 0 ? sampled.perModel : existing?.lastPerModel,
|
|
121
|
+
};
|
|
122
|
+
if (existing) Object.assign(existing, fresh);
|
|
123
|
+
else idx.accounts.push(fresh);
|
|
124
|
+
saveAccounts(idx);
|
|
125
|
+
return { account: fresh, poolSize: idx.accounts.length };
|
|
126
|
+
});
|
|
122
127
|
|
|
123
128
|
await cleanup();
|
|
124
129
|
|
|
125
130
|
console.log();
|
|
126
131
|
const usageNote = sampled ? ` · session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%` : "";
|
|
127
|
-
console.log(`${c.green("✓")} added ${c.bold(account.email)} (${account.subscriptionType ?? "?"})${usageNote} → pool now has ${
|
|
132
|
+
console.log(`${c.green("✓")} added ${c.bold(account.email)} (${account.subscriptionType ?? "?"})${usageNote} → pool now has ${poolSize} account(s)`);
|
|
128
133
|
return 0;
|
|
129
134
|
}
|
package/src/cli/check.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// `tokenmaxxing check` - one auto-switch evaluation, the same decision the
|
|
2
|
+
// Stop/SessionStart hooks make (flocked, idempotent). Installed as a periodic
|
|
3
|
+
// launchd/systemd job so a limit crossed mid-turn, or with no session running,
|
|
4
|
+
// still switches within minutes; the hooks only ever see turn boundaries.
|
|
5
|
+
// Running claude sessions adopt the swapped credential in place (<=30s).
|
|
6
|
+
|
|
7
|
+
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
8
|
+
import { log } from "../lib/log.ts";
|
|
9
|
+
import { c, fmtReset } from "./render.ts";
|
|
10
|
+
|
|
11
|
+
export async function cmdCheck(): Promise<number> {
|
|
12
|
+
let d;
|
|
13
|
+
try {
|
|
14
|
+
d = await evaluateAndMaybeSwap();
|
|
15
|
+
} catch (e) {
|
|
16
|
+
// unattended under the timer: the log is the only place anyone will look.
|
|
17
|
+
log("check.error", { err: String((e as Error).message ?? e) });
|
|
18
|
+
console.error(c.red(`check failed: ${String((e as Error).message ?? e)}`));
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
if (d.swapped && d.account) {
|
|
22
|
+
console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)}`);
|
|
23
|
+
} else if (d.waitUntil !== undefined && d.account) {
|
|
24
|
+
console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`));
|
|
25
|
+
} else {
|
|
26
|
+
console.log(c.dim(`no switch (${d.reason})`));
|
|
27
|
+
}
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
package/src/cli/doctor.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { checkSettings, installedBin } from "../lib/settings.ts";
|
|
6
|
-
import { isBinDirAhead } from "../lib/install.ts";
|
|
6
|
+
import { checkTimerHealthy, isBinDirAhead, timerActivationHint } from "../lib/install.ts";
|
|
7
7
|
import { paths } from "../lib/paths.ts";
|
|
8
8
|
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
9
9
|
import { readItem, liveTarget, parkedTarget } from "../lib/credstore.ts";
|
|
@@ -34,6 +34,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
34
34
|
check(s.statusLineOk, "statusLine shim installed in settings.json", "run `tokenmaxxing init`");
|
|
35
35
|
check(s.stopOk, "Stop hook installed in settings.json", "run `tokenmaxxing init`");
|
|
36
36
|
check(s.sessionStartOk, "SessionStart hook installed in settings.json", "run `tokenmaxxing init`");
|
|
37
|
+
check(checkTimerHealthy(), "periodic check timer active", timerActivationHint());
|
|
37
38
|
|
|
38
39
|
const idx = loadAccounts();
|
|
39
40
|
check(idx.accounts.length > 0, "at least one account in the pool", "run `tokenmaxxing init`");
|
package/src/cli/init.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { isApiKeyMode, readOAuthAccount } from "../lib/claudejson.ts";
|
|
|
6
6
|
import { readItem, writeItem, liveTarget, parkedTarget, mergeIntoLive } from "../lib/credstore.ts";
|
|
7
7
|
import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg } from "../lib/oauth.ts";
|
|
8
8
|
import { loadAccounts, saveAccounts, loadConfig, saveConfig } from "../lib/state.ts";
|
|
9
|
-
import { installSupervisor, shellRcPath, ensurePathInRc } from "../lib/install.ts";
|
|
9
|
+
import { installSupervisor, shellRcPath, ensurePathInRc, timerActivationHint, type InstallOutcome } from "../lib/install.ts";
|
|
10
10
|
import { resolveRealClaude } from "../lib/claudebin.ts";
|
|
11
11
|
import { credItemFor, paths } from "../lib/paths.ts";
|
|
12
12
|
import { CredentialBlobSchema, type Account } from "../lib/types.ts";
|
|
@@ -25,6 +25,11 @@ function ensurePathAhead(): void {
|
|
|
25
25
|
else console.log(c.yellow(`⚠ PATH line already in ${rc} - restart your shell to pick it up`));
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function reportTimer(out: InstallOutcome): void {
|
|
29
|
+
if (out.timerLoaded) console.log(`${c.green("✓")} periodic check timer active (every 3m)`);
|
|
30
|
+
else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
export async function cmdInit(): Promise<number> {
|
|
29
34
|
mkdirSync(paths.home, { recursive: true });
|
|
30
35
|
|
|
@@ -41,6 +46,7 @@ export async function cmdInit(): Promise<number> {
|
|
|
41
46
|
saveConfig(cfg);
|
|
42
47
|
const active = existingIdx.accounts.find((a) => a.accountUuid === existingIdx.activeAccountUuid);
|
|
43
48
|
console.log(`${c.green("✓")} re-installed supervisor + hooks (pool already has ${existingIdx.accounts.length} account${existingIdx.accounts.length === 1 ? "" : "s"} - not re-importing)`);
|
|
49
|
+
reportTimer(out);
|
|
44
50
|
if (!out.pathAhead) ensurePathAhead();
|
|
45
51
|
console.log(` active: ${c.bold(active?.label ?? "unknown")} · run ${c.cyan("tokenmaxxing add")} for more, ${c.cyan("tokenmaxxing status")} to check`);
|
|
46
52
|
return 0;
|
|
@@ -113,6 +119,7 @@ export async function cmdInit(): Promise<number> {
|
|
|
113
119
|
|
|
114
120
|
console.log(`${c.green("✓")} imported current account → ${c.bold(account.email)} (${account.subscriptionType ?? "?"})`);
|
|
115
121
|
console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/SessionStart hooks`);
|
|
122
|
+
reportTimer(out);
|
|
116
123
|
if (out.priorStatusLine) console.log(`${c.green("✓")} wrapped your existing statusLine (preserved)`);
|
|
117
124
|
if (!out.pathAhead) {
|
|
118
125
|
console.log();
|
package/src/cli/rename.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// `tokenmaxxing rename <selector> <new-label>` - relabel a pooled account.
|
|
2
2
|
|
|
3
|
+
import { withLock } from "../lib/lock.ts";
|
|
4
|
+
import { paths } from "../lib/paths.ts";
|
|
3
5
|
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
4
6
|
import { c } from "./render.ts";
|
|
5
7
|
import type { Account } from "../lib/types.ts";
|
|
@@ -14,20 +16,23 @@ export function findAccount(accounts: Account[], selector: string): Account | un
|
|
|
14
16
|
);
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
export function cmdRename(selector?: string, newLabel?: string): number {
|
|
19
|
+
export async function cmdRename(selector?: string, newLabel?: string): Promise<number> {
|
|
18
20
|
if (!selector || !newLabel) {
|
|
19
21
|
console.error("usage: tokenmaxxing rename <email|label|uuid> <new-label>");
|
|
20
22
|
return 2;
|
|
21
23
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
// under the flock: a concurrent swap's index write must not be clobbered.
|
|
25
|
+
return withLock(paths.lockFile, async () => {
|
|
26
|
+
const idx = loadAccounts();
|
|
27
|
+
const a = findAccount(idx.accounts, selector);
|
|
28
|
+
if (!a) {
|
|
29
|
+
console.error(c.red(`no account matches "${selector}"`));
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
const old = a.label;
|
|
33
|
+
a.label = newLabel;
|
|
34
|
+
saveAccounts(idx);
|
|
35
|
+
console.log(`renamed ${c.dim(old)} → ${c.bold(newLabel)}`);
|
|
36
|
+
return 0;
|
|
37
|
+
});
|
|
33
38
|
}
|
package/src/cli/rm.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// `tokenmaxxing rm <selector>` - remove a pooled account (not the active one).
|
|
2
2
|
|
|
3
3
|
import { deleteItem, parkedTarget } from "../lib/credstore.ts";
|
|
4
|
+
import { withLock } from "../lib/lock.ts";
|
|
5
|
+
import { paths } from "../lib/paths.ts";
|
|
4
6
|
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
5
7
|
import { findAccount } from "./rename.ts";
|
|
6
8
|
import { c } from "./render.ts";
|
|
@@ -10,19 +12,22 @@ export async function cmdRm(selector?: string): Promise<number> {
|
|
|
10
12
|
console.error("usage: tokenmaxxing rm <email|label|uuid>");
|
|
11
13
|
return 2;
|
|
12
14
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
15
|
+
// under the flock: a concurrent swap's index write must not be clobbered.
|
|
16
|
+
return withLock(paths.lockFile, async () => {
|
|
17
|
+
const idx = loadAccounts();
|
|
18
|
+
const a = findAccount(idx.accounts, selector);
|
|
19
|
+
if (!a) {
|
|
20
|
+
console.error(c.red(`no account matches "${selector}"`));
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
if (a.accountUuid === idx.activeAccountUuid) {
|
|
24
|
+
console.error(c.red(`${a.email} is the ACTIVE account - switch away before removing it.`));
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
await deleteItem(parkedTarget(a.keychainItem));
|
|
28
|
+
idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
|
|
29
|
+
saveAccounts(idx);
|
|
30
|
+
console.log(`removed ${c.bold(a.label)} from the pool (${idx.accounts.length} left)`);
|
|
31
|
+
return 0;
|
|
32
|
+
});
|
|
28
33
|
}
|
package/src/cli/status.ts
CHANGED
|
@@ -16,10 +16,8 @@ import type { FullUsage } from "../lib/usage.ts";
|
|
|
16
16
|
import type { UsageWindow } from "../lib/types.ts";
|
|
17
17
|
|
|
18
18
|
export async function cmdStatus(): Promise<number> {
|
|
19
|
-
|
|
19
|
+
let idx = loadAccounts();
|
|
20
20
|
const cfg = loadConfig();
|
|
21
|
-
const live = loadUsage();
|
|
22
|
-
const modelUsage = loadModelUsage();
|
|
23
21
|
const now = Date.now();
|
|
24
22
|
|
|
25
23
|
if (idx.accounts.length === 0) {
|
|
@@ -27,13 +25,17 @@ export async function cmdStatus(): Promise<number> {
|
|
|
27
25
|
return 0;
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
28
|
+
// Load, sample, and save entirely under the flock: parked refreshes must not
|
|
29
|
+
// collide with an in-flight swap, and a save of an index loaded before a
|
|
30
|
+
// concurrent swap would clobber the swap's activeAccountUuid.
|
|
33
31
|
console.error(c.dim("sampling live usage…"));
|
|
34
32
|
const outcomes = new Map<string, SampleOutcome>();
|
|
35
|
-
await withLock(paths.lockFile, () =>
|
|
36
|
-
|
|
33
|
+
await withLock(paths.lockFile, async () => {
|
|
34
|
+
idx = loadAccounts();
|
|
35
|
+
const live = loadUsage();
|
|
36
|
+
const modelUsage = loadModelUsage();
|
|
37
|
+
const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
|
|
38
|
+
await Promise.all(
|
|
37
39
|
idx.accounts.map(async (a) => {
|
|
38
40
|
const isActive = a.accountUuid === idx.activeAccountUuid && activeOrg === a.organizationUuid;
|
|
39
41
|
// Active account: prefer the free statusLine push (usage.json) so we never
|
|
@@ -57,9 +59,9 @@ export async function cmdStatus(): Promise<number> {
|
|
|
57
59
|
a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
58
60
|
if (Object.keys(outcome.usage.perModel).length > 0) a.lastPerModel = outcome.usage.perModel;
|
|
59
61
|
}),
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
);
|
|
63
|
+
saveAccounts(idx);
|
|
64
|
+
});
|
|
63
65
|
|
|
64
66
|
console.log(c.dim(`threshold ${cfg.threshold}% · ${idx.accounts.length} account(s)`));
|
|
65
67
|
console.log();
|
|
@@ -5,10 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
7
7
|
import { readPriorStatusLine } from "../lib/settings.ts";
|
|
8
|
-
import { writeUsage } from "../lib/state.ts";
|
|
8
|
+
import { loadLastSwapAt, writeUsage } from "../lib/state.ts";
|
|
9
9
|
import { parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
|
|
10
10
|
import type { UsageState } from "../lib/types.ts";
|
|
11
11
|
|
|
12
|
+
/** A session that hasn't adopted a fresh swap yet (<=30s keychain cache) pushes
|
|
13
|
+
* the OLD account's windows while ~/.claude.json already names the NEW org.
|
|
14
|
+
* Suppress the tee for this long after a swap so that mislabel never lands. */
|
|
15
|
+
const ADOPTION_GRACE_MS = 45_000;
|
|
16
|
+
|
|
12
17
|
async function readStdin(): Promise<string> {
|
|
13
18
|
const chunks: Uint8Array[] = [];
|
|
14
19
|
for await (const c of Bun.stdin.stream()) chunks.push(c);
|
|
@@ -22,7 +27,8 @@ export async function runStatusline(): Promise<number> {
|
|
|
22
27
|
try {
|
|
23
28
|
const obj = JSON.parse(raw);
|
|
24
29
|
const windows = parseStatusLineStdin(obj);
|
|
25
|
-
|
|
30
|
+
const lastSwapAt = loadLastSwapAt();
|
|
31
|
+
if (windows && (lastSwapAt == null || Date.now() - lastSwapAt >= ADOPTION_GRACE_MS)) {
|
|
26
32
|
const org = readOAuthAccount()?.organizationUuid ?? null;
|
|
27
33
|
const state: UsageState = { ...windows, org, ts: Date.now(), model: parseStatusLineModel(obj) };
|
|
28
34
|
writeUsage(state);
|
|
@@ -197,6 +197,9 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
197
197
|
launchArgs = ["--resume", sid, ...base];
|
|
198
198
|
continue;
|
|
199
199
|
}
|
|
200
|
+
// No marker: claude exited on its own (quit, crash, resume refused). Log it -
|
|
201
|
+
// "the process just exited" is undiagnosable without the code/signal.
|
|
202
|
+
log("supervisor.exit", { sid, respawns, code: child.exitCode, signal: child.signalCode });
|
|
200
203
|
return child.exitCode ?? (child.signalCode ? 1 : 0);
|
|
201
204
|
}
|
|
202
205
|
}
|
package/src/lib/decide.ts
CHANGED
|
@@ -11,17 +11,18 @@
|
|
|
11
11
|
// the OLD account, so org != activeOrg → we correctly do nothing until fresh usage
|
|
12
12
|
// for the new account arrives.
|
|
13
13
|
|
|
14
|
+
import { maxBy } from "es-toolkit";
|
|
14
15
|
import { z } from "zod";
|
|
15
16
|
import { withLock } from "./lock.ts";
|
|
16
17
|
import { paths } from "./paths.ts";
|
|
17
|
-
import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts, saveModelUsage } from "./state.ts";
|
|
18
|
+
import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts, saveModelUsage, writeUsage } from "./state.ts";
|
|
18
19
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
19
20
|
import { chooseAndSwap, performSwap } from "./swap.ts";
|
|
20
21
|
import { pickEarliestReset, usableAt } from "./picker.ts";
|
|
21
22
|
import { InvalidGrantError } from "./oauth.ts";
|
|
22
23
|
import { probeUsage } from "./usage.ts";
|
|
23
24
|
import { log } from "./log.ts";
|
|
24
|
-
import { AccountSchema, type Account, type Config, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
|
|
25
|
+
import { AccountSchema, type Account, type Config, type ModelInfo, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
|
|
25
26
|
|
|
26
27
|
const SwapDecisionSchema = z.object({
|
|
27
28
|
swapped: z.boolean(),
|
|
@@ -45,25 +46,52 @@ async function ensurePerModel(cfg: Config, org: string | null): Promise<ModelUsa
|
|
|
45
46
|
const fresh = cached && cached.org === org && Date.now() - cached.ts < cfg.policy.usagePollTtlMs;
|
|
46
47
|
if (fresh) return cached;
|
|
47
48
|
const full = await probeUsage();
|
|
48
|
-
if (!full)
|
|
49
|
+
if (!full) {
|
|
50
|
+
// Probing the live token fail-silently while it's busy is expected; stamp the
|
|
51
|
+
// cache so the next probe waits out the TTL instead of every turn re-paying
|
|
52
|
+
// the full backoff (the 2026-07-10 post-swap probe storm).
|
|
53
|
+
saveModelUsage({ perModel: cached?.org === org ? cached.perModel : {}, org, ts: Date.now() });
|
|
54
|
+
return cached;
|
|
55
|
+
}
|
|
49
56
|
const state: ModelUsageState = { perModel: full.perModel, org, ts: Date.now() };
|
|
50
57
|
saveModelUsage(state);
|
|
51
58
|
return state;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
/** Lowercased word tokens of a model id or display string: "claude-opus-4-8" /
|
|
62
|
+
* "Opus 4.8" -> ["claude","opus","4","8"] / ["opus","4","8"]. Model naming
|
|
63
|
+
* drifts per release ("Fable" became "Fable 5" in 2.1.206, and id grammar has
|
|
64
|
+
* historically flipped between family-first and version-first), so gates match
|
|
65
|
+
* a family token anywhere instead of an exact string - an exact-string gate
|
|
66
|
+
* silently disabled the per-model check in the 2026-07-09/10 incidents. */
|
|
67
|
+
export function familyTokens(s: string): string[] {
|
|
68
|
+
return s.trim().toLowerCase().split(/[\s.-]+/).filter((t) => t.length > 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The switchModels family the active model belongs to, from its id OR display
|
|
72
|
+
* tokens; null when the model is not capacity-constrained. */
|
|
73
|
+
export function matchedFamily(model: ModelInfo | null, families: string[]): string | null {
|
|
74
|
+
if (!model) return null;
|
|
75
|
+
const tokens = new Set([...familyTokens(model.id), ...familyTokens(model.display)]);
|
|
76
|
+
return families.find((f) => tokens.has(f)) ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The family's weekly cap among the `/usage` rows; when several rows match the
|
|
80
|
+
* family, the most-used one wins (switching early beats metering a depleted cap). */
|
|
81
|
+
function capForFamily(mu: ModelUsageState, family: string): UsageWindow | undefined {
|
|
82
|
+
const rows = Object.entries(mu.perModel)
|
|
83
|
+
.filter(([k]) => familyTokens(k).includes(family))
|
|
84
|
+
.map(([, w]) => w);
|
|
85
|
+
return maxBy(rows, (w) => w.usedPercentage);
|
|
58
86
|
}
|
|
59
87
|
|
|
60
88
|
/** True if the active account is over the floor on ANY applicable limit. */
|
|
61
89
|
function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, floor: number): boolean {
|
|
62
90
|
if (!u || !org || u.org !== org) return false;
|
|
63
91
|
if (u.fiveHour.usedPercentage >= floor || u.sevenDay.usedPercentage >= floor) return true;
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
const cap =
|
|
92
|
+
const family = matchedFamily(u.model, cfg.policy.switchModels);
|
|
93
|
+
if (family && mu && mu.org === org) {
|
|
94
|
+
const cap = capForFamily(mu, family);
|
|
67
95
|
if (cap && cap.usedPercentage >= floor) return true;
|
|
68
96
|
}
|
|
69
97
|
return false;
|
|
@@ -71,8 +99,7 @@ function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string |
|
|
|
71
99
|
|
|
72
100
|
/** Does the active model warrant a per-model `/usage` poll? */
|
|
73
101
|
function needsPerModel(u: UsageState | null, cfg: Config): boolean {
|
|
74
|
-
|
|
75
|
-
return !!display && cfg.policy.switchModels.includes(display.toLowerCase());
|
|
102
|
+
return matchedFamily(u?.model ?? null, cfg.policy.switchModels) !== null;
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecision> {
|
|
@@ -81,7 +108,12 @@ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecisi
|
|
|
81
108
|
const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
|
|
82
109
|
|
|
83
110
|
let usage = loadUsage();
|
|
84
|
-
if (!usage && activeOrg)
|
|
111
|
+
if (!usage && activeOrg) {
|
|
112
|
+
usage = await probeAggregate(activeOrg);
|
|
113
|
+
// Persist: post-swap the snapshots are cleared and the statusLine tee is in
|
|
114
|
+
// its grace window, so without this every turn boundary would re-probe.
|
|
115
|
+
if (usage) writeUsage(usage);
|
|
116
|
+
}
|
|
85
117
|
|
|
86
118
|
// per-model poll (TTL-cached) only when on a capacity-constrained model
|
|
87
119
|
const mu = needsPerModel(usage, cfg) ? await ensurePerModel(cfg, activeOrg) : null;
|
package/src/lib/install.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
6
6
|
import { basename, dirname, join } from "node:path";
|
|
7
|
+
import { escape } from "es-toolkit";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { HOME, paths } from "./paths.ts";
|
|
9
10
|
import { writeFileAtomic } from "./atomic.ts";
|
|
@@ -15,6 +16,7 @@ const InstallOutcomeSchema = z.object({
|
|
|
15
16
|
installedBin: z.string(),
|
|
16
17
|
priorStatusLine: z.string().nullable(),
|
|
17
18
|
pathAhead: z.boolean(),
|
|
19
|
+
timerLoaded: z.boolean(),
|
|
18
20
|
});
|
|
19
21
|
export type InstallOutcome = z.infer<typeof InstallOutcomeSchema>;
|
|
20
22
|
|
|
@@ -52,9 +54,134 @@ export function installSupervisor(): InstallOutcome {
|
|
|
52
54
|
installedBin: target,
|
|
53
55
|
priorStatusLine,
|
|
54
56
|
pathAhead: isBinDirAhead(),
|
|
57
|
+
timerLoaded: installCheckTimer(),
|
|
55
58
|
};
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
// ---- periodic `check` timer ------------------------------------------------
|
|
62
|
+
// The hooks evaluate only at turn boundaries; one long agentic turn can burn a
|
|
63
|
+
// window from healthy to depleted with zero boundaries (2026-07-10 incident).
|
|
64
|
+
// A timer closes that gap: launchd on macOS, a systemd user timer on Linux.
|
|
65
|
+
|
|
66
|
+
const CHECK_INTERVAL_S = 180;
|
|
67
|
+
const LAUNCHD_LABEL = "com.tokenmaxxing.check";
|
|
68
|
+
|
|
69
|
+
function launchdPlist(): string {
|
|
70
|
+
return join(paths.launchdAgentsDir, `${LAUNCHD_LABEL}.plist`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `gui/<uid>` launchd domain, or null when the platform has no getuid. */
|
|
74
|
+
function launchdDomain(): string | null {
|
|
75
|
+
const uid = process.getuid?.();
|
|
76
|
+
return uid == null ? null : `gui/${uid}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** launchctl/systemctl may be absent (spawnSync throws ENOENT) or hang on a
|
|
80
|
+
* dead session bus (ssh without lingering) - degrade, never crash or block. */
|
|
81
|
+
function run(cmd: string[]): boolean {
|
|
82
|
+
try {
|
|
83
|
+
return Bun.spawnSync(cmd, { stdout: "ignore", stderr: "ignore", timeout: 10_000 }).exitCode === 0;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Install + activate the periodic check job. False means the unit files are in
|
|
90
|
+
* place but activation failed (e.g. systemd user session absent over ssh) -
|
|
91
|
+
* the caller prints the manual activation step. */
|
|
92
|
+
export function installCheckTimer(): boolean {
|
|
93
|
+
if (process.platform === "darwin") {
|
|
94
|
+
const plist = launchdPlist();
|
|
95
|
+
writeFileAtomic(
|
|
96
|
+
plist,
|
|
97
|
+
`<?xml version="1.0" encoding="UTF-8"?>
|
|
98
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
99
|
+
<plist version="1.0">
|
|
100
|
+
<dict>
|
|
101
|
+
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
102
|
+
<key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string></array>
|
|
103
|
+
<key>StartInterval</key><integer>${CHECK_INTERVAL_S}</integer>
|
|
104
|
+
<key>StandardOutPath</key><string>/dev/null</string>
|
|
105
|
+
<key>StandardErrorPath</key><string>${escape(join(paths.home, "check.stderr.log"))}</string>
|
|
106
|
+
</dict>
|
|
107
|
+
</plist>
|
|
108
|
+
`,
|
|
109
|
+
0o644,
|
|
110
|
+
);
|
|
111
|
+
const domain = launchdDomain();
|
|
112
|
+
if (domain == null) return false;
|
|
113
|
+
run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]); // reload a changed plist
|
|
114
|
+
// bootstrap can lose a benign race with an in-flight bootout; loaded is loaded.
|
|
115
|
+
return run(["launchctl", "bootstrap", domain, plist]) || checkTimerHealthy();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// systemd: quote the path and escape `%` (unit-file specifier character).
|
|
119
|
+
const exec = `"${installedBin().replaceAll("%", "%%")}" check`;
|
|
120
|
+
writeFileAtomic(
|
|
121
|
+
join(paths.systemdUserDir, "tokenmaxxing-check.service"),
|
|
122
|
+
`[Unit]
|
|
123
|
+
Description=tokenmaxxing account-switch check
|
|
124
|
+
|
|
125
|
+
[Service]
|
|
126
|
+
Type=oneshot
|
|
127
|
+
ExecStart=${exec}
|
|
128
|
+
`,
|
|
129
|
+
0o644,
|
|
130
|
+
);
|
|
131
|
+
writeFileAtomic(
|
|
132
|
+
join(paths.systemdUserDir, "tokenmaxxing-check.timer"),
|
|
133
|
+
`[Unit]
|
|
134
|
+
Description=tokenmaxxing periodic account-switch check
|
|
135
|
+
|
|
136
|
+
[Timer]
|
|
137
|
+
OnBootSec=60
|
|
138
|
+
OnUnitActiveSec=${CHECK_INTERVAL_S}
|
|
139
|
+
AccuracySec=30
|
|
140
|
+
|
|
141
|
+
[Install]
|
|
142
|
+
WantedBy=timers.target
|
|
143
|
+
`,
|
|
144
|
+
0o644,
|
|
145
|
+
);
|
|
146
|
+
return (
|
|
147
|
+
run(["systemctl", "--user", "daemon-reload"]) &&
|
|
148
|
+
run(["systemctl", "--user", "enable", "--now", "tokenmaxxing-check.timer"])
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The manual activation command for an unloaded timer, per platform. */
|
|
153
|
+
export function timerActivationHint(): string {
|
|
154
|
+
if (process.platform === "darwin") {
|
|
155
|
+
return `launchctl bootstrap gui/$(id -u) ${launchdPlist()}`;
|
|
156
|
+
}
|
|
157
|
+
return "systemctl --user daemon-reload && systemctl --user enable --now tokenmaxxing-check.timer";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** True when the timer unit exists AND the service manager reports it loaded. */
|
|
161
|
+
export function checkTimerHealthy(): boolean {
|
|
162
|
+
if (process.platform === "darwin") {
|
|
163
|
+
const domain = launchdDomain();
|
|
164
|
+
return existsSync(launchdPlist()) && domain != null && run(["launchctl", "print", `${domain}/${LAUNCHD_LABEL}`]);
|
|
165
|
+
}
|
|
166
|
+
return (
|
|
167
|
+
existsSync(join(paths.systemdUserDir, "tokenmaxxing-check.timer")) &&
|
|
168
|
+
run(["systemctl", "--user", "is-active", "--quiet", "tokenmaxxing-check.timer"])
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function uninstallCheckTimer(): void {
|
|
173
|
+
if (process.platform === "darwin") {
|
|
174
|
+
const domain = launchdDomain();
|
|
175
|
+
if (domain != null) run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]);
|
|
176
|
+
rmSync(launchdPlist(), { force: true });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
run(["systemctl", "--user", "disable", "--now", "tokenmaxxing-check.timer"]);
|
|
180
|
+
rmSync(join(paths.systemdUserDir, "tokenmaxxing-check.timer"), { force: true });
|
|
181
|
+
rmSync(join(paths.systemdUserDir, "tokenmaxxing-check.service"), { force: true });
|
|
182
|
+
run(["systemctl", "--user", "daemon-reload"]);
|
|
183
|
+
}
|
|
184
|
+
|
|
58
185
|
/** The rc file of the user's login shell, or null when the shell is unknown.
|
|
59
186
|
* Overridable for hermetic tests. */
|
|
60
187
|
export function shellRcPath(): string | null {
|
|
@@ -81,6 +208,7 @@ export function ensurePathInRc(rc: string): "added" | "present" {
|
|
|
81
208
|
|
|
82
209
|
export function uninstallSupervisor(): void {
|
|
83
210
|
uninstallSettings();
|
|
211
|
+
uninstallCheckTimer();
|
|
84
212
|
for (const f of [paths.supervisorLink, join(paths.binDir, "xx"), installedBin()]) {
|
|
85
213
|
if (existsSync(f)) rmSync(f, { force: true });
|
|
86
214
|
}
|
package/src/lib/paths.ts
CHANGED
|
@@ -21,6 +21,7 @@ export const paths = {
|
|
|
21
21
|
accountsJson: join(TM_HOME, "accounts.json"),
|
|
22
22
|
usageJson: join(TM_HOME, "usage.json"),
|
|
23
23
|
modelUsageJson: join(TM_HOME, "model-usage.json"),
|
|
24
|
+
lastSwapJson: join(TM_HOME, "lastswap.json"),
|
|
24
25
|
respawnDir: join(TM_HOME, "respawn"),
|
|
25
26
|
binDir: join(TM_HOME, "bin"),
|
|
26
27
|
supervisorLink: join(TM_HOME, "bin", "claude"),
|
|
@@ -40,6 +41,10 @@ export const paths = {
|
|
|
40
41
|
),
|
|
41
42
|
/** ~/.claude - for the credential-refresh lock and projects/ transcripts. */
|
|
42
43
|
claudeDir: env("CLAUDE_CONFIG_DIR", join(HOME, ".claude")),
|
|
44
|
+
|
|
45
|
+
/** where the periodic-check timer units live (launchd / systemd user). */
|
|
46
|
+
launchdAgentsDir: env("TOKENMAXXING_LAUNCHD_DIR", join(HOME, "Library", "LaunchAgents")),
|
|
47
|
+
systemdUserDir: env("TOKENMAXXING_SYSTEMD_USER_DIR", join(HOME, ".config", "systemd", "user")),
|
|
43
48
|
} as const;
|
|
44
49
|
|
|
45
50
|
/** Claude's own credential-refresh lock (verified path filled from facts). */
|
package/src/lib/state.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Config + accounts index + usage snapshot persistence. All writes atomic.
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
4
4
|
import { isEqual } from "es-toolkit";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { paths, realClaudeBinFromEnv } from "./paths.ts";
|
|
@@ -8,6 +8,7 @@ import { writeFileAtomic } from "./atomic.ts";
|
|
|
8
8
|
import {
|
|
9
9
|
AccountsIndexSchema,
|
|
10
10
|
ConfigSchema,
|
|
11
|
+
LastSwapSchema,
|
|
11
12
|
ModelUsageStateSchema,
|
|
12
13
|
UsageStateSchema,
|
|
13
14
|
type AccountsIndex,
|
|
@@ -100,6 +101,29 @@ export function loadUsage(): UsageState | null {
|
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/** Drop the statusLine-fed snapshots after a swap: their windows belong to the
|
|
105
|
+
* pre-swap account and would otherwise be read under the new active org. */
|
|
106
|
+
export function clearUsageSnapshots(): void {
|
|
107
|
+
rmSync(paths.usageJson, { force: true });
|
|
108
|
+
rmSync(paths.modelUsageJson, { force: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- lastswap.json (epoch ms of the last swap; absent = never swapped) ----
|
|
112
|
+
|
|
113
|
+
export function loadLastSwapAt(): number | null {
|
|
114
|
+
if (!existsSync(paths.lastSwapJson)) return null;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = LastSwapSchema.safeParse(JSON.parse(readFileSync(paths.lastSwapJson, "utf8")));
|
|
117
|
+
return parsed.success ? parsed.data.ts : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function saveLastSwapAt(ts: number): void {
|
|
124
|
+
writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
|
|
125
|
+
}
|
|
126
|
+
|
|
103
127
|
/** Write-on-change: skip the write (and its fsync) when only `ts` would differ. */
|
|
104
128
|
export function writeUsage(next: UsageState): boolean {
|
|
105
129
|
const prev = loadUsage();
|
package/src/lib/swap.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// mark B active (inside the lock: a crash before this write leaves a stale
|
|
13
13
|
// active label, which is exactly what once made a harvest destroy a backup)
|
|
14
14
|
|
|
15
|
-
import { loadAccounts, saveAccounts } from "./state.ts";
|
|
15
|
+
import { clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
|
|
16
16
|
import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
17
17
|
import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg, InvalidGrantError } from "./oauth.ts";
|
|
18
18
|
import { swapOAuthAccount } from "./claudejson.ts";
|
|
@@ -110,6 +110,10 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
110
110
|
const t2 = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
|
|
111
111
|
if (t2) { t2.needsReauth = false; }
|
|
112
112
|
saveAccounts(idx);
|
|
113
|
+
// the snapshots on disk still describe the pre-swap account; under the new
|
|
114
|
+
// org label they'd trigger a bogus switch off the account just installed.
|
|
115
|
+
clearUsageSnapshots();
|
|
116
|
+
saveLastSwapAt(Date.now());
|
|
113
117
|
});
|
|
114
118
|
log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
|
|
115
119
|
}
|
package/src/lib/types.ts
CHANGED
|
@@ -94,6 +94,11 @@ export const AccountsIndexSchema = z.object({
|
|
|
94
94
|
activeAccountUuid: z.string().nullable(),
|
|
95
95
|
accounts: z.array(AccountSchema).default([]),
|
|
96
96
|
});
|
|
97
|
+
|
|
98
|
+
/** lastswap.json - epoch ms of the last credential swap. Its own tiny file (not
|
|
99
|
+
* accounts.json) so the statusLine shim reads a few bytes per tick and no other
|
|
100
|
+
* index writer can clobber it. Absent = no swap has ever run. */
|
|
101
|
+
export const LastSwapSchema = z.object({ ts: z.number() });
|
|
97
102
|
export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
|
|
98
103
|
|
|
99
104
|
export const ConfigSchema = z.object({
|
package/src/lib/usage.ts
CHANGED
|
@@ -213,13 +213,19 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
|
|
|
213
213
|
return full;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/** Escalating retry delays for the empty-footer case (usage endpoint throttled
|
|
217
|
+
* or the sampled token busy). Capped at ~7s of sleep: probeUsage runs inside
|
|
218
|
+
* Stop/SessionStart hooks and under the status flock, and the periodic `check`
|
|
219
|
+
* timer re-runs every 3 minutes anyway, owning the long-tail retry. */
|
|
220
|
+
const PROBE_RETRY_DELAYS_MS = [2000, 5000];
|
|
221
|
+
|
|
216
222
|
/**
|
|
217
223
|
* Run `claude -p '/usage'` (free, 0 tokens) and parse all three limit kinds.
|
|
218
224
|
* Pass `configDir` to sample a specific account (its CLAUDE_CONFIG_DIR); omit to
|
|
219
225
|
* sample the live account. All ambient credential overrides are scrubbed so the
|
|
220
226
|
* probe meters exactly the OAuth credential in the (possibly namespaced)
|
|
221
227
|
* keychain item. The empty-footer case (claude's own usage call throttled) is
|
|
222
|
-
* transient, so retry
|
|
228
|
+
* transient, so retry with backoff. Returns null if it never yields data.
|
|
223
229
|
*/
|
|
224
230
|
export async function probeUsage(configDir?: string, now = Date.now()): Promise<FullUsage | null> {
|
|
225
231
|
const env: Record<string, string> = { ...process.env, TOKENMAXXING_PROBE: "1" };
|
|
@@ -228,7 +234,11 @@ export async function probeUsage(configDir?: string, now = Date.now()): Promise<
|
|
|
228
234
|
|
|
229
235
|
for (let attempt = 0; ; attempt++) {
|
|
230
236
|
const full = await probeUsageOnce(env, now);
|
|
231
|
-
if (full
|
|
232
|
-
|
|
237
|
+
if (full) return full;
|
|
238
|
+
if (attempt >= PROBE_RETRY_DELAYS_MS.length) {
|
|
239
|
+
log("usage.probe_gave_up", { attempts: attempt + 1 });
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
await delay(PROBE_RETRY_DELAYS_MS[attempt]!);
|
|
233
243
|
}
|
|
234
244
|
}
|
package/src/main.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { cmdDoctor } from "./cli/doctor.ts";
|
|
|
16
16
|
import { cmdRm } from "./cli/rm.ts";
|
|
17
17
|
import { cmdRename } from "./cli/rename.ts";
|
|
18
18
|
import { cmdSwitch } from "./cli/switch.ts";
|
|
19
|
+
import { cmdCheck } from "./cli/check.ts";
|
|
19
20
|
import { uninstallSupervisor } from "./lib/install.ts";
|
|
20
21
|
import { c } from "./cli/render.ts";
|
|
21
22
|
|
|
@@ -24,6 +25,7 @@ function printHelp(): void {
|
|
|
24
25
|
|
|
25
26
|
${c.cyan("tokenmaxxing")} show the pool with usage bars (alias of ${c.cyan("status")})
|
|
26
27
|
${c.cyan("tokenmaxxing switch")} [sel] switch now to the best (or a specific) account
|
|
28
|
+
${c.cyan("tokenmaxxing check")} evaluate once, switch if over threshold (run by the periodic timer)
|
|
27
29
|
${c.cyan("tokenmaxxing init")} import the current account + install supervisor & hooks
|
|
28
30
|
${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
|
|
29
31
|
${c.cyan("tokenmaxxing ls")} list pooled accounts
|
|
@@ -56,6 +58,7 @@ async function main(): Promise<number> {
|
|
|
56
58
|
case "__session-start": return runSessionStart();
|
|
57
59
|
case undefined: return cmdStatus(); // bare `tokenmaxxing` / `xx` → status
|
|
58
60
|
case "switch": return cmdSwitch(args[1]);
|
|
61
|
+
case "check": return cmdCheck();
|
|
59
62
|
case "init": return cmdInit();
|
|
60
63
|
case "add": return cmdAdd();
|
|
61
64
|
case "ls": return cmdLs();
|