tokenmaxxing 1.8.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 -4
- package/README.md +1 -1
- package/agent-plugin/plugin.json +1 -1
- 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 +0 -40
- 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 +1 -33
- 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 -73
- 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 -34
- 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 +6 -127
- 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 -48
- 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 -40
- package/src/sdk.ts +0 -59
- package/agent-plugin/agents/tokenmaxxing-claude.md +0 -43
- package/agent-plugin/agents/tokenmaxxing-codex.md +0 -40
- package/agent-plugin/hooks/cursor-relay.json +0 -14
- package/agent-plugin/skills/relay-session/SKILL.md +0 -118
- package/agent-plugin/skills/relay-session/references/ipc.md +0 -23
- package/src/cli/relay.ts +0 -323
- package/src/entries/relaypermission.ts +0 -105
- package/src/lib/relay/config.ts +0 -84
- package/src/lib/relay/decide.ts +0 -75
- package/src/lib/relay/gc.ts +0 -80
- package/src/lib/relay/install.ts +0 -143
- package/src/lib/relay/markers.ts +0 -148
- package/src/lib/relay/modes.ts +0 -82
- package/src/lib/relay/protocol.ts +0 -61
- package/src/lib/relay/registry.ts +0 -175
- package/src/lib/relay/tmux.ts +0 -109
- package/src/lib/relay/turn.ts +0 -137
- package/src/lib/relay/worker.ts +0 -141
package/src/lib/paths.ts
CHANGED
|
@@ -1,21 +1,15 @@
|
|
|
1
|
-
// Central path resolution. EVERY externally-observable path is overridable via an
|
|
2
|
-
// env var so the whole tool can run hermetically in tests without touching the
|
|
3
|
-
// user's real ~/.config, ~/.claude, or login keychain.
|
|
4
|
-
|
|
5
1
|
import { homedir } from "node:os";
|
|
6
2
|
import { join } from "node:path";
|
|
7
3
|
import { z } from "zod";
|
|
8
4
|
|
|
9
5
|
const HOME = homedir();
|
|
10
6
|
|
|
11
|
-
/** A set env override; empty or unset parses to undefined and the fallback applies. */
|
|
12
7
|
const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
|
|
13
8
|
|
|
14
9
|
function env(name: string, fallback: string): string {
|
|
15
10
|
return EnvOverrideSchema.parse(process.env[name]) ?? fallback;
|
|
16
11
|
}
|
|
17
12
|
|
|
18
|
-
/** Root of all tokenmaxxing config + state. Default ~/.config/tokenmaxxing. */
|
|
19
13
|
const TM_HOME = env("TOKENMAXXING_HOME", join(HOME, ".config", "tokenmaxxing"));
|
|
20
14
|
|
|
21
15
|
export const paths = {
|
|
@@ -25,8 +19,8 @@ export const paths = {
|
|
|
25
19
|
usageJson: join(TM_HOME, "usage.json"),
|
|
26
20
|
modelUsageJson: join(TM_HOME, "model-usage.json"),
|
|
27
21
|
lastSwapJson: join(TM_HOME, "lastswap.json"),
|
|
28
|
-
/** the last depleted-wait decision, replayed to sibling hooks (self-expiring). */
|
|
29
22
|
depletedJson: join(TM_HOME, "depleted.json"),
|
|
23
|
+
nextCheckJson: join(TM_HOME, "nextcheck.json"),
|
|
30
24
|
respawnDir: join(TM_HOME, "respawn"),
|
|
31
25
|
binDir: join(TM_HOME, "bin"),
|
|
32
26
|
supervisorLink: join(TM_HOME, "bin", "claude"),
|
|
@@ -34,105 +28,64 @@ export const paths = {
|
|
|
34
28
|
logFile: join(TM_HOME, "tokenmaxxing.log"),
|
|
35
29
|
onboardDir: join(TM_HOME, "onboard"),
|
|
36
30
|
sampleDir: join(TM_HOME, "sample"),
|
|
37
|
-
/** linux only: parked credential .json files (0700 dir, 0600 files). */
|
|
38
31
|
credsDir: join(TM_HOME, "creds"),
|
|
39
|
-
/** Durable tmux relay companion: config + per-session state (not respawn/). */
|
|
40
|
-
relayJson: join(TM_HOME, "relay.json"),
|
|
41
|
-
relayDir: join(TM_HOME, "relay"),
|
|
42
32
|
|
|
43
|
-
/** ~/.claude.json - holds the active `oauthAccount` identity object. */
|
|
44
33
|
claudeJson: env("TOKENMAXXING_CLAUDE_JSON", join(HOME, ".claude.json")),
|
|
45
|
-
/** ~/.claude/settings.json - user-owned; we merge four entries into it. */
|
|
46
34
|
claudeSettings: env(
|
|
47
35
|
"TOKENMAXXING_CLAUDE_SETTINGS",
|
|
48
36
|
join(env("CLAUDE_CONFIG_DIR", join(HOME, ".claude")), "settings.json"),
|
|
49
37
|
),
|
|
50
|
-
/** ~/.claude - claude's config dir (settings.json, projects/ transcripts;
|
|
51
|
-
* credDir() falls back to it). */
|
|
52
38
|
claudeDir: env("CLAUDE_CONFIG_DIR", join(HOME, ".claude")),
|
|
53
39
|
|
|
54
|
-
/** where the periodic-check timer units live (launchd / systemd user). */
|
|
55
40
|
launchdAgentsDir: env("TOKENMAXXING_LAUNCHD_DIR", join(HOME, "Library", "LaunchAgents")),
|
|
56
41
|
systemdUserDir: env("TOKENMAXXING_SYSTEMD_USER_DIR", join(HOME, ".config", "systemd", "user")),
|
|
57
42
|
} as const;
|
|
58
43
|
|
|
59
|
-
/** Codex home: where the live auth.json lives. Test override first, then
|
|
60
|
-
* codex's own CODEX_HOME env, then its default ~/.codex. */
|
|
61
44
|
const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
|
|
62
45
|
|
|
63
46
|
export const codexPaths = {
|
|
64
47
|
home: CODEX_HOME,
|
|
65
|
-
/** the live credential file (codex file-mode store; verified 0.144.4/5). */
|
|
66
48
|
authJson: join(CODEX_HOME, "auth.json"),
|
|
67
|
-
/** user-level hook declarations codex reads (verified against the binary + docs). */
|
|
68
49
|
hooksJson: join(CODEX_HOME, "hooks.json"),
|
|
69
|
-
/** tokenmaxxing's codex pool state, parallel to the claude files in TM_HOME. */
|
|
70
50
|
accountsJson: join(TM_HOME, "codex-accounts.json"),
|
|
71
51
|
lastSwapJson: join(TM_HOME, "codex-lastswap.json"),
|
|
72
52
|
lockFile: join(TM_HOME, "codex-lock"),
|
|
73
|
-
/** parked auth.json blobs: 0600 files on BOTH platforms (codex's own store is
|
|
74
|
-
* a plaintext file, and parked blobs at ~6KB would risk the security(1)
|
|
75
|
-
* write-size trap that once truncated a 4.3KB claude blob). */
|
|
76
53
|
credsDir: join(TM_HOME, "codex-creds"),
|
|
77
54
|
onboardDir: join(TM_HOME, "codex-onboard"),
|
|
78
55
|
respawnDir: join(TM_HOME, "codex-respawn"),
|
|
79
|
-
/** one file per RUNNING supervised codex session: {accountId, pid, ts}. A
|
|
80
|
-
* running account's parked token must never be refreshed or targeted (its
|
|
81
|
-
* live rotations supersede the parked copy, and reuse is punished). */
|
|
82
56
|
presenceDir: join(TM_HOME, "codex-live"),
|
|
83
|
-
/** cross-session reconcile signals, one file per supervisorId: a deciding
|
|
84
|
-
* actor saw that supervisor's session running on a pooled NON-LIVE account
|
|
85
|
-
* (healthy or not - owner decisions 2026-07-20) while the live seat is
|
|
86
|
-
* usable; the session's OWN Stop hook promotes the signal into a respawn
|
|
87
|
-
* marker at its next turn boundary. */
|
|
88
57
|
reconcileDir: join(TM_HOME, "codex-reconcile"),
|
|
89
58
|
} as const;
|
|
90
59
|
|
|
91
|
-
/** Per-account parked codex credential file name: tokenmaxxing-codex-<id8>. */
|
|
92
60
|
export function codexCredItemFor(accountId: string): string {
|
|
93
61
|
return `tokenmaxxing-codex-${accountId.slice(0, 8)}`;
|
|
94
62
|
}
|
|
95
63
|
|
|
96
|
-
/** The macOS login-keychain generic-password the live `claude` reads. */
|
|
97
64
|
export const keychain = {
|
|
98
65
|
service: env("TOKENMAXXING_KEYCHAIN_SERVICE", "Claude Code-credentials"),
|
|
99
66
|
account: env("TOKENMAXXING_KEYCHAIN_ACCOUNT", process.env.USER ?? "unknown"),
|
|
100
67
|
} as const;
|
|
101
68
|
|
|
102
|
-
/** Per-account parked credential item name: tokenmaxxing-cred-<accountUuid[:8]>. */
|
|
103
69
|
export function credItemFor(accountUuid: string): string {
|
|
104
70
|
return `tokenmaxxing-cred-${accountUuid.slice(0, 8)}`;
|
|
105
71
|
}
|
|
106
72
|
|
|
107
|
-
/**
|
|
108
|
-
* The dir whose `.credentials.json` is claude's live credential on linux -
|
|
109
|
-
* mirrors claude's own resolution (verified 2.1.205 `Wde()`): the
|
|
110
|
-
* CLAUDE_SECURESTORAGE_CONFIG_DIR override is checked FIRST when defined
|
|
111
|
-
* (defined-but-empty falls to ~/.claude, NFC-normalized), else the config dir.
|
|
112
|
-
*/
|
|
113
73
|
export function credDir(): string {
|
|
114
74
|
const secure = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
|
|
115
75
|
if (secure !== undefined) return (secure || join(HOME, ".claude")).normalize("NFC");
|
|
116
76
|
return paths.claudeDir;
|
|
117
77
|
}
|
|
118
78
|
|
|
119
|
-
/**
|
|
120
|
-
* The keychain service claude uses when CLAUDE_CONFIG_DIR is set:
|
|
121
|
-
* `Claude Code-credentials-<first 8 hex of sha256(NFC(raw dir string))>`.
|
|
122
|
-
* Hash is over the RAW string, so the dir must be byte-stable.
|
|
123
|
-
*/
|
|
124
79
|
export function namespacedCredService(configDirRaw: string): string {
|
|
125
80
|
const h = new Bun.CryptoHasher("sha256");
|
|
126
81
|
h.update(configDirRaw.normalize("NFC"));
|
|
127
82
|
return `Claude Code-credentials-${h.digest("hex").slice(0, 8)}`;
|
|
128
83
|
}
|
|
129
84
|
|
|
130
|
-
/** Resolve the REAL claude binary (never our shim). Order: explicit env, config, PATH scan. */
|
|
131
85
|
export function realClaudeBinFromEnv(): string | undefined {
|
|
132
86
|
return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CLAUDE_BIN);
|
|
133
87
|
}
|
|
134
88
|
|
|
135
|
-
/** Same override hook for the real codex binary (tests / relocation). */
|
|
136
89
|
export function realCodexBinFromEnv(): string | undefined {
|
|
137
90
|
return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_BIN);
|
|
138
91
|
}
|
package/src/lib/picker.ts
CHANGED
|
@@ -1,25 +1,8 @@
|
|
|
1
|
-
// Choose the account to switch TO. Greedy policy: among usable accounts (no
|
|
2
|
-
// reauth, no window >= threshold that hasn't reset yet), take the one furthest
|
|
3
|
-
// behind its own weekly pace - highest pacePressure, the burn rate its
|
|
4
|
-
// remaining weekly quota demands to be consumed at before the fixed
|
|
5
|
-
// per-account reset forfeits it (weekly allowance is use-it-or-lose-it).
|
|
6
|
-
// This refines the older soonest-expiry policy in both directions: equal
|
|
7
|
-
// remaining reduces to soonest expiry first, equal expiry to most remaining
|
|
8
|
-
// first. Runs entirely off each account's cached windows (absolute UTC
|
|
9
|
-
// epochs, so a stale snapshot still resolves to the correct upcoming reset),
|
|
10
|
-
// which makes the pick deterministic and idempotent: re-running lands on the
|
|
11
|
-
// same account.
|
|
12
|
-
|
|
13
1
|
import { minBy, sortBy } from "es-toolkit";
|
|
14
2
|
import { z } from "zod";
|
|
15
3
|
import { familyTokens } from "./usage.ts";
|
|
16
4
|
import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresholds, type UsageWindow } from "./types.ts";
|
|
17
5
|
|
|
18
|
-
/** The effective per-window bars: configured thresholds minus the projection
|
|
19
|
-
* margin. ONE bar per window for BOTH the trigger and candidate screening -
|
|
20
|
-
* a screen laxer than the trigger would land swaps on accounts the trigger
|
|
21
|
-
* immediately re-flags (cooldown-throttled ping-pong). Every PickCtx and the
|
|
22
|
-
* trigger floors must be built from this. */
|
|
23
6
|
export function effectiveBars(cfg: Config): Thresholds {
|
|
24
7
|
return {
|
|
25
8
|
session: cfg.thresholds.session - cfg.policy.projectionMargin,
|
|
@@ -27,16 +10,6 @@ export function effectiveBars(cfg: Config): Thresholds {
|
|
|
27
10
|
};
|
|
28
11
|
}
|
|
29
12
|
|
|
30
|
-
/** LAYER 2 - the wall bars. The projection margin is subtracted the SAME way
|
|
31
|
-
* effectiveBars does it, for two reasons: (1) it keeps the documented disable
|
|
32
|
-
* contract honest - `hardThresholds == thresholds` then yields hardBars ==
|
|
33
|
-
* effectiveBars, so Layer 2 has no band to act in and is truly off (without
|
|
34
|
-
* the margin here a nonzero margin left a live band between the two, review
|
|
35
|
-
* catch PR #47); (2) at the default margin 0 the wall is still the literal 100
|
|
36
|
-
* (the server's own figure /rate-limit-options reads). Used only in the
|
|
37
|
-
* all-Layer-1-exhausted fallback, where an account under its wall is still
|
|
38
|
-
* worth squeezing. Config's refine (hardThresholds >= thresholds) guarantees
|
|
39
|
-
* hardBars >= effectiveBars, so Layer 2 is never stricter than Layer 1. */
|
|
40
13
|
export function hardBars(cfg: Config): Thresholds {
|
|
41
14
|
return {
|
|
42
15
|
session: cfg.hardThresholds.session - cfg.policy.projectionMargin,
|
|
@@ -47,16 +20,11 @@ export function hardBars(cfg: Config): Thresholds {
|
|
|
47
20
|
const PickCtxSchema = z.object({
|
|
48
21
|
now: z.number(),
|
|
49
22
|
thresholds: ThresholdsSchema,
|
|
50
|
-
/** account to exclude (hooks switch AWAY from it); null ranks everyone. */
|
|
51
23
|
currentAccountUuid: z.string().nullable(),
|
|
52
|
-
/** families whose per-model weekly cap counts toward exhaustion (from
|
|
53
|
-
* gatedFamilies): a candidate with a burnt gated cap is no switch target -
|
|
54
|
-
* landing on it would re-trigger the same gate and ping-pong the pool. */
|
|
55
24
|
switchFamilies: z.array(z.string()),
|
|
56
25
|
});
|
|
57
26
|
export type PickCtx = z.infer<typeof PickCtxSchema>;
|
|
58
27
|
|
|
59
|
-
/** The account's cached per-model windows that belong to a gated family. */
|
|
60
28
|
function gatedPerModelWindows(a: Account, families: string[]): UsageWindow[] {
|
|
61
29
|
return Object.entries(a.lastPerModel ?? {})
|
|
62
30
|
.filter(([model]) => families.some((f) => familyTokens(model).includes(f)))
|
|
@@ -66,23 +34,12 @@ function gatedPerModelWindows(a: Account, families: string[]): UsageWindow[] {
|
|
|
66
34
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
67
35
|
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
68
36
|
|
|
69
|
-
/** Epoch until which `w` blocks the account; anything <= now means it does not
|
|
70
|
-
* block. A window with no known reset (unparsed clock) is still bounded by
|
|
71
|
-
* its own duration past the sample time - a 5h window sampled 6h ago has
|
|
72
|
-
* certainly reset - so a benched account always recovers by itself. With no
|
|
73
|
-
* sample time either, it blocks indefinitely: guessing "usable now" would
|
|
74
|
-
* swap onto it with waitUntil=now and churn kill/respawn. */
|
|
75
37
|
function blockedUntil(w: UsageWindow, windowMs: number, sampledAt: number | undefined, threshold: number): number {
|
|
76
38
|
if (w.usedPercentage < threshold) return 0;
|
|
77
39
|
if (w.resetsAt != null) return w.resetsAt;
|
|
78
40
|
return sampledAt != null ? sampledAt + windowMs : Number.POSITIVE_INFINITY;
|
|
79
41
|
}
|
|
80
42
|
|
|
81
|
-
/** When each of the account's windows stops blocking: the two aggregates plus
|
|
82
|
-
* the gated per-model caps, each against its own threshold (session swaps
|
|
83
|
-
* earlier than the weekly windows, and a candidate is screened by the same
|
|
84
|
-
* bar that would trigger a switch off it - landing below the trigger bar
|
|
85
|
-
* would ping-pong). */
|
|
86
43
|
function blockingUntil(a: Account, ctx: PickCtx): number[] {
|
|
87
44
|
const u = a.lastUsage;
|
|
88
45
|
return [
|
|
@@ -92,50 +49,29 @@ function blockingUntil(a: Account, ctx: PickCtx): number[] {
|
|
|
92
49
|
blockedUntil(u.sevenDay, WEEK_MS, a.lastUsageAt, ctx.thresholds.weekly),
|
|
93
50
|
]
|
|
94
51
|
: []),
|
|
95
|
-
// per-model rows date by their OWN sample time when known: lastUsageAt
|
|
96
|
-
// advances on every engaged evaluation while the rows may be days older,
|
|
97
|
-
// which inflated the null-reset self-bound (closing-review catch).
|
|
98
52
|
...gatedPerModelWindows(a, ctx.switchFamilies).map((w) => blockedUntil(w, WEEK_MS, a.lastPerModelAt ?? a.lastUsageAt, ctx.thresholds.weekly)),
|
|
53
|
+
...(a.enforcedUntil != null ? [a.enforcedUntil] : []),
|
|
99
54
|
];
|
|
100
55
|
}
|
|
101
56
|
|
|
102
|
-
/** An account is "exhausted" if a window is >= its threshold and hasn't reset yet. */
|
|
103
57
|
export function isExhausted(a: Account, ctx: PickCtx): boolean {
|
|
104
58
|
return blockingUntil(a, ctx).some((t) => t > ctx.now);
|
|
105
59
|
}
|
|
106
60
|
|
|
107
|
-
/** Next occurrence of a weekly reset. The weekly reset is a fixed per-account
|
|
108
|
-
* anchor, so a cached (past) resetsAt extrapolates forward in 7-day steps -
|
|
109
|
-
* an old snapshot still yields the correct upcoming reset. */
|
|
110
61
|
export function nextWeeklyReset(resetsAt: number | null, now: number): number | null {
|
|
111
62
|
if (resetsAt == null || resetsAt > now) return resetsAt;
|
|
112
63
|
return resetsAt + (Math.floor((now - resetsAt) / WEEK_MS) + 1) * WEEK_MS;
|
|
113
64
|
}
|
|
114
65
|
|
|
115
|
-
/** Epoch ms when the account's weekly quota is next forfeited; an account with
|
|
116
|
-
* no sampled reset sorts last. */
|
|
117
66
|
export function weeklyExpiry(a: Account, now: number): number {
|
|
118
67
|
return nextWeeklyReset(a.lastUsage?.sevenDay.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
|
|
119
68
|
}
|
|
120
69
|
|
|
121
|
-
/** Soonest upcoming reset among the account's cached windows: the 5h session
|
|
122
|
-
* reset if still ahead, else the extrapolated weekly expiry; Infinity with no
|
|
123
|
-
* known reset anchor (sorts last). Display order for `status` and the
|
|
124
|
-
* statusLine pool (user decision 2026-07-18) - deliberately decoupled from
|
|
125
|
-
* swapPreference, which keeps ranking actual swaps. */
|
|
126
70
|
export function earliestReset(a: Account, now: number): number {
|
|
127
71
|
const fiveHour = a.lastUsage?.fiveHour.resetsAt;
|
|
128
72
|
return Math.min(fiveHour != null && fiveHour > now ? fiveHour : Number.POSITIVE_INFINITY, weeklyExpiry(a, now));
|
|
129
73
|
}
|
|
130
74
|
|
|
131
|
-
/** How far behind its own weekly pace the account is, measured forward: the
|
|
132
|
-
* burn rate (percent per ms) its remaining weekly quota must be consumed at
|
|
133
|
-
* to beat the reset that forfeits it. A backward-looking used/expected ratio
|
|
134
|
-
* blows up right after a reset (expected ~0) and ignores how much quota is
|
|
135
|
-
* at risk; the required forward rate has neither problem. A window past its
|
|
136
|
-
* cached reset counts as empty (the account is fresh again); an account with
|
|
137
|
-
* no sampled reset anchor has nothing to forfeit on any known clock and
|
|
138
|
-
* ranks last (0). */
|
|
139
75
|
export function pacePressure(a: Account, now: number): number {
|
|
140
76
|
const cached = a.lastUsage?.sevenDay;
|
|
141
77
|
const reset = nextWeeklyReset(cached?.resetsAt ?? null, now);
|
|
@@ -144,17 +80,9 @@ export function pacePressure(a: Account, now: number): number {
|
|
|
144
80
|
return Math.max(0, 100 - used) / Math.max(1, reset - now);
|
|
145
81
|
}
|
|
146
82
|
|
|
147
|
-
/** The switch preference: furthest behind its own weekly pace first (highest
|
|
148
|
-
* pacePressure), tiebreak soonest weekly expiry then lowest 7-day usage.
|
|
149
|
-
* Ranks swaps only; display surfaces order by earliestReset instead. */
|
|
150
83
|
const swapPreference = (now: number) => [
|
|
151
84
|
(a: Account) => -pacePressure(a, now),
|
|
152
85
|
(a: Account) => weeklyExpiry(a, now),
|
|
153
|
-
// unmeasured ranks LAST in this tiebreak (101 > any real percentage):
|
|
154
|
-
// `?? 0` made a never-sampled account look maximally safe and beat any
|
|
155
|
-
// measured one, swapping a healthy measured seat onto a complete unknown -
|
|
156
|
-
// the exact unmeasured-must-not-look-safe rule (closing-review catch);
|
|
157
|
-
// pacePressure already ranks unmeasured last by the same principle.
|
|
158
86
|
(a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 101,
|
|
159
87
|
];
|
|
160
88
|
|
|
@@ -165,11 +93,6 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
|
165
93
|
return sortBy(usable, swapPreference(ctx.now))[0] ?? null;
|
|
166
94
|
}
|
|
167
95
|
|
|
168
|
-
/** Greedy idempotence, shared by bare `xx switch` and the hooks/timer path:
|
|
169
|
-
* the active account keeps its seat while it is usable and no other usable
|
|
170
|
-
* account ranks STRICTLY better on swapPreference - swapping between equals
|
|
171
|
-
* buys nothing and would ping-pong. Rank with currentAccountUuid null so the
|
|
172
|
-
* active account competes. */
|
|
173
96
|
export function currentWins(active: Account | null, accounts: Account[], ctx: PickCtx): boolean {
|
|
174
97
|
if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
|
|
175
98
|
const best = pickBest(accounts, { ...ctx, currentAccountUuid: null });
|
|
@@ -177,9 +100,6 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
|
|
|
177
100
|
return swapPreference(ctx.now).every((k) => k(active) === k(best));
|
|
178
101
|
}
|
|
179
102
|
|
|
180
|
-
/** When an account becomes usable again: the latest blocking bound among its
|
|
181
|
-
* windows (all must clear), or `now` if nothing blocks. Consistent with
|
|
182
|
-
* isExhausted by construction (same blockingUntil). */
|
|
183
103
|
export function usableAt(a: Account, ctx: PickCtx): number {
|
|
184
104
|
const blocking = blockingUntil(a, ctx).filter((t) => t > ctx.now);
|
|
185
105
|
return blocking.length ? Math.max(...blocking) : ctx.now;
|
|
@@ -188,9 +108,6 @@ export function usableAt(a: Account, ctx: PickCtx): number {
|
|
|
188
108
|
const EarliestResetSchema = z.object({ account: AccountSchema, availableAt: z.number() });
|
|
189
109
|
export type EarliestReset = z.infer<typeof EarliestResetSchema>;
|
|
190
110
|
|
|
191
|
-
/** For the all-depleted case: the account (not current, not reauth) that becomes
|
|
192
|
-
* usable soonest. Accounts blocked with no known reset are unknowable, never
|
|
193
|
-
* a wait target. */
|
|
194
111
|
export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestReset | null {
|
|
195
112
|
const mapped = accounts
|
|
196
113
|
.filter((a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth)
|
package/src/lib/proc.ts
CHANGED
|
@@ -1,19 +1,5 @@
|
|
|
1
|
-
// Process identity beyond a bare PID: pids recycle, so anything that must act
|
|
2
|
-
// on "the process I recorded earlier" (reaping an orphan, trusting a presence
|
|
3
|
-
// file) pins pid + start time and treats a mismatch as a different process.
|
|
4
|
-
|
|
5
1
|
import { z } from "zod";
|
|
6
2
|
|
|
7
|
-
/** The ps lstart token for a pid, or null when no such process. pid + start
|
|
8
|
-
* time is the standard process identity: equality with the token captured
|
|
9
|
-
* at spawn proves this is still the SAME process, never a recycled pid.
|
|
10
|
-
* LC_ALL=C pins the lstart rendering (cubic review catch): the capturing and
|
|
11
|
-
* the comparing process can run under different locales (terminal vs
|
|
12
|
-
* launchd), and a formatting mismatch would silently break the identity.
|
|
13
|
-
* Tradeoff (flagged and accepted): lstart has one-second resolution, so a
|
|
14
|
-
* pid recycled onto a process started within the SAME wall-clock second
|
|
15
|
-
* would pass - landing on the exact pid AND second is vanishingly unlikely,
|
|
16
|
-
* and finer start-time sources are per-platform native calls ps cannot give. */
|
|
17
3
|
export function pidStartTime(pid: number): string | null {
|
|
18
4
|
const res = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart="], { env: { ...process.env, LC_ALL: "C" } });
|
|
19
5
|
if (res.exitCode !== 0) return null;
|
|
@@ -21,9 +7,6 @@ export function pidStartTime(pid: number): string | null {
|
|
|
21
7
|
return lstart === "" ? null : lstart;
|
|
22
8
|
}
|
|
23
9
|
|
|
24
|
-
/** Whether the pid names a LIVE process (signal-0 probe); EPERM still proves
|
|
25
|
-
* existence. Distinguishes "pid is dead" from "ps could not answer", which a
|
|
26
|
-
* null pidStartTime alone cannot. */
|
|
27
10
|
export function pidExists(pid: number): boolean {
|
|
28
11
|
try {
|
|
29
12
|
process.kill(pid, 0);
|
package/src/lib/sample.ts
CHANGED
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
// Live-sample a PARKED account's `/usage` without disturbing the live login.
|
|
2
|
-
// `/usage` is free (0 tokens), so `status` can show every account's real usage.
|
|
3
|
-
// We install the account's parked credential into a throwaway CLAUDE_CONFIG_DIR
|
|
4
|
-
// (the isolated credential claude reads for that dir), run `claude -p /usage`
|
|
5
|
-
// against it, then tear the item down.
|
|
6
|
-
//
|
|
7
|
-
// Contamination guards (the incident that motivated these):
|
|
8
|
-
// 1. probeUsage scrubs every ambient credential-override env var, so an
|
|
9
|
-
// inherited CLAUDE_CODE_OAUTH_TOKEN can't hijack the isolated probe.
|
|
10
|
-
// 2. before trusting a backup we verify (roles endpoint) that its token really
|
|
11
|
-
// belongs to this account - a mislabeled backup (drifted harvest) surfaces
|
|
12
|
-
// as an explicit error, never as another account's bars.
|
|
13
|
-
//
|
|
14
|
-
// Refresh tokens rotate single-use, so the one hazard is a rotation we fail to
|
|
15
|
-
// capture. Two guards: refresh an expiring token OURSELVES up front, and
|
|
16
|
-
// capture-before-delete the isolated item in case claude rotated it anyway.
|
|
17
|
-
//
|
|
18
|
-
// The caller MUST hold the tokenmaxxing flock so a parked refresh cannot collide
|
|
19
|
-
// with an in-flight performSwap refreshing the same account.
|
|
20
|
-
|
|
21
1
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
22
2
|
import { join } from "node:path";
|
|
23
3
|
import { z } from "zod";
|
|
@@ -28,9 +8,6 @@ import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg, InvalidGrantEr
|
|
|
28
8
|
import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
|
|
29
9
|
import { CredentialBlobSchema, type Account, type OAuthCreds, type RolesResponse } from "./types.ts";
|
|
30
10
|
|
|
31
|
-
/** Result of a live sample: the fresh usage, or why it could not be taken.
|
|
32
|
-
* `pingError` is set only when a requested ping (status --force) failed - the
|
|
33
|
-
* account's 5h timer may not have started even if the sample itself succeeded. */
|
|
34
11
|
const SampleOutcomeSchema = z.discriminatedUnion("ok", [
|
|
35
12
|
z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional() }),
|
|
36
13
|
z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional() }),
|
|
@@ -44,10 +21,6 @@ const IdentityCheckSchema = z.discriminatedUnion("status", [
|
|
|
44
21
|
]);
|
|
45
22
|
type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
|
|
46
23
|
|
|
47
|
-
/** Verify `creds` belongs to `account`. Only a definitive org DISAGREEMENT is
|
|
48
|
-
* a mismatch; an unreachable roles endpoint is "unavailable" and must never
|
|
49
|
-
* bench the account - a transient outage is not a dead credential, and
|
|
50
|
-
* flagging on it once removed every parked account from switching. */
|
|
51
24
|
async function checkIdentity(creds: OAuthCreds, account: Account): Promise<IdentityCheck> {
|
|
52
25
|
let org: RolesResponse;
|
|
53
26
|
try {
|
|
@@ -59,22 +32,11 @@ async function checkIdentity(creds: OAuthCreds, account: Account): Promise<Ident
|
|
|
59
32
|
return { status: "mismatch", reason: `credential actually belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)})` };
|
|
60
33
|
}
|
|
61
34
|
|
|
62
|
-
/** Stamp the blob's plan fields onto the account (caller persists). Runs only
|
|
63
|
-
* after the identity check passed, so a drifted credential can never write
|
|
64
|
-
* another account's tier. Absent blob fields keep the last-known values. */
|
|
65
35
|
function refreshPlanFields(account: Account, creds: OAuthCreds): void {
|
|
66
36
|
if (creds.subscriptionType != null) account.subscriptionType = creds.subscriptionType;
|
|
67
37
|
if (creds.rateLimitTier != null) account.rateLimitTier = creds.rateLimitTier;
|
|
68
38
|
}
|
|
69
39
|
|
|
70
|
-
/**
|
|
71
|
-
* Live-sample `account`'s `/usage` in isolation. On a dead refresh token or a
|
|
72
|
-
* mislabeled credential it sets `account.needsReauth` in place (the caller
|
|
73
|
-
* persists accounts.json). Mutates only the passed object and keychain items.
|
|
74
|
-
* With `ping`, one minimal metered request runs first (through the same
|
|
75
|
-
* isolated credential) so the account's 5h session window starts now and the
|
|
76
|
-
* sample that follows reports the freshly opened window.
|
|
77
|
-
*/
|
|
78
40
|
export async function probeParkedUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
|
|
79
41
|
const backup = parkedTarget(account.keychainItem);
|
|
80
42
|
const parkedRaw = await readItem(backup);
|
|
@@ -87,16 +49,6 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
87
49
|
return { ok: false, reason: `parked credential unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
|
|
88
50
|
}
|
|
89
51
|
|
|
90
|
-
// The parked copy must never be refreshed (or probed - the probe can rotate
|
|
91
|
-
// it too) while its account secretly owns the LIVE login: after a crash
|
|
92
|
-
// between performSwap's live install and the oauthAccount rewrite, status
|
|
93
|
-
// still routes the live account here, and a parked-side rotation would
|
|
94
|
-
// supersede the live item's single-use refresh token out from under the
|
|
95
|
-
// running session - or, if claude rotated first, falsely flag the healthy
|
|
96
|
-
// live account needsReauth (closing-review catch; mirrors the codex
|
|
97
|
-
// sampler's present-account invariant). Verified against the live blob's
|
|
98
|
-
// TRUE org, fail-closed like the rm guard: an unverifiable live owner
|
|
99
|
-
// refuses the sample rather than risking the live grant.
|
|
100
52
|
const liveRaw = await readItem(liveTarget());
|
|
101
53
|
if (liveRaw != null) {
|
|
102
54
|
let liveOrg: string;
|
|
@@ -111,9 +63,6 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
111
63
|
}
|
|
112
64
|
}
|
|
113
65
|
|
|
114
|
-
// Hand claude a token with comfortable headroom so it won't run its own refresh
|
|
115
|
-
// (which claude does within 300s of expiry - the same margin checked here).
|
|
116
|
-
// Refresh + persist ourselves first.
|
|
117
66
|
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
118
67
|
try {
|
|
119
68
|
creds = await refreshCredential(creds);
|
|
@@ -154,7 +103,6 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
154
103
|
if (pingError != null) outcome.pingError = pingError;
|
|
155
104
|
return outcome;
|
|
156
105
|
} finally {
|
|
157
|
-
// capture-before-delete: never discard a rotation claude may have performed.
|
|
158
106
|
const afterIso = await readItem(isoTarget);
|
|
159
107
|
if (afterIso && afterIso !== installed) await writeItem(backup, claudeAiOauthOnly(afterIso));
|
|
160
108
|
await deleteItem(isoTarget);
|
|
@@ -162,14 +110,6 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
162
110
|
}
|
|
163
111
|
}
|
|
164
112
|
|
|
165
|
-
/** Refresh the LIVE access token when near expiry, under claude's own refresh
|
|
166
|
-
* lock. Exported for `xx status`: every parked probe's fail-closed live-owner
|
|
167
|
-
* check reads this token, so it must be fresh BEFORE those probes run - even
|
|
168
|
-
* when the active account's own usage comes from the statusline tee and no
|
|
169
|
-
* active probe happens (cubic review catch, PR #35: the tee short-circuit
|
|
170
|
-
* skipped the refresh and every parked sample 401'd on the first post-idle
|
|
171
|
-
* status). No live item, or an unparsable one, is a no-op here: the callers'
|
|
172
|
-
* own guards surface those loudly. InvalidGrantError propagates. */
|
|
173
113
|
export async function ensureLiveTokenFresh(): Promise<void> {
|
|
174
114
|
const liveRaw = await readItem(liveTarget());
|
|
175
115
|
if (!liveRaw) return;
|
|
@@ -191,15 +131,7 @@ export async function ensureLiveTokenFresh(): Promise<void> {
|
|
|
191
131
|
});
|
|
192
132
|
}
|
|
193
133
|
|
|
194
|
-
/**
|
|
195
|
-
* Live-sample the ACTIVE account off the live login, verifying the live
|
|
196
|
-
* credential belongs to it. `/usage` with no CLAUDE_CONFIG_DIR meters the live
|
|
197
|
-
* keychain item. A drifted active label surfaces as an error, not another
|
|
198
|
-
* account's bars. With `ping`, one minimal metered request runs first (after
|
|
199
|
-
* the identity check - never spend quota on a drifted credential).
|
|
200
|
-
*/
|
|
201
134
|
export async function probeActiveUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
|
|
202
|
-
// A running claude keeps the live token fresh; after long idle it may not have.
|
|
203
135
|
try {
|
|
204
136
|
await ensureLiveTokenFresh();
|
|
205
137
|
} catch (e) {
|
package/src/lib/sessions.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// Persist the flags a managed session was launched with, so any later relaunch
|
|
2
|
-
// of that session id (a fresh supervisor invocation, or the depleted-pool
|
|
3
|
-
// recovery in #20) re-applies them instead of dropping --dangerously-skip-
|
|
4
|
-
// permissions / --model / etc.
|
|
5
|
-
|
|
6
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
7
2
|
import { join } from "node:path";
|
|
8
3
|
import { z } from "zod";
|
|
@@ -11,10 +6,6 @@ import { writeFileAtomic } from "./atomic.ts";
|
|
|
11
6
|
|
|
12
7
|
const SessionSchema = z.object({ flags: z.array(z.string()), cwd: z.string() });
|
|
13
8
|
|
|
14
|
-
// Matches claude's default transcript retention (cleanupPeriodDays 30): a
|
|
15
|
-
// transcript claude has already deleted cannot be resumed, so its flags file
|
|
16
|
-
// is dead weight. saveSessionFlags rewrites the file on every (re)launch, so
|
|
17
|
-
// an actively resumed session keeps its mtime fresh and is never pruned.
|
|
18
9
|
const SESSION_RETENTION_MS = 30 * 24 * 3600 * 1000;
|
|
19
10
|
|
|
20
11
|
function sessionFile(sid: string): string {
|
|
@@ -32,8 +23,6 @@ export function loadSessionFlags(sid: string): string[] | null {
|
|
|
32
23
|
return SessionSchema.parse(JSON.parse(readFileSync(f, "utf8"))).flags;
|
|
33
24
|
}
|
|
34
25
|
|
|
35
|
-
/** Delete session files past the retention window (also reaps stale
|
|
36
|
-
* writeFileAtomic temp siblings from a crashed writer). */
|
|
37
26
|
export function pruneStaleSessions(now: number): void {
|
|
38
27
|
const dir = join(paths.home, "sessions");
|
|
39
28
|
if (!existsSync(dir)) return;
|
|
@@ -42,8 +31,6 @@ export function pruneStaleSessions(now: number): void {
|
|
|
42
31
|
try {
|
|
43
32
|
if (now - statSync(p).mtimeMs > SESSION_RETENTION_MS) rmSync(p, { force: true });
|
|
44
33
|
} catch {
|
|
45
|
-
// A concurrent writeFileAtomic renames its tmp sibling away between
|
|
46
|
-
// readdir and stat; a vanished entry needs no pruning.
|
|
47
34
|
}
|
|
48
35
|
}
|
|
49
36
|
}
|