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 +1 -1
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/cli/codexrm.ts +49 -0
- package/src/cli/codexswitch.ts +5 -1
- package/src/cli/config.ts +15 -0
- package/src/cli/rename.ts +20 -0
- package/src/cli/serve.ts +13 -4
- package/src/entries/supervisor.ts +53 -5
- package/src/lib/codexauth.ts +8 -1
- package/src/lib/decide.ts +22 -7
- package/src/lib/slackbridge.ts +8 -1
- package/src/lib/types.ts +13 -1
- package/src/main.ts +9 -2
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.
|
|
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.
|
|
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
|
+
}
|
package/src/cli/codexswitch.ts
CHANGED
|
@@ -32,7 +32,11 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
|
32
32
|
}
|
|
33
33
|
const currentId = liveCodexAccountId();
|
|
34
34
|
|
|
35
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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 -
|
package/src/lib/codexauth.ts
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
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 (
|
|
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({
|
package/src/lib/slackbridge.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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();
|