tokenmaxxing 1.7.0 → 1.9.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 +2 -2
- package/README.md +1 -1
- package/agent-plugin/plugin.json +8 -2
- package/package.json +1 -1
- package/src/cli/add.ts +1 -8
- package/src/cli/auth.ts +0 -23
- package/src/cli/check.ts +19 -13
- package/src/cli/codexadd.ts +0 -17
- package/src/cli/codexinit.ts +11 -42
- package/src/cli/codexrm.ts +0 -13
- package/src/cli/codexswitch.ts +0 -15
- package/src/cli/config.ts +0 -30
- package/src/cli/doctor.ts +1 -14
- package/src/cli/init.ts +8 -34
- package/src/cli/ls.ts +0 -2
- package/src/cli/onboard.ts +0 -37
- package/src/cli/rename.ts +0 -19
- package/src/cli/render.ts +0 -23
- package/src/cli/rm.ts +0 -19
- package/src/cli/status.ts +0 -80
- package/src/cli/switch.ts +1 -49
- package/src/cli/watch.ts +0 -17
- package/src/entries/codexstophook.ts +2 -63
- package/src/entries/codexsupervisor.ts +1 -67
- package/src/entries/mcp.ts +0 -11
- package/src/entries/sessionstart.ts +1 -8
- package/src/entries/statusline.ts +0 -66
- package/src/entries/stopfailurehook.ts +93 -0
- package/src/entries/stophook.ts +3 -23
- package/src/entries/subagentstatusline.ts +0 -19
- package/src/entries/supervisor.ts +32 -132
- package/src/lib/atomic.ts +0 -16
- package/src/lib/claudebin.ts +4 -55
- package/src/lib/claudejson.ts +0 -10
- package/src/lib/claudelock.ts +13 -35
- package/src/lib/codexauth.ts +0 -29
- package/src/lib/codexbin.ts +0 -10
- package/src/lib/codexdecide.ts +1 -112
- package/src/lib/codexoauth.ts +0 -16
- package/src/lib/codexpick.ts +0 -31
- package/src/lib/codexpresence.ts +0 -35
- package/src/lib/codexsample.ts +0 -23
- package/src/lib/codexstate.ts +0 -7
- package/src/lib/codexswap.ts +0 -32
- package/src/lib/codexusage.ts +0 -28
- package/src/lib/credstore.ts +0 -24
- package/src/lib/decide.ts +127 -180
- package/src/lib/http.ts +0 -9
- package/src/lib/install.ts +57 -124
- package/src/lib/keychain.ts +1 -39
- package/src/lib/lock.ts +0 -24
- package/src/lib/log.ts +0 -14
- package/src/lib/oauth.ts +1 -31
- package/src/lib/paths.ts +1 -45
- package/src/lib/picker.ts +1 -84
- package/src/lib/proc.ts +0 -17
- package/src/lib/sample.ts +0 -68
- package/src/lib/sessions.ts +0 -13
- package/src/lib/settings.ts +15 -42
- package/src/lib/state.ts +23 -77
- package/src/lib/swap.ts +3 -87
- package/src/lib/tty.ts +0 -4
- package/src/lib/types.ts +13 -140
- package/src/lib/usage.ts +108 -196
- package/src/lib/worktree.ts +0 -8
- package/src/main.ts +5 -35
- package/src/sdk.ts +0 -59
package/src/cli/init.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing init` - import the account you're already on (no prompts), then
|
|
2
|
-
// install the supervisor + the four settings entries.
|
|
3
|
-
|
|
4
1
|
import { mkdirSync } from "node:fs";
|
|
5
2
|
import { isApiKeyMode, readOAuthAccount } from "../lib/claudejson.ts";
|
|
6
3
|
import { readItem, writeItem, liveTarget, parkedTarget, mergeIntoLive } from "../lib/credstore.ts";
|
|
@@ -8,14 +5,12 @@ import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg } from "../lib/
|
|
|
8
5
|
import { withClaudeRefreshLock } from "../lib/claudelock.ts";
|
|
9
6
|
import { loadAccounts, saveAccounts, loadConfig, pinBinOverride } from "../lib/state.ts";
|
|
10
7
|
import { withLock } from "../lib/lock.ts";
|
|
11
|
-
import { installSupervisor, shellRcPath, ensurePathInRc, timerActivationHint, type InstallOutcome } from "../lib/install.ts";
|
|
8
|
+
import { installSupervisor, shellRcPath, ensurePathInRc, managedShellRcSkipLines, timerActivationHint, type InstallOutcome } from "../lib/install.ts";
|
|
12
9
|
import { resolveVerifiedClaude } from "../lib/claudebin.ts";
|
|
13
10
|
import { credItemFor, paths } from "../lib/paths.ts";
|
|
14
11
|
import { CredentialBlobSchema, type Account } from "../lib/types.ts";
|
|
15
12
|
import { c, claudeTierLabel } from "./render.ts";
|
|
16
13
|
|
|
17
|
-
/** Put the supervisor bin dir on PATH via the user's shell rc (idempotent).
|
|
18
|
-
* Falls back to the manual instruction when the shell is unknown. */
|
|
19
14
|
function ensurePathAhead(): void {
|
|
20
15
|
const rc = shellRcPath();
|
|
21
16
|
if (!rc) {
|
|
@@ -24,7 +19,12 @@ function ensurePathAhead(): void {
|
|
|
24
19
|
}
|
|
25
20
|
const outcome = ensurePathInRc(rc);
|
|
26
21
|
if (outcome === "added") console.log(`${c.green("✓")} added ${paths.binDir} to PATH in ${rc} - restart your shell (or \`source ${rc}\`)`);
|
|
27
|
-
else
|
|
22
|
+
else if (outcome === "skipped") {
|
|
23
|
+
const hint = managedShellRcSkipLines();
|
|
24
|
+
console.log(c.yellow(`⚠ ${hint.headline}`));
|
|
25
|
+
console.log(c.yellow(` ${hint.detail}`));
|
|
26
|
+
console.log(c.yellow(` ${hint.exportLine}`));
|
|
27
|
+
} else console.log(c.yellow(`⚠ PATH line already in ${rc} - restart your shell to pick it up`));
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function reportTimer(out: InstallOutcome): void {
|
|
@@ -32,9 +32,6 @@ function reportTimer(out: InstallOutcome): void {
|
|
|
32
32
|
else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** The how-to-use epilogue both init paths end on: the whole point of the tool
|
|
36
|
-
* is that after init you just run `claude`, so say exactly that, and teach the
|
|
37
|
-
* `xx` shorthand every other command hangs off. Exported for the render test. */
|
|
38
35
|
export function printUsage(): void {
|
|
39
36
|
console.log();
|
|
40
37
|
console.log(` ${c.bold("how to use")} - ${c.cyan("xx")} is shorthand for ${c.cyan("tokenmaxxing")}:`);
|
|
@@ -48,25 +45,11 @@ export function printUsage(): void {
|
|
|
48
45
|
|
|
49
46
|
export async function cmdInit(): Promise<number> {
|
|
50
47
|
mkdirSync(paths.home, { recursive: true });
|
|
51
|
-
// Fail fast on a broken merged config BEFORE installing or claiming a
|
|
52
|
-
// successful repair: pinBinOverride writes sparsely without validating, so
|
|
53
|
-
// without this gate a re-init printed success while hooks, switching, and
|
|
54
|
-
// the statusline kept throwing on every loadConfig until the file was
|
|
55
|
-
// hand-repaired (bugbot review catch, PR #33). The throw carries the
|
|
56
|
-
// fix-or-remove recovery message.
|
|
57
48
|
loadConfig();
|
|
58
49
|
|
|
59
|
-
// Already initialized → repair install ONLY. Never re-import: ~/.claude.json's
|
|
60
|
-
// oauthAccount can drift from the live keychain cred (after swaps / concurrent
|
|
61
|
-
// sessions), and re-importing would park the wrong cred + mislabel active.
|
|
62
50
|
const existingIdx = loadAccounts();
|
|
63
51
|
if (existingIdx.accounts.length > 0) {
|
|
64
52
|
const out = installSupervisor();
|
|
65
|
-
// repair the claudeBin pin too - hooks run with claude's PATH and must
|
|
66
|
-
// never have to guess which binary is the real claude. Verified pinning:
|
|
67
|
-
// a pin that fails --version (or loops back into the wrapper) is replaced
|
|
68
|
-
// by a fresh PATH scan instead of being re-saved. Sparse write: only the
|
|
69
|
-
// pin lands in the file, never the merged config.
|
|
70
53
|
pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() });
|
|
71
54
|
const active = existingIdx.accounts.find((a) => a.accountUuid === existingIdx.activeAccountUuid);
|
|
72
55
|
console.log(`${c.green("✓")} re-installed supervisor + hooks (pool already has ${existingIdx.accounts.length} account${existingIdx.accounts.length === 1 ? "" : "s"} - not re-importing)`);
|
|
@@ -99,13 +82,9 @@ export async function cmdInit(): Promise<number> {
|
|
|
99
82
|
return 1;
|
|
100
83
|
}
|
|
101
84
|
|
|
102
|
-
// Verify the live credential actually belongs to the identity we're about to
|
|
103
|
-
// file it under - ~/.claude.json's oauthAccount can drift from the live
|
|
104
|
-
// keychain credential, and importing on drifted state parks a mislabeled blob.
|
|
105
85
|
let creds = blob.claudeAiOauth;
|
|
106
86
|
if (isAccessTokenExpiring(creds)) {
|
|
107
87
|
await withClaudeRefreshLock(async (lock) => {
|
|
108
|
-
// re-read inside the lock: a running claude may have rotated it already.
|
|
109
88
|
const raw2 = await readItem(liveTarget());
|
|
110
89
|
if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
|
|
111
90
|
const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
|
|
@@ -124,12 +103,8 @@ export async function cmdInit(): Promise<number> {
|
|
|
124
103
|
|
|
125
104
|
const uuid = oauthAccount.accountUuid;
|
|
126
105
|
const keychainItem = credItemFor(uuid);
|
|
127
|
-
// Park + index update under the tokenmaxxing flock, like every sibling
|
|
128
|
-
// index writer (add/auth/rm/status/rename): unlocked, a concurrent `xx add`
|
|
129
|
-
// completing between this path's load and save had its just-registered
|
|
130
|
-
// account silently clobbered out of the index (closing-review catch).
|
|
131
106
|
const account = await withLock(paths.lockFile, async () => {
|
|
132
|
-
await writeItem(parkedTarget(keychainItem), JSON.stringify({ claudeAiOauth: creds }));
|
|
107
|
+
await writeItem(parkedTarget(keychainItem), JSON.stringify({ claudeAiOauth: creds }));
|
|
133
108
|
const idx = loadAccounts();
|
|
134
109
|
const existing = idx.accounts.find((a) => a.accountUuid === uuid);
|
|
135
110
|
const imported: Account = {
|
|
@@ -151,7 +126,6 @@ export async function cmdInit(): Promise<number> {
|
|
|
151
126
|
return imported;
|
|
152
127
|
});
|
|
153
128
|
|
|
154
|
-
// Sparse write: only the pin lands in the file, never the merged config.
|
|
155
129
|
pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() });
|
|
156
130
|
|
|
157
131
|
const out = installSupervisor();
|
package/src/cli/ls.ts
CHANGED
package/src/cli/onboard.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
// One isolated Claude login, harvested. Shared by `add` (register whatever
|
|
2
|
-
// account the login lands on) and `auth` (require it to match an existing
|
|
3
|
-
// account). Drives the login in a throwaway CLAUDE_CONFIG_DIR - the ONLY use
|
|
4
|
-
// of CLAUDE_CONFIG_DIR in the tool - auto-exits the moment the login lands,
|
|
5
|
-
// samples that account's usage, then returns the credential + identity. The
|
|
6
|
-
// temp dir and isolated credential are always destroyed before returning.
|
|
7
|
-
|
|
8
1
|
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
9
2
|
import { join } from "node:path";
|
|
10
3
|
import { z } from "zod";
|
|
@@ -17,7 +10,6 @@ import { CredentialBlobSchema, OAuthAccountSchema } from "../lib/types.ts";
|
|
|
17
10
|
import { c } from "./render.ts";
|
|
18
11
|
|
|
19
12
|
const HarvestedLoginSchema = z.object({
|
|
20
|
-
/** the raw isolated credential blob (park it via claudeAiOauthOnly). */
|
|
21
13
|
blobRaw: z.string(),
|
|
22
14
|
blob: CredentialBlobSchema,
|
|
23
15
|
oauthAccount: OAuthAccountSchema,
|
|
@@ -25,7 +17,6 @@ const HarvestedLoginSchema = z.object({
|
|
|
25
17
|
});
|
|
26
18
|
export type HarvestedLogin = z.infer<typeof HarvestedLoginSchema>;
|
|
27
19
|
|
|
28
|
-
/** True once `/login` has written a usable identity into the onboard dir. */
|
|
29
20
|
function identityReady(cjPath: string): boolean {
|
|
30
21
|
if (!existsSync(cjPath)) return false;
|
|
31
22
|
try {
|
|
@@ -36,32 +27,16 @@ function identityReady(cjPath: string): boolean {
|
|
|
36
27
|
}
|
|
37
28
|
}
|
|
38
29
|
|
|
39
|
-
/**
|
|
40
|
-
* Run one isolated login session and harvest it. The caller prints its own
|
|
41
|
-
* instructions (which account to sign in as) BEFORE calling. Prints progress
|
|
42
|
-
* and failure diagnostics; returns null when no usable login landed.
|
|
43
|
-
*/
|
|
44
30
|
export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
45
31
|
const onboardDir = paths.onboardDir;
|
|
46
32
|
rmSync(onboardDir, { recursive: true, force: true });
|
|
47
33
|
mkdirSync(onboardDir, { recursive: true });
|
|
48
34
|
const iso = isolatedTarget(onboardDir);
|
|
49
|
-
// Reap a PREVIOUS run's stranded isolated credential too: on macOS the
|
|
50
|
-
// isolated store is a namespaced KEYCHAIN item, not a file in onboardDir,
|
|
51
|
-
// so the rmSync above never touched it - a signal-killed harvest (Ctrl-C
|
|
52
|
-
// after /login, before the ~400ms poll) left a live credential in the
|
|
53
|
-
// keychain that the next run's spawned claude would silently reuse as an
|
|
54
|
-
// already-authenticated session (closing-review catch). deleteItem on a
|
|
55
|
-
// missing item is a no-op; on Linux iso is a file inside onboardDir and the
|
|
56
|
-
// rmSync already covered it.
|
|
57
35
|
await deleteItem(iso);
|
|
58
36
|
const cjPath = join(onboardDir, ".claude.json");
|
|
59
37
|
const real = resolveRealClaude();
|
|
60
38
|
|
|
61
39
|
const savedTermios = saveTermios();
|
|
62
|
-
// Scrub the ambient credential/identity overrides claude honors BEFORE its
|
|
63
|
-
// keychain lookup (verified 2.1.205) - the onboard session must authenticate
|
|
64
|
-
// only via the /login the user performs inside it.
|
|
65
40
|
const env: Record<string, string> = { ...process.env, CLAUDE_CONFIG_DIR: onboardDir, TOKENMAXXING_PROBE: "1", TOKENMAXXING_SUPERVISED: "" };
|
|
66
41
|
for (const key of CRED_ENV_OVERRIDES) delete env[key];
|
|
67
42
|
const p = Bun.spawn([real], {
|
|
@@ -71,15 +46,7 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
|
71
46
|
env,
|
|
72
47
|
});
|
|
73
48
|
|
|
74
|
-
// The finally makes the "always destroyed before returning" header true for
|
|
75
|
-
// every non-signal exit - INCLUDING a failure while the login session is
|
|
76
|
-
// still being polled (review catch, PR #31): an exception must not strand a
|
|
77
|
-
// plaintext credential on disk or leave the spawned claude running. (An
|
|
78
|
-
// interactive Ctrl-C is reaped by the next run's cleanup-first, which
|
|
79
|
-
// deletes the isolated keychain item as well as the dir.)
|
|
80
49
|
try {
|
|
81
|
-
// Auto-exit (#17): watch for a completed login - identity written AND the
|
|
82
|
-
// isolated credential present - then SIGTERM claude. No manual /exit.
|
|
83
50
|
let exited = false;
|
|
84
51
|
const onExit = p.exited.then(() => { exited = true; });
|
|
85
52
|
while (!exited) {
|
|
@@ -91,9 +58,6 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
|
91
58
|
}
|
|
92
59
|
await p.exited;
|
|
93
60
|
await onExit;
|
|
94
|
-
// restore promptly so the harvest's own output renders on a sane terminal;
|
|
95
|
-
// the finally's second restore is an idempotent stty and covers the
|
|
96
|
-
// thrown-mid-poll path.
|
|
97
61
|
restoreTermios(savedTermios);
|
|
98
62
|
|
|
99
63
|
const blobRaw = await readItem(iso);
|
|
@@ -111,7 +75,6 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
|
111
75
|
return null;
|
|
112
76
|
}
|
|
113
77
|
|
|
114
|
-
// Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
|
|
115
78
|
console.log(c.dim("sampling usage..."));
|
|
116
79
|
const sampled = await probeUsage(onboardDir);
|
|
117
80
|
if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
|
package/src/cli/rename.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing rename [--codex] <selector> <new-label>` - relabel a pooled
|
|
2
|
-
// account. The pools are separate namespaces and one email can hold both a
|
|
3
|
-
// claude and a codex account, so the codex pool is targeted explicitly via
|
|
4
|
-
// `--codex` (mirroring `switch --codex`), never by searching both pools.
|
|
5
|
-
|
|
6
1
|
import { withLock } from "../lib/lock.ts";
|
|
7
2
|
import { codexPaths, paths } from "../lib/paths.ts";
|
|
8
3
|
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
@@ -10,7 +5,6 @@ import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
|
|
|
10
5
|
import { c } from "./render.ts";
|
|
11
6
|
import type { Account, CodexAccount } from "../lib/types.ts";
|
|
12
7
|
|
|
13
|
-
/** Resolve a claude account by email, label, or accountUuid prefix. */
|
|
14
8
|
export function findAccount(accounts: Account[], selector: string): Account | undefined {
|
|
15
9
|
const s = selector.toLowerCase();
|
|
16
10
|
return (
|
|
@@ -20,7 +14,6 @@ export function findAccount(accounts: Account[], selector: string): Account | un
|
|
|
20
14
|
);
|
|
21
15
|
}
|
|
22
16
|
|
|
23
|
-
/** Resolve a codex account by email, label, or accountId prefix. */
|
|
24
17
|
export function findCodexAccount(accounts: CodexAccount[], selector: string): CodexAccount | undefined {
|
|
25
18
|
const s = selector.toLowerCase();
|
|
26
19
|
return (
|
|
@@ -31,7 +24,6 @@ export function findCodexAccount(accounts: CodexAccount[], selector: string): Co
|
|
|
31
24
|
}
|
|
32
25
|
|
|
33
26
|
async function renameCodexAccount(input: { selector: string; newLabel: string }): Promise<number> {
|
|
34
|
-
// under the codex flock: a concurrent codex swap's index write must not be clobbered.
|
|
35
27
|
return withLock(codexPaths.lockFile, async () => {
|
|
36
28
|
const index = loadCodexAccounts();
|
|
37
29
|
const account = findCodexAccount(index.accounts, input.selector);
|
|
@@ -39,11 +31,6 @@ async function renameCodexAccount(input: { selector: string; newLabel: string })
|
|
|
39
31
|
console.error(c.red(`no codex account matches "${input.selector}"`));
|
|
40
32
|
return 1;
|
|
41
33
|
}
|
|
42
|
-
// labels resolve selectors first-match: a duplicate would make the other
|
|
43
|
-
// account unreachable by label and misdirect destructive commands like
|
|
44
|
-
// `rm` onto the wrong one (adversarial-review catch)
|
|
45
|
-
// case-insensitive, matching how findCodexAccount resolves selectors (PR
|
|
46
|
-
// #37 review catch: a casing-only duplicate slipped the === guard)
|
|
47
34
|
const taken = index.accounts.find((x) => x.accountId !== account.accountId && x.label.toLowerCase() === input.newLabel.toLowerCase());
|
|
48
35
|
if (taken) {
|
|
49
36
|
console.error(c.red(`label "${input.newLabel}" is already used by ${taken.accountId.slice(0, 8)} - labels must be unique within the pool`));
|
|
@@ -65,7 +52,6 @@ export async function cmdRename(argv: string[]): Promise<number> {
|
|
|
65
52
|
return 2;
|
|
66
53
|
}
|
|
67
54
|
if (codex) return renameCodexAccount({ selector, newLabel });
|
|
68
|
-
// under the flock: a concurrent swap's index write must not be clobbered.
|
|
69
55
|
return withLock(paths.lockFile, async () => {
|
|
70
56
|
const idx = loadAccounts();
|
|
71
57
|
const a = findAccount(idx.accounts, selector);
|
|
@@ -73,11 +59,6 @@ export async function cmdRename(argv: string[]): Promise<number> {
|
|
|
73
59
|
console.error(c.red(`no claude account matches "${selector}" (codex accounts rename via --codex)`));
|
|
74
60
|
return 1;
|
|
75
61
|
}
|
|
76
|
-
// labels resolve selectors first-match: a duplicate would make the other
|
|
77
|
-
// account unreachable by label and misdirect destructive commands like
|
|
78
|
-
// `rm` onto the wrong one (adversarial-review catch)
|
|
79
|
-
// case-insensitive, matching how findAccount resolves selectors (PR #37
|
|
80
|
-
// review catch: a casing-only duplicate slipped the === guard)
|
|
81
62
|
const taken = idx.accounts.find((x) => x.accountUuid !== a.accountUuid && x.label.toLowerCase() === newLabel.toLowerCase());
|
|
82
63
|
if (taken) {
|
|
83
64
|
console.error(c.red(`label "${newLabel}" is already used by ${taken.email} - labels must be unique within the pool`));
|
package/src/cli/render.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
// Terminal rendering helpers for the CLI (bars, colors, relative times).
|
|
2
|
-
|
|
3
1
|
import { clamp } from "es-toolkit";
|
|
4
2
|
|
|
5
|
-
/** ANSI painters, no-ops when disabled. The statusLine renders through a pipe
|
|
6
|
-
* (never a TTY), so it needs its own enable gate; the CLI gates on stdout. */
|
|
7
3
|
export function makeColors(enabled: boolean) {
|
|
8
4
|
const paint = (code: string) => (s: string) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
9
5
|
return {
|
|
@@ -18,9 +14,6 @@ export function makeColors(enabled: boolean) {
|
|
|
18
14
|
|
|
19
15
|
export const c = makeColors(!process.env.NO_COLOR && !!process.stdout.isTTY);
|
|
20
16
|
|
|
21
|
-
/** Used% -> RGB on a continuous green->yellow->red ramp, anchored at the
|
|
22
|
-
* semantic severity bands (pure green at 0, yellow at 75, red from 95) so the
|
|
23
|
-
* band boundaries read exactly like the old 3-color scheme. Blue stays 0. */
|
|
24
17
|
function rampRgb(usedPct: number): { r: number; g: number } {
|
|
25
18
|
const u = clamp(usedPct, 0, 100);
|
|
26
19
|
if (u >= 95) return { r: 255, g: 0 };
|
|
@@ -28,11 +21,6 @@ function rampRgb(usedPct: number): { r: number; g: number } {
|
|
|
28
21
|
return { r: Math.round((255 * u) / 75), g: 255 };
|
|
29
22
|
}
|
|
30
23
|
|
|
31
|
-
/** Usage-severity painter for the statusLine (user decision 2026-07-18: colors
|
|
32
|
-
* carry quota status instead of numbers). Truecolor when the terminal
|
|
33
|
-
* advertises it, else the nearest 256-color cube step; disabled -> identity,
|
|
34
|
-
* and callers must then fall back to numeric rendering (without color a
|
|
35
|
-
* drained window must not look fresh). */
|
|
36
24
|
export function makeUsagePaint(input: { enabled: boolean; truecolor: boolean }) {
|
|
37
25
|
return (usedPct: number) =>
|
|
38
26
|
(s: string): string => {
|
|
@@ -45,12 +33,6 @@ export function makeUsagePaint(input: { enabled: boolean; truecolor: boolean })
|
|
|
45
33
|
};
|
|
46
34
|
}
|
|
47
35
|
|
|
48
|
-
/** Plan label for a claude account, e.g. "max 20x": subscription name plus the
|
|
49
|
-
* multiplier segment of the rate-limit tier id ("default_claude_max_20x").
|
|
50
|
-
* Structural, never exact-string: the multiplier is any <digits>x segment, so
|
|
51
|
-
* tiers without one (e.g. "default_claude_zero") fall back to the bare
|
|
52
|
-
* subscription name, and an absent blob field falls back to null (unmeasured
|
|
53
|
-
* must not look like a tier). */
|
|
54
36
|
export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTier?: string }): string | null {
|
|
55
37
|
const segments = input.rateLimitTier?.split("_") ?? [];
|
|
56
38
|
const multiplier = segments.find((seg) => seg.length > 1 && seg.endsWith("x") && Number.isInteger(Number(seg.slice(0, -1))));
|
|
@@ -58,12 +40,10 @@ export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTie
|
|
|
58
40
|
return multiplier ? `${input.subscriptionType} ${multiplier}` : input.subscriptionType;
|
|
59
41
|
}
|
|
60
42
|
|
|
61
|
-
/** "1 account" / "3 accounts": counted nouns always pluralize properly. */
|
|
62
43
|
export function count(input: { n: number; noun: string }): string {
|
|
63
44
|
return `${input.n} ${input.noun}${input.n === 1 ? "" : "s"}`;
|
|
64
45
|
}
|
|
65
46
|
|
|
66
|
-
/** A fixed-width usage bar, colored by fill. */
|
|
67
47
|
export function bar(pct: number, width = 16): string {
|
|
68
48
|
const clamped = clamp(pct, 0, 100);
|
|
69
49
|
const filled = Math.round((clamped / 100) * width);
|
|
@@ -73,7 +53,6 @@ export function bar(pct: number, width = 16): string {
|
|
|
73
53
|
return `${paint(body)} ${label}`;
|
|
74
54
|
}
|
|
75
55
|
|
|
76
|
-
/** Relative-time string for a reset epoch, e.g. "resets in 2h13m". */
|
|
77
56
|
export function fmtReset(epochMs: number | null | undefined, now = Date.now()): string {
|
|
78
57
|
if (epochMs == null) return "";
|
|
79
58
|
const dsec = Math.round((epochMs - now) / 1000);
|
|
@@ -85,8 +64,6 @@ export function fmtReset(epochMs: number | null | undefined, now = Date.now()):
|
|
|
85
64
|
return `resets in ${m}m`;
|
|
86
65
|
}
|
|
87
66
|
|
|
88
|
-
/** Age of a cached sample, largest unit only: "just now", "3m ago", "2h ago",
|
|
89
|
-
* "5d ago". */
|
|
90
67
|
export function fmtAgo(epochMs: number, now = Date.now()): string {
|
|
91
68
|
const dsec = Math.max(0, Math.round((now - epochMs) / 1000));
|
|
92
69
|
if (dsec < 60) return "just now";
|
package/src/cli/rm.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing rm <selector>` - remove a pooled account (not the active one).
|
|
2
|
-
|
|
3
1
|
import { rmSync } from "node:fs";
|
|
4
2
|
import { join } from "node:path";
|
|
5
3
|
import { deleteItem, isolatedTarget, liveTarget, parkedTarget, readItem } from "../lib/credstore.ts";
|
|
@@ -16,7 +14,6 @@ export async function cmdRm(selector?: string): Promise<number> {
|
|
|
16
14
|
console.error("usage: tokenmaxxing rm <email|label|uuid>");
|
|
17
15
|
return 2;
|
|
18
16
|
}
|
|
19
|
-
// under the flock: a concurrent swap's index write must not be clobbered.
|
|
20
17
|
return withLock(paths.lockFile, async () => {
|
|
21
18
|
const idx = loadAccounts();
|
|
22
19
|
const a = findAccount(idx.accounts, selector);
|
|
@@ -28,13 +25,6 @@ export async function cmdRm(selector?: string): Promise<number> {
|
|
|
28
25
|
console.error(c.red(`${a.email} is the ACTIVE account - switch away before removing it.`));
|
|
29
26
|
return 1;
|
|
30
27
|
}
|
|
31
|
-
// The label drifts (manual /login); the token cannot lie. Removing the
|
|
32
|
-
// account whose credential is actually LIVE would destroy its only backup
|
|
33
|
-
// and leave the next swap refusing over an unpooled credential. Fail
|
|
34
|
-
// CLOSED (review catch, PR #31): when the live owner cannot be verified -
|
|
35
|
-
// unparsable blob, expired token, roles outage - refuse rather than trust
|
|
36
|
-
// the stale label; rm is destructive and can wait. The check is read-only:
|
|
37
|
-
// an expired bearer simply fails the roles call, nothing is ever rotated.
|
|
38
28
|
const live = await readItem(liveTarget());
|
|
39
29
|
if (live != null) {
|
|
40
30
|
let liveOrg: string;
|
|
@@ -51,15 +41,6 @@ export async function cmdRm(selector?: string): Promise<number> {
|
|
|
51
41
|
}
|
|
52
42
|
}
|
|
53
43
|
await deleteItem(parkedTarget(a.keychainItem));
|
|
54
|
-
// Sweep sample-probe residue too: a probe killed mid-run strands the
|
|
55
|
-
// account's isolated credential (macOS: a namespaced keychain item), and
|
|
56
|
-
// once the account leaves the pool nothing would ever probe-and-heal it
|
|
57
|
-
// again (closing-review critic gap). deleteItem on a missing item is a
|
|
58
|
-
// no-op. Hard delete, not trash, on purpose: the sample dir is a
|
|
59
|
-
// throwaway CLAUDE_CONFIG_DIR that can hold PLAINTEXT credential material
|
|
60
|
-
// on Linux - the credential-dir cleanup exception (owner 2026-07-16, same
|
|
61
|
-
// rule sample.ts and onboard.ts follow); trashing would move credentials
|
|
62
|
-
// into the Trash folder.
|
|
63
44
|
const sampleDir = join(paths.sampleDir, credItemFor(a.accountUuid));
|
|
64
45
|
await deleteItem(isolatedTarget(sampleDir));
|
|
65
46
|
rmSync(sampleDir, { recursive: true, force: true });
|
package/src/cli/status.ts
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing status`: accounts with 5h / weekly / per-model usage bars.
|
|
2
|
-
// Parked accounts are live-sampled in isolation (`claude -p /usage`); the active
|
|
3
|
-
// account is read off the free statusLine feed (usage.json) so we never poll its
|
|
4
|
-
// own busy token. A sample that fails falls back to the last-known values with a
|
|
5
|
-
// visible "(cached)" note - never a silent stale number. Fresh figures are
|
|
6
|
-
// persisted onto each account for the picker/switch logic.
|
|
7
|
-
//
|
|
8
|
-
// `--force` additionally PINGS every account (one minimal haiku request each)
|
|
9
|
-
// before sampling, so every account's 5h session window starts ticking NOW
|
|
10
|
-
// instead of lying dormant until first real use, and every bar is a live probe
|
|
11
|
-
// taken after the ping. The active account still prefers the tee only as a
|
|
12
|
-
// fallback: its own `/usage` fail-silents exactly when a live session is
|
|
13
|
-
// running it, and that session's tee is fresher than any cache.
|
|
14
|
-
|
|
15
1
|
import { sortBy } from "es-toolkit";
|
|
16
2
|
import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
|
|
17
3
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
@@ -27,17 +13,11 @@ import { bar, c, claudeTierLabel, count, fmtAgo, fmtReset } from "./render.ts";
|
|
|
27
13
|
import type { FullUsage } from "../lib/usage.ts";
|
|
28
14
|
import type { Account, Config, CodexWindow, UsageWindow } from "../lib/types.ts";
|
|
29
15
|
|
|
30
|
-
/** `preRender` runs after sampling, right before the first output line: `watch`
|
|
31
|
-
* keeps the previous frame on screen through the multi-second sample and
|
|
32
|
-
* clears only when the fresh frame is ready to paint (watch(1) semantics). */
|
|
33
16
|
export async function cmdStatus(force = false, preRender?: () => void): Promise<number> {
|
|
34
17
|
let idx = loadAccounts();
|
|
35
18
|
const cfg = loadConfig();
|
|
36
19
|
const now = Date.now();
|
|
37
20
|
|
|
38
|
-
// A window whose cached reset has passed is empty again; weekly windows recur
|
|
39
|
-
// on a fixed per-account anchor, so a stale weekly reset extrapolates forward.
|
|
40
|
-
// Fresh samples pass through unchanged (their resets are in the future).
|
|
41
21
|
const row = (name: string, w: UsageWindow, weekly: boolean) => {
|
|
42
22
|
const passed = w.resetsAt != null && w.resetsAt <= now;
|
|
43
23
|
const pct = passed ? 0 : w.usedPercentage;
|
|
@@ -46,10 +26,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
46
26
|
};
|
|
47
27
|
|
|
48
28
|
if (idx.accounts.length === 0) {
|
|
49
|
-
// A codex-only pool is a documented standalone flow: the empty-claude
|
|
50
|
-
// early return must still render it, and only a fully empty install gets
|
|
51
|
-
// the init hint (closing-review catch: bare `xx` claimed "no accounts"
|
|
52
|
-
// over a populated codex pool and pointed at the wrong init).
|
|
53
29
|
if (loadCodexAccounts().accounts.length > 0) {
|
|
54
30
|
console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
|
|
55
31
|
console.log();
|
|
@@ -60,9 +36,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
60
36
|
return 0;
|
|
61
37
|
}
|
|
62
38
|
|
|
63
|
-
// Load, sample, and save entirely under the flock: parked refreshes must not
|
|
64
|
-
// collide with an in-flight swap, and a save of an index loaded before a
|
|
65
|
-
// concurrent swap would clobber the swap's activeAccountUuid.
|
|
66
39
|
console.error(c.dim(force ? "pinging every account (starts each 5h session timer) + sampling live usage..." : "sampling live usage..."));
|
|
67
40
|
const outcomes = new Map<string, SampleOutcome>();
|
|
68
41
|
await withLock(paths.lockFile, async () => {
|
|
@@ -72,19 +45,8 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
72
45
|
const liveOAuth = readOAuthAccount();
|
|
73
46
|
const activeOrg = liveOAuth?.organizationUuid ?? null;
|
|
74
47
|
const probeOne = async (a: Account) => {
|
|
75
|
-
// Active = the org claude's own oauthAccount names, NOT the stored
|
|
76
|
-
// label conjunction: after a manual /login the label lags, and routing
|
|
77
|
-
// the genuinely-live account through the parked prober would refresh
|
|
78
|
-
// its stale parked grant and could flag a healthy account needs-reauth.
|
|
79
|
-
// probeActiveUsage's own roles check still fail-fasts on residual drift.
|
|
80
48
|
const isActive = activeOrg != null && activeOrg === a.organizationUuid;
|
|
81
|
-
// The tee path never opens the credential blob, so the active account's
|
|
82
|
-
// tier comes from the live oauthAccount instead - it names this very org
|
|
83
|
-
// (uuid-matched above), so the tier is attributed to its own identity.
|
|
84
49
|
if (isActive && liveOAuth?.organizationRateLimitTier != null) a.rateLimitTier = liveOAuth.organizationRateLimitTier;
|
|
85
|
-
// Active account: prefer the free statusLine push (usage.json) so we never
|
|
86
|
-
// poll its own token, which is busy exactly when it matters. per-model
|
|
87
|
-
// comes from model-usage.json (also statusLine-driven).
|
|
88
50
|
const fromStatusLine: FullUsage | null =
|
|
89
51
|
isActive && live && live.org === a.organizationUuid
|
|
90
52
|
? {
|
|
@@ -96,13 +58,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
96
58
|
let viaTee = false;
|
|
97
59
|
let outcome: SampleOutcome;
|
|
98
60
|
if (force) {
|
|
99
|
-
// Force: ping + live probe for everyone. The tee predates the ping,
|
|
100
|
-
// so it serves ONLY as the fallback for the fail-silent `/usage`
|
|
101
|
-
// case (the probe got past every guard and claude printed no limit
|
|
102
|
-
// lines). Any other failure - identity mismatch, dead refresh, a
|
|
103
|
-
// refused ping - must stay VISIBLE: the old unconditional fallback
|
|
104
|
-
// masked pre-ping failures as healthy tee data, silently skipping
|
|
105
|
-
// the ping with no warning (closing-review catch).
|
|
106
61
|
outcome = isActive ? await probeActiveUsage(a, { ping: true }) : await probeParkedUsage(a, { ping: true });
|
|
107
62
|
if (!outcome.ok && fromStatusLine && outcome.reason.includes("no limit data")) {
|
|
108
63
|
const failed = outcome;
|
|
@@ -121,35 +76,17 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
121
76
|
outcomes.set(a.accountUuid, outcome);
|
|
122
77
|
if (!outcome.ok) return;
|
|
123
78
|
a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
124
|
-
// stamp when the figures were actually measured: the statusLine tee's
|
|
125
|
-
// own write time for the push-fed active account, else the probe time.
|
|
126
79
|
a.lastUsageAt = viaTee && live ? live.ts : Date.now();
|
|
127
80
|
if (Object.keys(outcome.usage.perModel).length > 0) {
|
|
128
81
|
a.lastPerModel = outcome.usage.perModel;
|
|
129
|
-
// via the tee, the per-model rows come from model-usage.json whose
|
|
130
|
-
// own sample time may be DAYS older than the aggregate tee - dating
|
|
131
|
-
// them by the tee's timestamp inflated the null-reset self-bound
|
|
132
|
-
// (closing-review catch). A fresh probe's rows date by the probe.
|
|
133
82
|
a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
|
|
134
83
|
}
|
|
135
84
|
};
|
|
136
|
-
// The ACTIVE probe runs FIRST, alone: it refreshes an expired live access
|
|
137
|
-
// token under claude's refresh lock, and every parked probe's fail-closed
|
|
138
|
-
// live-owner check reads that same live token - run concurrently they
|
|
139
|
-
// raced the refresh and, after the machine idled past the token lifetime,
|
|
140
|
-
// every parked sample 401'd on the first `xx status` (closing-review
|
|
141
|
-
// catch). Parked probes then run concurrently as before.
|
|
142
85
|
const activeAccount = idx.accounts.find((a) => activeOrg != null && activeOrg === a.organizationUuid) ?? null;
|
|
143
86
|
if (activeAccount) await probeOne(activeAccount);
|
|
144
|
-
// even when the active account's usage came from the TEE (no active
|
|
145
|
-
// probe, no refresh side effect), the parked probes still need a fresh
|
|
146
|
-
// live token for their fail-closed live-owner checks (cubic review
|
|
147
|
-
// catch, PR #35). A dead live grant is deliberately not fatal here: each
|
|
148
|
-
// parked probe reports its own honest failure reason.
|
|
149
87
|
try {
|
|
150
88
|
await ensureLiveTokenFresh();
|
|
151
89
|
} catch {
|
|
152
|
-
// per-account guards surface the failure per probe
|
|
153
90
|
}
|
|
154
91
|
await Promise.all(idx.accounts.filter((a) => a !== activeAccount).map(probeOne));
|
|
155
92
|
saveAccounts(idx);
|
|
@@ -159,18 +96,12 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
159
96
|
console.log(c.dim(`thresholds 5h ${cfg.thresholds.session}% weekly ${cfg.thresholds.weekly}% (${count({ n: idx.accounts.length, noun: "claude account" })})`));
|
|
160
97
|
console.log();
|
|
161
98
|
|
|
162
|
-
// Display order (user decision 2026-07-18): earliest upcoming reset first
|
|
163
|
-
// (5h or extrapolated weekly, from the just-refreshed samples), needs-reauth
|
|
164
|
-
// last; the ● marker identifies the active account wherever it sorts.
|
|
165
99
|
const displayAccounts = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
|
|
166
|
-
// The marker matches the probe routing above: active = the org claude's own
|
|
167
|
-
// oauthAccount names (the swap label can lag after a manual /login).
|
|
168
100
|
const displayActiveOrg = readOAuthAccount()?.organizationUuid ?? null;
|
|
169
101
|
for (const a of displayAccounts) {
|
|
170
102
|
const active = displayActiveOrg != null && a.organizationUuid === displayActiveOrg;
|
|
171
103
|
const outcome = outcomes.get(a.accountUuid);
|
|
172
104
|
const failed = outcome ? !outcome.ok : false;
|
|
173
|
-
// On a failed sample, fall back to the last-known values (with a note below).
|
|
174
105
|
const usage = outcome?.ok ? outcome.usage : undefined;
|
|
175
106
|
const aggregate = usage ? { fiveHour: usage.session, sevenDay: usage.weekAll } : a.lastUsage;
|
|
176
107
|
const perModel = usage ? usage.perModel : a.lastPerModel;
|
|
@@ -184,8 +115,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
184
115
|
|
|
185
116
|
const tier = claudeTierLabel(a);
|
|
186
117
|
console.log(`${marker} ${c.bold(a.label || a.email)}${tier ? ` ${c.dim(tier)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
|
|
187
|
-
// Chart label convention (user rule 2026-07-17): everything lowercase,
|
|
188
|
-
// model names short ("fable", "spark").
|
|
189
118
|
if (aggregate) {
|
|
190
119
|
row("5h", aggregate.fiveHour, false);
|
|
191
120
|
row("week", aggregate.sevenDay, true);
|
|
@@ -198,10 +127,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
198
127
|
if (outcome?.pingError != null) {
|
|
199
128
|
console.log(` ${c.yellow("ping failed (5h timer may not have started)")}: ${c.dim(outcome.pingError)}`);
|
|
200
129
|
}
|
|
201
|
-
// A successful ping ALWAYS opens the 5h window (live-verified 2026-07-16),
|
|
202
|
-
// but the server's usage feed reflects it with a lag of up to a few
|
|
203
|
-
// minutes, so a probe taken seconds later can still show a dormant window.
|
|
204
|
-
// Say so rather than looking like the ping did nothing.
|
|
205
130
|
if (force && outcome?.ok && outcome.pingError == null && aggregate && aggregate.fiveHour.resetsAt == null) {
|
|
206
131
|
console.log(` ${c.dim("pinged - 5h timer started this run; the usage feed lags, re-run status shortly for the fresh window")}`);
|
|
207
132
|
}
|
|
@@ -212,9 +137,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
212
137
|
return 0;
|
|
213
138
|
}
|
|
214
139
|
|
|
215
|
-
/** The codex pool, appended when it is non-empty. Every account samples via
|
|
216
|
-
* the free usage GET (nothing metered, no window started): the live account
|
|
217
|
-
* through the live auth.json, parked accounts through their parked blobs. */
|
|
218
140
|
async function renderCodexSection(input: {
|
|
219
141
|
cfg: Config;
|
|
220
142
|
now: number;
|
|
@@ -251,8 +173,6 @@ async function renderCodexSection(input: {
|
|
|
251
173
|
console.log();
|
|
252
174
|
const windowLabel = (window: CodexWindow) =>
|
|
253
175
|
isSessionWindow({ window }) ? `${Math.round((window.windowSeconds ?? 0) / 3600)}h` : "week";
|
|
254
|
-
// Same display order as the claude pool: earliest upcoming reset first
|
|
255
|
-
// across every cached window, needs-reauth last.
|
|
256
176
|
const displayAccounts = sortBy(index.accounts, [
|
|
257
177
|
(a) => (a.needsReauth ? 1 : 0),
|
|
258
178
|
(a) => {
|