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.
Files changed (61) hide show
  1. package/DESIGN.md +34 -25
  2. package/README.md +6 -5
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +1 -0
  5. package/src/cli/auth.ts +25 -14
  6. package/src/cli/check.ts +3 -2
  7. package/src/cli/codexadd.ts +44 -40
  8. package/src/cli/codexinit.ts +59 -12
  9. package/src/cli/codexrm.ts +49 -0
  10. package/src/cli/codexswitch.ts +20 -2
  11. package/src/cli/config.ts +25 -1
  12. package/src/cli/doctor.ts +3 -3
  13. package/src/cli/init.ts +54 -32
  14. package/src/cli/onboard.ts +62 -45
  15. package/src/cli/rename.ts +20 -0
  16. package/src/cli/render.ts +0 -16
  17. package/src/cli/rm.ts +40 -2
  18. package/src/cli/serve.ts +638 -115
  19. package/src/cli/status.ts +69 -23
  20. package/src/cli/switch.ts +54 -19
  21. package/src/entries/codexstophook.ts +123 -4
  22. package/src/entries/codexsupervisor.ts +87 -13
  23. package/src/entries/sessionstart.ts +1 -1
  24. package/src/entries/statusline.ts +56 -20
  25. package/src/entries/stophook.ts +23 -9
  26. package/src/entries/supervisor.ts +184 -20
  27. package/src/lib/atomic.ts +28 -6
  28. package/src/lib/claudebin.ts +2 -2
  29. package/src/lib/claudejson.ts +5 -5
  30. package/src/lib/claudelock.ts +112 -37
  31. package/src/lib/codexauth.ts +18 -3
  32. package/src/lib/codexbin.ts +1 -1
  33. package/src/lib/codexdecide.ts +149 -19
  34. package/src/lib/codexpick.ts +17 -6
  35. package/src/lib/codexpresence.ts +59 -21
  36. package/src/lib/codexsample.ts +17 -8
  37. package/src/lib/codexswap.ts +10 -1
  38. package/src/lib/credstore.ts +6 -2
  39. package/src/lib/decide.ts +136 -49
  40. package/src/lib/install.ts +125 -17
  41. package/src/lib/keychain.ts +41 -15
  42. package/src/lib/lock.ts +57 -35
  43. package/src/lib/log.ts +36 -7
  44. package/src/lib/oauth.ts +18 -11
  45. package/src/lib/paths.ts +17 -11
  46. package/src/lib/picker.ts +11 -3
  47. package/src/lib/proc.ts +37 -0
  48. package/src/lib/sample.ts +91 -31
  49. package/src/lib/sessions.ts +23 -1
  50. package/src/lib/settings.ts +59 -18
  51. package/src/lib/slackbridge.ts +581 -81
  52. package/src/lib/slackstate.ts +159 -12
  53. package/src/lib/slackstream.ts +123 -20
  54. package/src/lib/state.ts +131 -35
  55. package/src/lib/swap.ts +109 -47
  56. package/src/lib/types.ts +92 -38
  57. package/src/lib/usage.ts +114 -16
  58. package/src/main.ts +70 -9
  59. package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
  60. package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
  61. 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: z.number(), weekly: z.number() }).partial(),
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: z.number(),
44
- greedySessionFloor: z.number(),
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
- export function loadConfig(): Config {
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
- raw = {};
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
- const p = parsed.success ? parsed.data : {};
64
- cfg.thresholds.session = p.thresholds?.session ?? cfg.thresholds.session;
65
- cfg.thresholds.weekly = p.thresholds?.weekly ?? cfg.thresholds.weekly;
66
- cfg.claudeBin = p.claudeBin ?? cfg.claudeBin;
67
- cfg.codexBin = p.codexBin ?? cfg.codexBin;
68
- cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
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
- // env overrides win for the real binaries (tests / relocation)
77
- const envBin = realClaudeBinFromEnv();
78
- if (envBin) cfg.claudeBin = envBin;
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
- export function saveConfig(c: Config): void {
85
- writeFileAtomic(paths.configJson, JSON.stringify(ConfigSchema.parse(c), null, 2) + "\n");
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
- const parsed = AccountsIndexSchema.safeParse(JSON.parse(readFileSync(paths.accountsJson, "utf8")));
96
- return parsed.success ? parsed.data : emptyIndex();
151
+ json = JSON.parse(readFileSync(paths.accountsJson, "utf8"));
97
152
  } catch {
98
- return emptyIndex();
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
- const parsed = LastSwapSchema.safeParse(JSON.parse(readFileSync(paths.lastSwapJson, "utf8")));
131
- return parsed.success ? parsed.data.ts : null;
193
+ json = JSON.parse(readFileSync(paths.lastSwapJson, "utf8"));
132
194
  } catch {
133
- return null;
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
- if ((e as { code?: string }).code !== "ENOENT") throw e;
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
- // refresh B (network, no lock)
6
- // resolve the live credential's TRUE owner (network, no lock)
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
- // harvest live its OWNER's backup (mandatory: refresh token rotates in place)
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 (inside the lock: a crash before this write leaves a stale
13
- // active label, which is exactly what once made a harvest destroy a backup)
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. refresh target's parked credential (network) BEFORE taking claude's lock.
39
- const parkedRaw = await readItem(parkedTarget(target.keychainItem));
40
- if (!parkedRaw) throw new Error(`no parked credential for ${target.email}`);
41
- let fresh: OAuthCreds;
42
- try {
43
- fresh = await refreshCredential(parseBlob(parkedRaw).claudeAiOauth);
44
- } catch (e) {
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
- let liveCreds = parseBlob(preLive).claudeAiOauth;
62
- let identifiable = true;
57
+ liveCreds = parseBlob(preLive).claudeAiOauth;
58
+ expectedLiveToken = liveCreds.accessToken;
63
59
  if (isAccessTokenExpiring(liveCreds, 60_000)) {
64
60
  try {
65
- liveCreds = await refreshCredential(liveCreds);
66
- await writeItem(liveTarget(), mergeIntoLive(preLive, liveCreds));
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
- identifiable = false;
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 (identifiable) {
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
- await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
104
- // persist B's rotated token into its small backup item.
105
- await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
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 INSIDE the critical section so a crash cannot leave the
108
- // installed credential and the active label pointing at different accounts.
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: Omit<PickCtx, "currentAccountUuid">): Promise<Account | null> {
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, { ...ctx, currentAccountUuid: idx.activeAccountUuid });
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
- }