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 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 hard bars - `five_hour >= 95%` OR `seven_day >= 98%`, per org - always force a switch 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.
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
- **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.
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, #tokenmaxxing-dogfooding): 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.
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.2.0",
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
@@ -20,6 +20,8 @@ import { c } from "./render.ts";
20
20
  export const KNOWN_KEYS = [
21
21
  "thresholds.session",
22
22
  "thresholds.weekly",
23
+ "hardThresholds.session",
24
+ "hardThresholds.weekly",
23
25
  "claudeBin",
24
26
  "codexBin",
25
27
  "policy.projectionMargin",
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
- if (!shouldManageCodex({ argv })) {
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
- if (!info.manage) {
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
@@ -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";
@@ -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 depleted. Wait for whichever recovers soonest (including
311
- // the current one), if that reset is within the auto-wait window. A dead
312
- // grant on the chosen pre-park target must not abort the wait: performSwap
313
- // persists needs-reauth before throwing, so each retry re-ranks without the
314
- // dead account and the loop terminates (mirrors the greedy loop above).
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: effectiveBars(cfg), currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
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,
@@ -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 "@U0BHS1YKNSK". Only a
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
- // Screening bars, split per window (user 2026-07-16): a session reset is
24
- // cheap to sit out, weekly quota is use-it-or-lose-it so it drains to 98.
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 = { ...DEFAULT_CONFIG, thresholds: { ...DEFAULT_CONFIG.thresholds }, policy: { ...DEFAULT_CONFIG.policy } };
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 { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./lib/claudebin.ts";
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 wrap depth is preset to the cap
56
- * so a poisoned claudeBin pin that leads back into the tokenmaxxing wrapper
57
- * aborts on first entry instead of fork-bombing.
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, [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
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
  }