tokenmaxxing 1.130.0 → 1.131.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +3 -3
- package/README.md +7 -7
- package/package.json +1 -1
- package/src/cli/auth.ts +1 -1
- package/src/cli/rename.ts +1 -1
- package/src/cli/seat.ts +0 -6
- package/src/cli/status.ts +5 -11
- package/src/lib/paths.ts +0 -19
- package/src/lib/provider.ts +1 -1
- package/src/lib/sessions.ts +3 -3
- package/src/lib/state.ts +1 -2
- package/src/lib/statusonly.ts +1 -1
- package/src/lib/types.ts +0 -1
- package/src/main.ts +20 -22
- package/src/lib/opencodego.ts +0 -99
package/DESIGN.md
CHANGED
|
@@ -30,7 +30,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
|
|
|
30
30
|
- A `claude` **supervisor** on your PATH ahead of the real binary (`~/.config/tokenmaxxing/bin/claude`) - you invoke it identically; a `claude` shell function or alias would shadow it, and `doctor` warns about one.
|
|
31
31
|
- Five `~/.claude/settings.json` entries (merged - other settings keys are preserved, but the `statusLine` slot is taken over): the tokenmaxxing `statusLine` renderer (native since 2026-07-11; it also tees usage), a `subagentStatusLine` (per-subagent rows in the agents panel), a `Stop` hook, a `StopFailure` hook, a `SessionStart` hook.
|
|
32
32
|
- **`~/.config/tokenmaxxing/`** - the single home for config and state:
|
|
33
|
-
- `config.json` - SPARSE overrides only (thresholds.session/weekly/accounts, claudeBin/codexBin/grokBin/
|
|
33
|
+
- `config.json` - SPARSE overrides only (thresholds.session/weekly/accounts, claudeBin/codexBin/grokBin/piBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
|
|
34
34
|
- `accounts.json` - non-secret index `{id, label, email, tier, addedAt, windows, lastUsageAt, lastProbeAt, probeFails, storeFails, usageRetryAt, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`). Neither index names an active account: the schema is `{version, accounts}`, and an older file that still carries `activeId` loads and drops the key on the next save.
|
|
35
35
|
- `stores/<uuid8>/` - one Claude Code credential store per pooled account (see below).
|
|
36
36
|
- `codex-stores/<uuid8>/` - one Codex credential store per pooled account: its `auth.json`, with everything else symlinked to the shared `~/.codex` so sessions, history, and config stay shared and `codex resume` works across stores.
|
|
@@ -40,7 +40,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
|
|
|
40
40
|
- `wait-queue.json` - the claims of supervised sessions that are moving or waiting (`{sessionId, accountId, at, waitUntil}`), naming the account each relaunches on: placement counts a claimed session there, and at most eight sessions wait on one account; a session that arrives when every reset within `policy.maxWaitMs` is full waits for the next reset with room up to twice `policy.maxWaitMs` out, joining the least-crowded reset when the horizon is full.
|
|
41
41
|
- `bin/claude` and `bin/codex` - the supervisors, beside the `tokenmaxxing` and `xx` entry points; `bin/pi` after `init --pi`.
|
|
42
42
|
- `pi-stores/claude/<uuid8>/` and `pi-stores/codex/<uuid8>/` - one pi store per pooled account that has a pi login: the account's own pi grant in `auth.json`, everything else symlinked to the shared `~/.pi/agent` ([pi support](docs/content/docs/pi.mdx)).
|
|
43
|
-
- `sessions/<sid>.json`, `onboard/`, `hub-key`, `update.json`, `spawnrate.json`, `tokenmaxxing.log`, the codex `codex-onboard/`, `codex-respawn/`, and `codex-live/`, and the `grok-*`
|
|
43
|
+
- `sessions/<sid>.json`, `onboard/`, `hub-key`, `update.json`, `spawnrate.json`, `tokenmaxxing.log`, the codex `codex-onboard/`, `codex-respawn/`, and `codex-live/`, and the `grok-*` state of the status-only grok pool: the full table is in [State directory layout](docs/content/docs/state-directory.mdx).
|
|
44
44
|
- Per-account **credential stores** follow the platform's Claude Code store: macOS = a login-keychain item keyed by the store path (never plaintext on disk); Linux = a 0600 `.credentials.json` inside the 0700 store directory (the same plaintext model claude itself uses - its Linux build has no keyring path at all, binary-verified 2.1.205). One `credstore` facade dispatches on a `{kind: keychain|file}` target; call sites never branch on platform. tokenmaxxing writes a store once, at onboarding; Claude Code's own refresh is the only writer afterwards.
|
|
45
45
|
|
|
46
46
|
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it runs the decision with no seat (nothing moves), folds fresh tees into the index, samples up to three accounts whose last sample attempt is oldest (the direct no-spend usage GET), deletes leftover atomic-write temp files under the state root, and on a Bun global install checks npm for a newer version once a day, so the cached figures every placement reads stay fresh.
|
|
@@ -118,7 +118,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
|
|
|
118
118
|
|
|
119
119
|
**Removed (1.37.0, issue #79):** the shared live credential and everything that served it: the swap-time harvest, park, and install, tokenmaxxing's own OAuth refresh grant and its interlock with Claude Code's refresh lock, the `~/.claude.json` `oauthAccount` rewrite, the 45-second post-swap cooldown, the pool-wide depleted-wait record, the parked-credential slots (`creds/`, `tokenmaxxing-cred-*`), the ambient `CLAUDE_CONFIG_DIR` refusal, and manual `switch` for the Claude pool.
|
|
120
120
|
|
|
121
|
-
**Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, per-account `codex-stores/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows), and the provider's `seats` capability (`shared` for Claude, `live` for Codex) is how the engine knows whether present accounts stay candidates and whether a move needs the caller's respawn. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), one store per pooled account with `CODEX_HOME` set per session (everything but `auth.json` symlinked to the shared home, so resume works across stores), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (persisted into the account's own store the instant it returns, never refreshed for an account running in another session), and the codex Stop hook drives the auto-switch through a codex supervisor shim that respawns `codex resume <session-id>` under the target's store (hooks must be trusted once via `/hooks`).
|
|
121
|
+
**Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, per-account `codex-stores/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows), and the provider's `seats` capability (`shared` for Claude, `live` for Codex) is how the engine knows whether present accounts stay candidates and whether a move needs the caller's respawn. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), one store per pooled account with `CODEX_HOME` set per session (everything but `auth.json` symlinked to the shared home, so resume works across stores), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (persisted into the account's own store the instant it returns, never refreshed for an account running in another session), and the codex Stop hook drives the auto-switch through a codex supervisor shim that respawns `codex resume <session-id>` under the target's store (hooks must be trusted once via `/hooks`). The status-only grok pool shares the engine's account commands and state layout with `statusOnly: true` and no supervisor or hooks ([Status-only pools](docs/content/docs/status-only-pools.mdx)). grok `status` reads the weekly subscription credit window from the credits billing GET.
|
|
122
122
|
|
|
123
123
|
**Removed (issue #173):** the Cursor Cloud relay and its setup tokens: `tokenmaxxing setup-token`, `tokenmaxxing cursor init`, `tokenmaxxing cloud run`, `setup-tokens.json`, `TOKENMAXXING_TOKENS`, and the `cloud/` state.
|
|
124
124
|
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with the most headroom per running session, and when that account nears its usage limit, tokenmaxxing resumes the session on a fresher account at a safe turn boundary - same conversation, compacted on the account it leaves and restarted under another account's credential store with a first prompt that asks Claude to continue. Works across many concurrent sessions, which spread over the pool instead of draining one account together. When the whole pool is at its limit, a session queues first come first served for a reset (at most eight sessions wait on one account; overflow waits for the next reset with room) and pauses with a countdown until its assigned reset, when that reset falls within twice `policy.maxWaitMs` (default 1h - a longer wait stays put rather than parking your terminal for hours).
|
|
4
4
|
|
|
5
|
-
> **Scope:** Claude Code on macOS and Linux, plus OpenAI's Codex CLI as a second switching pool; grok
|
|
5
|
+
> **Scope:** Claude Code on macOS and Linux, plus OpenAI's Codex CLI as a second switching pool; grok as a status-only pool. It pools **subscription** accounts (Pro/Max), not API keys.
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
$ claude
|
|
@@ -66,15 +66,15 @@ claude # use claude as always; each session gets its own accoun
|
|
|
66
66
|
| `tokenmaxxing init` | verify and pin the real `claude`, log the first account in (isolated) + install supervisor, hooks, PATH line, check timer, and hub service |
|
|
67
67
|
| `tokenmaxxing init --codex` | same for codex: log in the first account (isolated), install codex supervisor + Stop hook |
|
|
68
68
|
| `tokenmaxxing init --pi` | verify and pin the real `pi` and install the `pi` supervisor, which runs pi sessions on pooled Claude and ChatGPT accounts that have a pi login |
|
|
69
|
-
| `tokenmaxxing init --grok`
|
|
70
|
-
| `tokenmaxxing add [--codex \| --grok
|
|
71
|
-
| `tokenmaxxing auth [--codex \| --grok
|
|
69
|
+
| `tokenmaxxing init --grok` | pool the first grok login, status-only: no supervisor, no switching |
|
|
70
|
+
| `tokenmaxxing add [--codex \| --grok]` | register an additional account in that pool (isolated login, harvested once into its own store) |
|
|
71
|
+
| `tokenmaxxing auth [--codex \| --grok] [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 account that is flagged or has no usable credential in its store, one by one |
|
|
72
72
|
| `tokenmaxxing auth --pi [--codex] [sel \| --all]` | log a pooled Claude account (or a codex account with `--codex`) into pi, isolated, and keep that login as the account's pi credential after an identity check; `--all` walks every account without a pi login |
|
|
73
73
|
| `tokenmaxxing status [--cached]` | every pool: accounts with 5h / weekly / per-model usage bars, live session counts, exhausted-until-reset; `--cached` renders the stored figures without sampling |
|
|
74
74
|
| `tokenmaxxing config` | the config path and the effective values; edit the file in an editor, a bad value fails the next load with the field name |
|
|
75
75
|
| `tokenmaxxing check` | fold fresh tees, sample up to three stale accounts, and self-update a Bun global install once a day; the periodic timer runs this every tick |
|
|
76
76
|
| `tokenmaxxing doctor` | verify the install: PATH order, `claudeBin`, the five settings entries, the timer, the hub service, the credential identity of every Codex store and of every Claude store whose access token is still fresh (an expiring Claude token is reported as unverifiable, not as a failure), codex hook trust, and shell aliases that shadow `claude` |
|
|
77
|
-
| `tokenmaxxing rename [--codex \| --grok
|
|
77
|
+
| `tokenmaxxing rename [--codex \| --grok] <sel> <label>` / `rm [...] <sel>` | manage a pool (one email can hold both a claude and a codex account); `rm` is refused for an account with a running supervised session |
|
|
78
78
|
| `tokenmaxxing serve` | serve the CLIProxyAPI-compatible usage API on `http://localhost:8317` (`hub.port`) so a dashboard such as T3 Code's "Add a CLIProxyAPI hub" shows the quota of every pooled Claude and Codex account; the management key is the contents of `hub-key` in the state directory, minted on the first run, and `init` runs it as a background service beside the check timer. Details in [Usage hub](docs/content/docs/hub.mdx) |
|
|
79
79
|
| `tokenmaxxing uninstall [--yes]` | print the targets, then remove the shims, settings entries, codex Stop hook, check timer, hub service, and rc PATH line (accounts/stores kept); refused without `--yes` when `HOME` is the login home |
|
|
80
80
|
| `--json` | machine-readable output: one JSON document on stdout for `status`, `config`, and `check` (`ok` mirrors the exit code, failures add `error`) |
|
|
@@ -112,7 +112,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
112
112
|
}
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 0), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `thresholds.accounts` gives one account its own pair, keyed by label (`{"shared-seat": {"session": 75, "weekly": 75}}`), and `"credits": true` in an entry keeps an account with purchased usage credits usable past its windows, ranked after every account with plan headroom left, and a session on it past its bars moves to such an account; `policy.accountReleaseMs` (30 minutes for the session window, 5 hours for a weekly window by default) lifts each of its windows to the higher of its own and the global bar that close to the window's reset; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is the shortest sample interval, the one an account near a bar gets, for a seat's own sample, for `status`, and for the accounts each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick. `claudeBin`, `codexBin`, `grokBin`,
|
|
115
|
+
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 0), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `thresholds.accounts` gives one account its own pair, keyed by label (`{"shared-seat": {"session": 75, "weekly": 75}}`), and `"credits": true` in an entry keeps an account with purchased usage credits usable past its windows, ranked after every account with plan headroom left, and a session on it past its bars moves to such an account; `policy.accountReleaseMs` (30 minutes for the session window, 5 hours for a weekly window by default) lifts each of its windows to the higher of its own and the global bar that close to the window's reset; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is the shortest sample interval, the one an account near a bar gets, for a seat's own sample, for `status`, and for the accounts each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick. `claudeBin`, `codexBin`, `grokBin`, and `piBin` pin the real binaries; `init` writes them, and `TOKENMAXXING_CLAUDE_BIN`, `TOKENMAXXING_CODEX_BIN`, `TOKENMAXXING_GROK_BIN`, and `TOKENMAXXING_PI_BIN` override them for one process, except that `init` pins the binary it resolved into `config.json`.
|
|
116
116
|
|
|
117
117
|
State lives in `~/.config/tokenmaxxing/`; outside it the install touches Claude Code's `settings.json`, codex's `hooks.json`, the timer and usage hub units, and your shell rc. Each account's credential store is `stores/<uuid8>/`, which Claude Code reads and refreshes as its own: on macOS the credential is a login-keychain item keyed by the store path (never plaintext on disk), on Linux a 0600 `.credentials.json` inside the store (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
118
118
|
|
|
@@ -151,7 +151,7 @@ pi keeps its own credential and refreshes it itself, so each pooled account gets
|
|
|
151
151
|
- **One shared identity file.** `~/.claude.json` holds one `oauthAccount` that Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email; the statusline's `◆` marks the account a session actually uses.
|
|
152
152
|
- **Keychain (macOS).** The first keychain access for a new store happens inside the interactive `init`, `add`, or `auth` run, where a prompt can be answered, not inside a headless hook.
|
|
153
153
|
- **Plaintext credentials (Linux).** Claude Code itself stores Linux credentials as a 0600 plaintext file; a store follows the same model.
|
|
154
|
-
- **
|
|
154
|
+
- **The status-only grok pool does not switch.** grok `status` shows the weekly subscription credit window, and no grok session moves.
|
|
155
155
|
|
|
156
156
|
## How it's built
|
|
157
157
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.131.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/auth.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { usageNote } from "./add.ts";
|
|
|
11
11
|
import { c, count } from "./render.ts";
|
|
12
12
|
import type { Account, AccountsIndex } from "../lib/types.ts";
|
|
13
13
|
|
|
14
|
-
const AUTH_USAGE = "usage: tokenmaxxing auth [--codex | --grok
|
|
14
|
+
const AUTH_USAGE = "usage: tokenmaxxing auth [--codex | --grok] [<email|label|id> | --all]\n tokenmaxxing auth --pi [--codex] [<email|label|id> | --all]";
|
|
15
15
|
|
|
16
16
|
export type AuthPlan = { kind: "usage" } | { kind: "error"; message: string } | { kind: "pick" } | { kind: "targets"; ids: string[] };
|
|
17
17
|
|
package/src/cli/rename.ts
CHANGED
|
@@ -16,7 +16,7 @@ export function findAccount(accounts: Account[], selector: string): Account | un
|
|
|
16
16
|
export async function cmdRename(p: Provider, argv: string[]): Promise<number> {
|
|
17
17
|
const [selector, newLabel] = argv;
|
|
18
18
|
if (!selector || !newLabel) {
|
|
19
|
-
emitError({ message: "usage: tokenmaxxing rename [--codex | --grok
|
|
19
|
+
emitError({ message: "usage: tokenmaxxing rename [--codex | --grok] <email|label|id> <new-label>", paint: plain });
|
|
20
20
|
return 2;
|
|
21
21
|
}
|
|
22
22
|
return withLock(p.pool.lockFile, async () => {
|
package/src/cli/seat.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { borrowClaudeSeat } from "../lib/claude.ts";
|
|
2
2
|
import { borrowCodexSeat } from "../lib/codex.ts";
|
|
3
|
-
import { borrowOpencodeGoSeat } from "../lib/opencodego.ts";
|
|
4
3
|
import { emitError } from "./render.ts";
|
|
5
4
|
|
|
6
5
|
const POOLS = {
|
|
@@ -14,11 +13,6 @@ const POOLS = {
|
|
|
14
13
|
usage: "usage: tokenmaxxing seat --codex <pid> - print the CODEX_HOME of one pooled account, lent to <pid> until it exits and shared with codex sessions and other borrowers; codex processes that share one auth.json can lose a refresh race that signs the account out until `tokenmaxxing auth --codex` runs again",
|
|
15
14
|
none: "no usable codex account (pool empty, or every account at a limit, flagged for reauthentication, held by a pi session, or held by the supervised codex session this runs in) - use the ambient codex login",
|
|
16
15
|
},
|
|
17
|
-
"opencode-go": {
|
|
18
|
-
borrow: borrowOpencodeGoSeat,
|
|
19
|
-
usage: "usage: tokenmaxxing seat --opencode-go <pid> - print the store directory of one pooled opencode-go key, whose auth.json is an opencode auth file, lent to <pid> until it exits and shared with other borrowers",
|
|
20
|
-
none: "no usable opencode-go key (pool empty, or no store holds a usable key)",
|
|
21
|
-
},
|
|
22
16
|
};
|
|
23
17
|
|
|
24
18
|
export async function cmdSeat(pool: keyof typeof POOLS, pidRaw: string | undefined, extra: string[]): Promise<number> {
|
package/src/cli/status.ts
CHANGED
|
@@ -2,10 +2,9 @@ import { chunk, sortBy } from "es-toolkit";
|
|
|
2
2
|
import { claude } from "../lib/claude.ts";
|
|
3
3
|
import { codex } from "../lib/codex.ts";
|
|
4
4
|
import { grok } from "../lib/grok.ts";
|
|
5
|
-
import { opencodeGo } from "../lib/opencodego.ts";
|
|
6
5
|
import { loadAccounts, loadConfig, saveAccounts } from "../lib/state.ts";
|
|
7
6
|
import { withLock } from "../lib/lock.ts";
|
|
8
|
-
import { codexPool, grokPool
|
|
7
|
+
import { codexPool, grokPool } from "../lib/paths.ts";
|
|
9
8
|
import { barFor, bufferedUsed, earliestReset, gatedWindows, landGrant, landWindows, isExhausted, isSessionWindow, limitWindows, liveUsed, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
|
|
10
9
|
import type { Provider, SampleReport } from "../lib/provider.ts";
|
|
11
10
|
import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
|
|
@@ -39,7 +38,7 @@ type PoolReport = {
|
|
|
39
38
|
accounts: StatusAccount[];
|
|
40
39
|
};
|
|
41
40
|
|
|
42
|
-
export type StatusReport = { now: number; claude: PoolReport; codex: PoolReport; grok: PoolReport
|
|
41
|
+
export type StatusReport = { now: number; claude: PoolReport; codex: PoolReport; grok: PoolReport };
|
|
43
42
|
|
|
44
43
|
function currentWindow(a: Account, w: Window, bars: Bars | null, now: number): WindowReport {
|
|
45
44
|
const passed = w.resetsAt != null && w.resetsAt <= now;
|
|
@@ -263,10 +262,9 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
|
|
|
263
262
|
if (!json) {
|
|
264
263
|
const codexPooled = loadAccounts(codexPool).accounts.length > 0;
|
|
265
264
|
const grokPooled = loadAccounts(grokPool).accounts.length > 0;
|
|
266
|
-
const opencodeGoPooled = loadAccounts(opencodeGoPool).accounts.length > 0;
|
|
267
265
|
if (claudeReport.accounts.length === 0) {
|
|
268
|
-
if (!codexPooled && !grokPooled
|
|
269
|
-
console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`, `--grok
|
|
266
|
+
if (!codexPooled && !grokPooled) {
|
|
267
|
+
console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`, `--grok`)"));
|
|
270
268
|
return 0;
|
|
271
269
|
}
|
|
272
270
|
console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
|
|
@@ -278,9 +276,8 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
|
|
|
278
276
|
}
|
|
279
277
|
const codexReport = await collect(codex, cfg, now, cached);
|
|
280
278
|
const grokReport = await collect(grok, cfg, now, cached);
|
|
281
|
-
const opencodeGoReport = await collect(opencodeGo, cfg, now, cached);
|
|
282
279
|
if (json) {
|
|
283
|
-
const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport
|
|
280
|
+
const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport };
|
|
284
281
|
emitJson({ ok: true, ...report });
|
|
285
282
|
return 0;
|
|
286
283
|
}
|
|
@@ -290,8 +287,5 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
|
|
|
290
287
|
if (grokReport.accounts.length > 0) {
|
|
291
288
|
renderPool(grok, grokReport, `grok (${count({ n: grokReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
|
|
292
289
|
}
|
|
293
|
-
if (opencodeGoReport.accounts.length > 0) {
|
|
294
|
-
renderPool(opencodeGo, opencodeGoReport, `opencode-go (${count({ n: opencodeGoReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
|
|
295
|
-
}
|
|
296
290
|
return 0;
|
|
297
291
|
}
|
package/src/lib/paths.ts
CHANGED
|
@@ -55,11 +55,6 @@ export const grokPool = {
|
|
|
55
55
|
lockFile: join(TM_HOME, "grok-lock"),
|
|
56
56
|
} as const;
|
|
57
57
|
|
|
58
|
-
export const opencodeGoPool = {
|
|
59
|
-
accountsJson: join(TM_HOME, "opencode-go-accounts.json"),
|
|
60
|
-
lockFile: join(TM_HOME, "opencode-go-lock"),
|
|
61
|
-
} as const;
|
|
62
|
-
|
|
63
58
|
export type PoolPaths = { accountsJson: string; lockFile: string };
|
|
64
59
|
|
|
65
60
|
const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
|
|
@@ -130,20 +125,6 @@ export function grokSeatFromEnv(accountIds: string[], env: Record<string, string
|
|
|
130
125
|
return accountIds.find((id) => grokStoreDirFor(id) === home) ?? null;
|
|
131
126
|
}
|
|
132
127
|
|
|
133
|
-
export const opencodeGoPaths = {
|
|
134
|
-
storesDir: join(TM_HOME, "opencode-go-stores"),
|
|
135
|
-
onboardDir: join(TM_HOME, "opencode-go-onboard"),
|
|
136
|
-
presenceDir: join(TM_HOME, "opencode-go-live"),
|
|
137
|
-
} as const;
|
|
138
|
-
|
|
139
|
-
export function opencodeGoStoreDirFor(accountId: string): string {
|
|
140
|
-
return join(opencodeGoPaths.storesDir, shortId(accountId));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export function opencodeGoAuthJsonFor(accountId: string): string {
|
|
144
|
-
return join(opencodeGoStoreDirFor(accountId), "auth.json");
|
|
145
|
-
}
|
|
146
|
-
|
|
147
128
|
const CLAUDE_KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
148
129
|
|
|
149
130
|
function claudeKeychainAccount(): string {
|
package/src/lib/provider.ts
CHANGED
package/src/lib/sessions.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, realpathSync, rmSync, statSync, type Dirent }
|
|
|
2
2
|
import { basename, join, sep } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { errorMessage, log } from "./log.ts";
|
|
5
|
-
import { codexPaths, grokPaths,
|
|
5
|
+
import { codexPaths, grokPaths, paths, piPaths } from "./paths.ts";
|
|
6
6
|
import { writeFileAtomic } from "./atomic.ts";
|
|
7
7
|
import { presencePid } from "./presence.ts";
|
|
8
8
|
import { readJsonFile } from "./state.ts";
|
|
@@ -45,7 +45,7 @@ function recordLiveSession(sid: string, current: string): void {
|
|
|
45
45
|
const TMP_MARKER = ".tmp.";
|
|
46
46
|
const TMP_GRACE_MS = 3600 * 1000;
|
|
47
47
|
|
|
48
|
-
const STORE_PARENTS = [paths.storesDir, codexPaths.storesDir, grokPaths.storesDir,
|
|
48
|
+
const STORE_PARENTS = [paths.storesDir, codexPaths.storesDir, grokPaths.storesDir, join(piPaths.storesDir, "claude"), join(piPaths.storesDir, "codex")];
|
|
49
49
|
|
|
50
50
|
function listDir(dir: string, root: string): Dirent[] {
|
|
51
51
|
if (!existsSync(dir)) return [];
|
|
@@ -60,7 +60,7 @@ function listDir(dir: string, root: string): Dirent[] {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function tmpSweepDirs(root: string): string[] {
|
|
63
|
-
const dirs = [paths.home, paths.usageDir, paths.presenceDir, paths.respawnDir, sessionsDir(), paths.binDir, codexPaths.presenceDir, codexPaths.respawnDir, codexPaths.onboardDir
|
|
63
|
+
const dirs = [paths.home, paths.usageDir, paths.presenceDir, paths.respawnDir, sessionsDir(), paths.binDir, codexPaths.presenceDir, codexPaths.respawnDir, codexPaths.onboardDir];
|
|
64
64
|
for (const parent of STORE_PARENTS) {
|
|
65
65
|
for (const child of listDir(parent, root)) {
|
|
66
66
|
if (child.isDirectory()) dirs.push(join(parent, child.name));
|
package/src/lib/state.ts
CHANGED
|
@@ -34,12 +34,11 @@ export function loadConfig(): Config {
|
|
|
34
34
|
cfg.claudeBin = optionalEnv("TOKENMAXXING_CLAUDE_BIN") ?? cfg.claudeBin;
|
|
35
35
|
cfg.codexBin = optionalEnv("TOKENMAXXING_CODEX_BIN") ?? cfg.codexBin;
|
|
36
36
|
cfg.grokBin = optionalEnv("TOKENMAXXING_GROK_BIN") ?? cfg.grokBin;
|
|
37
|
-
cfg.opencodeBin = optionalEnv("TOKENMAXXING_OPENCODE_BIN") ?? cfg.opencodeBin;
|
|
38
37
|
cfg.piBin = optionalEnv("TOKENMAXXING_PI_BIN") ?? cfg.piBin;
|
|
39
38
|
return cfg;
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
export type BinKey = "claudeBin" | "codexBin" | "grokBin" | "
|
|
41
|
+
export type BinKey = "claudeBin" | "codexBin" | "grokBin" | "piBin";
|
|
43
42
|
|
|
44
43
|
export function pinBinOverride(input: { key: BinKey; bin: string }): void {
|
|
45
44
|
const raw: Record<string, unknown> = existsSync(paths.configJson) ? readJsonFile(paths.configJson, z.record(z.string(), z.unknown())) : {};
|
package/src/lib/statusonly.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type StatusOnlySpec = {
|
|
|
14
14
|
flag: string;
|
|
15
15
|
pool: PoolPaths;
|
|
16
16
|
binName: string;
|
|
17
|
-
binKey: "grokBin"
|
|
17
|
+
binKey: "grokBin";
|
|
18
18
|
storeDirFor: (accountId: string) => string;
|
|
19
19
|
authJsonFor: (accountId: string) => string;
|
|
20
20
|
onboardDir: string;
|
package/src/lib/types.ts
CHANGED
package/src/main.ts
CHANGED
|
@@ -13,7 +13,6 @@ import { runPiSupervisor } from "./entries/pisupervisor.ts";
|
|
|
13
13
|
import { claude } from "./lib/claude.ts";
|
|
14
14
|
import { codex } from "./lib/codex.ts";
|
|
15
15
|
import { grok } from "./lib/grok.ts";
|
|
16
|
-
import { opencodeGo } from "./lib/opencodego.ts";
|
|
17
16
|
import { cmdInit, cmdInitPi } from "./cli/init.ts";
|
|
18
17
|
import { cmdAdd } from "./cli/add.ts";
|
|
19
18
|
import { cmdAuth } from "./cli/auth.ts";
|
|
@@ -28,19 +27,18 @@ import { cmdServe } from "./cli/serve.ts";
|
|
|
28
27
|
import { CHECK_JOB, deactivationHint, HUB_JOB, onLoginHome, uninstallSupervisor, uninstallTargets } from "./lib/install.ts";
|
|
29
28
|
import { errorMessage } from "./lib/log.ts";
|
|
30
29
|
import { HOME } from "./lib/paths.ts";
|
|
31
|
-
import { c, emitError } from "./cli/render.ts";
|
|
30
|
+
import { c, count, emitError } from "./cli/render.ts";
|
|
32
31
|
|
|
33
32
|
const JSON_FLAG = "--json";
|
|
34
33
|
const CACHED_FLAG = "--cached";
|
|
35
34
|
const YES_FLAG = "--yes";
|
|
36
35
|
const CODEX_FLAG = "--codex";
|
|
37
36
|
const GROK_FLAG = "--grok";
|
|
38
|
-
const OPENCODE_GO_FLAG = "--opencode-go";
|
|
39
37
|
const PI_FLAG = "--pi";
|
|
40
38
|
const JSON_COMMANDS = new Set(["status", "config", "check"]);
|
|
41
39
|
const CODEX_COMMANDS = new Set(["init", "add", "auth", "rm", "rename", "seat"]);
|
|
42
40
|
const STATUS_ONLY_COMMANDS = new Set(["init", "add", "auth", "rm", "rename"]);
|
|
43
|
-
const
|
|
41
|
+
const MAX_OPERANDS = new Map([["init", 0], ["add", 0], ["rm", 1], ["rename", 2]]);
|
|
44
42
|
|
|
45
43
|
function printHelp(): void {
|
|
46
44
|
console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
|
|
@@ -51,19 +49,17 @@ function printHelp(): void {
|
|
|
51
49
|
${c.cyan("tokenmaxxing init --codex")} same for codex: log in the first account, isolated, install codex supervisor + Stop hook
|
|
52
50
|
${c.cyan("tokenmaxxing init --pi")} install the pi supervisor: pi sessions run on pooled Claude or ChatGPT accounts that are logged into pi
|
|
53
51
|
${c.cyan("tokenmaxxing init --grok")} pool grok Build logins (status-only: no supervisor yet)
|
|
54
|
-
${c.cyan("tokenmaxxing init --opencode-go")} pool opencode-go API keys (status-only: no supervisor yet)
|
|
55
52
|
${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
|
|
56
|
-
${c.cyan("tokenmaxxing add")} --codex | --grok
|
|
57
|
-
${c.cyan("tokenmaxxing auth")} [--codex | --grok
|
|
53
|
+
${c.cyan("tokenmaxxing add")} --codex | --grok same for the codex or grok pool
|
|
54
|
+
${c.cyan("tokenmaxxing auth")} [--codex | --grok] [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every account that is flagged or has no usable credential in its store, one by one)
|
|
58
55
|
${c.cyan("tokenmaxxing auth --pi")} [--codex] [sel | --all] log a pooled Claude account (or a codex account with --codex) into pi, isolated (bare = pick from a list; --all = every account without a pi login)
|
|
59
56
|
${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
|
|
60
57
|
${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)
|
|
61
58
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
62
|
-
${c.cyan("tokenmaxxing rename")} [--codex | --grok
|
|
63
|
-
${c.cyan("tokenmaxxing rm")} [--codex | --grok
|
|
59
|
+
${c.cyan("tokenmaxxing rename")} [--codex | --grok] <sel> <label>
|
|
60
|
+
${c.cyan("tokenmaxxing rm")} [--codex | --grok] <sel>
|
|
64
61
|
${c.cyan("tokenmaxxing seat")} <pid> lend one pooled Claude account to an unattended consumer until <pid> exits: prints the store directory to set as CLAUDE_SECURESTORAGE_CONFIG_DIR (the directory itself, as its owner, never a copy of its credential); host sessions and other borrowers share the account; exit 1 = none usable
|
|
65
62
|
${c.cyan("tokenmaxxing seat --codex")} <pid> borrow one pooled codex account for an unattended consumer (plugin, script): prints the CODEX_HOME to set, lent to <pid> until it exits; codex sessions and other borrowers share the account, and a refresh race between them can sign it out until auth --codex runs again; exit 1 = none usable, fall back to the ambient login
|
|
66
|
-
${c.cyan("tokenmaxxing seat --opencode-go")} <pid> lend one pooled opencode-go key to an unattended consumer until <pid> exits: prints the store directory, whose auth.json is an opencode auth file (mount it at $XDG_DATA_HOME/opencode/auth.json, then run a model such as opencode-go/mimo-v2.6-pro); other borrowers share the key; exit 1 = none usable
|
|
67
63
|
${c.cyan("tokenmaxxing serve")} serve the CLIProxyAPI-compatible usage API on http://localhost:<hub.port> (default 8317) so a dashboard such as T3 Code's "Add a CLIProxyAPI hub" shows every pooled Claude and Codex account's quota; the management key is the contents of hub-key in the state directory
|
|
68
64
|
${c.cyan("tokenmaxxing uninstall")} [--yes] print the targets, then remove supervisor + settings entries (refused without ${c.cyan("--yes")} when HOME is the login home)
|
|
69
65
|
|
|
@@ -97,14 +93,14 @@ async function main(): Promise<number> {
|
|
|
97
93
|
const json = jsonMode;
|
|
98
94
|
const cached = argv.includes(CACHED_FLAG);
|
|
99
95
|
const yes = argv.includes(YES_FLAG);
|
|
100
|
-
const providerFlags = [CODEX_FLAG, GROK_FLAG
|
|
96
|
+
const providerFlags = [CODEX_FLAG, GROK_FLAG].filter((f) => argv.includes(f));
|
|
101
97
|
if (providerFlags.length > 1) {
|
|
102
98
|
emitError({ json, message: `${providerFlags.join(" and ")} are mutually exclusive - pick one pool` });
|
|
103
99
|
return 2;
|
|
104
100
|
}
|
|
105
|
-
const provider = argv.includes(CODEX_FLAG) ? codex : argv.includes(GROK_FLAG) ? grok :
|
|
101
|
+
const provider = argv.includes(CODEX_FLAG) ? codex : argv.includes(GROK_FLAG) ? grok : claude;
|
|
106
102
|
const pi = argv.includes(PI_FLAG);
|
|
107
|
-
const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG && a !== YES_FLAG && a !== CODEX_FLAG && a !== GROK_FLAG && a !==
|
|
103
|
+
const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG && a !== YES_FLAG && a !== CODEX_FLAG && a !== GROK_FLAG && a !== PI_FLAG);
|
|
108
104
|
const sub = args[0];
|
|
109
105
|
|
|
110
106
|
if (pi && sub !== "init" && sub !== "auth") {
|
|
@@ -132,19 +128,21 @@ async function main(): Promise<number> {
|
|
|
132
128
|
return 2;
|
|
133
129
|
}
|
|
134
130
|
|
|
135
|
-
if (provider === grok
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (sub == null || !commands.has(sub)) {
|
|
139
|
-
emitError({ json, message: `${flag} applies to ${[...commands].join(", ")}, not ${sub ?? "status"}` });
|
|
140
|
-
return 2;
|
|
141
|
-
}
|
|
131
|
+
if (provider === grok && (sub == null || !STATUS_ONLY_COMMANDS.has(sub))) {
|
|
132
|
+
emitError({ json, message: `${GROK_FLAG} applies to ${[...STATUS_ONLY_COMMANDS].join(", ")}, not ${sub ?? "status"}` });
|
|
133
|
+
return 2;
|
|
142
134
|
}
|
|
143
135
|
|
|
144
136
|
if (json && sub != null && !JSON_COMMANDS.has(sub)) {
|
|
145
137
|
emitError({ json, message: `${sub} has no ${JSON_FLAG} form (${JSON_FLAG} applies to ${[...JSON_COMMANDS].join(", ")})` });
|
|
146
138
|
return 2;
|
|
147
139
|
}
|
|
140
|
+
|
|
141
|
+
const maxOperands = sub == null ? undefined : MAX_OPERANDS.get(sub);
|
|
142
|
+
if (maxOperands != null && args.length - 1 > maxOperands) {
|
|
143
|
+
emitError({ json, message: `${sub} takes at most ${count({ n: maxOperands, noun: "argument" })}, got: ${args.slice(1).join(" ")}` });
|
|
144
|
+
return 2;
|
|
145
|
+
}
|
|
148
146
|
switch (sub) {
|
|
149
147
|
case "__statusline": return runStatusline();
|
|
150
148
|
case "__subagent-statusline": return runSubagentStatusline();
|
|
@@ -175,7 +173,7 @@ async function main(): Promise<number> {
|
|
|
175
173
|
case "doctor": return cmdDoctor();
|
|
176
174
|
case "rm": return cmdRm(provider, args[1]);
|
|
177
175
|
case "rename": return cmdRename(provider, args.slice(1));
|
|
178
|
-
case "seat": return cmdSeat(provider === codex ? "codex" :
|
|
176
|
+
case "seat": return cmdSeat(provider === codex ? "codex" : "claude", args[1], args.slice(2));
|
|
179
177
|
case "serve": return cmdServe(args.slice(1));
|
|
180
178
|
case "uninstall": {
|
|
181
179
|
if (args.length > 1) {
|
|
@@ -200,7 +198,7 @@ async function main(): Promise<number> {
|
|
|
200
198
|
if (out.timer === "still-loaded") console.log(c.yellow(`⚠ the check job may still be loaded - run: ${deactivationHint(CHECK_JOB)}`));
|
|
201
199
|
if (out.hub === "still-loaded") console.log(c.yellow(`⚠ the usage hub job may still be loaded - run: ${deactivationHint(HUB_JOB)}`));
|
|
202
200
|
if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
|
|
203
|
-
console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/; pi: pi-stores/; grok: grok-stores
|
|
201
|
+
console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/; pi: pi-stores/; grok: grok-stores/) - remove accounts with \`xx rm\` to delete their credentials`);
|
|
204
202
|
return 0;
|
|
205
203
|
}
|
|
206
204
|
case "help":
|
package/src/lib/opencodego.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
import { countBy, minBy } from "es-toolkit";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
import { writeFileAtomic } from "./atomic.ts";
|
|
5
|
-
import { env, HOME, opencodeGoAuthJsonFor, opencodeGoPaths, opencodeGoPool, opencodeGoStoreDirFor } from "./paths.ts";
|
|
6
|
-
import { withLock } from "./lock.ts";
|
|
7
|
-
import { log } from "./log.ts";
|
|
8
|
-
import { livingPresences, seatCounts, writePresence } from "./presence.ts";
|
|
9
|
-
import type { Provider, SeatBorrow } from "./provider.ts";
|
|
10
|
-
import { loadAccounts, readJsonFile, type Harvest } from "./state.ts";
|
|
11
|
-
import { statusOnlyProvider, type AuthEntry } from "./statusonly.ts";
|
|
12
|
-
import { ErrnoSchema } from "./types.ts";
|
|
13
|
-
import { c } from "../cli/render.ts";
|
|
14
|
-
|
|
15
|
-
const OpencodeAuthEntrySchema = z.looseObject({ type: z.string(), key: z.string().optional() });
|
|
16
|
-
const OpencodeAuthFileSchema = z.record(z.string(), z.unknown());
|
|
17
|
-
|
|
18
|
-
function liveAuthPath(): string {
|
|
19
|
-
return join(env("XDG_DATA_HOME", join(HOME, ".local", "share")), "opencode", "auth.json");
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function idOfKey(key: string): string {
|
|
23
|
-
const h = new Bun.CryptoHasher("sha256");
|
|
24
|
-
h.update(`opencode-go:${key}`);
|
|
25
|
-
return h.digest("hex");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function harvestOf(key: string): Harvest {
|
|
29
|
-
const id = idOfKey(key);
|
|
30
|
-
return {
|
|
31
|
-
id,
|
|
32
|
-
email: null,
|
|
33
|
-
tier: "go",
|
|
34
|
-
sample: null,
|
|
35
|
-
park: async () => {
|
|
36
|
-
writeFileAtomic(opencodeGoAuthJsonFor(id), JSON.stringify({ "opencode-go": { type: "api", key } }, null, 2), 0o600);
|
|
37
|
-
},
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function readAuth(path: string): AuthEntry[] {
|
|
42
|
-
let map: z.infer<typeof OpencodeAuthFileSchema>;
|
|
43
|
-
try {
|
|
44
|
-
map = readJsonFile(path, OpencodeAuthFileSchema);
|
|
45
|
-
} catch (e) {
|
|
46
|
-
if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return [];
|
|
47
|
-
throw e;
|
|
48
|
-
}
|
|
49
|
-
const entry = OpencodeAuthEntrySchema.safeParse(map["opencode-go"]);
|
|
50
|
-
if (!entry.success || entry.data.type !== "api" || !entry.data.key) return [];
|
|
51
|
-
return [{ id: idOfKey(entry.data.key), usable: true, harvest: harvestOf(entry.data.key) }];
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export async function borrowOpencodeGoSeat(pid: number): Promise<SeatBorrow> {
|
|
55
|
-
const seatId = `seat-${pid}`;
|
|
56
|
-
const granted = await withLock(opencodeGoPool.lockFile, async (): Promise<SeatBorrow> => {
|
|
57
|
-
const accounts = loadAccounts(opencodeGoPool).accounts;
|
|
58
|
-
const living = livingPresences(opencodeGoPaths.presenceDir);
|
|
59
|
-
const held = living.find((p) => p.id === seatId);
|
|
60
|
-
const heldAccount = held ? (accounts.find((a) => a.id === held.accountId) ?? null) : null;
|
|
61
|
-
if (heldAccount) {
|
|
62
|
-
if (!(await opencodeGo.storeUsable(heldAccount))) {
|
|
63
|
-
return { denied: "the key this pid holds is no longer usable in its store - run `tokenmaxxing auth --opencode-go` and borrow again" };
|
|
64
|
-
}
|
|
65
|
-
return { store: opencodeGoStoreDirFor(heldAccount.id), id: heldAccount.id, reused: true };
|
|
66
|
-
}
|
|
67
|
-
const lent = countBy(living, (p) => p.accountId);
|
|
68
|
-
const usable = await Promise.all(accounts.map(opencodeGo.storeUsable));
|
|
69
|
-
const picked = minBy(accounts.filter((_, i) => usable[i]), (a) => lent[a.id] ?? 0);
|
|
70
|
-
if (!picked) return null;
|
|
71
|
-
writePresence({ dir: opencodeGoPaths.presenceDir, id: seatId, accountId: picked.id, pid });
|
|
72
|
-
return { store: opencodeGoStoreDirFor(picked.id), id: picked.id, reused: false };
|
|
73
|
-
});
|
|
74
|
-
if (granted && !("denied" in granted)) log("seat.grant", { account: granted.id.slice(0, 8), pid, reused: granted.reused });
|
|
75
|
-
return granted;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export const opencodeGo: Provider = statusOnlyProvider({
|
|
79
|
-
name: "opencode-go",
|
|
80
|
-
flag: " --opencode-go",
|
|
81
|
-
pool: opencodeGoPool,
|
|
82
|
-
binName: "opencode",
|
|
83
|
-
binKey: "opencodeBin",
|
|
84
|
-
storeDirFor: opencodeGoStoreDirFor,
|
|
85
|
-
authJsonFor: opencodeGoAuthJsonFor,
|
|
86
|
-
onboardDir: opencodeGoPaths.onboardDir,
|
|
87
|
-
homeEnv: "XDG_DATA_HOME",
|
|
88
|
-
loginArgs: ["providers", "login", "-p", "opencode-go"],
|
|
89
|
-
onboardAuthRel: join("opencode", "auth.json"),
|
|
90
|
-
liveAuthPath,
|
|
91
|
-
readAuth,
|
|
92
|
-
liveId: () => null,
|
|
93
|
-
presence: () => seatCounts(opencodeGoPaths.presenceDir),
|
|
94
|
-
versionOk: (out) => out !== "",
|
|
95
|
-
importIntro: "Pooling your opencode-go API key - your existing opencode auth stays as it is.",
|
|
96
|
-
foundLive: () => `found an opencode-go credential in ${liveAuthPath()} - pooling it; use \`tokenmaxxing add --opencode-go\` for more keys.`,
|
|
97
|
-
installNotice: "opencode-go pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled keys.",
|
|
98
|
-
loginStep: () => `Paste the opencode-go API key from ${c.bold("opencode.ai/zen")} when opencode asks for it.`,
|
|
99
|
-
});
|