@carjms/codexswitch 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -12,27 +12,40 @@ const HELP = `codexswitch — multi-account manager for the OpenAI Codex CLI
12
12
  Accounts
13
13
  login [name] log in to a new account (isolated "codex login") and store it
14
14
  import [name] import the account currently in ~/.codex/auth.json
15
+ add-key <name> [key] register an OpenAI API-key account (or read $OPENAI_API_KEY)
15
16
  list list stored accounts (alias: accounts, status)
16
17
  use <name> make <name> the active account in ~/.codex
17
18
  current show the active account
18
- next rotate to the next usable account
19
+ next switch to the next account in rotation order (wraps around)
19
20
  remove <name> delete a stored account
20
21
  rename <old> <new> rename a stored account
21
22
  enable <name> re-enable a disabled account
22
23
  disable <name> temporarily exclude an account from rotation
23
- priority <name> <n> set rotation priority (lower = preferred, default 0)
24
+ order [name...] show or set the rotation order in one command
25
+ priority <name> <n> set one account's priority (lower = preferred, default 0)
24
26
  clear-limit <name> forget a recorded rate-limit for an account
25
27
 
26
28
  Running codex
27
29
  run [name] [args...] run codex as <name> (or active account) in an isolated
28
30
  per-account CODEX_HOME; config/sessions are shared
29
- exec [args...] run "codex exec ..." and auto-rotate to the next
30
- account when a usage/rate limit is hit
31
+ exec [args...] run "codex exec ..." and auto-rotate through accounts in
32
+ rotation order on usage limits; the next account resumes
33
+ the same session (--skip-git-repo-check added automatically)
31
34
  -a, --account <n> start exec with a specific account
35
+ --no-resume restart the prompt instead of resuming on rotation
36
+
37
+ Settings
38
+ model [name|default] show/set the default model injected into run/exec
39
+ threshold [5h%] [wk%] rotate early when recorded usage reaches these percents
40
+ of the 5-hour / weekly window (default 95, one value = both)
41
+ cooldown [minutes] show/set rate-limit cooldown (default 60)
42
+ patterns [add|remove] extra regex patterns treated as rate-limit errors
32
43
 
33
44
  Maintenance
34
45
  sync save tokens refreshed by codex back into the store
35
- cooldown [minutes] show/set rate-limit cooldown (default 60)
46
+ export <file> back up all accounts + settings (contains tokens!)
47
+ restore <file> restore accounts from a backup file
48
+ completion <bash|zsh> print a shell completion script
36
49
  help show this help
37
50
 
38
51
  Environment
@@ -67,6 +80,9 @@ function storeAccount(name, auth) {
67
80
  store.writeAccountAuth(name, auth);
68
81
  const meta = store.loadMeta();
69
82
  if (!meta.accounts[name]) meta.accounts[name] = { priority: 0, addedAt: Date.now() };
83
+ // fresh credentials fix whatever got the account disabled or limited
84
+ delete meta.accounts[name].disabled;
85
+ delete meta.accounts[name].limitedUntil;
70
86
  store.saveMeta(meta);
71
87
  out(`${existed ? 'updated' : 'added'} account "${name}"${info.email ? ` (${info.email}, ${info.plan || 'unknown plan'})` : ''}`);
72
88
  return name;
@@ -119,16 +135,31 @@ function cmdList() {
119
135
  return;
120
136
  }
121
137
  const now = Date.now();
122
- const rows = accounts.map((a) => [
123
- a.active ? '*' : '',
124
- a.name,
125
- a.email,
126
- a.plan,
127
- a.priority,
128
- a.disabled ? 'disabled' : a.limitedUntil && a.limitedUntil > now ? `limited ${fmtRemaining(a.limitedUntil)}` : 'ok',
129
- fmtDate(a.lastRefresh),
130
- ]);
131
- out(table(rows, ['', 'name', 'email', 'plan', 'prio', 'status', 'token refreshed']));
138
+ const meta = store.loadMeta();
139
+ const pct = (win) => (win && typeof win.pct === 'number' ? `${Math.round(win.pct)}%` : '-');
140
+ const rows = accounts.map((a) => {
141
+ const over = store.overThreshold(a, meta, now);
142
+ const status = a.disabled
143
+ ? 'disabled'
144
+ : a.limitedUntil && a.limitedUntil > now
145
+ ? `limited ${fmtRemaining(a.limitedUntil)}`
146
+ : over
147
+ ? `over-${over}`
148
+ : 'ok';
149
+ return [
150
+ a.active ? '*' : '',
151
+ a.name,
152
+ a.email,
153
+ a.plan,
154
+ a.priority,
155
+ status,
156
+ pct(a.usage && a.usage.p5h),
157
+ pct(a.usage && a.usage.weekly),
158
+ fmtDate(a.lastRefresh),
159
+ ];
160
+ });
161
+ out(table(rows, ['', 'name', 'email', 'plan', 'prio', 'status', '5h', 'week', 'token refreshed']));
162
+ out(`(rotate threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}% — change with "codexswitch threshold")`);
132
163
  }
133
164
 
134
165
  function cmdUse(args) {
@@ -156,7 +187,10 @@ function cmdCurrent() {
156
187
  return;
157
188
  }
158
189
  const info = authInfo(live);
159
- const match = store.listAccounts().find((a) => a.accountId === info.accountId);
190
+ const accounts = store.listAccounts();
191
+ const match =
192
+ (info.email && accounts.find((a) => a.email === info.email)) ||
193
+ accounts.find((a) => a.accountId === info.accountId);
160
194
  const name = match ? match.name : '(not stored — run "codexswitch import")';
161
195
  const note = meta.active && match && meta.active !== match.name ? ` (meta says "${meta.active}" — out of sync)` : '';
162
196
  out(`active: ${name}${note}`);
@@ -164,12 +198,32 @@ function cmdCurrent() {
164
198
  }
165
199
 
166
200
  function cmdNext() {
167
- const meta = store.loadMeta();
168
- const next = store.pickAccount(meta.active ? [meta.active] : []);
201
+ const next = store.nextAccount();
169
202
  if (!next) throw new Error('no other usable account available');
170
203
  cmdUse([next.name]);
171
204
  }
172
205
 
206
+ // Register an API-key account (Console/platform billing instead of a
207
+ // ChatGPT plan). The key is stored only inside the account's auth.json.
208
+ function cmdAddKey(args) {
209
+ const name = requireArg(args, 0, 'account name');
210
+ const key = args[1] || process.env.OPENAI_API_KEY;
211
+ if (!key) {
212
+ throw new Error(
213
+ 'missing API key: pass it as the second argument or set OPENAI_API_KEY in the environment'
214
+ );
215
+ }
216
+ if (!/^sk-/.test(key)) throw new Error('that does not look like an OpenAI API key (should start with "sk-")');
217
+ storeAccount(name, {
218
+ auth_mode: 'apikey',
219
+ OPENAI_API_KEY: key,
220
+ tokens: null,
221
+ last_refresh: null,
222
+ });
223
+ const meta = store.loadMeta();
224
+ if (!meta.active) cmdUse([name]);
225
+ }
226
+
173
227
  function cmdRemove(args) {
174
228
  const name = requireArg(args, 0, 'account name');
175
229
  if (!store.accountExists(name)) throw new Error(`no such account: ${name}`);
@@ -230,7 +284,171 @@ function cmdSync() {
230
284
  out(updated ? `synced refreshed tokens into "${updated}"` : 'nothing to sync');
231
285
  }
232
286
 
287
+ // Set the rotation order in one go: listed accounts get priority 0..k-1,
288
+ // unlisted accounts follow after in their current order.
289
+ function cmdOrder(args) {
290
+ const accounts = store.listAccounts();
291
+ if (args.length === 0) {
292
+ if (accounts.length === 0) {
293
+ out('no accounts yet');
294
+ return;
295
+ }
296
+ accounts.forEach((a, i) => out(`${i + 1}. ${a.name}${a.active ? ' (active)' : ''}`));
297
+ return;
298
+ }
299
+ for (const name of args) {
300
+ if (!store.accountExists(name)) throw new Error(`no such account: ${name}`);
301
+ }
302
+ const dupe = args.find((n, i) => args.indexOf(n) !== i);
303
+ if (dupe) throw new Error(`account listed twice: ${dupe}`);
304
+ const meta = store.loadMeta();
305
+ let prio = 0;
306
+ for (const name of args) {
307
+ meta.accounts[name] = { ...(meta.accounts[name] || {}), priority: prio++ };
308
+ }
309
+ for (const a of accounts) {
310
+ if (args.includes(a.name)) continue;
311
+ meta.accounts[a.name] = { ...(meta.accounts[a.name] || {}), priority: prio++ };
312
+ }
313
+ store.saveMeta(meta);
314
+ out('rotation order set:');
315
+ store.listAccounts().forEach((a, i) => out(`${i + 1}. ${a.name}`));
316
+ }
317
+
318
+ function cmdModel(args) {
319
+ const meta = store.loadMeta();
320
+ if (args[0] == null) {
321
+ out(`model: ${meta.model || '(codex default)'}`);
322
+ return;
323
+ }
324
+ if (args[0] === 'default' || args[0] === 'clear') {
325
+ delete meta.model;
326
+ store.saveMeta(meta);
327
+ out('model reset to codex default');
328
+ return;
329
+ }
330
+ meta.model = args[0];
331
+ store.saveMeta(meta);
332
+ out(`default model set to "${meta.model}" (applied to run/exec)`);
333
+ }
334
+
335
+ // Rotate-early thresholds: how full the 5h / weekly window may get before
336
+ // the account is skipped in rotation.
337
+ function cmdThreshold(args) {
338
+ const meta = store.loadMeta();
339
+ if (args.length === 0) {
340
+ out(`threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`);
341
+ out('usage: codexswitch threshold <5h%> [weekly%] (one value sets both)');
342
+ return;
343
+ }
344
+ const parse = (s) => {
345
+ const n = parseInt(s, 10);
346
+ if (Number.isNaN(n) || n < 1 || n > 100) throw new Error(`threshold must be 1-100, got "${s}"`);
347
+ return n;
348
+ };
349
+ meta.threshold5h = parse(args[0]);
350
+ meta.thresholdWeekly = args[1] != null ? parse(args[1]) : meta.threshold5h;
351
+ store.saveMeta(meta);
352
+ out(`threshold set: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`);
353
+ }
354
+
355
+ function cmdPatterns(args) {
356
+ const meta = store.loadMeta();
357
+ const [sub, ...rest] = args;
358
+ if (!sub) {
359
+ out('built-in: usage limit / rate limit / too many requests / quota exceeded / 429');
360
+ if (meta.limitPatterns.length === 0) out('custom: (none)');
361
+ else meta.limitPatterns.forEach((p, i) => out(`custom ${i + 1}: ${p}`));
362
+ return;
363
+ }
364
+ if (sub === 'add') {
365
+ const pattern = rest.join(' ');
366
+ if (!pattern) throw new Error('usage: codexswitch patterns add <regex>');
367
+ new RegExp(pattern, 'i'); // validate — throws on bad regex
368
+ meta.limitPatterns.push(pattern);
369
+ store.saveMeta(meta);
370
+ out(`added pattern: ${pattern}`);
371
+ return;
372
+ }
373
+ if (sub === 'remove') {
374
+ const key = rest.join(' ');
375
+ const idx = /^\d+$/.test(key) ? parseInt(key, 10) - 1 : meta.limitPatterns.indexOf(key);
376
+ if (idx < 0 || idx >= meta.limitPatterns.length) throw new Error(`no such pattern: ${key}`);
377
+ const [removed] = meta.limitPatterns.splice(idx, 1);
378
+ store.saveMeta(meta);
379
+ out(`removed pattern: ${removed}`);
380
+ return;
381
+ }
382
+ throw new Error('usage: codexswitch patterns [add <regex> | remove <n|regex>]');
383
+ }
384
+
385
+ function cmdExport(args) {
386
+ const file = requireArg(args, 0, 'output file path');
387
+ const accounts = {};
388
+ for (const a of store.listAccounts()) accounts[a.name] = store.readAccountAuth(a.name);
389
+ if (Object.keys(accounts).length === 0) throw new Error('no accounts to export');
390
+ writeJSONAtomic(path.resolve(file), { version: 1, exportedAt: new Date().toISOString(), meta: store.loadMeta(), accounts }, 0o600);
391
+ out(`exported ${Object.keys(accounts).length} account(s) to ${file}`);
392
+ out('WARNING: this file contains login tokens — treat it like a password.');
393
+ }
394
+
395
+ function cmdRestore(args) {
396
+ const file = requireArg(args, 0, 'backup file path');
397
+ const data = readJSONSafe(path.resolve(file));
398
+ if (!data || data.version !== 1 || !data.accounts) throw new Error('not a codexswitch backup file');
399
+ store.ensureDirs();
400
+ const meta = store.loadMeta();
401
+ let n = 0;
402
+ for (const [name, auth] of Object.entries(data.accounts)) {
403
+ store.writeAccountAuth(name, auth);
404
+ const backup = (data.meta && data.meta.accounts && data.meta.accounts[name]) || {};
405
+ meta.accounts[name] = { ...backup, ...(meta.accounts[name] || {}) };
406
+ n++;
407
+ }
408
+ if (!meta.active && data.meta && data.meta.active) meta.active = data.meta.active;
409
+ store.saveMeta(meta);
410
+ out(`restored ${n} account(s) from ${file}`);
411
+ }
412
+
413
+ function cmdNames() {
414
+ for (const a of store.listAccounts()) out(a.name);
415
+ }
416
+
417
+ const COMMANDS =
418
+ 'login import add-key list use current next run exec order model remove rename ' +
419
+ 'enable disable priority clear-limit cooldown threshold patterns export restore sync completion help';
420
+
421
+ function cmdCompletion(args) {
422
+ const shell = args[0];
423
+ if (shell !== 'bash' && shell !== 'zsh') {
424
+ throw new Error('usage: codexswitch completion <bash|zsh> (append the output to your shell rc file)');
425
+ }
426
+ const script = `# codexswitch completion (${shell})
427
+ ${shell === 'zsh' ? 'autoload -Uz bashcompinit && bashcompinit\n' : ''}_codexswitch() {
428
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
429
+ if [ "\$COMP_CWORD" -eq 1 ]; then
430
+ COMPREPLY=( \$(compgen -W "${COMMANDS}" -- "\$cur") )
431
+ else
432
+ COMPREPLY=( \$(compgen -W "\$(codexswitch names 2>/dev/null)" -- "\$cur") )
433
+ fi
434
+ }
435
+ complete -F _codexswitch codexswitch cxs`;
436
+ out(script);
437
+ }
438
+
439
+ // Final argv for "codex exec": inject --skip-git-repo-check so it works in
440
+ // non-git folders, and the configured default model — unless the user
441
+ // already passed their own flags.
442
+ function buildExecArgs(rest, meta) {
443
+ const args = [...rest];
444
+ if (!args.includes('--skip-git-repo-check')) args.unshift('--skip-git-repo-check');
445
+ const hasModel = args.some((a) => a === '-m' || a === '--model' || a.startsWith('--model='));
446
+ if (meta.model && !hasModel) args.unshift('-m', meta.model);
447
+ return args;
448
+ }
449
+
233
450
  async function cmdRun(args) {
451
+ store.syncBack(); // pick up tokens refreshed by plain codex before overlaying
234
452
  let name = null;
235
453
  let rest = args;
236
454
  if (args[0] && store.accountExists(args[0])) {
@@ -244,28 +462,44 @@ async function cmdRun(args) {
244
462
  name = meta.active && store.accountExists(meta.active) ? meta.active : picked.name;
245
463
  }
246
464
  if (rest[0] === '--') rest = rest.slice(1);
465
+ const meta = store.loadMeta();
466
+ if (rest[0] === 'exec') {
467
+ rest = ['exec', ...buildExecArgs(rest.slice(1), meta)];
468
+ } else if (meta.model && rest.length === 0) {
469
+ rest = ['-m', meta.model];
470
+ }
247
471
  out(`[codexswitch] running codex as "${name}"`);
472
+ const startTs = Date.now();
248
473
  const res = await runner.runCodex(name, rest);
474
+ runner.recordUsage(name, res.profile, startTs);
249
475
  return res.code;
250
476
  }
251
477
 
478
+ const RESUME_PROMPT = 'Continue the previous task from exactly where it left off.';
479
+
252
480
  async function cmdExec(args) {
253
481
  let explicit = null;
482
+ let allowResume = true;
254
483
  const rest = [];
255
484
  for (let i = 0; i < args.length; i++) {
256
485
  if (args[i] === '-a' || args[i] === '--account') {
257
486
  explicit = requireArg(args, ++i, 'account name after --account');
487
+ } else if (args[i] === '--no-resume') {
488
+ allowResume = false;
258
489
  } else {
259
490
  rest.push(args[i]);
260
491
  }
261
492
  }
262
493
  if (explicit && !store.accountExists(explicit)) throw new Error(`no such account: ${explicit}`);
494
+ if (rest[0] === 'resume') allowResume = false; // user drives resume themselves
263
495
 
496
+ store.syncBack(); // pick up tokens refreshed by plain codex before overlaying
264
497
  const meta = store.loadMeta();
265
498
  const total = store.listAccounts().length;
266
499
  if (total === 0) throw new Error('no accounts — add one with "codexswitch login"');
267
500
 
268
501
  const tried = [];
502
+ let useResume = false;
269
503
  for (let attempt = 0; attempt < total; attempt++) {
270
504
  let name;
271
505
  if (explicit && attempt === 0) {
@@ -276,24 +510,61 @@ async function cmdExec(args) {
276
510
  name = picked.name;
277
511
  }
278
512
  tried.push(name);
279
- console.error(`[codexswitch] exec as "${name}"${attempt > 0 ? ` (attempt ${attempt + 1})` : ''}`);
280
- const res = await runner.runCodex(name, ['exec', ...rest], { capture: true });
513
+ console.error(
514
+ `[codexswitch] exec as "${name}"${attempt > 0 ? ` (attempt ${attempt + 1}${useResume ? ', resuming session' : ''})` : ''}`
515
+ );
516
+ // On rotation, continue the same session with the next account instead
517
+ // of restarting the whole prompt — the session files are shared.
518
+ const codexArgs = useResume
519
+ ? ['exec', 'resume', '--last', ...buildExecArgs([], meta), RESUME_PROMPT]
520
+ : ['exec', ...buildExecArgs(rest, meta)];
521
+ const startTs = Date.now();
522
+ const res = await runner.runCodex(name, codexArgs, { capture: true });
523
+ runner.recordUsage(name, res.profile, startTs);
281
524
  if (res.code === 0) {
282
525
  store.clearLimited(name);
526
+ warnIfOverThreshold(name);
283
527
  return 0;
284
528
  }
285
- if (runner.looksRateLimited(res.output)) {
529
+ if (runner.looksRateLimited(res.output, meta.limitPatterns)) {
286
530
  const until = Date.now() + runner.limitCooldownMs(res.output, meta.cooldownMinutes);
287
531
  store.markLimited(name, until);
288
- console.error(`[codexswitch] "${name}" hit a usage/rate limit (paused until ${fmtDate(until)}) — rotating`);
532
+ if (allowResume && !useResume && runner.sessionTouchedSince(res.profile, startTs)) {
533
+ useResume = true;
534
+ }
535
+ console.error(
536
+ `[codexswitch] "${name}" hit a usage/rate limit (paused until ${fmtDate(until)}) — rotating${useResume ? ' and resuming the session' : ''}`
537
+ );
538
+ continue;
539
+ }
540
+ if (runner.looksAuthFailed(res.output)) {
541
+ // A revoked token won't heal by itself — take the account out of
542
+ // rotation and keep the task going on the next one.
543
+ setFlag(name, { disabled: true });
544
+ console.error(
545
+ `[codexswitch] "${name}" has a revoked/invalid login — disabled. Fix it with: codexswitch login ${name}`
546
+ );
289
547
  continue;
290
548
  }
291
549
  return res.code; // real failure, don't burn other accounts on it
292
550
  }
293
- console.error('[codexswitch] all accounts are rate-limited or disabled');
551
+ console.error('[codexswitch] all accounts are rate-limited, over threshold, or disabled');
294
552
  return 2;
295
553
  }
296
554
 
555
+ function warnIfOverThreshold(name) {
556
+ const meta = store.loadMeta();
557
+ const account = store.listAccounts().find((a) => a.name === name);
558
+ if (!account) return;
559
+ const blocked = store.overThreshold(account, meta);
560
+ if (blocked) {
561
+ const pct = blocked === '5h' ? account.usage.p5h.pct : account.usage.weekly.pct;
562
+ console.error(
563
+ `[codexswitch] "${name}" is at ${Math.round(pct)}% of its ${blocked} limit (threshold ${blocked === '5h' ? meta.threshold5h : meta.thresholdWeekly}%) — the next exec will rotate to another account`
564
+ );
565
+ }
566
+ }
567
+
297
568
  async function main(argv) {
298
569
  const [cmd, ...args] = argv;
299
570
  switch (cmd) {
@@ -333,10 +604,29 @@ async function main(argv) {
333
604
  return 0;
334
605
  case 'priority':
335
606
  return cmdPriority(args), 0;
607
+ case 'order':
608
+ return cmdOrder(args), 0;
609
+ case 'model':
610
+ return cmdModel(args), 0;
611
+ case 'add-key':
612
+ case 'apikey':
613
+ return cmdAddKey(args), 0;
614
+ case 'threshold':
615
+ return cmdThreshold(args), 0;
616
+ case 'patterns':
617
+ return cmdPatterns(args), 0;
618
+ case 'export':
619
+ return cmdExport(args), 0;
620
+ case 'restore':
621
+ return cmdRestore(args), 0;
622
+ case 'names':
623
+ return cmdNames(), 0;
624
+ case 'completion':
625
+ return cmdCompletion(args), 0;
336
626
  case 'clear-limit': {
337
627
  const name = requireArg(args, 0, 'account name');
338
- store.clearLimited(name);
339
- out(`cleared rate-limit record for "${name}"`);
628
+ store.clearLimited(name, { includeUsage: true });
629
+ out(`cleared rate-limit and usage records for "${name}"`);
340
630
  return 0;
341
631
  }
342
632
  case 'cooldown':
package/src/runner.js CHANGED
@@ -65,6 +65,10 @@ function buildProfile(name) {
65
65
  const profile = path.join(p.profilesDir, name);
66
66
  ensureDir(profile);
67
67
 
68
+ // Sessions must exist in the real CODEX_HOME before linking, so that every
69
+ // profile shares them — resume-on-rotation and usage tracking depend on it.
70
+ ensureDir(path.join(p.codexHome, 'sessions'), 0o755);
71
+
68
72
  let entries = [];
69
73
  try {
70
74
  entries = fs.readdirSync(p.codexHome);
@@ -184,7 +188,7 @@ function runCodex(name, args, { capture = false } = {}) {
184
188
  collect(child.stdout, process.stdout);
185
189
  collect(child.stderr, process.stderr);
186
190
  }
187
- child.on('error', (err) => resolve({ code: 1, output: out, error: err }));
191
+ child.on('error', (err) => resolve({ code: 1, output: out, error: err, profile }));
188
192
  child.on('close', (code) => {
189
193
  // codex may have refreshed the token while running — persist it.
190
194
  try {
@@ -192,16 +196,115 @@ function runCodex(name, args, { capture = false } = {}) {
192
196
  } catch {
193
197
  /* best effort */
194
198
  }
195
- resolve({ code: code == null ? 1 : code, output: out });
199
+ resolve({ code: code == null ? 1 : code, output: out, profile });
196
200
  });
197
201
  });
198
202
  }
199
203
 
204
+ // ---- usage tracking ---------------------------------------------------
205
+ // Codex writes token_count events into session rollout files; when the
206
+ // backend sends x-codex-* headers they include rate_limits with the 5h
207
+ // (primary) and weekly (secondary) used_percent and reset times. Some
208
+ // exec-mode versions report null there, so all parsing is best-effort.
209
+
210
+ function listRolloutsSince(sessionsDir, sinceMs) {
211
+ const found = [];
212
+ const walk = (dir, depth) => {
213
+ let entries;
214
+ try {
215
+ entries = fs.readdirSync(dir, { withFileTypes: true });
216
+ } catch {
217
+ return;
218
+ }
219
+ for (const e of entries) {
220
+ const full = path.join(dir, e.name);
221
+ if (e.isDirectory() && depth < 4) walk(full, depth + 1);
222
+ else if (e.isFile() && /^rollout-.*\.jsonl$/.test(e.name)) {
223
+ try {
224
+ const st = fs.statSync(full);
225
+ if (st.mtimeMs >= sinceMs) found.push({ full, mtime: st.mtimeMs });
226
+ } catch {
227
+ /* ignore */
228
+ }
229
+ }
230
+ }
231
+ };
232
+ walk(sessionsDir, 0);
233
+ return found.sort((a, b) => a.mtime - b.mtime);
234
+ }
235
+
236
+ function windowUsage(win, eventTs) {
237
+ if (!win || typeof win.used_percent !== 'number') return null;
238
+ const usage = { pct: win.used_percent, resetAt: null, windowMinutes: win.window_minutes || null };
239
+ if (typeof win.resets_in_seconds === 'number') usage.resetAt = eventTs + win.resets_in_seconds * 1000;
240
+ else if (typeof win.resets_at === 'number') usage.resetAt = win.resets_at * 1000;
241
+ return usage;
242
+ }
243
+
244
+ // Scan rollouts touched since `sinceMs` for the newest non-null rate_limits
245
+ // snapshot. Returns { p5h, weekly, at } or null when codex reported nothing.
246
+ function scanUsage(sessionsDir, sinceMs) {
247
+ let result = null;
248
+ for (const f of listRolloutsSince(sessionsDir, sinceMs)) {
249
+ let text;
250
+ try {
251
+ text = fs.readFileSync(f.full, 'utf8');
252
+ } catch {
253
+ continue;
254
+ }
255
+ for (const line of text.split('\n')) {
256
+ if (!line.includes('rate_limits')) continue;
257
+ try {
258
+ const obj = JSON.parse(line);
259
+ const rl = (obj.payload && obj.payload.rate_limits) || obj.rate_limits;
260
+ if (!rl) continue;
261
+ const eventTs = obj.timestamp ? Date.parse(obj.timestamp) || Date.now() : Date.now();
262
+ const p5h = windowUsage(rl.primary, eventTs);
263
+ const weekly = windowUsage(rl.secondary, eventTs);
264
+ if (p5h || weekly) result = { p5h, weekly, at: eventTs };
265
+ } catch {
266
+ /* malformed line */
267
+ }
268
+ }
269
+ }
270
+ return result;
271
+ }
272
+
273
+ // After running codex for an account, persist the freshest usage snapshot.
274
+ function recordUsage(name, profile, sinceMs) {
275
+ const usage = scanUsage(path.join(profile, 'sessions'), sinceMs);
276
+ if (usage) store.saveUsage(name, usage);
277
+ return usage;
278
+ }
279
+
280
+ // Was a session rollout created/updated during this run? (drives whether a
281
+ // rotated retry can "exec resume" instead of restarting from scratch)
282
+ function sessionTouchedSince(profile, sinceMs) {
283
+ return listRolloutsSince(path.join(profile, 'sessions'), sinceMs).length > 0;
284
+ }
285
+
200
286
  const LIMIT_RE =
201
- /usage limit|rate limit|too many requests|quota (?:exceeded|reached)|\b429\b|hit your (?:usage|weekly|5h) limit/i;
287
+ /usage[ _]limit|rate[ _]limit|too many requests|quota (?:exceeded|reached)|\b429\b|hit your (?:usage|weekly|5h) limit/i;
288
+
289
+ // Credential failures (revoked/invalidated tokens) — re-login required, so
290
+ // rotation should skip this account and tell the user how to fix it.
291
+ const AUTH_RE =
292
+ /refresh[ _]token[ _](?:was )?(?:revoked|invalidated)|token_invalidated|please log ?out and sign in again|please try signing in again|401 unauthorized/i;
293
+
294
+ function looksAuthFailed(output) {
295
+ return AUTH_RE.test(output || '');
296
+ }
202
297
 
203
- function looksRateLimited(output) {
204
- return LIMIT_RE.test(output || '');
298
+ function looksRateLimited(output, extraPatterns = []) {
299
+ if (LIMIT_RE.test(output || '')) return true;
300
+ for (const p of extraPatterns) {
301
+ try {
302
+ if (new RegExp(p, 'i').test(output || '')) return true;
303
+ } catch {
304
+ /* invalid user pattern — ignore */
305
+ }
306
+ }
307
+ return false;
205
308
  }
206
309
 
207
310
  // Try to extract "try again in 2 hours 30 minutes" style hints; fall back
@@ -219,5 +322,9 @@ module.exports = {
219
322
  buildProfile,
220
323
  runCodex,
221
324
  looksRateLimited,
325
+ looksAuthFailed,
222
326
  limitCooldownMs,
327
+ scanUsage,
328
+ recordUsage,
329
+ sessionTouchedSince,
223
330
  };