tokenmaxxing 0.19.0 → 0.21.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 -23
- package/README.md +4 -4
- 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/codexswitch.ts +15 -1
- package/src/cli/config.ts +10 -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/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +650 -78
- 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 +134 -18
- 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 +10 -2
- 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 +114 -42
- 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 +583 -76
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +127 -21
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +79 -37
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +61 -7
- 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,7 +40,10 @@ 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>;
|
|
@@ -140,10 +143,14 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
|
|
|
140
143
|
u = { fiveHour: full.session, sevenDay: full.weekAll, org, ts, model: null };
|
|
141
144
|
writeUsage(u);
|
|
142
145
|
}
|
|
143
|
-
mu = { perModel: full.perModel, org, ts };
|
|
146
|
+
mu = { perModel: full.perModel, org, ts, sampledAt: ts };
|
|
144
147
|
saveModelUsage(mu);
|
|
145
148
|
} else {
|
|
146
|
-
|
|
149
|
+
// the anti-storm stamp: ts=now suppresses re-probing, but the carried
|
|
150
|
+
// rows keep their ORIGINAL sample time - dating them by ts rolled the
|
|
151
|
+
// null-reset self-bound forward on every failed probe (closing-review
|
|
152
|
+
// catch).
|
|
153
|
+
mu = { perModel: mu?.org === org ? (mu?.perModel ?? {}) : {}, org, ts, sampledAt: mu?.org === org ? (mu?.sampledAt ?? mu?.ts) : undefined };
|
|
147
154
|
saveModelUsage(mu);
|
|
148
155
|
}
|
|
149
156
|
}
|
|
@@ -156,7 +163,7 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
|
|
|
156
163
|
* sooner runs model-blind on data the swap itself invalidated - that is how a
|
|
157
164
|
* model-aware swap got immediately undone into an A<->B respawn loop. Manual
|
|
158
165
|
* `switch` is unaffected. */
|
|
159
|
-
const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
166
|
+
export const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
160
167
|
|
|
161
168
|
/**
|
|
162
169
|
* `anticipatory` allows the depleted path to swap onto an account that is still
|
|
@@ -170,7 +177,7 @@ const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
|
170
177
|
export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false): Promise<SwapDecision> {
|
|
171
178
|
const lastSwapAt = loadLastSwapAt();
|
|
172
179
|
if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
|
|
173
|
-
return { swapped: false, account: null, reason: "post-swap-cooldown" };
|
|
180
|
+
return depletedReplay(now) ?? { swapped: false, account: null, reason: "post-swap-cooldown" };
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
const cfg = loadConfig();
|
|
@@ -178,8 +185,16 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
178
185
|
|
|
179
186
|
const { u: usage, mu } = await loadFreshSnapshots(cfg, activeOrg, now);
|
|
180
187
|
|
|
181
|
-
// cheap pre-check off the lock - the common case exits here.
|
|
188
|
+
// cheap pre-check off the lock - the common case exits here. When there is
|
|
189
|
+
// NO measurement for the live org (a pre-park just cleared the snapshots),
|
|
190
|
+
// a recorded depleted-wait still replays; a fresh measurement that reads
|
|
191
|
+
// under-threshold never does - measured-healthy must win over a stale wait.
|
|
182
192
|
if (!isEngaged(usage, mu, activeOrg, cfg, now)) {
|
|
193
|
+
const measured = usage != null && activeOrg != null && usage.org === activeOrg;
|
|
194
|
+
if (!measured) {
|
|
195
|
+
const replay = depletedReplay(now);
|
|
196
|
+
if (replay) return replay;
|
|
197
|
+
}
|
|
183
198
|
return { swapped: false, account: null, reason: "under-threshold-or-stale" };
|
|
184
199
|
}
|
|
185
200
|
|
|
@@ -189,24 +204,57 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
189
204
|
const u2 = loadUsage() ?? usage;
|
|
190
205
|
const mu2 = needsPerModel(u2, cfg) ? loadModelUsage() ?? mu : null;
|
|
191
206
|
|
|
207
|
+
// A live login whose org is KNOWN but outside the pool: do nothing - the
|
|
208
|
+
// codex org-guard analog. performSwap would refuse any swap over it (an
|
|
209
|
+
// unpooled credential's only copy must never be overwritten), and the
|
|
210
|
+
// seat fallback below must not stand in a stale pooled label for it: the
|
|
211
|
+
// depleted path could then park a supervised session against the LABELED
|
|
212
|
+
// account's reset while the running login is someone else entirely
|
|
213
|
+
// (pullfrog review catch, PR #33).
|
|
214
|
+
if (org2 != null && !idx.accounts.some((a) => a.organizationUuid === org2)) {
|
|
215
|
+
return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
|
|
216
|
+
}
|
|
217
|
+
|
|
192
218
|
// record the active account's aggregate usage so the picker + `status` see it.
|
|
219
|
+
// Resolved by the LIVE org the guard just verified, never the
|
|
220
|
+
// activeAccountUuid label: after a manual /login the label drifts (the
|
|
221
|
+
// surviving drift source, see cli/switch.ts), and a label-keyed write
|
|
222
|
+
// would stamp the live account's windows onto whichever account the label
|
|
223
|
+
// still names (closing-review catch, mirrors the codex live-identity rule).
|
|
193
224
|
if (u2 && org2 && u2.org === org2) {
|
|
194
|
-
const active = idx.accounts.find((a) => a.
|
|
225
|
+
const active = idx.accounts.find((a) => a.organizationUuid === org2);
|
|
195
226
|
if (active) {
|
|
196
227
|
active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
|
|
197
228
|
active.lastUsageAt = u2.ts;
|
|
198
229
|
// Snapshot per-model caps too, so they still show after we switch away.
|
|
199
230
|
// An empty map is a failed probe's anti-storm stamp, not a measurement -
|
|
200
231
|
// it must not erase the burnt-cap snapshot the picker screens on.
|
|
201
|
-
if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0)
|
|
232
|
+
if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) {
|
|
233
|
+
active.lastPerModel = mu2.perModel;
|
|
234
|
+
// the rows' TRUE sample time, not the write time: an anti-storm
|
|
235
|
+
// stamp re-writes ts while carrying old rows (closing-review catch).
|
|
236
|
+
active.lastPerModelAt = mu2.sampledAt ?? mu2.ts;
|
|
237
|
+
}
|
|
202
238
|
saveAccounts(idx);
|
|
203
239
|
}
|
|
204
240
|
}
|
|
205
241
|
|
|
206
242
|
if (!isEngaged(u2, mu2, org2, cfg, now)) {
|
|
207
|
-
return { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
243
|
+
return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
208
244
|
}
|
|
209
245
|
|
|
246
|
+
// The SEAT every path below evaluates and excludes: the live org's pooled
|
|
247
|
+
// account when resolvable, the stored label only as fallback - the same
|
|
248
|
+
// identity rule as the usage stamp above and depletedReplay. Trusting the
|
|
249
|
+
// label here let the greedy convergence judge a stale account as "the
|
|
250
|
+
// seat" after a manual /login, ranking against the wrong cached windows
|
|
251
|
+
// and even offering the LIVE account as a swap target (bugbot review
|
|
252
|
+
// catch, PR #33).
|
|
253
|
+
const seatOf = (idx2: { activeAccountUuid: string | null; accounts: Account[] }): Account | null =>
|
|
254
|
+
idx2.accounts.find((a) => a.organizationUuid === org2) ??
|
|
255
|
+
idx2.accounts.find((a) => a.accountUuid === idx2.activeAccountUuid) ??
|
|
256
|
+
null;
|
|
257
|
+
|
|
210
258
|
// Candidates are screened by the same families that drove this decision, so
|
|
211
259
|
// the pool cannot ping-pong onto an account the gate would immediately flag.
|
|
212
260
|
const switchFamilies = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
|
|
@@ -224,11 +272,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
224
272
|
const ctxAll = { now, thresholds: effectiveBars(cfg), currentAccountUuid: null, switchFamilies };
|
|
225
273
|
while (true) {
|
|
226
274
|
const cur = loadAccounts();
|
|
227
|
-
const active =
|
|
275
|
+
const active = seatOf(cur);
|
|
228
276
|
if (currentWins(active, cur.accounts, ctxAll)) {
|
|
229
277
|
return { swapped: false, account: null, reason: "current-best" };
|
|
230
278
|
}
|
|
231
|
-
const best = pickBest(cur.accounts, { ...ctxAll, currentAccountUuid:
|
|
279
|
+
const best = pickBest(cur.accounts, { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null });
|
|
232
280
|
if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
|
|
233
281
|
try {
|
|
234
282
|
await performSwap(best);
|
|
@@ -241,42 +289,66 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
241
289
|
}
|
|
242
290
|
}
|
|
243
291
|
|
|
244
|
-
const landed = await chooseAndSwap({ now, thresholds: effectiveBars(cfg), switchFamilies });
|
|
292
|
+
const landed = await chooseAndSwap({ now, thresholds: effectiveBars(cfg), switchFamilies, currentAccountUuid: seatOf(loadAccounts())?.accountUuid ?? null });
|
|
245
293
|
if (landed) return { swapped: true, account: landed, reason: "swapped" };
|
|
246
294
|
|
|
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
|
-
|
|
295
|
+
// Every account is depleted. Wait for whichever recovers soonest (including
|
|
296
|
+
// the current one), if that reset is within the auto-wait window. A dead
|
|
297
|
+
// grant on the chosen pre-park target must not abort the wait: performSwap
|
|
298
|
+
// persists needs-reauth before throwing, so each retry re-ranks without the
|
|
299
|
+
// dead account and the loop terminates (mirrors the greedy loop above).
|
|
300
|
+
while (true) {
|
|
301
|
+
const fresh = loadAccounts();
|
|
302
|
+
const current = seatOf(fresh);
|
|
303
|
+
const ctx = { now, thresholds: effectiveBars(cfg), currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
|
|
304
|
+
const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
|
|
305
|
+
const other = pickEarliestReset(fresh.accounts, ctx);
|
|
254
306
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
307
|
+
let target: Account | null = null;
|
|
308
|
+
let waitUntil = Number.POSITIVE_INFINITY;
|
|
309
|
+
if (other && other.availableAt < currentAt) { target = other.account; waitUntil = other.availableAt; }
|
|
310
|
+
else if (current) { target = current; waitUntil = currentAt; }
|
|
311
|
+
else if (other) { target = other.account; waitUntil = other.availableAt; }
|
|
260
312
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
313
|
+
if (!target || waitUntil - now > cfg.policy.maxWaitMs) {
|
|
314
|
+
log("decide.depleted", { waitUntil: Number.isFinite(waitUntil) ? waitUntil : 0 });
|
|
315
|
+
return { swapped: false, account: null, reason: "all-depleted", ...(Number.isFinite(waitUntil) ? { waitUntil } : {}) };
|
|
316
|
+
}
|
|
265
317
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
if (!isCurrent) {
|
|
272
|
-
try {
|
|
273
|
-
await performSwap(target);
|
|
274
|
-
} catch (e) {
|
|
275
|
-
if (e instanceof InvalidGrantError) return { swapped: false, account: null, reason: "all-depleted" };
|
|
276
|
-
throw e;
|
|
318
|
+
const isCurrent = target.accountUuid === (current?.accountUuid ?? null);
|
|
319
|
+
if (!isCurrent && !anticipatory) {
|
|
320
|
+
log("decide.depleted_no_park", { account: target.accountUuid.slice(0, 8), waitUntil });
|
|
321
|
+
return { swapped: false, account: null, reason: "all-depleted", waitUntil };
|
|
277
322
|
}
|
|
323
|
+
if (!isCurrent) {
|
|
324
|
+
try {
|
|
325
|
+
await performSwap(target);
|
|
326
|
+
} catch (e) {
|
|
327
|
+
if (e instanceof InvalidGrantError) continue; // dead grant - re-rank without it
|
|
328
|
+
throw e;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Persist the wait so sibling hooks arriving through the cooldown / raced
|
|
332
|
+
// / cleared-snapshot exits replay it and write their OWN respawn markers.
|
|
333
|
+
saveDepletedWait({ waitUntil, accountUuid: target.accountUuid, ts: now });
|
|
334
|
+
log("decide.depleted_wait", { account: target.accountUuid.slice(0, 8), waitUntil });
|
|
335
|
+
return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
|
|
278
336
|
}
|
|
279
|
-
log("decide.depleted_wait", { account: target.accountUuid.slice(0, 8), waitUntil });
|
|
280
|
-
return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
|
|
281
337
|
});
|
|
282
338
|
}
|
|
339
|
+
|
|
340
|
+
/** The recorded depleted-wait, iff still standing: unexpired and still naming
|
|
341
|
+
* the LIVE seat. The check reads claude's own oauthAccount, not the
|
|
342
|
+
* accounts.json label: a tokenmaxxing swap rewrites oauthAccount inside its
|
|
343
|
+
* critical section and a manual /login rewrites it too, while the label lags
|
|
344
|
+
* a manual /login and would replay a wait for an account no longer live
|
|
345
|
+
* (review catch, PR #31). A real swap elsewhere, a manual /login, or the
|
|
346
|
+
* reset passing all kill the record. */
|
|
347
|
+
function depletedReplay(now: number): SwapDecision | null {
|
|
348
|
+
const rec = loadDepletedWait();
|
|
349
|
+
if (!rec || rec.waitUntil <= now) return null;
|
|
350
|
+
const account = loadAccounts().accounts.find((a) => a.accountUuid === rec.accountUuid) ?? null;
|
|
351
|
+
if (!account) return null;
|
|
352
|
+
if (account.organizationUuid !== (readOAuthAccount()?.organizationUuid ?? null)) return null;
|
|
353
|
+
return { swapped: false, account, reason: "depleted-wait", waitUntil: rec.waitUntil };
|
|
354
|
+
}
|
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
|
}
|
package/src/lib/keychain.ts
CHANGED
|
@@ -11,8 +11,15 @@ const KeychainTargetSchema = z.object({ service: z.string(), account: z.string()
|
|
|
11
11
|
export type KeychainTarget = z.infer<typeof KeychainTargetSchema>;
|
|
12
12
|
|
|
13
13
|
const SECURITY = "/usr/bin/security";
|
|
14
|
-
// security(1) interactive mode has a
|
|
15
|
-
|
|
14
|
+
// security(1) interactive mode has a 4096-byte line buffer. The gate below
|
|
15
|
+
// measures the ASSEMBLED line against this (with margin), never the raw
|
|
16
|
+
// secret: quoteDouble expansion (one byte per `"`/`\` when the blob contains
|
|
17
|
+
// an apostrophe) once pushed a raw-length-passing line over the buffer, which
|
|
18
|
+
// SPLITS the line - the write exits 1 ("unknown command" for the spilled
|
|
19
|
+
// remainder) AND the item is left holding a TRUNCATED secret (empirically
|
|
20
|
+
// verified 2026-07-20: a 3667-byte secret assembling to a 5574-byte line
|
|
21
|
+
// corrupted the item to its first 2682 bytes before the error surfaced).
|
|
22
|
+
const INTERACTIVE_MAX_LINE = 4000;
|
|
16
23
|
|
|
17
24
|
/** Double-quote + backslash-escape for security(1)'s interactive tokenizer. */
|
|
18
25
|
function quoteDouble(s: string): string {
|
|
@@ -24,26 +31,40 @@ function quoteValue(s: string): string {
|
|
|
24
31
|
return s.includes("'") ? quoteDouble(s) : `'${s}'`;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
/** Read an item's password blob. Returns null
|
|
34
|
+
/** Read an item's password blob. Returns null ONLY when the item verifiably
|
|
35
|
+
* does not exist (exit 44, errSecItemNotFound - empirically pinned on this
|
|
36
|
+
* Mac 2026-07-20). Every other failure THROWS: a locked keychain or denied
|
|
37
|
+
* ACL prompt reading as "absent" silently disarmed every fail-closed
|
|
38
|
+
* live-owner guard and the mandatory pre-swap harvest, all of which key off
|
|
39
|
+
* a null read (closing-review catch). stderr never carries the secret. */
|
|
28
40
|
export async function readItem(t: KeychainTarget): Promise<string | null> {
|
|
29
41
|
const p = Bun.spawn([SECURITY, "find-generic-password", "-s", t.service, "-a", t.account, "-w"], {
|
|
30
42
|
stdout: "pipe",
|
|
31
|
-
stderr: "
|
|
43
|
+
stderr: "pipe",
|
|
32
44
|
});
|
|
33
|
-
const out = await new Response(p.stdout).text();
|
|
45
|
+
const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
34
46
|
await p.exited;
|
|
35
|
-
if (p.exitCode
|
|
47
|
+
if (p.exitCode === 44) return null;
|
|
48
|
+
if (p.exitCode !== 0) {
|
|
49
|
+
throw new Error(`keychain read failed (exit ${p.exitCode}): ${err.trim().slice(0, 200)} - a locked keychain or denied ACL must fail loudly, never read as absent`);
|
|
50
|
+
}
|
|
36
51
|
return out.replace(/\n$/, ""); // security appends exactly one trailing newline
|
|
37
52
|
}
|
|
38
53
|
|
|
39
|
-
/**
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
/** The full `security -i` line for a write; its LENGTH is the argv-fallback
|
|
55
|
+
* gate, so it is assembled once, here. */
|
|
56
|
+
function interactiveLine(t: KeychainTarget, secret: string): string {
|
|
57
|
+
return (
|
|
43
58
|
`add-generic-password -U -a ${quoteDouble(t.account)} -s ${quoteDouble(t.service)} ` +
|
|
44
|
-
`-w ${quoteValue(secret)}\n
|
|
59
|
+
`-w ${quoteValue(secret)}\n`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** ps-safe write: the command line + secret arrive on stdin, never in argv.
|
|
64
|
+
* The caller guarantees the encoded line fits the interactive buffer. */
|
|
65
|
+
async function writeViaInteractive(encodedLine: Uint8Array): Promise<void> {
|
|
45
66
|
const p = Bun.spawn([SECURITY, "-i"], {
|
|
46
|
-
stdin:
|
|
67
|
+
stdin: encodedLine,
|
|
47
68
|
stdout: "ignore",
|
|
48
69
|
stderr: "pipe",
|
|
49
70
|
});
|
|
@@ -66,10 +87,15 @@ async function writeViaArgv(t: KeychainTarget, secret: string): Promise<void> {
|
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
/** Create-or-update an item (`-U`) with `secret` as its password. Prefers the
|
|
69
|
-
* ps-safe stdin path; falls back to argv
|
|
70
|
-
*
|
|
90
|
+
* ps-safe stdin path; falls back to argv when the ASSEMBLED interactive line
|
|
91
|
+
* would exceed the buffer (see INTERACTIVE_MAX_LINE - gating on the raw
|
|
92
|
+
* secret length let quote expansion corrupt the item). Throws on failure. */
|
|
71
93
|
export async function writeItem(t: KeychainTarget, secret: string): Promise<void> {
|
|
72
|
-
|
|
94
|
+
// measured in UTF-8 BYTES, the unit the stdin buffer actually consumes:
|
|
95
|
+
// String.length counts UTF-16 code units and under-counts multibyte
|
|
96
|
+
// characters (cubic review catch, PR #35).
|
|
97
|
+
const encoded = new TextEncoder().encode(interactiveLine(t, secret));
|
|
98
|
+
if (encoded.length <= INTERACTIVE_MAX_LINE) return writeViaInteractive(encoded);
|
|
73
99
|
return writeViaArgv(t, secret);
|
|
74
100
|
}
|
|
75
101
|
|