tokenmaxxing 1.45.0 → 1.47.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 +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/lib/claude.ts +13 -5
- package/src/lib/picker.ts +17 -6
- package/src/lib/sample.ts +61 -47
- package/src/lib/types.ts +5 -3
package/DESIGN.md
CHANGED
|
@@ -40,7 +40,7 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
|
|
|
40
40
|
- `bin/claude` - the supervisor.
|
|
41
41
|
- Per-account **credential stores** follow the platform's Claude Code store: macOS = a login-keychain item keyed by the store path (never plaintext on disk); Linux = a 0600 `.credentials.json` inside the 0700 store directory (the same plaintext model claude itself uses - its Linux build has no keyring path at all, binary-verified 2.1.205). One `credstore` facade dispatches on a `{kind: keychain|file}` target; call sites never branch on platform. tokenmaxxing writes a store once, at onboarding; Claude Code's own refresh is the only writer afterwards.
|
|
42
42
|
|
|
43
|
-
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it folds fresh tees into the index and samples the
|
|
43
|
+
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it folds fresh tees into the index and samples the accounts whose `/usage` figures are oldest (up to three per tick), so the cached figures every placement reads stay fresh.
|
|
44
44
|
|
|
45
45
|
The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process. The `claude` binary, `~/.claude`, `~/.claude.json`, and Claude Code's default credential store are untouched.
|
|
46
46
|
|
|
@@ -83,7 +83,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
|
|
|
83
83
|
The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account that ranks first; nothing usable, wait for the soonest reset. The same ranking places a session at launch.
|
|
84
84
|
|
|
85
85
|
- Ranking (decided by consult for #79): greatest remaining headroom to the session bar divided by the number of sessions after placement, `(bar - used) / (sessions + 1)`, then highest weekly pace pressure (remaining weekly percent over time to the weekly reset), then soonest weekly reset, then lowest weekly usage; unknown usage ranks last. Organization membership is not an input. Codex keeps the pure pace ordering and excludes accounts running in another codex session, because codex has no cross-process refresh lock and two sessions must never share a login.
|
|
86
|
-
- Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples
|
|
86
|
+
- Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, under each account's store, so every account is attempted about once per three ticks per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the `/usage` child.
|
|
87
87
|
- The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
|
|
88
88
|
- Neither pool has a post-swap cooldown or a manual `switch`: a move is a respawn of one session under a fixed store, so nothing another session reads is invalidated by it, and placement at launch replaces the manual pick.
|
|
89
89
|
|
|
@@ -95,7 +95,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
|
|
|
95
95
|
|
|
96
96
|
## 6. Honest papercuts
|
|
97
97
|
- **A move restarts the process.** Every move is a `claude` stop and `--resume`; anything typed in the split second before the SIGTERM is lost, the supervisor resets terminal mode so nothing is left garbled, and the first turn on the new account re-uploads context once (prompt cache is org-scoped). A bar-triggered move compacts on the old account first so that upload is the summary, not the transcript; a move after a refusal cannot, and re-uploads the full context.
|
|
98
|
-
- **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the next prompt continues the conversation on the fresh account.
|
|
98
|
+
- **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the next prompt continues the conversation on the fresh account. The static session-bar margin (`policy.projectionMargin`, default 3) reduces this.
|
|
99
99
|
- **Unsupervised sessions are not moved.** A claude started outside the supervisor runs on whatever login its environment names, by default Claude Code's default store, which tokenmaxxing never writes.
|
|
100
100
|
- **One shared identity file.** `~/.claude.json` keeps a single `oauthAccount`, which Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email. The store decides which credential a session uses.
|
|
101
101
|
- **Dead stores.** A refresh Claude Code cannot complete dead-clears the store; the sampler and the decision recognize the empty tokens without a request, flag the account, and `auth` repairs it in place.
|
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
101
101
|
{
|
|
102
102
|
"thresholds": { "session": 90, "weekly": 98 },
|
|
103
103
|
"policy": {
|
|
104
|
-
"projectionMargin":
|
|
104
|
+
"projectionMargin": 3,
|
|
105
105
|
"switchModels": ["fable"],
|
|
106
106
|
"usagePollTtlMs": 90000,
|
|
107
107
|
"checkIntervalMs": 60000
|
|
@@ -109,7 +109,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
109
109
|
}
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from
|
|
112
|
+
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 3), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a `/usage` attempt stays fresh, for a seat's per-model poll and for the account each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
|
|
113
113
|
|
|
114
114
|
State lives entirely in `~/.config/tokenmaxxing/`. Each account's credential store is `stores/<uuid8>/`, which Claude Code reads and refreshes as its own: on macOS the credential is a login-keychain item keyed by the store path (never plaintext on disk), on Linux a 0600 `.credentials.json` inside the store (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
115
115
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.47.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/lib/claude.ts
CHANGED
|
@@ -35,15 +35,18 @@ function presence(): Map<string, number> {
|
|
|
35
35
|
return seatCounts(paths.presenceDir);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const PROBE_BACKOFF_CAP_MS = 30 * 60 * 1000;
|
|
39
|
+
|
|
38
40
|
async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
|
|
39
41
|
const ttl = cfg.policy.usagePollTtlMs;
|
|
40
|
-
const
|
|
42
|
+
const interval = Math.min(ttl * 2 ** (account.probeFails ?? 0), PROBE_BACKOFF_CAP_MS);
|
|
43
|
+
const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= interval;
|
|
41
44
|
const snap = loadUsageSnapshot(account.id);
|
|
42
45
|
const fresh = snap != null && now - snap.at <= ttl;
|
|
43
46
|
const needsPerModel = snap != null && gatedFamilies(snap.state.model, cfg.policy.switchModels).length > 0;
|
|
44
47
|
if (opts.probe && !probeAttempted && (!fresh || needsPerModel)) {
|
|
45
48
|
const startedAt = Date.now();
|
|
46
|
-
const outcome = await probeAccountUsage(account);
|
|
49
|
+
const outcome = await probeAccountUsage(account, { retries: 0 });
|
|
47
50
|
await withLock(claudePool.lockFile, () => {
|
|
48
51
|
const idx = loadAccounts(claudePool);
|
|
49
52
|
const a = idx.accounts.find((x) => x.id === account.id);
|
|
@@ -51,9 +54,14 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
|
|
|
51
54
|
a.lastProbeAt = startedAt;
|
|
52
55
|
a.tier = account.tier;
|
|
53
56
|
if (account.needsReauth) a.needsReauth = true;
|
|
54
|
-
if (outcome.ok
|
|
55
|
-
a.
|
|
56
|
-
a.lastUsageAt
|
|
57
|
+
if (outcome.ok) {
|
|
58
|
+
a.probeFails = 0;
|
|
59
|
+
if (a.lastUsageAt == null || startedAt > a.lastUsageAt) {
|
|
60
|
+
a.windows = mergeWindows(windowsOf(outcome.usage, startedAt), a.windows);
|
|
61
|
+
a.lastUsageAt = startedAt;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
a.probeFails = (a.probeFails ?? 0) + 1;
|
|
57
65
|
}
|
|
58
66
|
saveAccounts(claudePool, idx);
|
|
59
67
|
});
|
package/src/lib/picker.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresh
|
|
|
6
6
|
export function thresholdBars(cfg: Config): Thresholds {
|
|
7
7
|
return {
|
|
8
8
|
session: cfg.thresholds.session - cfg.policy.projectionMargin,
|
|
9
|
-
weekly: cfg.thresholds.weekly
|
|
9
|
+
weekly: cfg.thresholds.weekly,
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -87,11 +87,22 @@ export function earliestReset(a: Account, now: number): number {
|
|
|
87
87
|
return Math.min(session != null && session > now ? session : Number.POSITIVE_INFINITY, weeklyExpiry(a, now));
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
function windowResetAt(w: Window, now: number): number | null {
|
|
91
|
+
if (w.resetsAt != null) return nextWeeklyReset(w.resetsAt, now);
|
|
92
|
+
if (w.windowSeconds != null) return w.sampledAt + w.windowSeconds * 1000;
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function pacePressure(a: Account, ctx: PickCtx): number {
|
|
97
|
+
const binding = minBy(gatedWindows(a, ctx.families), (w) => Math.max(0, 100 - liveUsed(w, ctx.now)));
|
|
98
|
+
if (binding != null) {
|
|
99
|
+
const reset = windowResetAt(binding, ctx.now);
|
|
100
|
+
if (reset != null) return Math.max(0, 100 - liveUsed(binding, ctx.now)) / Math.max(1, reset - ctx.now);
|
|
101
|
+
}
|
|
91
102
|
const weekly = weeklyWindow(a);
|
|
92
|
-
const reset = nextWeeklyReset(weekly?.resetsAt ?? null, now);
|
|
103
|
+
const reset = nextWeeklyReset(weekly?.resetsAt ?? null, ctx.now);
|
|
93
104
|
if (weekly == null || reset == null) return 0;
|
|
94
|
-
return Math.max(0, 100 - liveUsed(weekly, now)) / Math.max(1, reset - now);
|
|
105
|
+
return Math.max(0, 100 - liveUsed(weekly, ctx.now)) / Math.max(1, reset - ctx.now);
|
|
95
106
|
}
|
|
96
107
|
|
|
97
108
|
export function seatHeadroom(a: Account, ctx: PickCtx): number {
|
|
@@ -102,7 +113,7 @@ export function seatHeadroom(a: Account, ctx: PickCtx): number {
|
|
|
102
113
|
|
|
103
114
|
const swapPreference = (ctx: PickCtx) => [
|
|
104
115
|
...(ctx.seats == null ? [] : [(a: Account) => -seatHeadroom(a, ctx)]),
|
|
105
|
-
(a: Account) => -pacePressure(a, ctx
|
|
116
|
+
(a: Account) => -pacePressure(a, ctx),
|
|
106
117
|
(a: Account) => weeklyExpiry(a, ctx.now),
|
|
107
118
|
(a: Account) => weeklyWindow(a)?.usedPercentage ?? 101,
|
|
108
119
|
];
|
|
@@ -116,7 +127,7 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
|
|
|
116
127
|
if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
|
|
117
128
|
const best = pickBest(accounts, { ...ctx, currentId: null });
|
|
118
129
|
if (best == null || best.id === active.id) return true;
|
|
119
|
-
if (margin > 1) return pacePressure(best, ctx
|
|
130
|
+
if (margin > 1) return pacePressure(best, ctx) <= pacePressure(active, ctx) * margin;
|
|
120
131
|
return swapPreference(ctx).every((k) => k(active) === k(best));
|
|
121
132
|
}
|
|
122
133
|
|
package/src/lib/sample.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { minBy } from "es-toolkit";
|
|
4
3
|
import { z } from "zod";
|
|
5
4
|
import { writeFileAtomic } from "./atomic.ts";
|
|
6
5
|
import { readStore } from "./credstore.ts";
|
|
@@ -69,11 +68,14 @@ export async function runProbe(account: Account, dir: string, opts: { retries?:
|
|
|
69
68
|
return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
export async function probeAccountUsage(account: Account): Promise<SampleOutcome> {
|
|
71
|
+
export async function probeAccountUsage(account: Account, opts: { retries?: number } = {}): Promise<SampleOutcome> {
|
|
73
72
|
const prepared = await prepareProbe(account);
|
|
74
|
-
return prepared.ok ? runProbe(account, prepared.dir,
|
|
73
|
+
return prepared.ok ? runProbe(account, prepared.dir, opts) : prepared;
|
|
75
74
|
}
|
|
76
75
|
|
|
76
|
+
const SAMPLE_BATCH = 3;
|
|
77
|
+
const STORE_FAILS_REAUTH = 5;
|
|
78
|
+
|
|
77
79
|
export async function sampleOldest(cfg: Config): Promise<void> {
|
|
78
80
|
const reserved = await withLock(claudePool.lockFile, async () => {
|
|
79
81
|
const now = Date.now();
|
|
@@ -81,52 +83,64 @@ export async function sampleOldest(cfg: Config): Promise<void> {
|
|
|
81
83
|
let dirty = false;
|
|
82
84
|
for (const a of idx.accounts) dirty = foldTee(a) || dirty;
|
|
83
85
|
const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
|
|
84
|
-
const stale = idx.accounts
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
const stale = idx.accounts
|
|
87
|
+
.filter((a) => a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs)
|
|
88
|
+
.sort((a, b) => sampledAt(a) - sampledAt(b))
|
|
89
|
+
.slice(0, SAMPLE_BATCH);
|
|
90
|
+
if (stale.length === 0) {
|
|
87
91
|
if (dirty) saveAccounts(claudePool, idx);
|
|
88
|
-
return
|
|
89
|
-
}
|
|
90
|
-
target.lastProbeAt = now;
|
|
91
|
-
const prepared = await prepareProbe(target, "-tick");
|
|
92
|
-
saveAccounts(claudePool, idx);
|
|
93
|
-
if (!prepared.ok) {
|
|
94
|
-
log("sample.failed", { account: target.id.slice(0, 8), reason: prepared.reason.slice(0, 200) });
|
|
95
|
-
return null;
|
|
92
|
+
return [];
|
|
96
93
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
outcome = await runProbe(reserved.account, reserved.dir, { retries: 0 });
|
|
94
|
+
const batch: { account: Account; dir: string; token: string | null }[] = [];
|
|
95
|
+
for (const target of stale) {
|
|
96
|
+
target.lastProbeAt = now;
|
|
97
|
+
const prepared = await prepareProbe(target, "-tick");
|
|
98
|
+
if (!prepared.ok) {
|
|
99
|
+
target.storeFails = (target.storeFails ?? 0) + 1;
|
|
100
|
+
if (target.storeFails >= STORE_FAILS_REAUTH) target.needsReauth = true;
|
|
101
|
+
log("sample.failed", { account: target.id.slice(0, 8), reason: prepared.reason.slice(0, 200) });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
target.storeFails = 0;
|
|
105
|
+
let token: string | null = null;
|
|
106
|
+
if (!seatCounts(paths.presenceDir).has(target.id)) {
|
|
107
|
+
const creds = await readStore(target.id).catch(() => null);
|
|
108
|
+
if (creds && !isDeadCredential(creds) && !isAccessTokenExpiring(creds)) token = creds.accessToken;
|
|
109
|
+
}
|
|
110
|
+
batch.push({ account: target, dir: prepared.dir, token });
|
|
115
111
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
await withLock(claudePool.lockFile, () => {
|
|
120
|
-
const idx = loadAccounts(claudePool);
|
|
121
|
-
const stored = idx.accounts.find((a) => a.id === reserved.account.id);
|
|
122
|
-
if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
|
|
123
|
-
stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
|
|
124
|
-
stored.lastUsageAt = startedAt;
|
|
125
|
-
saveAccounts(claudePool, idx);
|
|
126
|
-
}
|
|
127
|
-
log(outcome.ok ? "sample.ok" : "sample.failed", {
|
|
128
|
-
account: reserved.account.id.slice(0, 8),
|
|
129
|
-
...(outcome.ok ? { via } : { reason: outcome.reason.slice(0, 200) }),
|
|
130
|
-
});
|
|
112
|
+
saveAccounts(claudePool, idx);
|
|
113
|
+
return batch;
|
|
131
114
|
});
|
|
115
|
+
await Promise.all(
|
|
116
|
+
reserved.map(async ({ account, dir, token }) => {
|
|
117
|
+
const startedAt = Date.now();
|
|
118
|
+
let via = "probe";
|
|
119
|
+
let outcome: SampleOutcome;
|
|
120
|
+
if (token) {
|
|
121
|
+
const usage = await fetchUsageDirect(token);
|
|
122
|
+
if (usage) {
|
|
123
|
+
via = "get";
|
|
124
|
+
outcome = { ok: true, usage };
|
|
125
|
+
} else {
|
|
126
|
+
outcome = await runProbe(account, dir, { retries: 0 });
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
outcome = await runProbe(account, dir, { retries: 0 });
|
|
130
|
+
}
|
|
131
|
+
await withLock(claudePool.lockFile, () => {
|
|
132
|
+
const idx = loadAccounts(claudePool);
|
|
133
|
+
const stored = idx.accounts.find((a) => a.id === account.id);
|
|
134
|
+
if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
|
|
135
|
+
stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
|
|
136
|
+
stored.lastUsageAt = startedAt;
|
|
137
|
+
saveAccounts(claudePool, idx);
|
|
138
|
+
}
|
|
139
|
+
log(outcome.ok ? "sample.ok" : "sample.failed", {
|
|
140
|
+
account: account.id.slice(0, 8),
|
|
141
|
+
...(outcome.ok ? { via } : { reason: outcome.reason.slice(0, 200) }),
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
132
146
|
}
|
package/src/lib/types.ts
CHANGED
|
@@ -70,6 +70,8 @@ export const AccountSchema = z.object({
|
|
|
70
70
|
windows: z.array(WindowSchema).default([]),
|
|
71
71
|
lastUsageAt: z.number().optional(),
|
|
72
72
|
lastProbeAt: z.number().optional(),
|
|
73
|
+
probeFails: z.number().optional(),
|
|
74
|
+
storeFails: z.number().optional(),
|
|
73
75
|
enforcedUntil: z.number().optional(),
|
|
74
76
|
needsReauth: z.boolean().optional(),
|
|
75
77
|
oauthAccount: OAuthAccountSchema.optional(),
|
|
@@ -114,7 +116,7 @@ export const ConfigSchema = z
|
|
|
114
116
|
opencodeBin: z.string().default(""),
|
|
115
117
|
policy: z
|
|
116
118
|
.object({
|
|
117
|
-
projectionMargin: z.number().min(0).max(100).default(
|
|
119
|
+
projectionMargin: z.number().min(0).max(100).default(3),
|
|
118
120
|
switchModels: z
|
|
119
121
|
.array(z.string())
|
|
120
122
|
.default(["fable"])
|
|
@@ -125,9 +127,9 @@ export const ConfigSchema = z
|
|
|
125
127
|
})
|
|
126
128
|
.prefault({}),
|
|
127
129
|
})
|
|
128
|
-
.refine((cfg) => cfg.policy.projectionMargin <
|
|
130
|
+
.refine((cfg) => cfg.policy.projectionMargin < cfg.thresholds.session, {
|
|
129
131
|
path: ["policy", "projectionMargin"],
|
|
130
|
-
message: "must be strictly below
|
|
132
|
+
message: "must be strictly below the session threshold (the session bar would hit zero and every account would read as exhausted)",
|
|
131
133
|
});
|
|
132
134
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
133
135
|
|