tokenmaxxing 0.19.1 → 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 +34 -25
- package/README.md +6 -5
- package/package.json +1 -1
- package/src/cli/add.ts +1 -0
- package/src/cli/auth.ts +25 -14
- package/src/cli/check.ts +3 -2
- package/src/cli/codexadd.ts +44 -40
- package/src/cli/codexinit.ts +59 -12
- package/src/cli/codexrm.ts +49 -0
- package/src/cli/codexswitch.ts +20 -2
- package/src/cli/config.ts +25 -1
- package/src/cli/doctor.ts +3 -3
- package/src/cli/init.ts +54 -32
- package/src/cli/onboard.ts +62 -45
- package/src/cli/rename.ts +20 -0
- package/src/cli/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +638 -115
- package/src/cli/status.ts +69 -23
- package/src/cli/switch.ts +54 -19
- package/src/entries/codexstophook.ts +123 -4
- package/src/entries/codexsupervisor.ts +87 -13
- package/src/entries/sessionstart.ts +1 -1
- package/src/entries/statusline.ts +56 -20
- package/src/entries/stophook.ts +23 -9
- package/src/entries/supervisor.ts +184 -20
- package/src/lib/atomic.ts +28 -6
- package/src/lib/claudebin.ts +2 -2
- package/src/lib/claudejson.ts +5 -5
- package/src/lib/claudelock.ts +112 -37
- package/src/lib/codexauth.ts +18 -3
- package/src/lib/codexbin.ts +1 -1
- package/src/lib/codexdecide.ts +149 -19
- package/src/lib/codexpick.ts +17 -6
- package/src/lib/codexpresence.ts +59 -21
- package/src/lib/codexsample.ts +17 -8
- package/src/lib/codexswap.ts +10 -1
- package/src/lib/credstore.ts +6 -2
- package/src/lib/decide.ts +136 -49
- package/src/lib/install.ts +125 -17
- package/src/lib/keychain.ts +41 -15
- package/src/lib/lock.ts +57 -35
- package/src/lib/log.ts +36 -7
- package/src/lib/oauth.ts +18 -11
- package/src/lib/paths.ts +17 -11
- package/src/lib/picker.ts +11 -3
- package/src/lib/proc.ts +37 -0
- package/src/lib/sample.ts +91 -31
- package/src/lib/sessions.ts +23 -1
- package/src/lib/settings.ts +59 -18
- package/src/lib/slackbridge.ts +581 -81
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +123 -20
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +92 -38
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +70 -9
- package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
- package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/src/lib/codexsample.ts
CHANGED
|
@@ -35,16 +35,25 @@ export async function sampleCodexAccount(input: { account: CodexAccount; liveAcc
|
|
|
35
35
|
let auth = isLive ? readLiveCodexAuth() : readParkedCodexAuth({ credFile: account.credFile });
|
|
36
36
|
if (!auth) return { ok: false, reason: isLive ? "live auth.json vanished" : "no parked credential", deadGrant: false };
|
|
37
37
|
if (isCodexAccessExpiring({ auth, now })) {
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
38
|
+
// NEVER refresh a PRESENT account's token, live or parked. A parked
|
|
39
|
+
// blob whose account is RUNNING in another supervised session is
|
|
40
|
+
// superseded by that session's live rotations, and the LIVE blob's
|
|
41
|
+
// running session can be mid-turn refreshing the same rotating token
|
|
42
|
+
// concurrently (both actors share the 300s margin): either way the
|
|
43
|
+
// loser of the race is reuse-punished into a dead grant family
|
|
44
|
+
// (closing-review catch; the idle-turn-boundary safety argument covers
|
|
45
|
+
// only the invoking session's own account). Parked reports a miss; live
|
|
46
|
+
// fetches on the unrotated token, still valid within the margin, and
|
|
47
|
+
// degrades to an honest miss once it expires.
|
|
48
|
+
const running = presentCodexAccountIds().has(account.accountId);
|
|
49
|
+
if (running && !isLive) {
|
|
43
50
|
return { ok: false, reason: "running in a live codex session (parked token refresh unsafe)", deadGrant: false };
|
|
44
51
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
if (!running) {
|
|
53
|
+
auth = await refreshCodexAuth({ auth, now });
|
|
54
|
+
if (isLive) writeLiveCodexAuth({ auth });
|
|
55
|
+
writeParkedCodexAuth({ credFile: account.credFile, auth });
|
|
56
|
+
}
|
|
48
57
|
}
|
|
49
58
|
const usage = await fetchCodexUsage({ auth });
|
|
50
59
|
return { ok: true, usage };
|
package/src/lib/codexswap.ts
CHANGED
|
@@ -78,7 +78,16 @@ export async function performCodexSwap(input: { target: CodexAccount }): Promise
|
|
|
78
78
|
writeParkedCodexAuth({ credFile: target.credFile, auth: fresh });
|
|
79
79
|
|
|
80
80
|
if (live && liveOwner) {
|
|
81
|
-
|
|
81
|
+
// Last-moment re-read: a running codex (the Apps surface) can rotate the
|
|
82
|
+
// live token outside our flock between the identity resolution above and
|
|
83
|
+
// this harvest, and parking the earlier snapshot would strand the newest
|
|
84
|
+
// rotation of a reuse-punished grant family. A mid-swap identity change
|
|
85
|
+
// refuses rather than harvesting under a stale owner.
|
|
86
|
+
const liveNow = readLiveCodexAuth();
|
|
87
|
+
if (!liveNow || codexIdentityOf({ auth: liveNow }).accountId !== liveOwner.accountId) {
|
|
88
|
+
throw new Error("live codex credential changed mid-swap - refusing to harvest under a stale identity; retry");
|
|
89
|
+
}
|
|
90
|
+
writeParkedCodexAuth({ credFile: liveOwner.credFile, auth: liveNow });
|
|
82
91
|
log("codexswap.harvest", { account: liveOwner.accountId.slice(0, 8) });
|
|
83
92
|
}
|
|
84
93
|
|
package/src/lib/credstore.ts
CHANGED
|
@@ -23,8 +23,10 @@ export type CredTarget = z.infer<typeof CredTargetSchema>;
|
|
|
23
23
|
|
|
24
24
|
const darwin = process.platform === "darwin";
|
|
25
25
|
|
|
26
|
+
const BlobRecordSchema = z.record(z.string(), z.unknown());
|
|
27
|
+
|
|
26
28
|
function isEnoent(e: unknown): boolean {
|
|
27
|
-
return
|
|
29
|
+
return e instanceof Error && "code" in e && e.code === "ENOENT";
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/** Read a target's credential blob. Returns null if it does not exist. */
|
|
@@ -91,7 +93,9 @@ export function claudeAiOauthOnly(fullBlobRaw: string): string {
|
|
|
91
93
|
/** Merge a fresh `claudeAiOauth` into the CURRENT live blob, preserving every
|
|
92
94
|
* sibling key (MCP OAuth state, etc.). Returns the full blob string to install. */
|
|
93
95
|
export function mergeIntoLive(currentLiveRaw: string | null, freshClaudeAiOauth: unknown): string {
|
|
94
|
-
|
|
96
|
+
// null-check, not falsiness: an EXISTING-but-empty blob is corruption and
|
|
97
|
+
// must surface (JSON.parse throws), never be silently replaced as "missing".
|
|
98
|
+
const base = currentLiveRaw == null ? {} : BlobRecordSchema.parse(JSON.parse(currentLiveRaw));
|
|
95
99
|
base["claudeAiOauth"] = freshClaudeAiOauth;
|
|
96
100
|
return JSON.stringify(base);
|
|
97
101
|
}
|
package/src/lib/decide.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { maxBy } from "es-toolkit";
|
|
|
27
27
|
import { z } from "zod";
|
|
28
28
|
import { withLock } from "./lock.ts";
|
|
29
29
|
import { paths } from "./paths.ts";
|
|
30
|
-
import { loadAccounts, loadConfig, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
|
|
30
|
+
import { loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
|
|
31
31
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
32
32
|
import { chooseAndSwap, performSwap } from "./swap.ts";
|
|
33
33
|
import { currentWins, effectiveBars, pickBest, pickEarliestReset, usableAt } from "./picker.ts";
|
|
@@ -40,15 +40,30 @@ const SwapDecisionSchema = z.object({
|
|
|
40
40
|
swapped: z.boolean(),
|
|
41
41
|
account: AccountSchema.nullable(),
|
|
42
42
|
reason: z.string(),
|
|
43
|
-
/** set when every account is depleted
|
|
43
|
+
/** set when every account is depleted and the soonest recovery is known:
|
|
44
|
+
* epoch ms that account recovers. The wait target on depleted-wait;
|
|
45
|
+
* informational on a bare all-depleted (callers like `xx serve` park on
|
|
46
|
+
* it - nothing here waits). */
|
|
44
47
|
waitUntil: z.number().optional(),
|
|
45
48
|
});
|
|
46
49
|
export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
|
|
47
50
|
|
|
51
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
52
|
+
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
53
|
+
|
|
48
54
|
/** A window's usable-against percentage NOW: one whose cached reset has passed
|
|
49
|
-
* is empty again, never a switch reason.
|
|
50
|
-
|
|
51
|
-
|
|
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;
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
/** The family's weekly cap among the `/usage` rows; when several rows match the
|
|
@@ -58,7 +73,7 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
|
|
|
58
73
|
const rows = Object.entries(mu.perModel)
|
|
59
74
|
.filter(([k]) => familyTokens(k).includes(family))
|
|
60
75
|
.map(([, w]) => w);
|
|
61
|
-
return maxBy(rows, (w) => liveUsed(w, now));
|
|
76
|
+
return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
/** True if the active account is over its floor on ANY screening bar: the 5h
|
|
@@ -68,11 +83,14 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
|
|
|
68
83
|
function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, now: number): boolean {
|
|
69
84
|
if (!u || !org || u.org !== org) return false;
|
|
70
85
|
const bars = effectiveBars(cfg);
|
|
71
|
-
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;
|
|
72
90
|
if (mu && mu.org === org) {
|
|
73
91
|
for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
|
|
74
92
|
const cap = capForFamily(mu, family, now);
|
|
75
|
-
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;
|
|
76
94
|
}
|
|
77
95
|
}
|
|
78
96
|
return false;
|
|
@@ -88,7 +106,7 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
|
|
|
88
106
|
* a fresh session rides its account - no churn. */
|
|
89
107
|
function isEngaged(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, now: number): boolean {
|
|
90
108
|
if (!u || !org || u.org !== org) return false;
|
|
91
|
-
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);
|
|
92
110
|
}
|
|
93
111
|
|
|
94
112
|
const SnapshotsSchema = z.object({
|
|
@@ -140,10 +158,14 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
|
|
|
140
158
|
u = { fiveHour: full.session, sevenDay: full.weekAll, org, ts, model: null };
|
|
141
159
|
writeUsage(u);
|
|
142
160
|
}
|
|
143
|
-
mu = { perModel: full.perModel, org, ts };
|
|
161
|
+
mu = { perModel: full.perModel, org, ts, sampledAt: ts };
|
|
144
162
|
saveModelUsage(mu);
|
|
145
163
|
} else {
|
|
146
|
-
|
|
164
|
+
// the anti-storm stamp: ts=now suppresses re-probing, but the carried
|
|
165
|
+
// rows keep their ORIGINAL sample time - dating them by ts rolled the
|
|
166
|
+
// null-reset self-bound forward on every failed probe (closing-review
|
|
167
|
+
// catch).
|
|
168
|
+
mu = { perModel: mu?.org === org ? (mu?.perModel ?? {}) : {}, org, ts, sampledAt: mu?.org === org ? (mu?.sampledAt ?? mu?.ts) : undefined };
|
|
147
169
|
saveModelUsage(mu);
|
|
148
170
|
}
|
|
149
171
|
}
|
|
@@ -156,7 +178,7 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
|
|
|
156
178
|
* sooner runs model-blind on data the swap itself invalidated - that is how a
|
|
157
179
|
* model-aware swap got immediately undone into an A<->B respawn loop. Manual
|
|
158
180
|
* `switch` is unaffected. */
|
|
159
|
-
const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
181
|
+
export const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
160
182
|
|
|
161
183
|
/**
|
|
162
184
|
* `anticipatory` allows the depleted path to swap onto an account that is still
|
|
@@ -170,7 +192,7 @@ const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
|
170
192
|
export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false): Promise<SwapDecision> {
|
|
171
193
|
const lastSwapAt = loadLastSwapAt();
|
|
172
194
|
if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
|
|
173
|
-
return { swapped: false, account: null, reason: "post-swap-cooldown" };
|
|
195
|
+
return depletedReplay(now) ?? { swapped: false, account: null, reason: "post-swap-cooldown" };
|
|
174
196
|
}
|
|
175
197
|
|
|
176
198
|
const cfg = loadConfig();
|
|
@@ -178,8 +200,16 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
178
200
|
|
|
179
201
|
const { u: usage, mu } = await loadFreshSnapshots(cfg, activeOrg, now);
|
|
180
202
|
|
|
181
|
-
// cheap pre-check off the lock - the common case exits here.
|
|
203
|
+
// cheap pre-check off the lock - the common case exits here. When there is
|
|
204
|
+
// NO measurement for the live org (a pre-park just cleared the snapshots),
|
|
205
|
+
// a recorded depleted-wait still replays; a fresh measurement that reads
|
|
206
|
+
// under-threshold never does - measured-healthy must win over a stale wait.
|
|
182
207
|
if (!isEngaged(usage, mu, activeOrg, cfg, now)) {
|
|
208
|
+
const measured = usage != null && activeOrg != null && usage.org === activeOrg;
|
|
209
|
+
if (!measured) {
|
|
210
|
+
const replay = depletedReplay(now);
|
|
211
|
+
if (replay) return replay;
|
|
212
|
+
}
|
|
183
213
|
return { swapped: false, account: null, reason: "under-threshold-or-stale" };
|
|
184
214
|
}
|
|
185
215
|
|
|
@@ -189,24 +219,57 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
189
219
|
const u2 = loadUsage() ?? usage;
|
|
190
220
|
const mu2 = needsPerModel(u2, cfg) ? loadModelUsage() ?? mu : null;
|
|
191
221
|
|
|
222
|
+
// A live login whose org is KNOWN but outside the pool: do nothing - the
|
|
223
|
+
// codex org-guard analog. performSwap would refuse any swap over it (an
|
|
224
|
+
// unpooled credential's only copy must never be overwritten), and the
|
|
225
|
+
// seat fallback below must not stand in a stale pooled label for it: the
|
|
226
|
+
// depleted path could then park a supervised session against the LABELED
|
|
227
|
+
// account's reset while the running login is someone else entirely
|
|
228
|
+
// (pullfrog review catch, PR #33).
|
|
229
|
+
if (org2 != null && !idx.accounts.some((a) => a.organizationUuid === org2)) {
|
|
230
|
+
return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
|
|
231
|
+
}
|
|
232
|
+
|
|
192
233
|
// record the active account's aggregate usage so the picker + `status` see it.
|
|
234
|
+
// Resolved by the LIVE org the guard just verified, never the
|
|
235
|
+
// activeAccountUuid label: after a manual /login the label drifts (the
|
|
236
|
+
// surviving drift source, see cli/switch.ts), and a label-keyed write
|
|
237
|
+
// would stamp the live account's windows onto whichever account the label
|
|
238
|
+
// still names (closing-review catch, mirrors the codex live-identity rule).
|
|
193
239
|
if (u2 && org2 && u2.org === org2) {
|
|
194
|
-
const active = idx.accounts.find((a) => a.
|
|
240
|
+
const active = idx.accounts.find((a) => a.organizationUuid === org2);
|
|
195
241
|
if (active) {
|
|
196
242
|
active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
|
|
197
243
|
active.lastUsageAt = u2.ts;
|
|
198
244
|
// Snapshot per-model caps too, so they still show after we switch away.
|
|
199
245
|
// An empty map is a failed probe's anti-storm stamp, not a measurement -
|
|
200
246
|
// it must not erase the burnt-cap snapshot the picker screens on.
|
|
201
|
-
if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0)
|
|
247
|
+
if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) {
|
|
248
|
+
active.lastPerModel = mu2.perModel;
|
|
249
|
+
// the rows' TRUE sample time, not the write time: an anti-storm
|
|
250
|
+
// stamp re-writes ts while carrying old rows (closing-review catch).
|
|
251
|
+
active.lastPerModelAt = mu2.sampledAt ?? mu2.ts;
|
|
252
|
+
}
|
|
202
253
|
saveAccounts(idx);
|
|
203
254
|
}
|
|
204
255
|
}
|
|
205
256
|
|
|
206
257
|
if (!isEngaged(u2, mu2, org2, cfg, now)) {
|
|
207
|
-
return { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
258
|
+
return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
208
259
|
}
|
|
209
260
|
|
|
261
|
+
// The SEAT every path below evaluates and excludes: the live org's pooled
|
|
262
|
+
// account when resolvable, the stored label only as fallback - the same
|
|
263
|
+
// identity rule as the usage stamp above and depletedReplay. Trusting the
|
|
264
|
+
// label here let the greedy convergence judge a stale account as "the
|
|
265
|
+
// seat" after a manual /login, ranking against the wrong cached windows
|
|
266
|
+
// and even offering the LIVE account as a swap target (bugbot review
|
|
267
|
+
// catch, PR #33).
|
|
268
|
+
const seatOf = (idx2: { activeAccountUuid: string | null; accounts: Account[] }): Account | null =>
|
|
269
|
+
idx2.accounts.find((a) => a.organizationUuid === org2) ??
|
|
270
|
+
idx2.accounts.find((a) => a.accountUuid === idx2.activeAccountUuid) ??
|
|
271
|
+
null;
|
|
272
|
+
|
|
210
273
|
// Candidates are screened by the same families that drove this decision, so
|
|
211
274
|
// the pool cannot ping-pong onto an account the gate would immediately flag.
|
|
212
275
|
const switchFamilies = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
|
|
@@ -224,11 +287,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
224
287
|
const ctxAll = { now, thresholds: effectiveBars(cfg), currentAccountUuid: null, switchFamilies };
|
|
225
288
|
while (true) {
|
|
226
289
|
const cur = loadAccounts();
|
|
227
|
-
const active =
|
|
290
|
+
const active = seatOf(cur);
|
|
228
291
|
if (currentWins(active, cur.accounts, ctxAll)) {
|
|
229
292
|
return { swapped: false, account: null, reason: "current-best" };
|
|
230
293
|
}
|
|
231
|
-
const best = pickBest(cur.accounts, { ...ctxAll, currentAccountUuid:
|
|
294
|
+
const best = pickBest(cur.accounts, { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null });
|
|
232
295
|
if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
|
|
233
296
|
try {
|
|
234
297
|
await performSwap(best);
|
|
@@ -241,42 +304,66 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
241
304
|
}
|
|
242
305
|
}
|
|
243
306
|
|
|
244
|
-
const landed = await chooseAndSwap({ now, thresholds: effectiveBars(cfg), switchFamilies });
|
|
307
|
+
const landed = await chooseAndSwap({ now, thresholds: effectiveBars(cfg), switchFamilies, currentAccountUuid: seatOf(loadAccounts())?.accountUuid ?? null });
|
|
245
308
|
if (landed) return { swapped: true, account: landed, reason: "swapped" };
|
|
246
309
|
|
|
247
|
-
// Every account is depleted. Wait for whichever recovers soonest (including
|
|
248
|
-
// current one), if that reset is within the auto-wait window.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
310
|
+
// Every account is depleted. Wait for whichever recovers soonest (including
|
|
311
|
+
// the current one), if that reset is within the auto-wait window. A dead
|
|
312
|
+
// grant on the chosen pre-park target must not abort the wait: performSwap
|
|
313
|
+
// persists needs-reauth before throwing, so each retry re-ranks without the
|
|
314
|
+
// dead account and the loop terminates (mirrors the greedy loop above).
|
|
315
|
+
while (true) {
|
|
316
|
+
const fresh = loadAccounts();
|
|
317
|
+
const current = seatOf(fresh);
|
|
318
|
+
const ctx = { now, thresholds: effectiveBars(cfg), currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
|
|
319
|
+
const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
|
|
320
|
+
const other = pickEarliestReset(fresh.accounts, ctx);
|
|
254
321
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
322
|
+
let target: Account | null = null;
|
|
323
|
+
let waitUntil = Number.POSITIVE_INFINITY;
|
|
324
|
+
if (other && other.availableAt < currentAt) { target = other.account; waitUntil = other.availableAt; }
|
|
325
|
+
else if (current) { target = current; waitUntil = currentAt; }
|
|
326
|
+
else if (other) { target = other.account; waitUntil = other.availableAt; }
|
|
260
327
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
328
|
+
if (!target || waitUntil - now > cfg.policy.maxWaitMs) {
|
|
329
|
+
log("decide.depleted", { waitUntil: Number.isFinite(waitUntil) ? waitUntil : 0 });
|
|
330
|
+
return { swapped: false, account: null, reason: "all-depleted", ...(Number.isFinite(waitUntil) ? { waitUntil } : {}) };
|
|
331
|
+
}
|
|
265
332
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
333
|
+
const isCurrent = target.accountUuid === (current?.accountUuid ?? null);
|
|
334
|
+
if (!isCurrent && !anticipatory) {
|
|
335
|
+
log("decide.depleted_no_park", { account: target.accountUuid.slice(0, 8), waitUntil });
|
|
336
|
+
return { swapped: false, account: null, reason: "all-depleted", waitUntil };
|
|
337
|
+
}
|
|
338
|
+
if (!isCurrent) {
|
|
339
|
+
try {
|
|
340
|
+
await performSwap(target);
|
|
341
|
+
} catch (e) {
|
|
342
|
+
if (e instanceof InvalidGrantError) continue; // dead grant - re-rank without it
|
|
343
|
+
throw e;
|
|
344
|
+
}
|
|
277
345
|
}
|
|
346
|
+
// Persist the wait so sibling hooks arriving through the cooldown / raced
|
|
347
|
+
// / cleared-snapshot exits replay it and write their OWN respawn markers.
|
|
348
|
+
saveDepletedWait({ waitUntil, accountUuid: target.accountUuid, ts: now });
|
|
349
|
+
log("decide.depleted_wait", { account: target.accountUuid.slice(0, 8), waitUntil });
|
|
350
|
+
return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
|
|
278
351
|
}
|
|
279
|
-
log("decide.depleted_wait", { account: target.accountUuid.slice(0, 8), waitUntil });
|
|
280
|
-
return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
|
|
281
352
|
});
|
|
282
353
|
}
|
|
354
|
+
|
|
355
|
+
/** The recorded depleted-wait, iff still standing: unexpired and still naming
|
|
356
|
+
* the LIVE seat. The check reads claude's own oauthAccount, not the
|
|
357
|
+
* accounts.json label: a tokenmaxxing swap rewrites oauthAccount inside its
|
|
358
|
+
* critical section and a manual /login rewrites it too, while the label lags
|
|
359
|
+
* a manual /login and would replay a wait for an account no longer live
|
|
360
|
+
* (review catch, PR #31). A real swap elsewhere, a manual /login, or the
|
|
361
|
+
* reset passing all kill the record. */
|
|
362
|
+
function depletedReplay(now: number): SwapDecision | null {
|
|
363
|
+
const rec = loadDepletedWait();
|
|
364
|
+
if (!rec || rec.waitUntil <= now) return null;
|
|
365
|
+
const account = loadAccounts().accounts.find((a) => a.accountUuid === rec.accountUuid) ?? null;
|
|
366
|
+
if (!account) return null;
|
|
367
|
+
if (account.organizationUuid !== (readOAuthAccount()?.organizationUuid ?? null)) return null;
|
|
368
|
+
return { swapped: false, account, reason: "depleted-wait", waitUntil: rec.waitUntil };
|
|
369
|
+
}
|
package/src/lib/install.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
// The wrapper is a 2-line `exec ... __supervise "$@"` shim so dispatch never
|
|
3
3
|
// depends on argv0 semantics.
|
|
4
4
|
|
|
5
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { basename, dirname, join } from "node:path";
|
|
7
7
|
import { escape } from "es-toolkit";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { codexPaths, HOME, paths } from "./paths.ts";
|
|
10
10
|
import { writeFileAtomic } from "./atomic.ts";
|
|
11
|
-
import { installedBin, installSettings, uninstallSettings } from "./settings.ts";
|
|
11
|
+
import { installedBin, installSettings, isOurHookCommand, uninstallSettings } from "./settings.ts";
|
|
12
12
|
import { resolveRealClaude } from "./claudebin.ts";
|
|
13
13
|
|
|
14
14
|
const InstallOutcomeSchema = z.object({
|
|
@@ -84,19 +84,27 @@ function codexStopHookCommand(): string {
|
|
|
84
84
|
* preserving every other declaration. Codex skips new hooks until the user
|
|
85
85
|
* trusts them via /hooks (trust is recorded against the hook's hash), so the
|
|
86
86
|
* caller must surface that step. */
|
|
87
|
+
/** Surgical WITHIN groups, ownership verified structurally (closing-review
|
|
88
|
+
* catch, mirroring settings.ts's removeHook fix): the old whole-group filter
|
|
89
|
+
* deleted a foreign hook the user had appended into our group - the natural
|
|
90
|
+
* edit, since install writes exactly one group - and its includes() match
|
|
91
|
+
* claimed any command merely mentioning the subcommand. */
|
|
92
|
+
function withoutOurCodexStopHooks(groups: { hooks: { type?: string; command?: string }[] }[]): typeof groups {
|
|
93
|
+
return groups
|
|
94
|
+
.map((group) => ({ ...group, hooks: group.hooks.filter((hook) => !isOurHookCommand(hook.command ?? "", CODEX_STOP_HOOK_SUBCOMMAND)) }))
|
|
95
|
+
.filter((group) => group.hooks.length > 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
87
98
|
export function installCodexStopHook(): void {
|
|
88
99
|
const current = existsSync(codexPaths.hooksJson)
|
|
89
100
|
? CodexHooksFileSchema.parse(JSON.parse(readFileSync(codexPaths.hooksJson, "utf8")))
|
|
90
101
|
: CodexHooksFileSchema.parse({});
|
|
91
|
-
const foreign = current.hooks.Stop.filter(
|
|
92
|
-
(group) => !group.hooks.some((hook) => hook.command?.includes(CODEX_STOP_HOOK_SUBCOMMAND)),
|
|
93
|
-
);
|
|
94
102
|
const next = {
|
|
95
103
|
...current,
|
|
96
104
|
hooks: {
|
|
97
105
|
...current.hooks,
|
|
98
106
|
Stop: [
|
|
99
|
-
...
|
|
107
|
+
...withoutOurCodexStopHooks(current.hooks.Stop),
|
|
100
108
|
{ hooks: [{ type: "command", command: codexStopHookCommand(), timeout: 120, statusMessage: "tokenmaxxing switch check" }] },
|
|
101
109
|
],
|
|
102
110
|
},
|
|
@@ -112,7 +120,7 @@ export function uninstallCodexStopHook(): void {
|
|
|
112
120
|
...current,
|
|
113
121
|
hooks: {
|
|
114
122
|
...current.hooks,
|
|
115
|
-
Stop: current.hooks.Stop
|
|
123
|
+
Stop: withoutOurCodexStopHooks(current.hooks.Stop),
|
|
116
124
|
},
|
|
117
125
|
};
|
|
118
126
|
writeFileAtomic(codexPaths.hooksJson, JSON.stringify(next, null, 2) + "\n");
|
|
@@ -165,7 +173,7 @@ function run(cmd: string[]): boolean {
|
|
|
165
173
|
/** Install + activate the periodic check job. False means the unit files are in
|
|
166
174
|
* place but activation failed (e.g. systemd user session absent over ssh) -
|
|
167
175
|
* the caller prints the manual activation step. */
|
|
168
|
-
|
|
176
|
+
function installCheckTimer(): boolean {
|
|
169
177
|
if (process.platform === "darwin") {
|
|
170
178
|
const plist = launchdPlist();
|
|
171
179
|
writeFileAtomic(
|
|
@@ -245,17 +253,71 @@ export function checkTimerHealthy(): boolean {
|
|
|
245
253
|
);
|
|
246
254
|
}
|
|
247
255
|
|
|
248
|
-
|
|
256
|
+
/** The manual deactivation command for a still-loaded timer, per platform. */
|
|
257
|
+
export function timerDeactivationHint(): string {
|
|
258
|
+
if (process.platform === "darwin") {
|
|
259
|
+
return `launchctl bootout gui/$(id -u)/${LAUNCHD_LABEL}`;
|
|
260
|
+
}
|
|
261
|
+
return "systemctl --user disable --now tokenmaxxing-check.timer";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Is the launchd check job loaded? Exit contract verified on this machine
|
|
265
|
+
* (macOS 26, 2026-07-20): `launchctl print` exits 0 for a loaded job and 113
|
|
266
|
+
* for a missing one. Anything else - including a spawn failure or timeout -
|
|
267
|
+
* is "unavailable": an unanswerable probe must never read as "not loaded". */
|
|
268
|
+
function launchdJobLoaded(): "loaded" | "not-loaded" | "unavailable" {
|
|
269
|
+
const domain = launchdDomain();
|
|
270
|
+
if (domain == null) return "unavailable";
|
|
271
|
+
try {
|
|
272
|
+
const { exitCode } = Bun.spawnSync(["launchctl", "print", `${domain}/${LAUNCHD_LABEL}`], { stdout: "ignore", stderr: "ignore", timeout: 10_000 });
|
|
273
|
+
if (exitCode === 0) return "loaded";
|
|
274
|
+
return exitCode === 113 ? "not-loaded" : "unavailable";
|
|
275
|
+
} catch {
|
|
276
|
+
return "unavailable";
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Is the systemd check timer active? Classification goes by the state
|
|
281
|
+
* string, never the exit code: systemctl(1) documents only "0 if at least
|
|
282
|
+
* one is active, non-zero otherwise" for is-active, but guarantees "unless
|
|
283
|
+
* --quiet is specified, this will also print the current unit state to
|
|
284
|
+
* standard output" (verified against the systemd manpage 2026-07-20). The
|
|
285
|
+
* failure mode is container-verified (ubuntu:24.04 systemd, no user bus,
|
|
286
|
+
* 2026-07-20): a dead session bus prints NOTHING to stdout ("Failed to
|
|
287
|
+
* connect to bus" goes to stderr, exit 1), so an empty or unrecognized
|
|
288
|
+
* stdout is "unavailable" - "cannot ask" never reads as "inactive". */
|
|
289
|
+
function systemdTimerActive(): "active" | "not-active" | "unavailable" {
|
|
290
|
+
try {
|
|
291
|
+
const proc = Bun.spawnSync(["systemctl", "--user", "is-active", "tokenmaxxing-check.timer"], { stdout: "pipe", stderr: "ignore", timeout: 10_000 });
|
|
292
|
+
const state = proc.stdout.toString().trim();
|
|
293
|
+
if (state === "active" || state === "activating" || state === "reloading") return "active";
|
|
294
|
+
if (state === "inactive" || state === "failed" || state === "deactivating" || state === "unknown" || state === "maintenance") return "not-active";
|
|
295
|
+
return "unavailable";
|
|
296
|
+
} catch {
|
|
297
|
+
return "unavailable";
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** True when the job is verifiably no longer loaded. A swallowed bootout
|
|
302
|
+
* failure once meant a half-uninstalled state kept firing `tokenmaxxing
|
|
303
|
+
* check` every 180s against a package that may be gone, silently
|
|
304
|
+
* (closing-review catch): deactivation is checked, not assumed - a job seen
|
|
305
|
+
* loaded must deactivate successfully, and an unanswerable probe (service
|
|
306
|
+
* manager unusable) reports false rather than pretending it is gone. */
|
|
307
|
+
function uninstallCheckTimer(): boolean {
|
|
249
308
|
if (process.platform === "darwin") {
|
|
250
309
|
const domain = launchdDomain();
|
|
251
|
-
|
|
310
|
+
const loaded = launchdJobLoaded();
|
|
311
|
+
const deactivated = loaded === "loaded" && domain != null ? run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]) : loaded === "not-loaded";
|
|
252
312
|
rmSync(launchdPlist(), { force: true });
|
|
253
|
-
return;
|
|
313
|
+
return deactivated;
|
|
254
314
|
}
|
|
255
|
-
|
|
315
|
+
const active = systemdTimerActive();
|
|
316
|
+
const deactivated = active === "active" ? run(["systemctl", "--user", "disable", "--now", "tokenmaxxing-check.timer"]) : active === "not-active";
|
|
256
317
|
rmSync(join(paths.systemdUserDir, "tokenmaxxing-check.timer"), { force: true });
|
|
257
318
|
rmSync(join(paths.systemdUserDir, "tokenmaxxing-check.service"), { force: true });
|
|
258
319
|
run(["systemctl", "--user", "daemon-reload"]);
|
|
320
|
+
return deactivated;
|
|
259
321
|
}
|
|
260
322
|
|
|
261
323
|
/** The rc file of the user's login shell, or null when the shell is unknown.
|
|
@@ -275,10 +337,33 @@ const PATH_LINE_MARK = "# tokenmaxxing PATH";
|
|
|
275
337
|
* A pre-existing hand-added line for the bin dir also counts as present. */
|
|
276
338
|
export function ensurePathInRc(rc: string): "added" | "present" {
|
|
277
339
|
const dir = paths.binDir.startsWith(`${HOME}/`) ? `$HOME${paths.binDir.slice(HOME.length)}` : paths.binDir;
|
|
278
|
-
|
|
279
|
-
|
|
340
|
+
// Write through a dotfile-managed symlink, never over it: writeFileAtomic
|
|
341
|
+
// renames a sibling temp over its target, which would replace the link with
|
|
342
|
+
// a plain file while the dotfiles target keeps the stale line (PR #36
|
|
343
|
+
// second-round catch).
|
|
344
|
+
const target = existsSync(rc) ? realpathSync(rc) : rc;
|
|
345
|
+
const current = existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
346
|
+
const isCurrentExport = (line: string) => line.includes(`${paths.binDir}:`) || line.includes(`${dir}:`);
|
|
347
|
+
const lines = current === "" ? [] : current.split("\n");
|
|
348
|
+
// A marked line for a DIFFERENT dir is removed even when the current dir is
|
|
349
|
+
// also exported: PATH prepends stack, so a stale marked line BELOW the
|
|
350
|
+
// current one would still win resolution - the recursion incident's exact
|
|
351
|
+
// vector (closing-review catch + PR #36 second-round catch). A bare marker
|
|
352
|
+
// check alone once kept such a line alive after a TOKENMAXXING_HOME
|
|
353
|
+
// relocation.
|
|
354
|
+
const kept = lines.filter((line) => isCurrentExport(line) || !line.includes(PATH_LINE_MARK));
|
|
355
|
+
if (kept.length !== lines.length) {
|
|
356
|
+
const body = kept.join("\n");
|
|
357
|
+
const sep0 = body === "" || body.endsWith("\n") ? "" : "\n";
|
|
358
|
+
const addition = kept.some(isCurrentExport) ? "" : `export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`;
|
|
359
|
+
// preserve the rc's own mode: writeFileAtomic defaults to 0600, which
|
|
360
|
+
// would silently tighten a normally 0644 shell rc (PR #36 review catch)
|
|
361
|
+
writeFileAtomic(target, `${body}${sep0}${addition}`, statSync(target).mode & 0o777);
|
|
362
|
+
return "added";
|
|
363
|
+
}
|
|
364
|
+
if (lines.some(isCurrentExport)) return "present";
|
|
280
365
|
const sep = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
281
|
-
appendFileSync(
|
|
366
|
+
appendFileSync(target, `${sep}export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`);
|
|
282
367
|
return "added";
|
|
283
368
|
}
|
|
284
369
|
|
|
@@ -318,11 +403,34 @@ export function findClaudeShadowers(rcText: string): ShellShadower[] {
|
|
|
318
403
|
return out;
|
|
319
404
|
}
|
|
320
405
|
|
|
321
|
-
|
|
406
|
+
/** Remove ONLY the marker-tagged PATH line this tool added; a hand-added
|
|
407
|
+
* PATH entry without the marker is the user's own. A stale `# tokenmaxxing
|
|
408
|
+
* PATH` line pointing at an emptied binDir is exactly how the supervisor
|
|
409
|
+
* recursion incident started (see AGENTS.md), so uninstall must not leave
|
|
410
|
+
* one behind (closing-review catch). Returns true when a line was removed. */
|
|
411
|
+
export function removePathFromRc(rc: string): boolean {
|
|
412
|
+
if (!existsSync(rc)) return false;
|
|
413
|
+
// same symlink + mode treatment as ensurePathInRc: write through a
|
|
414
|
+
// dotfile-managed link, keep the rc's own permissions (PR #36 catches)
|
|
415
|
+
const target = realpathSync(rc);
|
|
416
|
+
const lines = readFileSync(target, "utf8").split("\n");
|
|
417
|
+
const kept = lines.filter((line) => !line.includes(PATH_LINE_MARK));
|
|
418
|
+
if (kept.length === lines.length) return false;
|
|
419
|
+
writeFileAtomic(target, kept.join("\n"), statSync(target).mode & 0o777);
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const UninstallOutcomeSchema = z.object({ timerDeactivated: z.boolean(), pathLineRemoved: z.boolean() });
|
|
424
|
+
export type UninstallOutcome = z.infer<typeof UninstallOutcomeSchema>;
|
|
425
|
+
|
|
426
|
+
export function uninstallSupervisor(): UninstallOutcome {
|
|
322
427
|
uninstallSettings();
|
|
323
|
-
uninstallCheckTimer();
|
|
428
|
+
const timerDeactivated = uninstallCheckTimer();
|
|
324
429
|
uninstallCodexSupervisor();
|
|
325
430
|
for (const f of [paths.supervisorLink, join(paths.binDir, "xx"), installedBin()]) {
|
|
326
431
|
if (existsSync(f)) rmSync(f, { force: true });
|
|
327
432
|
}
|
|
433
|
+
const rc = shellRcPath();
|
|
434
|
+
const pathLineRemoved = rc != null && removePathFromRc(rc);
|
|
435
|
+
return { timerDeactivated, pathLineRemoved };
|
|
328
436
|
}
|