tokenmaxxing 1.2.0 → 1.3.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 +5 -3
- package/package.json +1 -1
- package/src/cli/config.ts +2 -0
- package/src/cli/switch.ts +3 -1
- package/src/entries/codexsupervisor.ts +5 -2
- package/src/entries/supervisor.ts +8 -2
- package/src/lib/claudebin.ts +8 -0
- package/src/lib/codexdecide.ts +7 -1
- package/src/lib/decide.ts +36 -7
- package/src/lib/picker.ts +17 -0
- package/src/lib/slackstate.ts +2 -1
- package/src/lib/state.ts +16 -3
- package/src/lib/types.ts +20 -0
- package/src/sdk.ts +9 -5
package/DESIGN.md
CHANGED
|
@@ -79,9 +79,11 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
|
|
|
79
79
|
---
|
|
80
80
|
|
|
81
81
|
## 5. Rotation policy
|
|
82
|
-
The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from there it greedily converges on the usable account furthest behind its weekly pace, staying put whenever the current account wins or ties. The
|
|
82
|
+
The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from there it greedily converges on the usable account furthest behind its weekly pace, staying put whenever the current account wins or ties. The **Layer 1 screening bars** - `five_hour >= 95%` OR `seven_day >= 98%`, per org (`thresholds`) - force a switch onto a fresher account and also screen candidates. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`bar - policy.projectionMargin`, a fixed configured margin) so a single large turn is less likely to blow past 100% before the next Stop hook.
|
|
83
83
|
|
|
84
|
-
**
|
|
84
|
+
**Two layers - pump the last drops.** The screening bars deliberately leave headroom, so when *every* account is over them Layer 1 alone would park the pool with 2-5% of each account's quota still unspent. **Layer 2 - the wall bars** (`hardThresholds`, default `100/100`, the server's own limit) - is the fallback reached only at that point: the session **holds its seat and squeezes** while it is under the wall, else swaps onto the best still-under-wall account (the same pace-pressure ranking as every other swap - squeeze the account whose weekly quota is most about to be forfeited first), and only parks (depleted-wait) once every account has truly walled. Recovery is then measured against the wall, not the screening bar, so an account whose 5h window drops below 100 is squeezable again even while its weekly window still sits above the Layer 1 bar. The wall reading is the statusLine's own `rate_limits` feed - the same server-side figure claude's `/rate-limit-options` renders - so when an account genuinely maxes out the tee shows 100 and Layer 2 moves on; a single-turn overshoot is caught one boundary later (the periodic `check` timer, or the next Stop hook) without needing to sniff assistant text. The serve/SDK path additionally stamps an account walled the instant an *errored* turn result reports a limit (`recordObservedLimit`, gated on `is_error`), because it has no statusLine tee. Set `hardThresholds` equal to `thresholds` to disable Layer 2. **Layer 2 is Claude-only:** a swap on Claude is a hot, in-place credential adoption every concurrent session follows automatically, whereas a running Codex refuses another account's credential (restart is the switch), so a last-drop-swap there would strand any sibling still on the walled account - Codex instead keeps riding its current account to the wall (its existing all-exhausted stay-put already squeezes it).
|
|
85
|
+
|
|
86
|
+
**Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches on the Fable cap; a Sonnet session rides the aggregate. Both layers apply the per-model gate: a burnt Fable cap screens an account out of a Layer 1 switch, and a Fable cap at the wall screens it out of the Layer 2 squeeze too.
|
|
85
87
|
|
|
86
88
|
---
|
|
87
89
|
|
|
@@ -107,7 +109,7 @@ A local Socket Mode daemon (no public URL) that turns Slack threads into Claude
|
|
|
107
109
|
- **One Slack app per daemon** (live incident 2026-07-20): Slack Socket Mode delivers each event envelope to exactly one of an app's open connections, so two serve daemons sharing one Slack app steal each other's events - mentions usually survive (Slack emits `app_mention` plus `message.channels` for one mention and the Chat SDK dedupes them per-process), but a non-mention thread follow-up rides a single envelope and silently dies when the wrong daemon receives it. No code can route Slack's load-balancing (the hello frame's `num_connections` is discarded by `@slack/socket-mode` before any event fires), so the constraint is documented (docs limitations page) and diagnosed: `serve.unlinked_channel` logs once per channel per run with the shared-app explanation. Every host needs its own Slack app with its own tokens.
|
|
108
110
|
- **Todo checklist card**: TodoWrite is bookkeeping, not a real tool run, so it never opens a generic card. Instead each stream gets one stable-id "Todos" card (id `todos`, subagents `todos-<parent_tool_use_id>`) that updates in place on every TodoWrite: `✅ content` for completed, `🔄 activeForm` for the in-progress item (live narration), `⬜ content` for pending (the Chat SDK Plan object's own iconography); card status goes complete only when every item is completed. The "Todos have been modified successfully" tool_result is suppressed, but a FAILED TodoWrite flips the card to error (the optimistic checklist must not claim a state that never took effect). Card ids do not carry across messages, so a TodoWrite after a dead-segment recovery starts a fresh card in the follow-on message: accepted, the latest state is always in the newest message. claude >= 2.1.142 defaults to the structured Task tools and never emits TodoWrite, so relayThread sets `CLAUDE_CODE_ENABLE_TASKS: "0"` in the spawn env (the documented opt-out; without it the checklist card is inert).
|
|
109
111
|
- **Terminal echo** (2026-07-18): the daemon registers a `log()` echo (`setLogEcho` in log.ts, off by default so hooks and the statusline keep their stdout protocols clean), so every event while it runs - serve.* plus the in-process swap/decision events from `ensureBestAccount()`/`stopHookCheck` - also prints one colored line in the foreground terminal (`formatLogLine`: dim HH:MM:SS, event painted red/yellow/cyan by structural endsWith severity, redacted fields). `serve.turn_done`/`serve.turn_failed` (with seconds) close every relayed turn, so a foreground `xx serve` is observable without tailing `tokenmaxxing.log`.
|
|
110
|
-
- **Live-verified end-to-end** (2026-07-18,
|
|
112
|
+
- **Live-verified end-to-end** (2026-07-18, in a live workspace channel): mention opens a session (worktree-per-thread at the time), replies stream, thread follow-ups resume with context, cards + fenced code render, segmentation and queue folding behave. Plus the hermetic suite: schemas/links, stream mapping, fail-fast paths.
|
|
111
113
|
|
|
112
114
|
---
|
|
113
115
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/config.ts
CHANGED
package/src/cli/switch.ts
CHANGED
|
@@ -113,7 +113,9 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
113
113
|
// No usable target swapped in: everything is depleted, or the remaining
|
|
114
114
|
// candidates' refresh tokens just died (performSwap persists needs-reauth
|
|
115
115
|
// before throwing, hence the reload). Stay on / switch to whichever
|
|
116
|
-
// recovers soonest.
|
|
116
|
+
// recovers soonest. (Layer 2 - the wall squeeze - is deliberately confined
|
|
117
|
+
// to the automatic decision path in decide.ts, which decides off the live
|
|
118
|
+
// statusLine tee; bare `xx switch` stays cache-only and simply parks here.)
|
|
117
119
|
const fresh = loadAccounts();
|
|
118
120
|
const earliest = pickEarliestReset(fresh.accounts, everyone);
|
|
119
121
|
if (!earliest) {
|
|
@@ -13,7 +13,7 @@ import { join } from "node:path";
|
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { codexPaths, paths } from "../lib/paths.ts";
|
|
15
15
|
import { withLock } from "../lib/lock.ts";
|
|
16
|
-
import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
|
|
16
|
+
import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, UNMANAGED_ENV, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
|
|
17
17
|
import { resolveRealCodex } from "../lib/codexbin.ts";
|
|
18
18
|
import { clearCodexPresence, writeCodexPresence } from "../lib/codexpresence.ts";
|
|
19
19
|
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
@@ -92,7 +92,10 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
|
|
|
92
92
|
const real = resolveRealCodex();
|
|
93
93
|
const childEnv = { ...process.env, [WRAP_DEPTH_ENV]: String(depth + 1) };
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
// The unmanaged-zone sentinel forces passthrough regardless of argv, exactly
|
|
96
|
+
// like the claude shim: a serve turn's agent running `codex exec` must reach
|
|
97
|
+
// the real codex instead of dying at the shared depth cap.
|
|
98
|
+
if (!shouldManageCodex({ argv }) || process.env[UNMANAGED_ENV]) {
|
|
96
99
|
// STRIP the supervisor pairing env from unmanaged spawns: a nested codex
|
|
97
100
|
// launched from inside a supervised session (e.g. its agent running
|
|
98
101
|
// `codex exec ...`) would otherwise inherit the OUTER session's id, and
|
|
@@ -12,7 +12,7 @@ import { join } from "node:path";
|
|
|
12
12
|
import { maxBy } from "es-toolkit";
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { paths } from "../lib/paths.ts";
|
|
15
|
-
import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, resolveRealClaude, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
|
|
15
|
+
import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, UNMANAGED_ENV, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, resolveRealClaude, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
|
|
16
16
|
import { saveTermios, restoreTermios } from "../lib/tty.ts";
|
|
17
17
|
import { loadSessionFlags, pruneStaleSessions, saveSessionFlags } from "../lib/sessions.ts";
|
|
18
18
|
import { RespawnMarkerSchema } from "../lib/types.ts";
|
|
@@ -274,7 +274,13 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
274
274
|
const childEnv = { ...process.env, [WRAP_DEPTH_ENV]: String(depth + 1) };
|
|
275
275
|
|
|
276
276
|
// Pass-through: no session management, no respawn - exact stock behavior.
|
|
277
|
-
|
|
277
|
+
// The unmanaged-zone sentinel (pooledSpawnEnv) forces it regardless of argv:
|
|
278
|
+
// everything below an SDK-driven session runs the real claude unsupervised,
|
|
279
|
+
// so a repo script invoking `claude` from inside a serve turn works instead
|
|
280
|
+
// of dying at the depth cap. The sentinel rides childEnv, so the whole
|
|
281
|
+
// subtree stays unmanaged, and depth still counts wrapper entries toward
|
|
282
|
+
// the cap above, keeping a poisoned pin inside the zone bounded.
|
|
283
|
+
if (!info.manage || process.env[UNMANAGED_ENV]) {
|
|
278
284
|
// STRIP the supervision pairing env (mirrors the codex shim's passthrough
|
|
279
285
|
// arm, closing-review catch): a nested unmanaged claude inside a
|
|
280
286
|
// supervised session (e.g. the agent running `claude -p ...`) would
|
package/src/lib/claudebin.ts
CHANGED
|
@@ -15,6 +15,14 @@ import { writeFileAtomic } from "./atomic.ts";
|
|
|
15
15
|
* fork-bombing the machine (2026-07-12: ~1800 runaway bun processes). */
|
|
16
16
|
export const WRAP_DEPTH_ENV = "TOKENMAXXING_WRAP_DEPTH";
|
|
17
17
|
export const MAX_WRAP_DEPTH = 5;
|
|
18
|
+
/** "No session management anywhere below this env": the wrapper passes a
|
|
19
|
+
* `claude`/`codex` invocation straight through to the real binary, unmanaged.
|
|
20
|
+
* Set by pooledSpawnEnv so an SDK-driven session's descendants (an agent
|
|
21
|
+
* running `bun run validate` whose script invokes `claude -p`) still work
|
|
22
|
+
* instead of dying at the depth cap, which is a LOOP diagnostic, not an
|
|
23
|
+
* unmanaged-zone marker. Depth keeps incrementing per wrapper entry under
|
|
24
|
+
* this sentinel, so a poisoned pin inside the zone still aborts at the cap. */
|
|
25
|
+
export const UNMANAGED_ENV = "TOKENMAXXING_UNMANAGED";
|
|
18
26
|
/** Stable fragment of the loop-abort diagnostic; verifyRealClaude greps a
|
|
19
27
|
* child's stderr for it to name the failure precisely. */
|
|
20
28
|
export const LOOP_DIAGNOSIS = "wrapper re-entered without reaching the real claude";
|
package/src/lib/codexdecide.ts
CHANGED
|
@@ -260,7 +260,13 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
|
|
|
260
260
|
|
|
261
261
|
// Hard path: a bar is crossed. Land on the best usable candidate, walking
|
|
262
262
|
// past dead grants; a fully depleted pool stays put (no pre-park: nothing
|
|
263
|
-
// can pause a codex session for a countdown yet).
|
|
263
|
+
// can pause a codex session for a countdown yet). Layer 2 (the wall) is
|
|
264
|
+
// deliberately claude-only: a running codex cannot hot-adopt a swapped
|
|
265
|
+
// credential (restart IS the switch), so a last-drop-swap onto a still-
|
|
266
|
+
// under-wall account would strand any concurrent sibling on the departed
|
|
267
|
+
// account (the reconcile can only signal siblings onto a Layer-1-usable
|
|
268
|
+
// seat), and a hold-only Layer 2 is identical to codex already staying put
|
|
269
|
+
// here - so codex just rides the current account to its wall.
|
|
264
270
|
const tried = new Set<string>();
|
|
265
271
|
while (true) {
|
|
266
272
|
const current = loadCodexAccounts();
|
package/src/lib/decide.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { paths } from "./paths.ts";
|
|
|
30
30
|
import { loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
|
|
31
31
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
32
32
|
import { chooseAndSwap, performSwap } from "./swap.ts";
|
|
33
|
-
import { currentWins, effectiveBars, pickBest, pickEarliestReset, usableAt } from "./picker.ts";
|
|
33
|
+
import { currentWins, effectiveBars, hardBars, isExhausted, pickBest, pickEarliestReset, usableAt } from "./picker.ts";
|
|
34
34
|
import { InvalidGrantError } from "./oauth.ts";
|
|
35
35
|
import { familyTokens, gatedFamilies, probeUsage } from "./usage.ts";
|
|
36
36
|
import { log } from "./log.ts";
|
|
@@ -307,15 +307,44 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
307
307
|
const landed = await chooseAndSwap({ now, thresholds: effectiveBars(cfg), switchFamilies, currentAccountUuid: seatOf(loadAccounts())?.accountUuid ?? null });
|
|
308
308
|
if (landed) return { swapped: true, account: landed, reason: "swapped" };
|
|
309
309
|
|
|
310
|
-
// Every account is
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
310
|
+
// ── LAYER 2 (the wall). Every account is exhausted at the Layer 1
|
|
311
|
+
// screening bars, so Layer 1 alone would park the pool right here with
|
|
312
|
+
// quota still unspent on every account. Before parking, pump the last drops
|
|
313
|
+
// against the hard wall bars (default the server's own 100% limit, the same
|
|
314
|
+
// figure /rate-limit-options reads): hold the seat while it is still under
|
|
315
|
+
// its wall, else move onto the best still-under-wall account (chooseAndSwap
|
|
316
|
+
// keeps the usual pace-pressure ranking - squeeze the account whose weekly
|
|
317
|
+
// quota is most about to be forfeited first). Only when EVERY account has
|
|
318
|
+
// truly walled do we fall through to the depleted-wait park below. The wall
|
|
319
|
+
// reading is the statusLine's authoritative rate_limits tee (the same data
|
|
320
|
+
// /rate-limit-options renders); a single-turn overshoot is caught one
|
|
321
|
+
// boundary later by the check timer or the next Stop hook, and the serve/SDK
|
|
322
|
+
// path additionally stamps observed limits on errored results.
|
|
323
|
+
const hardCtx = { now, thresholds: hardBars(cfg), currentAccountUuid: null, switchFamilies };
|
|
324
|
+
const seat = seatOf(loadAccounts());
|
|
325
|
+
if (seat && !seat.needsReauth && !isExhausted(seat, hardCtx)) {
|
|
326
|
+
log("decide.last_drop_hold", { account: seat.accountUuid.slice(0, 8) });
|
|
327
|
+
return { swapped: false, account: null, reason: "last-drop-hold" };
|
|
328
|
+
}
|
|
329
|
+
const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null });
|
|
330
|
+
if (squeezed) {
|
|
331
|
+
log("decide.last_drop_swap", { account: squeezed.accountUuid.slice(0, 8) });
|
|
332
|
+
return { swapped: true, account: squeezed, reason: "last-drop-swap" };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Every account is walled. Wait for whichever drops below its wall soonest
|
|
336
|
+
// (including the current one), if that reset is within the auto-wait window.
|
|
337
|
+
// Recovery is measured against the WALL, not the screening bars: an account
|
|
338
|
+
// whose session window resets below 100 is squeezable again even while its
|
|
339
|
+
// weekly window still sits above the Layer 1 bar, so waiting on the Layer 1
|
|
340
|
+
// reset would over-park. A dead grant on the chosen pre-park target must not
|
|
341
|
+
// abort the wait: performSwap persists needs-reauth before throwing, so each
|
|
342
|
+
// retry re-ranks without the dead account and the loop terminates (mirrors
|
|
343
|
+
// the greedy loop above).
|
|
315
344
|
while (true) {
|
|
316
345
|
const fresh = loadAccounts();
|
|
317
346
|
const current = seatOf(fresh);
|
|
318
|
-
const ctx = { now, thresholds:
|
|
347
|
+
const ctx = { now, thresholds: hardBars(cfg), currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
|
|
319
348
|
const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
|
|
320
349
|
const other = pickEarliestReset(fresh.accounts, ctx);
|
|
321
350
|
|
package/src/lib/picker.ts
CHANGED
|
@@ -27,6 +27,23 @@ export function effectiveBars(cfg: Config): Thresholds {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
|
|
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
|
+
export function hardBars(cfg: Config): Thresholds {
|
|
41
|
+
return {
|
|
42
|
+
session: cfg.hardThresholds.session - cfg.policy.projectionMargin,
|
|
43
|
+
weekly: cfg.hardThresholds.weekly - cfg.policy.projectionMargin,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
const PickCtxSchema = z.object({
|
|
31
48
|
now: z.number(),
|
|
32
49
|
thresholds: ThresholdsSchema,
|
package/src/lib/slackstate.ts
CHANGED
|
@@ -310,7 +310,8 @@ export function bareChannelId(id: string): string {
|
|
|
310
310
|
* Strip a leading Slack mention OF THE BOT from message text. Two forms: raw
|
|
311
311
|
* mrkdwn ("<@U0123> rest") and the bare form the Chat SDK's incoming
|
|
312
312
|
* mrkdwn->markdown normalization produces ("@U0123 rest") - observed live
|
|
313
|
-
* 2026-07-18 when a relayed prompt arrived starting
|
|
313
|
+
* 2026-07-18 when a relayed prompt arrived starting with the bot's bare
|
|
314
|
+
* "@U..." token. Only a
|
|
314
315
|
* token whose id equals botUserId is stripped (review catch 2026-07-18:
|
|
315
316
|
* handleTurn runs this over every subscribed message, so a follow-up starting
|
|
316
317
|
* with a colleague's mention must keep it - the prompt would otherwise lose
|
package/src/lib/state.ts
CHANGED
|
@@ -20,9 +20,14 @@ import {
|
|
|
20
20
|
// ---- config.json (minimal, fixed schema) ---------------------------------
|
|
21
21
|
|
|
22
22
|
const DEFAULT_CONFIG: Config = {
|
|
23
|
-
//
|
|
24
|
-
// cheap to sit out, weekly quota is use-it-or-lose-it so it drains
|
|
23
|
+
// LAYER 1 - screening bars, split per window (user 2026-07-16): a session
|
|
24
|
+
// reset is cheap to sit out, weekly quota is use-it-or-lose-it so it drains
|
|
25
|
+
// to 98. These drive normal account-to-account switching with headroom.
|
|
25
26
|
thresholds: { session: 95, weekly: 98 },
|
|
27
|
+
// LAYER 2 - the wall bars (default the server's own 100% limit). Reached only
|
|
28
|
+
// once every account is over its Layer 1 bar: from there a session pumps the
|
|
29
|
+
// last drops up to the wall instead of parking with quota unspent.
|
|
30
|
+
hardThresholds: { session: 100, weekly: 100 },
|
|
26
31
|
claudeBin: "",
|
|
27
32
|
codexBin: "",
|
|
28
33
|
// per-model weekly caps exist only for Sonnet and Fable (no Opus-only quota,
|
|
@@ -42,6 +47,7 @@ const PercentSchema = z.number().min(0).max(100);
|
|
|
42
47
|
export const ConfigFileSchema = z
|
|
43
48
|
.object({
|
|
44
49
|
thresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
|
|
50
|
+
hardThresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
|
|
45
51
|
claudeBin: z.string(),
|
|
46
52
|
codexBin: z.string(),
|
|
47
53
|
policy: z
|
|
@@ -69,9 +75,16 @@ export type MergeOutcome = z.infer<typeof MergeOutcomeSchema>;
|
|
|
69
75
|
* throw, silently disabling status/switch/hooks/statusline until the file is
|
|
70
76
|
* hand-repaired (closing-review catch). */
|
|
71
77
|
export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutcome {
|
|
72
|
-
const cfg: Config = {
|
|
78
|
+
const cfg: Config = {
|
|
79
|
+
...DEFAULT_CONFIG,
|
|
80
|
+
thresholds: { ...DEFAULT_CONFIG.thresholds },
|
|
81
|
+
hardThresholds: { ...DEFAULT_CONFIG.hardThresholds },
|
|
82
|
+
policy: { ...DEFAULT_CONFIG.policy },
|
|
83
|
+
};
|
|
73
84
|
cfg.thresholds.session = p.thresholds?.session ?? cfg.thresholds.session;
|
|
74
85
|
cfg.thresholds.weekly = p.thresholds?.weekly ?? cfg.thresholds.weekly;
|
|
86
|
+
cfg.hardThresholds.session = p.hardThresholds?.session ?? cfg.hardThresholds.session;
|
|
87
|
+
cfg.hardThresholds.weekly = p.hardThresholds?.weekly ?? cfg.hardThresholds.weekly;
|
|
75
88
|
cfg.claudeBin = p.claudeBin ?? cfg.claudeBin;
|
|
76
89
|
cfg.codexBin = p.codexBin ?? cfg.codexBin;
|
|
77
90
|
cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
|
package/src/lib/types.ts
CHANGED
|
@@ -142,6 +142,18 @@ export type Thresholds = z.infer<typeof ThresholdsSchema>;
|
|
|
142
142
|
export const ConfigSchema = z
|
|
143
143
|
.object({
|
|
144
144
|
thresholds: ThresholdsSchema,
|
|
145
|
+
/** LAYER 2 - the wall bars. `thresholds` (Layer 1) screen normal
|
|
146
|
+
* account-to-account switching and deliberately leave headroom; these are
|
|
147
|
+
* the true-wall bars the decision falls back to ONLY once every account is
|
|
148
|
+
* exhausted at Layer 1. Below the wall a session holds its seat and pumps
|
|
149
|
+
* the last drops; a window at/over the wall (default 100 = the server's own
|
|
150
|
+
* limit, the same figure /rate-limit-options reads) is genuinely spent and
|
|
151
|
+
* the pool moves to the next account, parking only when all are walled.
|
|
152
|
+
* Set equal to `thresholds` to disable Layer 2 (hardBars subtracts the same
|
|
153
|
+
* projectionMargin as effectiveBars, so equal thresholds collapse to one
|
|
154
|
+
* effective bar and Layer 2 has no band to act in). At the default margin 0
|
|
155
|
+
* the wall is the literal 100. */
|
|
156
|
+
hardThresholds: ThresholdsSchema,
|
|
145
157
|
claudeBin: z.string(),
|
|
146
158
|
/** the real codex binary (empty = resolve from PATH); pinned by `init --codex`. */
|
|
147
159
|
codexBin: z.string(),
|
|
@@ -168,6 +180,14 @@ export const ConfigSchema = z
|
|
|
168
180
|
// switch path churns. Per-field bounds alone cannot see this.
|
|
169
181
|
.refine((cfg) => cfg.policy.projectionMargin < Math.min(cfg.thresholds.session, cfg.thresholds.weekly), {
|
|
170
182
|
message: "policy.projectionMargin must be strictly below both thresholds (effectiveBars would hit zero and every account would read as exhausted)",
|
|
183
|
+
})
|
|
184
|
+
// The wall must sit at or above each screening bar. A wall BELOW its
|
|
185
|
+
// screening bar would make Layer 2 "usable" a stricter test than Layer 1
|
|
186
|
+
// screening - the pool could reach the wall fallback and find every account
|
|
187
|
+
// already over the (lower) wall, parking earlier than Layer 1 alone would.
|
|
188
|
+
// Equal is allowed and simply disables Layer 2 for that window.
|
|
189
|
+
.refine((cfg) => cfg.hardThresholds.session >= cfg.thresholds.session && cfg.hardThresholds.weekly >= cfg.thresholds.weekly, {
|
|
190
|
+
message: "hardThresholds (the Layer 2 wall) must be at or above thresholds (the Layer 1 screening bars) for both windows",
|
|
171
191
|
});
|
|
172
192
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
173
193
|
|
package/src/sdk.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// dependency set (zod, es-toolkit, ky).
|
|
15
15
|
|
|
16
16
|
import { z } from "zod";
|
|
17
|
-
import {
|
|
17
|
+
import { UNMANAGED_ENV, resolveRealClaude } from "./lib/claudebin.ts";
|
|
18
18
|
import { evaluateAndMaybeSwap, type SwapDecision } from "./lib/decide.ts";
|
|
19
19
|
import { CRED_ENV_OVERRIDES } from "./lib/usage.ts";
|
|
20
20
|
import { log } from "./lib/log.ts";
|
|
@@ -52,9 +52,13 @@ const AMBIENT_STORE_VARS = ["CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CONFIG_DI
|
|
|
52
52
|
* The env an SDK-spawned claude must run under to meter the POOLED live
|
|
53
53
|
* credential: every ambient credential override is scrubbed (claude honors
|
|
54
54
|
* them BEFORE its keychain/file lookup, so one inherited ANTHROPIC_API_KEY
|
|
55
|
-
* silently meters the wrong account), and the
|
|
56
|
-
* so a
|
|
57
|
-
*
|
|
55
|
+
* silently meters the wrong account), and the unmanaged-zone sentinel is set
|
|
56
|
+
* so a descendant invoking `claude`/`codex` through the on-PATH wrapper gets a
|
|
57
|
+
* passthrough to the real binary instead of nested supervision (or, before
|
|
58
|
+
* this sentinel existed, a depth-cap abort that broke legitimate nested runs
|
|
59
|
+
* like a repo's `bun run validate` invoking `claude -p`). The wrap depth is
|
|
60
|
+
* NOT preset: it keeps counting real wrapper entries, so a poisoned claudeBin
|
|
61
|
+
* pin below here still dies at the cap.
|
|
58
62
|
*
|
|
59
63
|
* Returns a FULL environment, not a patch: the Agent SDK's Options.env
|
|
60
64
|
* REPLACES the subprocess env rather than merging over process.env (verified
|
|
@@ -69,7 +73,7 @@ export function pooledSpawnEnv(): Record<string, string> {
|
|
|
69
73
|
);
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
|
-
const env: Record<string, string> = { ...process.env, [
|
|
76
|
+
const env: Record<string, string> = { ...process.env, [UNMANAGED_ENV]: "1" };
|
|
73
77
|
for (const k of CRED_ENV_OVERRIDES) delete env[k];
|
|
74
78
|
return env;
|
|
75
79
|
}
|