tokenmaxxing 1.8.0 → 1.9.1
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 +2 -4
- package/README.md +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli/add.ts +1 -8
- package/src/cli/auth.ts +0 -23
- package/src/cli/check.ts +19 -13
- package/src/cli/codexadd.ts +0 -17
- package/src/cli/codexinit.ts +0 -40
- package/src/cli/codexrm.ts +0 -13
- package/src/cli/codexswitch.ts +0 -15
- package/src/cli/config.ts +0 -30
- package/src/cli/doctor.ts +1 -14
- package/src/cli/init.ts +1 -33
- package/src/cli/ls.ts +0 -2
- package/src/cli/onboard.ts +0 -37
- package/src/cli/rename.ts +0 -19
- package/src/cli/render.ts +0 -23
- package/src/cli/rm.ts +0 -19
- package/src/cli/status.ts +0 -80
- package/src/cli/switch.ts +1 -49
- package/src/cli/watch.ts +0 -17
- package/src/entries/codexstophook.ts +2 -73
- package/src/entries/codexsupervisor.ts +1 -67
- package/src/entries/mcp.ts +0 -11
- package/src/entries/sessionstart.ts +1 -8
- package/src/entries/statusline.ts +0 -66
- package/src/entries/stopfailurehook.ts +93 -0
- package/src/entries/stophook.ts +3 -34
- package/src/entries/subagentstatusline.ts +0 -19
- package/src/entries/supervisor.ts +32 -132
- package/src/lib/atomic.ts +0 -16
- package/src/lib/claudebin.ts +4 -55
- package/src/lib/claudejson.ts +0 -10
- package/src/lib/claudelock.ts +13 -35
- package/src/lib/codexauth.ts +0 -29
- package/src/lib/codexbin.ts +0 -10
- package/src/lib/codexdecide.ts +1 -112
- package/src/lib/codexoauth.ts +0 -16
- package/src/lib/codexpick.ts +0 -31
- package/src/lib/codexpresence.ts +0 -35
- package/src/lib/codexsample.ts +0 -23
- package/src/lib/codexstate.ts +0 -7
- package/src/lib/codexswap.ts +0 -32
- package/src/lib/codexusage.ts +4 -29
- package/src/lib/credstore.ts +0 -24
- package/src/lib/decide.ts +127 -180
- package/src/lib/http.ts +0 -9
- package/src/lib/install.ts +6 -127
- package/src/lib/keychain.ts +1 -39
- package/src/lib/lock.ts +0 -24
- package/src/lib/log.ts +0 -14
- package/src/lib/oauth.ts +1 -31
- package/src/lib/paths.ts +1 -48
- package/src/lib/picker.ts +1 -84
- package/src/lib/proc.ts +0 -17
- package/src/lib/sample.ts +0 -68
- package/src/lib/sessions.ts +0 -13
- package/src/lib/settings.ts +15 -42
- package/src/lib/state.ts +23 -77
- package/src/lib/swap.ts +3 -87
- package/src/lib/tty.ts +0 -4
- package/src/lib/types.ts +13 -140
- package/src/lib/usage.ts +108 -196
- package/src/lib/worktree.ts +0 -8
- package/src/main.ts +5 -40
- package/src/sdk.ts +0 -59
- package/agent-plugin/agents/tokenmaxxing-claude.md +0 -43
- package/agent-plugin/agents/tokenmaxxing-codex.md +0 -40
- package/agent-plugin/hooks/cursor-relay.json +0 -14
- package/agent-plugin/skills/relay-session/SKILL.md +0 -118
- package/agent-plugin/skills/relay-session/references/ipc.md +0 -23
- package/src/cli/relay.ts +0 -323
- package/src/entries/relaypermission.ts +0 -105
- package/src/lib/relay/config.ts +0 -84
- package/src/lib/relay/decide.ts +0 -75
- package/src/lib/relay/gc.ts +0 -80
- package/src/lib/relay/install.ts +0 -143
- package/src/lib/relay/markers.ts +0 -148
- package/src/lib/relay/modes.ts +0 -82
- package/src/lib/relay/protocol.ts +0 -61
- package/src/lib/relay/registry.ts +0 -175
- package/src/lib/relay/tmux.ts +0 -109
- package/src/lib/relay/turn.ts +0 -137
- package/src/lib/relay/worker.ts +0 -141
package/DESIGN.md
CHANGED
|
@@ -37,7 +37,7 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
|
|
|
37
37
|
- `bin/claude` - the supervisor.
|
|
38
38
|
- Per-account **credentials** follow the platform's Claude Code store: macOS = login-keychain items `tokenmaxxing-cred-<accountUuid[:8]>` (never plaintext on disk); Linux = 0600 files `creds/tokenmaxxing-cred-<accountUuid[:8]>.json` (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.
|
|
39
39
|
|
|
40
|
-
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` every
|
|
40
|
+
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check --if-due` every 60s, self-paced up to 300s from the live account's headroom: hooks alone miss long agentic turns, so the timer is the backstop that keeps switching engaged mid-turn.
|
|
41
41
|
|
|
42
42
|
The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process. The `claude` binary and `~/.claude` layout are untouched.
|
|
43
43
|
|
|
@@ -111,9 +111,7 @@ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from the
|
|
|
111
111
|
|
|
112
112
|
**Shipped since (0.13.0):** Codex as a second pool, parallel state (`codex-accounts.json`, `codex-creds/`, own flock), same pace-pressure policy. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), 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 (harvest-by-true-owner, persist every rotation), and codex's new Stop-hook system drives the auto-swap through a codex supervisor shim that respawns `codex resume <session-id>` (hooks must be trusted once via `/hooks`). Sibling sessions left on ANY non-live account are reconciled cross-session (owner decisions 2026-07-20; a non-live session cannot refresh cross-account and would wedge at token expiry, healthy or not): the deciding actor drops a reconcile marker addressed to the sibling's supervisor, and the sibling's own Stop hook promotes it into a respawn onto the live account at its next turn boundary - the only safe respawn point - while a blocked live seat never receives a signal.
|
|
113
113
|
|
|
114
|
-
**
|
|
115
|
-
|
|
116
|
-
**Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the single refresh-grant call in the swap; Slack serve as the relay bus; soft concurrency caps; Cloud Agents without local tmux.
|
|
114
|
+
**Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the single refresh-grant call in the swap; Slack as a message bus; soft concurrency caps.
|
|
117
115
|
|
|
118
116
|
---
|
|
119
117
|
|
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ programs.tokenmaxxing.package = inputs.tokenmaxxing.packages.${pkgs.system}.defa
|
|
|
50
50
|
# then: tokenmaxxing init
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
`init` imports the account you're already on, installs the `claude` supervisor +
|
|
53
|
+
`init` imports the account you're already on, installs the `claude` supervisor + five `settings.json` entries (the tokenmaxxing statusLine, a subagentStatusLine, a Stop hook, a StopFailure hook, a SessionStart hook), and adds the supervisor's bin dir to PATH in your shell rc (idempotent; it must sit ahead of the real `claude` to intercept it). Restart your shell, then add more accounts and go:
|
|
54
54
|
|
|
55
55
|
```sh
|
|
56
56
|
tokenmaxxing add # logs one in, in isolation, and pools it
|
package/agent-plugin/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "tokenmaxxing",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.9.1",
|
|
5
5
|
"description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "anaclumos",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/add.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing add` - register an ADDITIONAL account. Drives one isolated
|
|
2
|
-
// login (see onboard.ts), then pools whatever account it landed on. Your
|
|
3
|
-
// primary login is never touched.
|
|
4
|
-
|
|
5
1
|
import { withLock } from "../lib/lock.ts";
|
|
6
2
|
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
7
3
|
import { credItemFor, paths } from "../lib/paths.ts";
|
|
@@ -22,11 +18,8 @@ export async function cmdAdd(): Promise<number> {
|
|
|
22
18
|
const uuid = oauthAccount.accountUuid;
|
|
23
19
|
const keychainItem = credItemFor(uuid);
|
|
24
20
|
|
|
25
|
-
// under the flock: a concurrent swap both harvests into parked items and
|
|
26
|
-
// writes the index, so the backup write and the index upsert must land in
|
|
27
|
-
// one critical section.
|
|
28
21
|
const { account, poolSize } = await withLock(paths.lockFile, async () => {
|
|
29
|
-
await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw));
|
|
22
|
+
await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw));
|
|
30
23
|
const idx = loadAccounts();
|
|
31
24
|
const existing = idx.accounts.find((a) => a.accountUuid === uuid);
|
|
32
25
|
const fresh: Account = {
|
package/src/cli/auth.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing auth` - reauthenticate a pooled account in place. The dead
|
|
2
|
-
// refresh token cannot heal itself (a needs-reauth account can never win a
|
|
3
|
-
// swap), so this drives one isolated login per target and REQUIRES the login
|
|
4
|
-
// to land on the target account: the harvested identity must match, otherwise
|
|
5
|
-
// nothing changes. Bare `auth` lists the pool (emails shown) and asks which;
|
|
6
|
-
// `auth <sel>` targets one account, stating the email to sign in with;
|
|
7
|
-
// `auth --all` walks every needs-reauth account one by one.
|
|
8
|
-
|
|
9
1
|
import { partition } from "es-toolkit";
|
|
10
2
|
import { z } from "zod";
|
|
11
3
|
import { withLock } from "../lib/lock.ts";
|
|
@@ -20,18 +12,13 @@ import type { Account, AccountsIndex } from "../lib/types.ts";
|
|
|
20
12
|
const AUTH_USAGE = "usage: tokenmaxxing auth [<email|label|id> | --all]";
|
|
21
13
|
|
|
22
14
|
const AuthPlanSchema = z.discriminatedUnion("kind", [
|
|
23
|
-
/** malformed argv: print AUTH_USAGE, exit 2. */
|
|
24
15
|
z.object({ kind: z.literal("usage") }),
|
|
25
|
-
/** unresolvable state (empty pool, unknown selector): exit 1. */
|
|
26
16
|
z.object({ kind: z.literal("error"), message: z.string() }),
|
|
27
|
-
/** bare `auth`: ask interactively. */
|
|
28
17
|
z.object({ kind: z.literal("pick") }),
|
|
29
18
|
z.object({ kind: z.literal("targets"), uuids: z.array(z.string()) }),
|
|
30
19
|
]);
|
|
31
20
|
export type AuthPlan = z.infer<typeof AuthPlanSchema>;
|
|
32
21
|
|
|
33
|
-
/** Resolve argv into a reauth plan (pure - unit-tested). Argv shape is
|
|
34
|
-
* validated before any pool state is consulted. */
|
|
35
22
|
export function planAuth(input: { accounts: Account[]; argv: string[] }): AuthPlan {
|
|
36
23
|
const all = input.argv.includes("--all");
|
|
37
24
|
const rest = input.argv.filter((a) => a !== "--all");
|
|
@@ -50,7 +37,6 @@ export function planAuth(input: { accounts: Account[]; argv: string[] }): AuthPl
|
|
|
50
37
|
return { kind: "pick" };
|
|
51
38
|
}
|
|
52
39
|
|
|
53
|
-
/** Needs-reauth accounts first (they are why the user is here), pool order within. */
|
|
54
40
|
export function pickerOrder(accounts: Account[]): Account[] {
|
|
55
41
|
const [flagged, healthy] = partition(accounts, (a) => a.needsReauth === true);
|
|
56
42
|
return [...flagged, ...healthy];
|
|
@@ -100,16 +86,7 @@ async function reauthOne(target: Account): Promise<boolean> {
|
|
|
100
86
|
return false;
|
|
101
87
|
}
|
|
102
88
|
|
|
103
|
-
// under the flock: a concurrent swap both harvests into parked items and
|
|
104
|
-
// writes the index, so the credential write and the needsReauth clear must
|
|
105
|
-
// land in one critical section (else a swap-away harvest of the live blob
|
|
106
|
-
// could overwrite this fresh backup right before it is marked healthy).
|
|
107
89
|
const isActive = await withLock(paths.lockFile, async () => {
|
|
108
|
-
// The account must still be pooled BEFORE anything is written: the
|
|
109
|
-
// interactive login can sit open for minutes, and a concurrent `xx rm`
|
|
110
|
-
// completing in that window used to get its parked item recreated as an
|
|
111
|
-
// orphan no pool entry tracks - plus a false "reauthed" success
|
|
112
|
-
// (closing-review catch).
|
|
113
90
|
const idx = loadAccounts();
|
|
114
91
|
const account = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
|
|
115
92
|
if (!account) {
|
package/src/cli/check.ts
CHANGED
|
@@ -1,30 +1,36 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// launchd/systemd job so a limit crossed mid-turn, or with no session running,
|
|
4
|
-
// still switches within minutes; the hooks only ever see turn boundaries.
|
|
5
|
-
// Running claude sessions adopt the swapped credential in place (<=30s).
|
|
6
|
-
|
|
7
|
-
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
1
|
+
import { CHECK_DELAY_FLOOR_MS, checkDelayMs, evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
2
|
+
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
8
3
|
import { log } from "../lib/log.ts";
|
|
4
|
+
import { loadConfig, loadNextCheckDueAt, saveNextCheckDueAt } from "../lib/state.ts";
|
|
9
5
|
import { c, fmtReset } from "./render.ts";
|
|
10
6
|
|
|
11
|
-
|
|
7
|
+
const TICK_SLACK_MS = CHECK_DELAY_FLOOR_MS / 2;
|
|
8
|
+
|
|
9
|
+
export async function cmdCheck(args: string[] = []): Promise<number> {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
const dueAt = args.includes("--if-due") ? loadNextCheckDueAt(now) : null;
|
|
12
|
+
if (dueAt != null && now + TICK_SLACK_MS < dueAt) {
|
|
13
|
+
console.log(c.dim(`not due (${Math.ceil((dueAt - now) / 1000)}s)`));
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
12
16
|
let d;
|
|
13
17
|
try {
|
|
14
|
-
d = await evaluateAndMaybeSwap();
|
|
18
|
+
d = await evaluateAndMaybeSwap(now);
|
|
15
19
|
} catch (e) {
|
|
16
|
-
// unattended under the timer: the log is the only place anyone will look.
|
|
17
20
|
const detail = e instanceof Error ? e.message : String(e);
|
|
18
21
|
log("check.error", { err: detail });
|
|
19
22
|
console.error(c.red(`check failed: ${detail}`));
|
|
20
23
|
return 1;
|
|
21
24
|
}
|
|
25
|
+
const delayMs = checkDelayMs({ cfg: loadConfig(), org: readOAuthAccount()?.organizationUuid ?? null, now, decision: d });
|
|
26
|
+
saveNextCheckDueAt({ dueAt: now + delayMs, ts: now });
|
|
27
|
+
const next = c.dim(`next in ${Math.round(delayMs / 1000)}s`);
|
|
22
28
|
if (d.swapped && d.account) {
|
|
23
|
-
console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)}`);
|
|
29
|
+
console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)} ${next}`);
|
|
24
30
|
} else if (d.waitUntil !== undefined && d.account) {
|
|
25
|
-
console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`));
|
|
31
|
+
console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`) + ` ${next}`);
|
|
26
32
|
} else {
|
|
27
|
-
console.log(c.dim(`no switch (${d.reason})`));
|
|
33
|
+
console.log(c.dim(`no switch (${d.reason}) `) + next);
|
|
28
34
|
}
|
|
29
35
|
return 0;
|
|
30
36
|
}
|
package/src/cli/codexadd.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing add --codex` - register an ADDITIONAL codex account. Runs
|
|
2
|
-
// `codex login --device-auth` inside a throwaway CODEX_HOME pinned to
|
|
3
|
-
// file-mode credential storage (auto mode would keyring-namespace the login on
|
|
4
|
-
// macOS and leave nothing harvestable), then parks the resulting auth.json
|
|
5
|
-
// under the token's OWN identity and indexes it. The primary login is never
|
|
6
|
-
// touched. Device auth is the default (user decision 2026-07-20): the browser
|
|
7
|
-
// flow binds localhost on the machine running the command, so it dead-ends
|
|
8
|
-
// over ssh; the device code works from any browser.
|
|
9
|
-
|
|
10
1
|
import { mkdirSync, rmSync } from "node:fs";
|
|
11
2
|
import { join } from "node:path";
|
|
12
3
|
import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV } from "../lib/claudebin.ts";
|
|
@@ -24,11 +15,6 @@ import { c, count } from "./render.ts";
|
|
|
24
15
|
export async function cmdCodexAdd(): Promise<number> {
|
|
25
16
|
const real = resolveRealCodex();
|
|
26
17
|
const onboardDir = codexPaths.onboardDir;
|
|
27
|
-
// Deliberate hard delete (not trash; user decision 2026-07-16): the onboard
|
|
28
|
-
// dir holds a plaintext live credential after login, and a trashed copy
|
|
29
|
-
// would keep that token readable. The dir is tokenmaxxing's own transient
|
|
30
|
-
// artifact; its one durable output is parked separately before cleanup.
|
|
31
|
-
// Same rationale at the end-of-run cleanup below.
|
|
32
18
|
rmSync(onboardDir, { recursive: true, force: true });
|
|
33
19
|
mkdirSync(onboardDir, { recursive: true });
|
|
34
20
|
writeFileAtomic(join(onboardDir, "config.toml"), 'cli_auth_credentials_store = "file"\n');
|
|
@@ -52,9 +38,6 @@ export async function cmdCodexAdd(): Promise<number> {
|
|
|
52
38
|
await p.exited;
|
|
53
39
|
restoreTermios(savedTermios);
|
|
54
40
|
|
|
55
|
-
// The finally guarantees the plaintext onboard home is destroyed on every
|
|
56
|
-
// non-signal exit; an exception mid-registration must not strand it. (An
|
|
57
|
-
// interactive Ctrl-C is reaped by the next run's rmSync-first.)
|
|
58
41
|
let account: CodexAccount;
|
|
59
42
|
let poolSize: number;
|
|
60
43
|
try {
|
package/src/cli/codexinit.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing init --codex` - bring the codex side up: verify + pin the real
|
|
2
|
-
// codex binary, import the existing live login as codex account #1, install
|
|
3
|
-
// the on-PATH codex supervisor shim and the Stop hook declaration, and tell
|
|
4
|
-
// the user about the one step tokenmaxxing cannot do for them: codex skips
|
|
5
|
-
// hooks it has not been TRUSTED to run (trust is recorded against the hook's
|
|
6
|
-
// hash via /hooks in the codex TUI), so auto-switching stays inert until then.
|
|
7
|
-
|
|
8
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
2
|
import { resolveRealCodex, verifyRealCodex } from "../lib/codexbin.ts";
|
|
10
3
|
import { codexIdentityOf, readLiveCodexAuth, writeParkedCodexAuth } from "../lib/codexauth.ts";
|
|
@@ -18,16 +11,6 @@ import { codexCredItemFor, codexPaths } from "../lib/paths.ts";
|
|
|
18
11
|
import type { CodexAccount, CodexUsage } from "../lib/types.ts";
|
|
19
12
|
import { c } from "./render.ts";
|
|
20
13
|
|
|
21
|
-
/** Fail fast when config.toml pins the credential store away from the plain
|
|
22
|
-
* file tokenmaxxing swaps (structural line scan: the repo ships no TOML
|
|
23
|
-
* parser, and this single key is the only one we ever inspect). The real key
|
|
24
|
-
* is `cli_auth_credentials_store` (binary-verified 0.144.5; an earlier
|
|
25
|
-
* verification misread the enum TYPE name as a `_mode` key). An ABSENT key is
|
|
26
|
-
* allowed: the DEFAULT at 0.144.5 is `file` itself (source-verified:
|
|
27
|
-
* AuthCredentialsStoreMode derives Default on the File variant), and init
|
|
28
|
-
* separately verifies a harvestable auth.json.
|
|
29
|
-
* Any EXPLICIT non-file pin (keyring, auto, ephemeral) is a deliberate store
|
|
30
|
-
* choice tokenmaxxing cannot honor. */
|
|
31
14
|
function storePinnedAwayFromFile(): boolean {
|
|
32
15
|
const configToml = `${codexPaths.home}/config.toml`;
|
|
33
16
|
if (!existsSync(configToml)) return false;
|
|
@@ -36,8 +19,6 @@ function storePinnedAwayFromFile(): boolean {
|
|
|
36
19
|
if (!line.startsWith("cli_auth_credentials_store")) continue;
|
|
37
20
|
const rest = line.slice("cli_auth_credentials_store".length).trimStart();
|
|
38
21
|
if (!rest.startsWith("=")) continue;
|
|
39
|
-
// Value only: strip an inline TOML comment and the quotes structurally so
|
|
40
|
-
// a comment mentioning "keyring" never false-positives the fail-fast.
|
|
41
22
|
const beforeComment = rest.slice(1).split("#", 1)[0]!;
|
|
42
23
|
const value = beforeComment.replaceAll('"', "").replaceAll("'", "").trim();
|
|
43
24
|
return value !== "file";
|
|
@@ -46,7 +27,6 @@ function storePinnedAwayFromFile(): boolean {
|
|
|
46
27
|
}
|
|
47
28
|
|
|
48
29
|
export async function cmdCodexInit(): Promise<number> {
|
|
49
|
-
// Fail fast on a broken merged config before installing (see cmdInit).
|
|
50
30
|
loadConfig();
|
|
51
31
|
const real = resolveRealCodex();
|
|
52
32
|
const fail = verifyRealCodex({ bin: real });
|
|
@@ -54,7 +34,6 @@ export async function cmdCodexInit(): Promise<number> {
|
|
|
54
34
|
console.error(c.red(`codex binary failed verification: ${real}: ${fail}`));
|
|
55
35
|
return 1;
|
|
56
36
|
}
|
|
57
|
-
// Sparse write: only the pin lands in the file, never the merged config.
|
|
58
37
|
pinBinOverride({ key: "codexBin", bin: real });
|
|
59
38
|
|
|
60
39
|
if (storePinnedAwayFromFile()) {
|
|
@@ -81,13 +60,6 @@ export async function cmdCodexInit(): Promise<number> {
|
|
|
81
60
|
|
|
82
61
|
const credFile = codexCredItemFor(identity.accountId);
|
|
83
62
|
|
|
84
|
-
// A RUNNING supervised session on this account can rotate auth.json at any
|
|
85
|
-
// moment (codex persists rotations instantly, and the flock serializes only
|
|
86
|
-
// tokenmaxxing actors) - a snapshot parked now could hold an already-
|
|
87
|
-
// superseded refresh token whose next refresh is reuse-punished (cubic
|
|
88
|
-
// review catch, PR #35). Refuse loudly, like the sampler's present-account
|
|
89
|
-
// rule; sessions launched around the shim are the same accepted gap as
|
|
90
|
-
// everywhere presence is the signal.
|
|
91
63
|
if (presentCodexAccountIds().has(identity.accountId)) {
|
|
92
64
|
console.error(c.red("a live supervised codex session is running this account - its token rotates under us, so parking a snapshot now could poison the backup."));
|
|
93
65
|
console.error(c.dim("close that codex session (or let it exit) and re-run `tokenmaxxing init --codex`."));
|
|
@@ -95,21 +67,9 @@ export async function cmdCodexInit(): Promise<number> {
|
|
|
95
67
|
}
|
|
96
68
|
|
|
97
69
|
const account = await withLock(codexPaths.lockFile, () => {
|
|
98
|
-
// Re-check presence INSIDE the critical section (pullfrog review catch,
|
|
99
|
-
// PR #35): supervisor spawns are flock-serialized too, so one can start -
|
|
100
|
-
// check passed, presence written, session live - entirely between the
|
|
101
|
-
// friendly pre-lock check above and this lock acquisition. The throw
|
|
102
|
-
// routes through the CLI error boundary as a clean failure.
|
|
103
70
|
if (presentCodexAccountIds().has(identity.accountId)) {
|
|
104
71
|
throw new Error("a live supervised codex session started running this account mid-init - close it and re-run `tokenmaxxing init --codex`");
|
|
105
72
|
}
|
|
106
|
-
// Park INSIDE the flock, from a blob RE-READ inside it (closing-review
|
|
107
|
-
// catch): the pre-lock snapshot is seconds stale (a network usage GET sits
|
|
108
|
-
// in between), and parking it unlocked could clobber a concurrent swap's
|
|
109
|
-
// just-harvested newest rotation with a superseded refresh token - whose
|
|
110
|
-
// next refresh is reuse-punished into a dead grant family. An identity
|
|
111
|
-
// that changed since the pre-lock read means a swap landed mid-init:
|
|
112
|
-
// abort rather than file the wrong account.
|
|
113
73
|
const fresh2 = readLiveCodexAuth();
|
|
114
74
|
if (!fresh2 || codexIdentityOf({ auth: fresh2 }).accountId !== identity.accountId) {
|
|
115
75
|
throw new Error("the live codex login changed while init was running (a concurrent swap?) - re-run `tokenmaxxing init --codex`");
|
package/src/cli/codexrm.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
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
1
|
import { withLock } from "../lib/lock.ts";
|
|
7
2
|
import { codexPaths } from "../lib/paths.ts";
|
|
8
3
|
import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
|
|
@@ -17,7 +12,6 @@ export async function cmdCodexRm(selector?: string): Promise<number> {
|
|
|
17
12
|
console.error("usage: tokenmaxxing rm --codex <email|label|id>");
|
|
18
13
|
return 2;
|
|
19
14
|
}
|
|
20
|
-
// under the codex flock: a concurrent swap's index write must not be clobbered.
|
|
21
15
|
return withLock(codexPaths.lockFile, async () => {
|
|
22
16
|
const index = loadCodexAccounts();
|
|
23
17
|
const account = findCodexAccount(index.accounts, selector);
|
|
@@ -25,10 +19,6 @@ export async function cmdCodexRm(selector?: string): Promise<number> {
|
|
|
25
19
|
console.error(c.red(`no codex account matches "${selector}"`));
|
|
26
20
|
return 1;
|
|
27
21
|
}
|
|
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
22
|
if (liveCodexAccountId() === account.accountId) {
|
|
33
23
|
console.error(c.red(`${account.label} is the LIVE codex account - run \`tokenmaxxing switch --codex\` to move off it first.`));
|
|
34
24
|
return 1;
|
|
@@ -37,9 +27,6 @@ export async function cmdCodexRm(selector?: string): Promise<number> {
|
|
|
37
27
|
console.error(c.red(`${account.label} is running in a live codex session - close that session before removing it.`));
|
|
38
28
|
return 1;
|
|
39
29
|
}
|
|
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
30
|
deleteParkedCodexAuth({ credFile: account.credFile });
|
|
44
31
|
index.accounts = index.accounts.filter((x) => x.accountId !== account.accountId);
|
|
45
32
|
saveCodexAccounts({ index });
|
package/src/cli/codexswitch.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing switch --codex [sel]`. Bare form is GREEDY and IDEMPOTENT off
|
|
2
|
-
// cached windows, exactly like the claude switch: rank every codex account
|
|
3
|
-
// (current included) by pace pressure among those under their screening bars;
|
|
4
|
-
// when the current account already wins, do nothing. The current account is
|
|
5
|
-
// the LIVE auth.json's own identity, never the stored label. The swap takes
|
|
6
|
-
// effect for the NEXT codex start: a running codex refuses a different
|
|
7
|
-
// account's credential (restart is the switch; the Stop hook + supervisor
|
|
8
|
-
// automate that for supervised sessions).
|
|
9
|
-
|
|
10
1
|
import { withLock } from "../lib/lock.ts";
|
|
11
2
|
import { codexPaths } from "../lib/paths.ts";
|
|
12
3
|
import { loadConfig } from "../lib/state.ts";
|
|
@@ -32,10 +23,6 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
|
32
23
|
}
|
|
33
24
|
const currentId = liveCodexAccountId();
|
|
34
25
|
|
|
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
26
|
if (sel) {
|
|
40
27
|
const target = index.accounts.find(
|
|
41
28
|
(account) => account.label === sel || account.email === sel || account.accountId.startsWith(sel),
|
|
@@ -60,8 +47,6 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
|
60
47
|
try {
|
|
61
48
|
await performCodexSwap({ target });
|
|
62
49
|
} catch (e) {
|
|
63
|
-
// a dead grant is an expected operational state, not a stack trace
|
|
64
|
-
// (closing-review catch; mirrors the claude selector path).
|
|
65
50
|
if (e instanceof CodexInvalidGrantError) {
|
|
66
51
|
console.error(c.red(`${target.label}'s refresh token is dead - re-add it with \`tokenmaxxing add --codex\``));
|
|
67
52
|
return 1;
|
package/src/cli/config.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing config` - inspect, edit, and housekeep config.json (user ask
|
|
2
|
-
// 2026-07-16). Operates on the SPARSE file: config.json holds only overrides
|
|
3
|
-
// and loadConfig merges defaults at read time, so baking defaults into the
|
|
4
|
-
// file would freeze future default changes. `tidy` is the housekeeper: it
|
|
5
|
-
// drops keys the schema no longer knows (e.g. the pre-0.7 flat `threshold`)
|
|
6
|
-
// and normalizes switchModels casing; get/set/unset only ever report them.
|
|
7
|
-
|
|
8
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
2
|
import { isPlainObject } from "es-toolkit";
|
|
10
3
|
import { get, set, unset } from "es-toolkit/compat";
|
|
@@ -14,9 +7,6 @@ import { ConfigFileSchema, loadConfig, mergeConfigFile } from "../lib/state.ts";
|
|
|
14
7
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
15
8
|
import { c } from "./render.ts";
|
|
16
9
|
|
|
17
|
-
/** Hand-maintained mirror of ConfigFileSchema's dotted keys; an invariant test
|
|
18
|
-
* (test/config.test.ts) pins the two together so a new schema field cannot
|
|
19
|
-
* silently become invisible to get/set/tidy. */
|
|
20
10
|
export const KNOWN_KEYS = [
|
|
21
11
|
"thresholds.session",
|
|
22
12
|
"thresholds.weekly",
|
|
@@ -42,7 +32,6 @@ function writeRawFile(input: { raw: Record<string, unknown> }): void {
|
|
|
42
32
|
writeFileAtomic(paths.configJson, JSON.stringify(input.raw, null, 2) + "\n");
|
|
43
33
|
}
|
|
44
34
|
|
|
45
|
-
/** Dotted keys in `obj` (two levels: this config nests exactly once). */
|
|
46
35
|
function dottedKeys(obj: Record<string, unknown>): string[] {
|
|
47
36
|
const keys: string[] = [];
|
|
48
37
|
for (const [key, value] of Object.entries(obj)) {
|
|
@@ -94,7 +83,6 @@ function cmdGet(key: string): number {
|
|
|
94
83
|
return 0;
|
|
95
84
|
}
|
|
96
85
|
|
|
97
|
-
/** JSON when it parses (numbers, booleans, arrays), else the literal string. */
|
|
98
86
|
function parseValue(text: string): unknown {
|
|
99
87
|
try {
|
|
100
88
|
return JSON.parse(text);
|
|
@@ -117,19 +105,12 @@ function cmdSet(key: string, valueText: string): number {
|
|
|
117
105
|
console.error(c.red(`rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`));
|
|
118
106
|
return 1;
|
|
119
107
|
}
|
|
120
|
-
// The per-field gate passed; the MERGED whole must too, or this write makes
|
|
121
|
-
// every later loadConfig throw (the projectionMargin-vs-thresholds refine),
|
|
122
|
-
// silently disabling status/switch/hooks/statusline until the file is
|
|
123
|
-
// hand-repaired (closing-review catch).
|
|
124
108
|
const mergedCheck = mergeConfigFile(validated.data);
|
|
125
109
|
if (!mergedCheck.ok) {
|
|
126
110
|
console.error(c.red(`rejected: ${mergedCheck.detail}`));
|
|
127
111
|
return 1;
|
|
128
112
|
}
|
|
129
113
|
writeRawFile({ raw: next });
|
|
130
|
-
// Report the FILE-level change: with an env override in place, the effective
|
|
131
|
-
// value would not move, and an unchanged-looking arrow would misrepresent
|
|
132
|
-
// the write that just happened.
|
|
133
114
|
const beforeFile = get(raw, key);
|
|
134
115
|
console.log(
|
|
135
116
|
`${key}: ${beforeFile === undefined ? "(default)" : JSON.stringify(beforeFile)} -> ${JSON.stringify(get(next, key))}`,
|
|
@@ -139,8 +120,6 @@ function cmdSet(key: string, valueText: string): number {
|
|
|
139
120
|
return 0;
|
|
140
121
|
}
|
|
141
122
|
|
|
142
|
-
/** Remove parents emptied by a deletion or a strip: the sparse file must not
|
|
143
|
-
* accumulate `{}` husks. */
|
|
144
123
|
function pruneEmptyParents(raw: Record<string, unknown>): void {
|
|
145
124
|
for (const [topKey, value] of Object.entries(raw)) {
|
|
146
125
|
if (isPlainObject(value) && Object.keys(value).length === 0) {
|
|
@@ -162,11 +141,6 @@ function cmdUnset(key: string): number {
|
|
|
162
141
|
const next = structuredClone(raw);
|
|
163
142
|
unset(next, key);
|
|
164
143
|
pruneEmptyParents(next);
|
|
165
|
-
// Same merged-whole gate as `set` (adversarial-review catch): removing one
|
|
166
|
-
// override shifts the merged value back to its default, and the
|
|
167
|
-
// projectionMargin-vs-thresholds refine can fail on the RESULT even though
|
|
168
|
-
// every remaining field is individually valid - writing that file would make
|
|
169
|
-
// every later loadConfig throw, bricking status/switch/hooks/statusline.
|
|
170
144
|
const validated = ConfigFileSchema.safeParse(next);
|
|
171
145
|
if (!validated.success) {
|
|
172
146
|
console.error(c.red(`rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`));
|
|
@@ -185,8 +159,6 @@ function cmdUnset(key: string): number {
|
|
|
185
159
|
function cmdTidy(): number {
|
|
186
160
|
const raw = readRawFile();
|
|
187
161
|
const dropped = unknownFileKeys(raw);
|
|
188
|
-
// ConfigFileSchema strips unknown keys at both levels; switchModels casing
|
|
189
|
-
// normalizes to what the loader would use anyway.
|
|
190
162
|
const parsed = ConfigFileSchema.parse(raw);
|
|
191
163
|
const next = RawFileSchema.parse(JSON.parse(JSON.stringify(parsed)));
|
|
192
164
|
const models = get(next, "policy.switchModels");
|
|
@@ -195,7 +167,6 @@ function cmdTidy(): number {
|
|
|
195
167
|
if (normalized != null) set(next, "policy.switchModels", normalized);
|
|
196
168
|
pruneEmptyParents(next);
|
|
197
169
|
|
|
198
|
-
// Honest housekeeping: say exactly what changed, write only when something did.
|
|
199
170
|
if (JSON.stringify(next) === JSON.stringify(raw)) {
|
|
200
171
|
console.log(c.dim("nothing to tidy"));
|
|
201
172
|
return 0;
|
|
@@ -216,7 +187,6 @@ export function cmdConfig(args: string[]): number {
|
|
|
216
187
|
if (sub === "unset" && key !== undefined) return cmdUnset(key);
|
|
217
188
|
if (sub === "tidy") return cmdTidy();
|
|
218
189
|
} catch (e) {
|
|
219
|
-
// A corrupt config.json fails fast with a recovery step, not a stack trace.
|
|
220
190
|
console.error(c.red(`config.json is unreadable: ${e instanceof Error ? e.message : String(e)}`));
|
|
221
191
|
console.error(c.dim(`fix or delete ${paths.configJson} (defaults apply when it is absent), then re-run`));
|
|
222
192
|
return 1;
|
package/src/cli/doctor.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing doctor` - verify the supervisor + four settings entries survived
|
|
2
|
-
// and the pool is healthy.
|
|
3
|
-
|
|
4
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
2
|
import { verifyRealClaude } from "../lib/claudebin.ts";
|
|
6
3
|
import { checkSettings, installedBin } from "../lib/settings.ts";
|
|
@@ -12,8 +9,6 @@ import { isAccessTokenExpiring, fetchTokenOrg } from "../lib/oauth.ts";
|
|
|
12
9
|
import { CredentialBlobSchema, type RolesResponse } from "../lib/types.ts";
|
|
13
10
|
import { c } from "./render.ts";
|
|
14
11
|
|
|
15
|
-
/** The org a stored blob's token truly belongs to; null = expired (unverifiable
|
|
16
|
-
* read-only - doctor never refreshes). Throws on unreadable blob / API failure. */
|
|
17
12
|
async function blobOrg(raw: string): Promise<RolesResponse | null> {
|
|
18
13
|
const creds = CredentialBlobSchema.parse(JSON.parse(raw)).claudeAiOauth;
|
|
19
14
|
if (isAccessTokenExpiring(creds)) return null;
|
|
@@ -35,6 +30,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
35
30
|
check(s.statusLineOk, "statusLine shim installed in settings.json", "run `tokenmaxxing init`");
|
|
36
31
|
check(s.subagentStatusLineOk, "subagentStatusLine shim installed in settings.json", "run `tokenmaxxing init`");
|
|
37
32
|
check(s.stopOk, "Stop hook installed in settings.json", "run `tokenmaxxing init`");
|
|
33
|
+
check(s.stopFailureOk, "StopFailure hook installed in settings.json", "run `tokenmaxxing init`");
|
|
38
34
|
check(s.sessionStartOk, "SessionStart hook installed in settings.json", "run `tokenmaxxing init`");
|
|
39
35
|
check(checkTimerHealthy(), "periodic check timer active", timerActivationHint());
|
|
40
36
|
|
|
@@ -45,9 +41,6 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
45
41
|
const live = await readItem(liveTarget());
|
|
46
42
|
check(!!live, "live credential readable");
|
|
47
43
|
|
|
48
|
-
// Identity agreement: a stored credential must belong to the account it is
|
|
49
|
-
// filed under - a mislabeled blob once made every consumer of a backup
|
|
50
|
-
// (sampling, swap) silently act on another account.
|
|
51
44
|
const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid);
|
|
52
45
|
if (live && active) {
|
|
53
46
|
try {
|
|
@@ -77,16 +70,10 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
77
70
|
const cfg = loadConfig();
|
|
78
71
|
check(!!cfg.claudeBin && existsSync(cfg.claudeBin), "real claude binary resolved", "set claudeBin in config.json");
|
|
79
72
|
if (cfg.claudeBin && existsSync(cfg.claudeBin)) {
|
|
80
|
-
// Behavioral: the pin must answer --version without re-entering the wrapper.
|
|
81
|
-
// Catches a poisoned pin (a shim that resolves `claude` back to us) that
|
|
82
|
-
// existence checks cannot - the 2026-07-12 recursive-spawn incident.
|
|
83
73
|
const fail = verifyRealClaude(cfg.claudeBin);
|
|
84
74
|
check(fail === null, "claudeBin launches the real claude", fail ?? undefined);
|
|
85
75
|
}
|
|
86
76
|
|
|
87
|
-
// Warnings only: an interactive alias/function can shadow or bypass the
|
|
88
|
-
// wrapper in ways PATH checks cannot see (`alias claude=...`, or a `cc`-style
|
|
89
|
-
// alias hardcoding an absolute path to the real binary).
|
|
90
77
|
const rc = shellRcPath();
|
|
91
78
|
if (rc && existsSync(rc)) {
|
|
92
79
|
for (const s of findClaudeShadowers(readFileSync(rc, "utf8"))) {
|
package/src/cli/init.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing init` - import the account you're already on (no prompts), then
|
|
2
|
-
// install the supervisor + the four settings entries.
|
|
3
|
-
|
|
4
1
|
import { mkdirSync } from "node:fs";
|
|
5
2
|
import { isApiKeyMode, readOAuthAccount } from "../lib/claudejson.ts";
|
|
6
3
|
import { readItem, writeItem, liveTarget, parkedTarget, mergeIntoLive } from "../lib/credstore.ts";
|
|
@@ -14,9 +11,6 @@ import { credItemFor, paths } from "../lib/paths.ts";
|
|
|
14
11
|
import { CredentialBlobSchema, type Account } from "../lib/types.ts";
|
|
15
12
|
import { c, claudeTierLabel } from "./render.ts";
|
|
16
13
|
|
|
17
|
-
/** Put the supervisor bin dir on PATH via the user's shell rc (idempotent).
|
|
18
|
-
* Falls back to the manual instruction when the shell is unknown or the rc
|
|
19
|
-
* target is immutable (Home Manager / nix-store). */
|
|
20
14
|
function ensurePathAhead(): void {
|
|
21
15
|
const rc = shellRcPath();
|
|
22
16
|
if (!rc) {
|
|
@@ -38,9 +32,6 @@ function reportTimer(out: InstallOutcome): void {
|
|
|
38
32
|
else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
|
|
39
33
|
}
|
|
40
34
|
|
|
41
|
-
/** The how-to-use epilogue both init paths end on: the whole point of the tool
|
|
42
|
-
* is that after init you just run `claude`, so say exactly that, and teach the
|
|
43
|
-
* `xx` shorthand every other command hangs off. Exported for the render test. */
|
|
44
35
|
export function printUsage(): void {
|
|
45
36
|
console.log();
|
|
46
37
|
console.log(` ${c.bold("how to use")} - ${c.cyan("xx")} is shorthand for ${c.cyan("tokenmaxxing")}:`);
|
|
@@ -54,25 +45,11 @@ export function printUsage(): void {
|
|
|
54
45
|
|
|
55
46
|
export async function cmdInit(): Promise<number> {
|
|
56
47
|
mkdirSync(paths.home, { recursive: true });
|
|
57
|
-
// Fail fast on a broken merged config BEFORE installing or claiming a
|
|
58
|
-
// successful repair: pinBinOverride writes sparsely without validating, so
|
|
59
|
-
// without this gate a re-init printed success while hooks, switching, and
|
|
60
|
-
// the statusline kept throwing on every loadConfig until the file was
|
|
61
|
-
// hand-repaired (bugbot review catch, PR #33). The throw carries the
|
|
62
|
-
// fix-or-remove recovery message.
|
|
63
48
|
loadConfig();
|
|
64
49
|
|
|
65
|
-
// Already initialized → repair install ONLY. Never re-import: ~/.claude.json's
|
|
66
|
-
// oauthAccount can drift from the live keychain cred (after swaps / concurrent
|
|
67
|
-
// sessions), and re-importing would park the wrong cred + mislabel active.
|
|
68
50
|
const existingIdx = loadAccounts();
|
|
69
51
|
if (existingIdx.accounts.length > 0) {
|
|
70
52
|
const out = installSupervisor();
|
|
71
|
-
// repair the claudeBin pin too - hooks run with claude's PATH and must
|
|
72
|
-
// never have to guess which binary is the real claude. Verified pinning:
|
|
73
|
-
// a pin that fails --version (or loops back into the wrapper) is replaced
|
|
74
|
-
// by a fresh PATH scan instead of being re-saved. Sparse write: only the
|
|
75
|
-
// pin lands in the file, never the merged config.
|
|
76
53
|
pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() });
|
|
77
54
|
const active = existingIdx.accounts.find((a) => a.accountUuid === existingIdx.activeAccountUuid);
|
|
78
55
|
console.log(`${c.green("✓")} re-installed supervisor + hooks (pool already has ${existingIdx.accounts.length} account${existingIdx.accounts.length === 1 ? "" : "s"} - not re-importing)`);
|
|
@@ -105,13 +82,9 @@ export async function cmdInit(): Promise<number> {
|
|
|
105
82
|
return 1;
|
|
106
83
|
}
|
|
107
84
|
|
|
108
|
-
// Verify the live credential actually belongs to the identity we're about to
|
|
109
|
-
// file it under - ~/.claude.json's oauthAccount can drift from the live
|
|
110
|
-
// keychain credential, and importing on drifted state parks a mislabeled blob.
|
|
111
85
|
let creds = blob.claudeAiOauth;
|
|
112
86
|
if (isAccessTokenExpiring(creds)) {
|
|
113
87
|
await withClaudeRefreshLock(async (lock) => {
|
|
114
|
-
// re-read inside the lock: a running claude may have rotated it already.
|
|
115
88
|
const raw2 = await readItem(liveTarget());
|
|
116
89
|
if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
|
|
117
90
|
const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
|
|
@@ -130,12 +103,8 @@ export async function cmdInit(): Promise<number> {
|
|
|
130
103
|
|
|
131
104
|
const uuid = oauthAccount.accountUuid;
|
|
132
105
|
const keychainItem = credItemFor(uuid);
|
|
133
|
-
// Park + index update under the tokenmaxxing flock, like every sibling
|
|
134
|
-
// index writer (add/auth/rm/status/rename): unlocked, a concurrent `xx add`
|
|
135
|
-
// completing between this path's load and save had its just-registered
|
|
136
|
-
// account silently clobbered out of the index (closing-review catch).
|
|
137
106
|
const account = await withLock(paths.lockFile, async () => {
|
|
138
|
-
await writeItem(parkedTarget(keychainItem), JSON.stringify({ claudeAiOauth: creds }));
|
|
107
|
+
await writeItem(parkedTarget(keychainItem), JSON.stringify({ claudeAiOauth: creds }));
|
|
139
108
|
const idx = loadAccounts();
|
|
140
109
|
const existing = idx.accounts.find((a) => a.accountUuid === uuid);
|
|
141
110
|
const imported: Account = {
|
|
@@ -157,7 +126,6 @@ export async function cmdInit(): Promise<number> {
|
|
|
157
126
|
return imported;
|
|
158
127
|
});
|
|
159
128
|
|
|
160
|
-
// Sparse write: only the pin lands in the file, never the merged config.
|
|
161
129
|
pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() });
|
|
162
130
|
|
|
163
131
|
const out = installSupervisor();
|
package/src/cli/ls.ts
CHANGED