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/state.ts
CHANGED
|
@@ -31,58 +31,109 @@ const DEFAULT_CONFIG: Config = {
|
|
|
31
31
|
policy: { projectionMargin: 0, greedySessionFloor: 50, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
/** Percent-of-window values: out-of-range bars make every account read as
|
|
35
|
+
* exhausted, so the schema rejects them at the config gate. The cross-field
|
|
36
|
+
* case (a projectionMargin at or above a threshold zeroes the effective bar)
|
|
37
|
+
* is caught by ConfigSchema's refine on the merged result. */
|
|
38
|
+
const PercentSchema = z.number().min(0).max(100);
|
|
39
|
+
|
|
34
40
|
/** On-disk shape (all optional); validated via Zod, merged over defaults.
|
|
35
41
|
* Exported for `xx config`, which edits and housekeeps the sparse file. */
|
|
36
42
|
export const ConfigFileSchema = z
|
|
37
43
|
.object({
|
|
38
|
-
thresholds: z.object({ session:
|
|
44
|
+
thresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
|
|
39
45
|
claudeBin: z.string(),
|
|
40
46
|
codexBin: z.string(),
|
|
41
47
|
policy: z
|
|
42
48
|
.object({
|
|
43
|
-
projectionMargin:
|
|
44
|
-
greedySessionFloor:
|
|
49
|
+
projectionMargin: PercentSchema,
|
|
50
|
+
greedySessionFloor: PercentSchema,
|
|
45
51
|
switchModels: z.array(z.string()),
|
|
46
|
-
usagePollTtlMs: z.number(),
|
|
47
|
-
maxWaitMs: z.number(),
|
|
52
|
+
usagePollTtlMs: z.number().int().positive(),
|
|
53
|
+
maxWaitMs: z.number().int().positive(),
|
|
48
54
|
})
|
|
49
55
|
.partial(),
|
|
50
56
|
})
|
|
51
57
|
.partial();
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
const MergeOutcomeSchema = z.union([
|
|
60
|
+
z.object({ ok: z.literal(true), config: ConfigSchema }),
|
|
61
|
+
z.object({ ok: z.literal(false), detail: z.string() }),
|
|
62
|
+
]);
|
|
63
|
+
export type MergeOutcome = z.infer<typeof MergeOutcomeSchema>;
|
|
64
|
+
|
|
65
|
+
/** Merge a validated sparse file over the defaults, apply the env binary
|
|
66
|
+
* overrides, and validate the merged WHOLE (the projectionMargin-vs-
|
|
67
|
+
* thresholds refine). Shared by loadConfig and `xx config set`: set must
|
|
68
|
+
* reject a value whose merged result would make every later loadConfig
|
|
69
|
+
* throw, silently disabling status/switch/hooks/statusline until the file is
|
|
70
|
+
* hand-repaired (closing-review catch). */
|
|
71
|
+
export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutcome {
|
|
54
72
|
const cfg: Config = { ...DEFAULT_CONFIG, thresholds: { ...DEFAULT_CONFIG.thresholds }, policy: { ...DEFAULT_CONFIG.policy } };
|
|
73
|
+
cfg.thresholds.session = p.thresholds?.session ?? cfg.thresholds.session;
|
|
74
|
+
cfg.thresholds.weekly = p.thresholds?.weekly ?? cfg.thresholds.weekly;
|
|
75
|
+
cfg.claudeBin = p.claudeBin ?? cfg.claudeBin;
|
|
76
|
+
cfg.codexBin = p.codexBin ?? cfg.codexBin;
|
|
77
|
+
cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
|
|
78
|
+
cfg.policy.greedySessionFloor = p.policy?.greedySessionFloor ?? cfg.policy.greedySessionFloor;
|
|
79
|
+
cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
|
|
80
|
+
cfg.policy.maxWaitMs = p.policy?.maxWaitMs ?? cfg.policy.maxWaitMs;
|
|
81
|
+
if (p.policy?.switchModels) {
|
|
82
|
+
cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
|
|
83
|
+
}
|
|
84
|
+
// env overrides win for the real binaries (tests / relocation)
|
|
85
|
+
const envBin = realClaudeBinFromEnv();
|
|
86
|
+
if (envBin) cfg.claudeBin = envBin;
|
|
87
|
+
const envCodexBin = realCodexBinFromEnv();
|
|
88
|
+
if (envCodexBin) cfg.codexBin = envCodexBin;
|
|
89
|
+
const merged = ConfigSchema.safeParse(cfg);
|
|
90
|
+
if (!merged.success) {
|
|
91
|
+
// per-field values passed but the merged whole is unusable (the
|
|
92
|
+
// projectionMargin-vs-thresholds refine); name the reason, not a zod dump.
|
|
93
|
+
return { ok: false, detail: merged.error.issues.map((issue) => issue.message).join("; ") };
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, config: merged.data };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function loadConfig(): Config {
|
|
99
|
+
let fileData: z.infer<typeof ConfigFileSchema> = {};
|
|
55
100
|
if (existsSync(paths.configJson)) {
|
|
56
|
-
let raw: unknown
|
|
101
|
+
let raw: unknown;
|
|
57
102
|
try {
|
|
58
103
|
raw = JSON.parse(readFileSync(paths.configJson, "utf8"));
|
|
59
104
|
} catch {
|
|
60
|
-
|
|
105
|
+
// Silent defaults here once meant a corrupt file could quietly unpin
|
|
106
|
+
// claudeBin; a damaged config is the user's to repair, loudly.
|
|
107
|
+
throw new Error(`${paths.configJson} is corrupt (unparsable JSON) - fix or remove it`);
|
|
61
108
|
}
|
|
62
109
|
const parsed = ConfigFileSchema.safeParse(raw);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
cfg.policy.greedySessionFloor = p.policy?.greedySessionFloor ?? cfg.policy.greedySessionFloor;
|
|
70
|
-
cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
|
|
71
|
-
cfg.policy.maxWaitMs = p.policy?.maxWaitMs ?? cfg.policy.maxWaitMs;
|
|
72
|
-
if (p.policy?.switchModels) {
|
|
73
|
-
cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
|
|
110
|
+
if (!parsed.success) {
|
|
111
|
+
// Valid JSON with wrong-typed KNOWN keys must not silently drop pins
|
|
112
|
+
// like claudeBin; unknown keys are stripped by the schema and stay
|
|
113
|
+
// tolerated (that is `config tidy`'s territory, not an error).
|
|
114
|
+
const fields = parsed.error.issues.map((issue) => issue.path.join(".")).join(", ");
|
|
115
|
+
throw new Error(`${paths.configJson} has wrong-typed values (${fields}) - fix or remove them`);
|
|
74
116
|
}
|
|
117
|
+
fileData = parsed.data;
|
|
75
118
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const envCodexBin = realCodexBinFromEnv();
|
|
80
|
-
if (envCodexBin) cfg.codexBin = envCodexBin;
|
|
81
|
-
return ConfigSchema.parse(cfg);
|
|
119
|
+
const outcome = mergeConfigFile(fileData);
|
|
120
|
+
if (!outcome.ok) throw new Error(`${paths.configJson} is invalid: ${outcome.detail} - fix or remove the offending values`);
|
|
121
|
+
return outcome.config;
|
|
82
122
|
}
|
|
83
123
|
|
|
84
|
-
|
|
85
|
-
|
|
124
|
+
/** Pin one binary path into the SPARSE config file, preserving every other
|
|
125
|
+
* override verbatim. Never write the merged config here: baking defaults (or
|
|
126
|
+
* an ambient TOKENMAXXING_*_BIN env override) into the file freezes future
|
|
127
|
+
* default changes as stale explicit values and misreports every `xx config`
|
|
128
|
+
* source as "file" (closing-review catch; the sparse-overrides contract is
|
|
129
|
+
* config.ts's header). Throws on a corrupt file, like loadConfig. */
|
|
130
|
+
export function pinBinOverride(input: { key: "claudeBin" | "codexBin"; bin: string }): void {
|
|
131
|
+
let raw: Record<string, unknown> = {};
|
|
132
|
+
if (existsSync(paths.configJson)) {
|
|
133
|
+
raw = z.record(z.string(), z.unknown()).parse(JSON.parse(readFileSync(paths.configJson, "utf8")));
|
|
134
|
+
}
|
|
135
|
+
raw[input.key] = input.bin;
|
|
136
|
+
writeFileAtomic(paths.configJson, JSON.stringify(raw, null, 2) + "\n");
|
|
86
137
|
}
|
|
87
138
|
|
|
88
139
|
// ---- accounts.json -------------------------------------------------------
|
|
@@ -90,13 +141,22 @@ export function saveConfig(c: Config): void {
|
|
|
90
141
|
const emptyIndex = (): AccountsIndex => ({ version: 1, activeAccountUuid: null, accounts: [] });
|
|
91
142
|
|
|
92
143
|
export function loadAccounts(): AccountsIndex {
|
|
144
|
+
// Absent = genuinely empty. Present-but-unreadable THROWS (mirrors the codex
|
|
145
|
+
// state loaders): a truncated index once read as an empty pool would send
|
|
146
|
+
// `init` down first-time onboarding and overwrite it, orphaning every parked
|
|
147
|
+
// credential. Damaged state is the user's to repair, loudly.
|
|
93
148
|
if (!existsSync(paths.accountsJson)) return emptyIndex();
|
|
149
|
+
let json: unknown;
|
|
94
150
|
try {
|
|
95
|
-
|
|
96
|
-
return parsed.success ? parsed.data : emptyIndex();
|
|
151
|
+
json = JSON.parse(readFileSync(paths.accountsJson, "utf8"));
|
|
97
152
|
} catch {
|
|
98
|
-
|
|
153
|
+
throw new Error(`${paths.accountsJson} is corrupt (unparsable JSON) - refusing to treat a damaged pool as empty; repair or remove the file`);
|
|
154
|
+
}
|
|
155
|
+
const parsed = AccountsIndexSchema.safeParse(json);
|
|
156
|
+
if (!parsed.success) {
|
|
157
|
+
throw new Error(`${paths.accountsJson} does not match the accounts schema - refusing to treat a damaged pool as empty; repair or remove the file`);
|
|
99
158
|
}
|
|
159
|
+
return parsed.data;
|
|
100
160
|
}
|
|
101
161
|
|
|
102
162
|
export function saveAccounts(idx: AccountsIndex): void {
|
|
@@ -122,22 +182,57 @@ export function clearUsageSnapshots(): void {
|
|
|
122
182
|
rmSync(paths.modelUsageJson, { force: true });
|
|
123
183
|
}
|
|
124
184
|
|
|
125
|
-
// ---- lastswap.json (epoch ms of the last swap; absent = never swapped
|
|
185
|
+
// ---- lastswap.json (epoch ms of the last swap; absent = never swapped;
|
|
186
|
+
// present-but-corrupt THROWS - silently reading a damaged swap clock as
|
|
187
|
+
// never-swapped would bypass the post-swap cooldown) ----
|
|
126
188
|
|
|
127
189
|
export function loadLastSwapAt(): number | null {
|
|
128
190
|
if (!existsSync(paths.lastSwapJson)) return null;
|
|
191
|
+
let json: unknown;
|
|
129
192
|
try {
|
|
130
|
-
|
|
131
|
-
return parsed.success ? parsed.data.ts : null;
|
|
193
|
+
json = JSON.parse(readFileSync(paths.lastSwapJson, "utf8"));
|
|
132
194
|
} catch {
|
|
133
|
-
|
|
195
|
+
throw new Error(`${paths.lastSwapJson} is corrupt (unparsable JSON) - refusing to treat a damaged swap clock as never-swapped; repair or remove the file`);
|
|
134
196
|
}
|
|
197
|
+
return LastSwapSchema.parse(json).ts;
|
|
135
198
|
}
|
|
136
199
|
|
|
137
200
|
export function saveLastSwapAt(ts: number): void {
|
|
138
201
|
writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
|
|
139
202
|
}
|
|
140
203
|
|
|
204
|
+
// ---- depleted.json (the last depleted-wait decision; absent = none) ----
|
|
205
|
+
// Written on every depleted-wait so hooks that hit an early exit (post-swap
|
|
206
|
+
// cooldown, raced re-check, cleared snapshots) can REPLAY the wait to their
|
|
207
|
+
// own supervisor: without the replay only the first session's Stop hook ever
|
|
208
|
+
// saw a marker-writable decision and sibling sessions never paused (DESIGN.md
|
|
209
|
+
// 3.5's fan-out). The record dies three ways: waitUntil passing, the live
|
|
210
|
+
// seat (claude's oauthAccount) moving off the recorded account, and ANY
|
|
211
|
+
// completed swap clearing it outright (performSwap - the depleted path
|
|
212
|
+
// re-records its own wait right after its pre-park swap returns).
|
|
213
|
+
|
|
214
|
+
const DepletedWaitSchema = z.object({ waitUntil: z.number(), accountUuid: z.string(), ts: z.number() });
|
|
215
|
+
export type DepletedWait = z.infer<typeof DepletedWaitSchema>;
|
|
216
|
+
|
|
217
|
+
export function loadDepletedWait(): DepletedWait | null {
|
|
218
|
+
if (!existsSync(paths.depletedJson)) return null;
|
|
219
|
+
let json: unknown;
|
|
220
|
+
try {
|
|
221
|
+
json = JSON.parse(readFileSync(paths.depletedJson, "utf8"));
|
|
222
|
+
} catch {
|
|
223
|
+
throw new Error(`${paths.depletedJson} is corrupt (unparsable JSON) - repair or remove the file`);
|
|
224
|
+
}
|
|
225
|
+
return DepletedWaitSchema.parse(json);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function saveDepletedWait(rec: DepletedWait): void {
|
|
229
|
+
writeFileAtomic(paths.depletedJson, JSON.stringify(DepletedWaitSchema.parse(rec)));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function clearDepletedWait(): void {
|
|
233
|
+
rmSync(paths.depletedJson, { force: true });
|
|
234
|
+
}
|
|
235
|
+
|
|
141
236
|
/** An alive feed re-proving unchanged figures still refreshes `ts` this often,
|
|
142
237
|
* so cache-age displays stay honest without a write+fsync per tick. */
|
|
143
238
|
const USAGE_TS_REFRESH_MS = 10 * 60_000;
|
|
@@ -155,7 +250,8 @@ export function writeUsage(next: UsageState): boolean {
|
|
|
155
250
|
} catch (e) {
|
|
156
251
|
// The file vanished mid-race: a concurrent swap just invalidated these
|
|
157
252
|
// figures. Suppressing stays correct; a write would resurrect them.
|
|
158
|
-
|
|
253
|
+
const errno = z.object({ code: z.string() }).safeParse(e);
|
|
254
|
+
if (!errno.success || errno.data.code !== "ENOENT") throw e;
|
|
159
255
|
}
|
|
160
256
|
return false;
|
|
161
257
|
}
|
package/src/lib/swap.ts
CHANGED
|
@@ -2,23 +2,27 @@
|
|
|
2
2
|
// caller - the Stop hook or a CLI command). The keychain/json writes additionally
|
|
3
3
|
// run under claude's own refresh lock so they can't interleave with a token refresh.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// resolve the live credential's TRUE owner (network, no lock; an expiring
|
|
6
|
+
// live credential refreshes under claude's refresh lock first)
|
|
7
|
+
// refresh B's parked credential (network, no lock), persisting the rotation
|
|
8
|
+
// at once - UNLESS B IS the live owner (label drift): then the live blob is
|
|
9
|
+
// already the newest rotation and the parked copy must not be refreshed
|
|
7
10
|
// ── under claude refresh lock ──
|
|
8
|
-
//
|
|
11
|
+
// verify the live item still holds the token the owner was resolved from
|
|
12
|
+
// (a concurrent /login or refresh in the unlocked gap aborts the swap)
|
|
13
|
+
// harvest live → its OWNER's backup (mandatory: refresh token rotates in
|
|
14
|
+
// place; when the owner IS the target this repairs its stale backup)
|
|
9
15
|
// install B into the live item
|
|
10
|
-
// persist B's rotated token into B's backup
|
|
11
16
|
// rewrite oauthAccount in ~/.claude.json
|
|
12
|
-
// mark B active (
|
|
13
|
-
//
|
|
17
|
+
// mark B active (kept adjacent to the identity write: the files cannot be
|
|
18
|
+
// crash-atomic together, but the next swap's true-owner resolution catches
|
|
19
|
+
// and logs any crash drift - swap.harvest_drift)
|
|
14
20
|
|
|
15
|
-
import { clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
|
|
21
|
+
import { clearDepletedWait, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
|
|
16
22
|
import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
17
23
|
import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg, InvalidGrantError } from "./oauth.ts";
|
|
18
24
|
import { swapOAuthAccount } from "./claudejson.ts";
|
|
19
|
-
import { withLock } from "./lock.ts";
|
|
20
25
|
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
21
|
-
import { paths } from "./paths.ts";
|
|
22
26
|
import { log } from "./log.ts";
|
|
23
27
|
import { pickBest, type PickCtx } from "./picker.ts";
|
|
24
28
|
import { CredentialBlobSchema, type Account, type OAuthCreds } from "./types.ts";
|
|
@@ -35,43 +39,47 @@ function parseBlob(raw: string) {
|
|
|
35
39
|
export async function performSwap(target: Account): Promise<void> {
|
|
36
40
|
const idx = loadAccounts();
|
|
37
41
|
|
|
38
|
-
// 1.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (e instanceof InvalidGrantError) {
|
|
46
|
-
const t = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
|
|
47
|
-
if (t) { t.needsReauth = true; saveAccounts(idx); }
|
|
48
|
-
log("swap.invalid_grant", { account: target.accountUuid.slice(0, 8) });
|
|
49
|
-
}
|
|
50
|
-
throw e;
|
|
51
|
-
}
|
|
52
|
-
// 2. resolve the live credential's TRUE owner - the harvest destination.
|
|
53
|
-
// activeAccountUuid is a label, and labels drift from the blob they describe
|
|
54
|
-
// (crash mid-swap, manual /login, historical re-init); harvesting by label is
|
|
55
|
-
// how a backup once got destroyed. The token itself cannot lie. A rotation
|
|
56
|
-
// between here and the harvest write keeps the same owner, so this can stay
|
|
57
|
-
// outside the (fast, local) critical section.
|
|
42
|
+
// 1. resolve the live credential's TRUE owner - the harvest destination -
|
|
43
|
+
// BEFORE any rotation. activeAccountUuid is a label, and labels drift from
|
|
44
|
+
// the blob they describe (crash mid-swap, manual /login, historical
|
|
45
|
+
// re-init); harvesting by label is how a backup once got destroyed, and
|
|
46
|
+
// refreshing the target's parked copy while the target is secretly the
|
|
47
|
+
// LIVE account would rotate a superseded grant and flag a healthy account
|
|
48
|
+
// needs-reauth (review catch, iteration 3). The token itself cannot lie.
|
|
58
49
|
const preLive = await readItem(liveTarget());
|
|
59
50
|
let liveOwner: Account | null = null;
|
|
51
|
+
let liveCreds: OAuthCreds | null = null;
|
|
52
|
+
// What the live item's accessToken must still be when the critical section
|
|
53
|
+
// below re-reads it: the owner resolution here happens UNLOCKED, so a change
|
|
54
|
+
// in between means the resolved owner may describe a different credential.
|
|
55
|
+
let expectedLiveToken: string | null = null;
|
|
60
56
|
if (preLive) {
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
liveCreds = parseBlob(preLive).claudeAiOauth;
|
|
58
|
+
expectedLiveToken = liveCreds.accessToken;
|
|
63
59
|
if (isAccessTokenExpiring(liveCreds, 60_000)) {
|
|
64
60
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
61
|
+
await withClaudeRefreshLock(async (lock) => {
|
|
62
|
+
// re-read inside the lock: claude may have rotated it while we waited.
|
|
63
|
+
const raw2 = await readItem(liveTarget());
|
|
64
|
+
if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
|
|
65
|
+
const current = parseBlob(raw2).claudeAiOauth;
|
|
66
|
+
const next = isAccessTokenExpiring(current, 60_000) ? await refreshCredential(current) : current;
|
|
67
|
+
liveCreds = next;
|
|
68
|
+
expectedLiveToken = next.accessToken;
|
|
69
|
+
if (next === current) return;
|
|
70
|
+
if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
|
|
71
|
+
await writeItem(liveTarget(), mergeIntoLive(raw2, next));
|
|
72
|
+
});
|
|
67
73
|
} catch (e) {
|
|
68
74
|
if (!(e instanceof InvalidGrantError)) throw e;
|
|
69
75
|
// dead credential family: nothing worth preserving, skip the harvest.
|
|
70
|
-
|
|
76
|
+
// expectedLiveToken keeps the on-disk token - the failed refresh wrote
|
|
77
|
+
// nothing, so the blob is unchanged until someone else changes it.
|
|
78
|
+
liveCreds = null;
|
|
71
79
|
log("swap.harvest_skipped_dead_live", {});
|
|
72
80
|
}
|
|
73
81
|
}
|
|
74
|
-
if (
|
|
82
|
+
if (liveCreds != null) {
|
|
75
83
|
const org = await fetchTokenOrg(liveCreds.accessToken);
|
|
76
84
|
liveOwner = idx.accounts.find((a) => a.organizationUuid === org.organization_uuid) ?? null;
|
|
77
85
|
if (!liveOwner) {
|
|
@@ -88,11 +96,53 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
88
96
|
}
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
// 2. the credential to install. When the target IS the live owner (label
|
|
100
|
+
// drift made us "swap onto" the account already live), the live item holds
|
|
101
|
+
// the newest rotation and NOTHING must be installed over it - refreshing
|
|
102
|
+
// the parked copy would rotate a superseded grant, and installing any
|
|
103
|
+
// pre-lock snapshot could clobber a rotation claude makes meanwhile; the
|
|
104
|
+
// harvest below repairs the stale backup and the label commit repairs the
|
|
105
|
+
// drift. Otherwise refresh the parked credential and persist the rotation
|
|
106
|
+
// before ANY later step can fail (mirrors codexswap).
|
|
107
|
+
const selfSwap = liveOwner != null && liveOwner.accountUuid === target.accountUuid;
|
|
108
|
+
let fresh: OAuthCreds | null = null;
|
|
109
|
+
if (!selfSwap) {
|
|
110
|
+
const parkedRaw = await readItem(parkedTarget(target.keychainItem));
|
|
111
|
+
if (!parkedRaw) throw new Error(`no parked credential for ${target.email}`);
|
|
112
|
+
try {
|
|
113
|
+
fresh = await refreshCredential(parseBlob(parkedRaw).claudeAiOauth);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e instanceof InvalidGrantError) {
|
|
116
|
+
const t = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
|
|
117
|
+
if (t) { t.needsReauth = true; saveAccounts(idx); }
|
|
118
|
+
log("swap.invalid_grant", { account: target.accountUuid.slice(0, 8) });
|
|
119
|
+
}
|
|
120
|
+
throw e;
|
|
121
|
+
}
|
|
122
|
+
await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
|
|
123
|
+
}
|
|
124
|
+
|
|
91
125
|
// 3. the fast, local, atomic-vs-claude-refresh critical section.
|
|
92
|
-
await withClaudeRefreshLock(async () => {
|
|
126
|
+
await withClaudeRefreshLock(async (lock) => {
|
|
93
127
|
const currentLive = await readItem(liveTarget());
|
|
128
|
+
if (lock.compromised()) throw new Error("refresh lock compromised - aborting the swap before any write");
|
|
129
|
+
|
|
130
|
+
// The owner above was resolved UNLOCKED (network calls must not sit inside
|
|
131
|
+
// claude's refresh lock), so a manual /login or a claude refresh can have
|
|
132
|
+
// replaced the live item since. Harvesting the replaced blob under the
|
|
133
|
+
// stale owner would corrupt that owner's only backup - the exact incident
|
|
134
|
+
// class the owner-first order exists to prevent (review catch, PR #31).
|
|
135
|
+
// Any change aborts before a single write; the next check re-resolves the
|
|
136
|
+
// true owner and retries.
|
|
137
|
+
const currentToken = currentLive == null ? null : parseBlob(currentLive).claudeAiOauth.accessToken;
|
|
138
|
+
if (currentToken !== expectedLiveToken) {
|
|
139
|
+
throw new Error("live credential changed while unlocked (concurrent /login or refresh) - aborting this swap; the next check re-resolves the owner and retries");
|
|
140
|
+
}
|
|
94
141
|
|
|
95
142
|
// harvest the live claudeAiOauth into its OWNER's (small) backup item.
|
|
143
|
+
// When the owner IS the target (label drift), the live blob is the newest
|
|
144
|
+
// rotation - the parked refresh was skipped above - so this same write is
|
|
145
|
+
// exactly the repair of a stale or dead parked backup.
|
|
96
146
|
if (liveOwner && currentLive) {
|
|
97
147
|
await writeItem(parkedTarget(liveOwner.keychainItem), claudeAiOauthOnly(currentLive));
|
|
98
148
|
log("swap.harvest", { account: liveOwner.accountUuid.slice(0, 8) });
|
|
@@ -100,12 +150,17 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
100
150
|
|
|
101
151
|
// install B: merge B's fresh claudeAiOauth into the CURRENT live blob so all
|
|
102
152
|
// sibling state (MCP OAuth tokens, etc.) is preserved across the swap.
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
|
|
153
|
+
// (B's rotation was already persisted to its backup right after the refresh.)
|
|
154
|
+
// A self-swap installs NOTHING: the live item already holds the newest
|
|
155
|
+
// rotation, re-read under this lock; only the label below needs repair.
|
|
156
|
+
if (fresh != null) {
|
|
157
|
+
await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
|
|
158
|
+
}
|
|
106
159
|
swapOAuthAccount(target.oauthAccount);
|
|
107
|
-
// record B as active
|
|
108
|
-
//
|
|
160
|
+
// record B as active immediately after the identity write. These separate
|
|
161
|
+
// files cannot be crash-atomic together, so a crash may leave intermediate
|
|
162
|
+
// state; the next swap resolves the live owner from the token itself and
|
|
163
|
+
// logs any drift (swap.harvest_drift).
|
|
109
164
|
idx.activeAccountUuid = target.accountUuid;
|
|
110
165
|
const t2 = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
|
|
111
166
|
if (t2) { t2.needsReauth = false; }
|
|
@@ -113,6 +168,12 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
113
168
|
// the snapshots on disk still describe the pre-swap account; under the new
|
|
114
169
|
// org label they'd trigger a bogus switch off the account just installed.
|
|
115
170
|
clearUsageSnapshots();
|
|
171
|
+
// any completed swap supersedes a recorded depleted-wait: an unexpired
|
|
172
|
+
// stale record could otherwise replay through this swap's own cooldown
|
|
173
|
+
// and pause the fresh seat for a long-gone decision (review catch,
|
|
174
|
+
// PR #31). The depleted path re-records its own wait right after its
|
|
175
|
+
// pre-park swap returns.
|
|
176
|
+
clearDepletedWait();
|
|
116
177
|
saveLastSwapAt(Date.now());
|
|
117
178
|
});
|
|
118
179
|
log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
|
|
@@ -123,12 +184,17 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
123
184
|
* Assumes the caller holds the flock (does NOT lock - avoids same-process
|
|
124
185
|
* flock self-deadlock). Returns the account landed on, or null if none usable.
|
|
125
186
|
*/
|
|
126
|
-
export async function chooseAndSwap(ctx:
|
|
187
|
+
export async function chooseAndSwap(ctx: PickCtx): Promise<Account | null> {
|
|
188
|
+
// ctx.currentAccountUuid is the caller-resolved SEAT (decide.ts resolves it
|
|
189
|
+
// by the live org, label fallback): resolving here off activeAccountUuid
|
|
190
|
+
// re-imported the label drift the caller just resolved away, and under
|
|
191
|
+
// drift the LIVE account could be picked as its own swap target (bugbot
|
|
192
|
+
// review catch, PR #33).
|
|
127
193
|
const tried = new Set<string>();
|
|
128
194
|
while (true) {
|
|
129
195
|
const idx = loadAccounts();
|
|
130
196
|
const candidates = idx.accounts.filter((a) => !tried.has(a.accountUuid));
|
|
131
|
-
const best = pickBest(candidates,
|
|
197
|
+
const best = pickBest(candidates, ctx);
|
|
132
198
|
if (!best) return null;
|
|
133
199
|
tried.add(best.accountUuid);
|
|
134
200
|
try {
|
|
@@ -141,7 +207,3 @@ export async function chooseAndSwap(ctx: Omit<PickCtx, "currentAccountUuid">): P
|
|
|
141
207
|
}
|
|
142
208
|
}
|
|
143
209
|
|
|
144
|
-
/** Standalone lock-taking variant for CLI/manual use (NEVER call under a held flock). */
|
|
145
|
-
export async function swapToBest(ctx: Omit<PickCtx, "currentAccountUuid">): Promise<Account | null> {
|
|
146
|
-
return withLock(paths.lockFile, () => chooseAndSwap(ctx));
|
|
147
|
-
}
|