tokenmaxxing 0.21.0 → 1.0.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
@@ -72,7 +72,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
72
72
 
73
73
  - **`tokenmaxxing init` imports the account you're already on - automatically, no prompts, no re-login.** It reads the live `Claude Code-credentials` keychain blob plus the `oauthAccount` object in `~/.claude.json` (email, `organizationUuid`, `accountUuid`, plan tier) and writes them as **account #1** into tokenmaxxing's store (`tokenmaxxing-cred-<accountUuid[:8]>` + an `accounts.json` index entry). Nothing about your current session changes - that account stays active; it's now just also a registered pool member. After this one command you already have a working (single-account) pool. `init` also installs the supervisor + the four settings entries.
74
74
  - If the current auth is API-key mode (`ANTHROPIC_API_KEY`/`apiKeyHelper`) rather than a subscription `/login`, there's no quota-poolable subscription credential to import - `init` says so and points you to `/login` first (per-token API billing isn't what tokenmaxxing pools).
75
- - **`tokenmaxxing add`** - registers *additional* accounts: logs one in via a throwaway `CLAUDE_CONFIG_DIR=~/.config/tokenmaxxing/onboard` (your primary login untouched), harvests it into the store, deletes the temp dir + its namespaced item. This is the **only** time `CLAUDE_CONFIG_DIR` is ever used.
75
+ - **`tokenmaxxing add`** - registers *additional* accounts: logs one in via a throwaway `CLAUDE_CONFIG_DIR=~/.config/tokenmaxxing/onboard` (your primary login untouched), harvests it into the store, deletes the temp dir + its namespaced item. `CLAUDE_CONFIG_DIR` is SET by tokenmaxxing only for throwaway isolated stores like this one: the same harvest serves `auth`, and parked-account `/usage` sampling probes under `sample/<item>` the same way; an AMBIENT `CLAUDE_CONFIG_DIR` is refused by every CLI command and by the SDK's pooledSpawnEnv.
76
76
  - **`tokenmaxxing auth [sel | --all]`** - reauthenticates an *existing* pool member whose refresh token died (a needs-reauth account can never heal through a swap: the dead token is exactly what a swap would need). Same isolated-login harvest as `add`, but it states which email to sign in with and **requires the login to land on the target account** (harvested `accountUuid` must match, else nothing changes) - the credential write and the needs-reauth clear happen in one flock critical section so a concurrent swap's harvest cannot clobber the fresh backup. Bare `auth` lists the pool with emails and asks which; `--all` walks every flagged account one by one.
77
77
  - Both commands exercise `security` reads/writes interactively (where a macOS keychain ACL prompt is acceptable), so the first access never happens cold inside a headless hook.
78
78
 
package/README.md CHANGED
@@ -39,6 +39,7 @@ claude # use claude as always
39
39
  | `tokenmaxxing add` | register an additional account (isolated login, harvested into the pool) |
40
40
  | `tokenmaxxing add --codex` | register an additional codex account (isolated login) |
41
41
  | `tokenmaxxing auth [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every needs-reauth account one by one |
42
+ | `tokenmaxxing switch [sel]` | switch the claude pool: bare picks the best account greedily (no-op when the current one wins), a selector targets one |
42
43
  | `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start) |
43
44
  | `tokenmaxxing ls` | list pooled accounts |
44
45
  | `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
@@ -47,7 +48,7 @@ claude # use claude as always
47
48
  | `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
48
49
  | `tokenmaxxing serve` | Slack bridge daemon (Socket Mode, no public URL): `setup` prints the app manifest and stores the two tokens, `link <channel-id> <repo>` ties a channel to a repo (`--yolo` for full-autonomy bypassPermissions sessions), then mentioning the bot in that channel opens a Claude Code session per thread in the repo checkout (the session cuts its own git worktree only when a task needs isolation) and thread messages relay in and out |
49
50
  | `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
50
- | `tokenmaxxing rename [--codex] <sel> <label>` / `rm <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
51
+ | `tokenmaxxing rename [--codex] <sel> <label>` / `rm [--codex] <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
51
52
  | `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
52
53
 
53
54
  ## How switching decides
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.21.0",
3
+ "version": "1.0.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",
@@ -0,0 +1,49 @@
1
+ // `tokenmaxxing rm --codex <selector>` - remove a pooled codex account (not
2
+ // the live one). Until this existed the uninstall message pointed users at
3
+ // `xx rm` for every parked credential while codex blobs were unremovable
4
+ // (adversarial-review catch).
5
+
6
+ import { withLock } from "../lib/lock.ts";
7
+ import { codexPaths } from "../lib/paths.ts";
8
+ import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
9
+ import { deleteParkedCodexAuth } from "../lib/codexauth.ts";
10
+ import { liveCodexAccountId } from "../lib/codexsample.ts";
11
+ import { presentCodexAccountIds } from "../lib/codexpresence.ts";
12
+ import { findCodexAccount } from "./rename.ts";
13
+ import { c } from "./render.ts";
14
+
15
+ export async function cmdCodexRm(selector?: string): Promise<number> {
16
+ if (!selector) {
17
+ console.error("usage: tokenmaxxing rm --codex <email|label|id>");
18
+ return 2;
19
+ }
20
+ // under the codex flock: a concurrent swap's index write must not be clobbered.
21
+ return withLock(codexPaths.lockFile, async () => {
22
+ const index = loadCodexAccounts();
23
+ const account = findCodexAccount(index.accounts, selector);
24
+ if (!account) {
25
+ console.error(c.red(`no codex account matches "${selector}"`));
26
+ return 1;
27
+ }
28
+ // The live identity is decoded from auth.json itself (id_token claims),
29
+ // offline ground truth - labels drift, the blob cannot lie. An unreadable
30
+ // live blob THROWS out of liveCodexAccountId (the codex loaders' contract),
31
+ // which fails this destructive command loudly rather than trusting a label.
32
+ if (liveCodexAccountId() === account.accountId) {
33
+ console.error(c.red(`${account.label} is the LIVE codex account - run \`tokenmaxxing switch --codex\` to move off it first.`));
34
+ return 1;
35
+ }
36
+ if (presentCodexAccountIds().has(account.accountId)) {
37
+ console.error(c.red(`${account.label} is running in a live codex session - close that session before removing it.`));
38
+ return 1;
39
+ }
40
+ // Parked codex blobs are plain 0600 files; hard delete on purpose (the
41
+ // credential-dir cleanup exception - trashing would move a credential
42
+ // into the Trash folder).
43
+ deleteParkedCodexAuth({ credFile: account.credFile });
44
+ index.accounts = index.accounts.filter((x) => x.accountId !== account.accountId);
45
+ saveCodexAccounts({ index });
46
+ console.log(`removed codex account ${c.bold(account.label)} from the pool (${index.accounts.length} left)`);
47
+ return 0;
48
+ });
49
+ }
@@ -32,7 +32,11 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
32
32
  }
33
33
  const currentId = liveCodexAccountId();
34
34
 
35
- if (sel !== undefined) {
35
+ // truthiness on purpose, mirroring the claude switch: an EMPTY selector
36
+ // must mean "no selector" - `startsWith("")` matches every account, so
37
+ // `sel !== undefined` let `xx switch --codex ""` swap onto the first
38
+ // account (adversarial-review catch)
39
+ if (sel) {
36
40
  const target = index.accounts.find(
37
41
  (account) => account.label === sel || account.email === sel || account.accountId.startsWith(sel),
38
42
  );
package/src/cli/config.ts CHANGED
@@ -160,6 +160,21 @@ function cmdUnset(key: string): number {
160
160
  const next = structuredClone(raw);
161
161
  unset(next, key);
162
162
  pruneEmptyParents(next);
163
+ // Same merged-whole gate as `set` (adversarial-review catch): removing one
164
+ // override shifts the merged value back to its default, and the
165
+ // projectionMargin-vs-thresholds refine can fail on the RESULT even though
166
+ // every remaining field is individually valid - writing that file would make
167
+ // every later loadConfig throw, bricking status/switch/hooks/statusline.
168
+ const validated = ConfigFileSchema.safeParse(next);
169
+ if (!validated.success) {
170
+ console.error(c.red(`rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`));
171
+ return 1;
172
+ }
173
+ const mergedCheck = mergeConfigFile(validated.data);
174
+ if (!mergedCheck.ok) {
175
+ console.error(c.red(`rejected: ${mergedCheck.detail} (adjust the conflicting override before unsetting ${key})`));
176
+ return 1;
177
+ }
163
178
  writeRawFile({ raw: next });
164
179
  console.log(`${key} unset -> ${JSON.stringify(get(loadConfig(), key))} (default)`);
165
180
  return 0;
package/src/cli/rename.ts CHANGED
@@ -39,6 +39,16 @@ async function renameCodexAccount(input: { selector: string; newLabel: string })
39
39
  console.error(c.red(`no codex account matches "${input.selector}"`));
40
40
  return 1;
41
41
  }
42
+ // labels resolve selectors first-match: a duplicate would make the other
43
+ // account unreachable by label and misdirect destructive commands like
44
+ // `rm` onto the wrong one (adversarial-review catch)
45
+ // case-insensitive, matching how findCodexAccount resolves selectors (PR
46
+ // #37 review catch: a casing-only duplicate slipped the === guard)
47
+ const taken = index.accounts.find((x) => x.accountId !== account.accountId && x.label.toLowerCase() === input.newLabel.toLowerCase());
48
+ if (taken) {
49
+ console.error(c.red(`label "${input.newLabel}" is already used by ${taken.accountId.slice(0, 8)} - labels must be unique within the pool`));
50
+ return 1;
51
+ }
42
52
  const old = account.label;
43
53
  account.label = input.newLabel;
44
54
  saveCodexAccounts({ index });
@@ -63,6 +73,16 @@ export async function cmdRename(argv: string[]): Promise<number> {
63
73
  console.error(c.red(`no claude account matches "${selector}" (codex accounts rename via --codex)`));
64
74
  return 1;
65
75
  }
76
+ // labels resolve selectors first-match: a duplicate would make the other
77
+ // account unreachable by label and misdirect destructive commands like
78
+ // `rm` onto the wrong one (adversarial-review catch)
79
+ // case-insensitive, matching how findAccount resolves selectors (PR #37
80
+ // review catch: a casing-only duplicate slipped the === guard)
81
+ const taken = idx.accounts.find((x) => x.accountUuid !== a.accountUuid && x.label.toLowerCase() === newLabel.toLowerCase());
82
+ if (taken) {
83
+ console.error(c.red(`label "${newLabel}" is already used by ${taken.email} - labels must be unique within the pool`));
84
+ return 1;
85
+ }
66
86
  const old = a.label;
67
87
  a.label = newLabel;
68
88
  saveAccounts(idx);
package/src/cli/serve.ts CHANGED
@@ -363,9 +363,12 @@ export function buildServeRuntime(seam: {
363
363
  // exists to detect. Outside a drain, or on success, clear it.
364
364
  // An announced drop is TERMINAL: relayThread told the user to resend, so
365
365
  // retaining the marker would replay work the drop notice disclaimed
366
- // (duplicate turns, quota, side effects). null outcome = relay threw =
366
+ // (duplicate turns, quota, side effects). A turn whose child reached a
367
+ // SUCCESSFUL result is also terminal even when failed (that failure is
368
+ // Slack delivery, not a killed child - resuming would re-run completed
369
+ // work; adversarial-review catch). null outcome = relay threw =
367
370
  // still presumed killed.
368
- const presumedKilled = draining && (outcome === null || (outcome.failed && !outcome.announcedDrop));
371
+ const presumedKilled = draining && (outcome === null || (outcome.failed && !outcome.announcedDrop && !outcome.resultReceived));
369
372
  // An unannounced drop OUTSIDE a drain still clears the marker on
370
373
  // purpose (retention would re-execute the turn at the next restart; see
371
374
  // notifyDelivered's doc) - but the loss must be operator-visible.
@@ -730,8 +733,14 @@ async function runDaemon(): Promise<number> {
730
733
  // expiry is SILENT (chat 4.34.0 has no app callback for it), so the TTL
731
734
  // must outlast the longest legitimate hold: a depleted-pool park
732
735
  // (PARK_MAX_MS 14min) plus a long claude turn. Expired-and-folded beats
733
- // silently-vanished, hence a full hour.
734
- concurrency: { strategy: "queue", queueEntryTtlMs: 3_600_000 },
736
+ // silently-vanished, hence a full hour. maxQueueSize matters for the same
737
+ // reason: the SDK default is 10 with a LOG-LESS drop-oldest trim
738
+ // (@chat-adapter/state-memory enqueue splices the front, and the SDK's
739
+ // message-dropped log only fires for drop-newest), so a >10-message burst
740
+ // behind one long turn silently ate the oldest instructions
741
+ // (adversarial-review catch). 100 outlasts any legitimate burst; the TTL
742
+ // stays the real bound.
743
+ concurrency: { strategy: "queue", queueEntryTtlMs: 3_600_000, maxQueueSize: 100 },
735
744
  // without this a cards-only segment in post-and-edit fallback would
736
745
  // strand a bare "..." placeholder message.
737
746
  fallbackStreamingPlaceholderText: null,
@@ -155,6 +155,44 @@ export function stripSessionFlags(argv: string[]): string[] {
155
155
  return out;
156
156
  }
157
157
 
158
+ /** Remove positional tokens (the one-shot initial prompt) while keeping every
159
+ * flag and its consumed value(s). A positional is a submit-once user turn:
160
+ * persisting or replaying it on a respawn / later `--resume` re-injects the
161
+ * original instruction into an already-progressed session (adversarial-review
162
+ * HIGH catch) - only real flags like --model belong in sessions/ files and
163
+ * respawn args.
164
+ *
165
+ * TRADEOFF (WONTFIX, flagged PR #37): when claude adds a value-taking root
166
+ * flag before the sets above are updated, that value reads as a positional
167
+ * and is dropped, so the respawn launches without it and claude errors on the
168
+ * missing argument - loud, and the user relaunches. The alternative default
169
+ * (treat a bare token after an UNRECOGNIZED flag as that flag's value) turns
170
+ * the same staleness silent: a newly added BOOLEAN flag sitting before the
171
+ * prompt would make the prompt look like a value and replay a submit-once
172
+ * turn, which is the exact harm this function exists to prevent. The
173
+ * ambiguity is irreducible without claude's own option table, and a loud
174
+ * broken launch beats a silent re-submit. */
175
+ export function stripPositionals(argv: string[]): string[] {
176
+ const out: string[] = [];
177
+ for (let i = 0; i < argv.length; i++) {
178
+ const a = argv[i]!;
179
+ // `--` ends option parsing: everything after it is positional (a prompt
180
+ // deliberately starting with "-"), never a flag to persist (PR #37
181
+ // review catch). The delimiter itself is dropped with them.
182
+ if (a === "--") break;
183
+ if (!a.startsWith("-")) continue;
184
+ out.push(a);
185
+ if (VALUE_TAKING_ROOT_FLAGS.has(a)) {
186
+ if (i + 1 < argv.length) out.push(argv[++i]!);
187
+ } else if (VARIADIC_ROOT_FLAGS.has(a)) {
188
+ while (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) out.push(argv[++i]!);
189
+ } else if (OPTIONAL_VALUE_ROOT_FLAGS.has(a)) {
190
+ if (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) out.push(argv[++i]!);
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
158
196
  /** Newest transcript session id for the current cwd (for `-c`). claude's
159
197
  * project-dir slug maps EVERY non-alphanumeric char to "-": the regex below
160
198
  * mirrors claude's own, byte for byte (binary-verified 2.1.215, the external-
@@ -272,9 +310,17 @@ export async function runSupervisor(argv: string[]): Promise<number> {
272
310
  // this time (a bare `claude --resume <id>`, or the depleted-pool recovery).
273
311
  if (resuming && base.length === 0) {
274
312
  const persisted = loadSessionFlags(sid);
275
- if (persisted) base = persisted;
313
+ // enforce the flags-only contract at the trust boundary, not just at
314
+ // write: a sessions/ file written before stripPositionals existed can
315
+ // still carry the original prompt, and restoring it verbatim would
316
+ // re-submit that prompt on a bare `claude --resume` (PR #37 review
317
+ // catch). Idempotent on well-formed files.
318
+ if (persisted) base = stripPositionals(persisted);
276
319
  }
277
- saveSessionFlags(sid, base, process.cwd());
320
+ // The FIRST launch keeps a positional prompt (the user just typed it);
321
+ // everything persisted or respawned carries flags only.
322
+ const persistable = stripPositionals(base);
323
+ saveSessionFlags(sid, persistable, process.cwd());
278
324
  pruneStaleSessions(Date.now());
279
325
 
280
326
  let launchArgs = resuming ? ["--resume", sid, ...base] : ["--session-id", sid, ...base];
@@ -330,9 +376,11 @@ export async function runSupervisor(argv: string[]): Promise<number> {
330
376
  // /clear they differ, and resuming the pinned id would revive the
331
377
  // pre-/clear conversation (closing-review HIGH catch). Persist the
332
378
  // flags under that transcript id too, so a later bare
333
- // `claude --resume <id>` restores them (PR #36 review catch).
334
- saveSessionFlags(m.sessionId, base, process.cwd());
335
- launchArgs = ["--resume", m.sessionId, ...base];
379
+ // `claude --resume <id>` restores them (PR #36 review catch). FLAGS
380
+ // only: replaying a positional prompt would re-submit it as a fresh
381
+ // turn on the progressed session (adversarial-review HIGH catch).
382
+ saveSessionFlags(m.sessionId, persistable, process.cwd());
383
+ launchArgs = ["--resume", m.sessionId, ...persistable];
336
384
  continue;
337
385
  }
338
386
  // No marker: claude exited on its own (quit, crash, resume refused). Log it -
@@ -4,7 +4,7 @@
4
4
  // rust-v0.144.5 manager.rs), so every mutation here runs under tokenmaxxing's
5
5
  // own codex flock, held by the caller.
6
6
 
7
- import { readFileSync } from "node:fs";
7
+ import { readFileSync, rmSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { z } from "zod";
10
10
  import { writeFileAtomic } from "./atomic.ts";
@@ -65,6 +65,13 @@ export function writeParkedCodexAuth(input: { credFile: string; auth: CodexAuthJ
65
65
  writeFileAtomic(parkedPath(input), JSON.stringify(CodexAuthJsonSchema.parse(input.auth), null, 2), 0o600);
66
66
  }
67
67
 
68
+ /** `rm --codex` uses this so the path shape (.json suffix) has one owner:
69
+ * a hand-built path without the suffix silently missed the real file under
70
+ * rmSync force (PR #37 review catch). */
71
+ export function deleteParkedCodexAuth(input: { credFile: string }): void {
72
+ rmSync(parkedPath(input), { force: true });
73
+ }
74
+
68
75
  /** Identity claims inside the id_token JWT payload (verified against a live
69
76
  * 0.144.4 token: the chatgpt fields sit under the api.openai.com/auth claim). */
70
77
  const IdClaimsSchema = z.looseObject({
package/src/lib/decide.ts CHANGED
@@ -48,10 +48,22 @@ const SwapDecisionSchema = z.object({
48
48
  });
49
49
  export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
50
50
 
51
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
52
+ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
53
+
51
54
  /** A window's usable-against percentage NOW: one whose cached reset has passed
52
- * is empty again, never a switch reason. */
53
- function liveUsed(w: UsageWindow, now: number): number {
54
- return w.resetsAt != null && w.resetsAt <= now ? 0 : w.usedPercentage;
55
+ * is empty again, never a switch reason. A NULL-reset window (reset clock
56
+ * failed to parse) self-bounds at sampledAt + the window's own duration,
57
+ * mirroring the picker's blockedUntil and the codex liveUsed: without the
58
+ * bound the trigger side kept reading a long-stale over-bar row as live while
59
+ * the screening side had already released it - the two halves of one decision
60
+ * disagreed, forcing hard-path swaps (or waitUntil=now respawn churn) off a
61
+ * healthy account (adversarial-review catch). */
62
+ function liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: number; now: number }): number {
63
+ const { window: w, windowMs, sampledAt, now } = input;
64
+ if (w.resetsAt != null) return w.resetsAt <= now ? 0 : w.usedPercentage;
65
+ if (now >= sampledAt + windowMs) return 0;
66
+ return w.usedPercentage;
55
67
  }
56
68
 
57
69
  /** The family's weekly cap among the `/usage` rows; when several rows match the
@@ -61,7 +73,7 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
61
73
  const rows = Object.entries(mu.perModel)
62
74
  .filter(([k]) => familyTokens(k).includes(family))
63
75
  .map(([, w]) => w);
64
- return maxBy(rows, (w) => liveUsed(w, now));
76
+ return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
65
77
  }
66
78
 
67
79
  /** True if the active account is over its floor on ANY screening bar: the 5h
@@ -71,11 +83,14 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
71
83
  function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, now: number): boolean {
72
84
  if (!u || !org || u.org !== org) return false;
73
85
  const bars = effectiveBars(cfg);
74
- if (liveUsed(u.fiveHour, now) >= bars.session || liveUsed(u.sevenDay, now) >= bars.weekly) return true;
86
+ if (
87
+ liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= bars.session ||
88
+ liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }) >= bars.weekly
89
+ ) return true;
75
90
  if (mu && mu.org === org) {
76
91
  for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
77
92
  const cap = capForFamily(mu, family, now);
78
- if (cap && liveUsed(cap, now) >= bars.weekly) return true;
93
+ if (cap && liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }) >= bars.weekly) return true;
79
94
  }
80
95
  }
81
96
  return false;
@@ -91,7 +106,7 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
91
106
  * a fresh session rides its account - no churn. */
92
107
  function isEngaged(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, now: number): boolean {
93
108
  if (!u || !org || u.org !== org) return false;
94
- return liveUsed(u.fiveHour, now) >= cfg.policy.greedySessionFloor || isOver(u, mu, org, cfg, now);
109
+ return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, org, cfg, now);
95
110
  }
96
111
 
97
112
  const SnapshotsSchema = z.object({
@@ -41,6 +41,11 @@ export const TurnOutcomeSchema = z.object({
41
41
  * re-send it"): a drain must NOT presume a killed child and retain the
42
42
  * resume marker, or startup replays work the user was told to resend. */
43
43
  announcedDrop: z.boolean(),
44
+ /** the LAST attempt's claude child ran to a SUCCESSFUL result: the work is
45
+ * done even if Slack delivery later failed (textLost sets failed for the
46
+ * operator's benefit). A drain must not read that delivery failure as a
47
+ * killed child and re-run completed work (adversarial-review catch). */
48
+ resultReceived: z.boolean(),
44
49
  });
45
50
  export type TurnOutcome = z.infer<typeof TurnOutcomeSchema>;
46
51
 
@@ -384,7 +389,7 @@ export async function relayThread(input: {
384
389
  * out a depleted-pool countdown. */
385
390
  drainSignal?: AbortSignal;
386
391
  }): Promise<TurnOutcome> {
387
- const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false, rateLimited: false, finish: false, announcedDrop: false };
392
+ const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false, rateLimited: false, finish: false, announcedDrop: false, resultReceived: false };
388
393
  let segment: ReturnType<typeof pushableStream> | null = null;
389
394
  let segmentMeta: { text: boolean } | null = null;
390
395
  let lastPost: Promise<unknown> = Promise.resolve();
@@ -478,6 +483,7 @@ export async function relayThread(input: {
478
483
  postedText = false;
479
484
  outcome.failed = false;
480
485
  outcome.rateLimited = false;
486
+ outcome.resultReceived = false;
481
487
  // outcome.finish stays sticky across retries: the tool call already
482
488
  // happened in this session, and a limit right after it must not unfinish
483
489
  // the thread.
@@ -575,6 +581,7 @@ export async function relayThread(input: {
575
581
  if (outcome.rateLimited) await recordObservedLimit({ text, now: Date.now(), org: spawnOrg });
576
582
  } else {
577
583
  result = message.result;
584
+ outcome.resultReceived = true;
578
585
  }
579
586
  }
580
587
  for (const part of agentEventChunks({ state: mapState, message })) {
package/src/lib/types.ts CHANGED
@@ -311,13 +311,25 @@ export const CodexUsageSchema = z.object({
311
311
  });
312
312
  export type CodexUsage = z.infer<typeof CodexUsageSchema>;
313
313
 
314
+ /** A parked-credential file NAME, never a path. These are machine-written
315
+ * (`tokenmaxxing-codex-<id8>`), so a separator here means the index is
316
+ * corrupted - and `join(credsDir, credFile)` would normalize `../` right out
317
+ * of codex-creds, letting `rm --codex` unlink another file (review catch).
318
+ * Refusing at parse time matches the codex loaders' throw-on-unparsable
319
+ * contract and covers the read/write paths with the delete. */
320
+ const BareFileNameSchema = z
321
+ .string()
322
+ .refine((s) => s.length > 0 && s !== "." && s !== ".." && !s.includes("/") && !s.includes("\\"), {
323
+ message: "credFile must be a bare file name, not a path",
324
+ });
325
+
314
326
  /** A pooled codex account (codex-accounts.json - NON-secret). */
315
327
  export const CodexAccountSchema = z.object({
316
328
  accountId: z.string(),
317
329
  email: z.string().nullable(),
318
330
  label: z.string(),
319
331
  planType: z.string().nullable(),
320
- credFile: z.string(),
332
+ credFile: BareFileNameSchema,
321
333
  addedAt: z.string(),
322
334
  needsReauth: z.boolean().optional(),
323
335
  lastUsage: z
package/src/main.ts CHANGED
@@ -24,6 +24,7 @@ import { cmdStatus } from "./cli/status.ts";
24
24
  import { cmdWatch } from "./cli/watch.ts";
25
25
  import { cmdDoctor } from "./cli/doctor.ts";
26
26
  import { cmdRm } from "./cli/rm.ts";
27
+ import { cmdCodexRm } from "./cli/codexrm.ts";
27
28
  import { cmdRename } from "./cli/rename.ts";
28
29
  import { cmdSwitch } from "./cli/switch.ts";
29
30
  import { cmdCheck } from "./cli/check.ts";
@@ -52,7 +53,7 @@ function printHelp(): void {
52
53
  ${c.cyan("tokenmaxxing serve")} [setup|link|unlink|links] Slack bridge daemon: mention the bot in a linked channel to open a claude session per thread in the repo checkout
53
54
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
54
55
  ${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
55
- ${c.cyan("tokenmaxxing rm")} <sel>
56
+ ${c.cyan("tokenmaxxing rm")} [--codex] <sel>
56
57
  ${c.cyan("tokenmaxxing uninstall")} remove supervisor + settings entries
57
58
 
58
59
  ${c.dim("(aliased as")} ${c.cyan("xx")}${c.dim(")")} - then just run ${c.bold("claude")} as always; it switches accounts near quota automatically.`);
@@ -122,7 +123,13 @@ async function main(): Promise<number> {
122
123
  case "status": return cmdStatus(args.includes("--force"));
123
124
  case "watch": return cmdWatch(args[1]);
124
125
  case "doctor": return cmdDoctor();
125
- case "rm": return cmdRm(args[1]);
126
+ // --codex accepted anywhere, like switch/rename: the two pools are
127
+ // separate namespaces and codex accounts were otherwise unremovable
128
+ // (adversarial-review catch).
129
+ case "rm": {
130
+ const rest = args.slice(1).filter((a) => a !== "--codex");
131
+ return args.includes("--codex") ? cmdCodexRm(rest[0]) : cmdRm(rest[0]);
132
+ }
126
133
  case "rename": return cmdRename(args.slice(1));
127
134
  case "uninstall": {
128
135
  const out = uninstallSupervisor();