@carjms/codexswitch 0.1.0 → 0.3.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/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
@@ -119,16 +132,31 @@ function cmdList() {
119
132
  return;
120
133
  }
121
134
  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']));
135
+ const meta = store.loadMeta();
136
+ const pct = (win) => (win && typeof win.pct === 'number' ? `${Math.round(win.pct)}%` : '-');
137
+ const rows = accounts.map((a) => {
138
+ const over = store.overThreshold(a, meta, now);
139
+ const status = a.disabled
140
+ ? 'disabled'
141
+ : a.limitedUntil && a.limitedUntil > now
142
+ ? `limited ${fmtRemaining(a.limitedUntil)}`
143
+ : over
144
+ ? `over-${over}`
145
+ : 'ok';
146
+ return [
147
+ a.active ? '*' : '',
148
+ a.name,
149
+ a.email,
150
+ a.plan,
151
+ a.priority,
152
+ status,
153
+ pct(a.usage && a.usage.p5h),
154
+ pct(a.usage && a.usage.weekly),
155
+ fmtDate(a.lastRefresh),
156
+ ];
157
+ });
158
+ out(table(rows, ['', 'name', 'email', 'plan', 'prio', 'status', '5h', 'week', 'token refreshed']));
159
+ out(`(rotate threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}% — change with "codexswitch threshold")`);
132
160
  }
133
161
 
134
162
  function cmdUse(args) {
@@ -164,12 +192,32 @@ function cmdCurrent() {
164
192
  }
165
193
 
166
194
  function cmdNext() {
167
- const meta = store.loadMeta();
168
- const next = store.pickAccount(meta.active ? [meta.active] : []);
195
+ const next = store.nextAccount();
169
196
  if (!next) throw new Error('no other usable account available');
170
197
  cmdUse([next.name]);
171
198
  }
172
199
 
200
+ // Register an API-key account (Console/platform billing instead of a
201
+ // ChatGPT plan). The key is stored only inside the account's auth.json.
202
+ function cmdAddKey(args) {
203
+ const name = requireArg(args, 0, 'account name');
204
+ const key = args[1] || process.env.OPENAI_API_KEY;
205
+ if (!key) {
206
+ throw new Error(
207
+ 'missing API key: pass it as the second argument or set OPENAI_API_KEY in the environment'
208
+ );
209
+ }
210
+ if (!/^sk-/.test(key)) throw new Error('that does not look like an OpenAI API key (should start with "sk-")');
211
+ storeAccount(name, {
212
+ auth_mode: 'apikey',
213
+ OPENAI_API_KEY: key,
214
+ tokens: null,
215
+ last_refresh: null,
216
+ });
217
+ const meta = store.loadMeta();
218
+ if (!meta.active) cmdUse([name]);
219
+ }
220
+
173
221
  function cmdRemove(args) {
174
222
  const name = requireArg(args, 0, 'account name');
175
223
  if (!store.accountExists(name)) throw new Error(`no such account: ${name}`);
@@ -230,6 +278,169 @@ function cmdSync() {
230
278
  out(updated ? `synced refreshed tokens into "${updated}"` : 'nothing to sync');
231
279
  }
232
280
 
281
+ // Set the rotation order in one go: listed accounts get priority 0..k-1,
282
+ // unlisted accounts follow after in their current order.
283
+ function cmdOrder(args) {
284
+ const accounts = store.listAccounts();
285
+ if (args.length === 0) {
286
+ if (accounts.length === 0) {
287
+ out('no accounts yet');
288
+ return;
289
+ }
290
+ accounts.forEach((a, i) => out(`${i + 1}. ${a.name}${a.active ? ' (active)' : ''}`));
291
+ return;
292
+ }
293
+ for (const name of args) {
294
+ if (!store.accountExists(name)) throw new Error(`no such account: ${name}`);
295
+ }
296
+ const dupe = args.find((n, i) => args.indexOf(n) !== i);
297
+ if (dupe) throw new Error(`account listed twice: ${dupe}`);
298
+ const meta = store.loadMeta();
299
+ let prio = 0;
300
+ for (const name of args) {
301
+ meta.accounts[name] = { ...(meta.accounts[name] || {}), priority: prio++ };
302
+ }
303
+ for (const a of accounts) {
304
+ if (args.includes(a.name)) continue;
305
+ meta.accounts[a.name] = { ...(meta.accounts[a.name] || {}), priority: prio++ };
306
+ }
307
+ store.saveMeta(meta);
308
+ out('rotation order set:');
309
+ store.listAccounts().forEach((a, i) => out(`${i + 1}. ${a.name}`));
310
+ }
311
+
312
+ function cmdModel(args) {
313
+ const meta = store.loadMeta();
314
+ if (args[0] == null) {
315
+ out(`model: ${meta.model || '(codex default)'}`);
316
+ return;
317
+ }
318
+ if (args[0] === 'default' || args[0] === 'clear') {
319
+ delete meta.model;
320
+ store.saveMeta(meta);
321
+ out('model reset to codex default');
322
+ return;
323
+ }
324
+ meta.model = args[0];
325
+ store.saveMeta(meta);
326
+ out(`default model set to "${meta.model}" (applied to run/exec)`);
327
+ }
328
+
329
+ // Rotate-early thresholds: how full the 5h / weekly window may get before
330
+ // the account is skipped in rotation.
331
+ function cmdThreshold(args) {
332
+ const meta = store.loadMeta();
333
+ if (args.length === 0) {
334
+ out(`threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`);
335
+ out('usage: codexswitch threshold <5h%> [weekly%] (one value sets both)');
336
+ return;
337
+ }
338
+ const parse = (s) => {
339
+ const n = parseInt(s, 10);
340
+ if (Number.isNaN(n) || n < 1 || n > 100) throw new Error(`threshold must be 1-100, got "${s}"`);
341
+ return n;
342
+ };
343
+ meta.threshold5h = parse(args[0]);
344
+ meta.thresholdWeekly = args[1] != null ? parse(args[1]) : meta.threshold5h;
345
+ store.saveMeta(meta);
346
+ out(`threshold set: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`);
347
+ }
348
+
349
+ function cmdPatterns(args) {
350
+ const meta = store.loadMeta();
351
+ const [sub, ...rest] = args;
352
+ if (!sub) {
353
+ out('built-in: usage limit / rate limit / too many requests / quota exceeded / 429');
354
+ if (meta.limitPatterns.length === 0) out('custom: (none)');
355
+ else meta.limitPatterns.forEach((p, i) => out(`custom ${i + 1}: ${p}`));
356
+ return;
357
+ }
358
+ if (sub === 'add') {
359
+ const pattern = rest.join(' ');
360
+ if (!pattern) throw new Error('usage: codexswitch patterns add <regex>');
361
+ new RegExp(pattern, 'i'); // validate — throws on bad regex
362
+ meta.limitPatterns.push(pattern);
363
+ store.saveMeta(meta);
364
+ out(`added pattern: ${pattern}`);
365
+ return;
366
+ }
367
+ if (sub === 'remove') {
368
+ const key = rest.join(' ');
369
+ const idx = /^\d+$/.test(key) ? parseInt(key, 10) - 1 : meta.limitPatterns.indexOf(key);
370
+ if (idx < 0 || idx >= meta.limitPatterns.length) throw new Error(`no such pattern: ${key}`);
371
+ const [removed] = meta.limitPatterns.splice(idx, 1);
372
+ store.saveMeta(meta);
373
+ out(`removed pattern: ${removed}`);
374
+ return;
375
+ }
376
+ throw new Error('usage: codexswitch patterns [add <regex> | remove <n|regex>]');
377
+ }
378
+
379
+ function cmdExport(args) {
380
+ const file = requireArg(args, 0, 'output file path');
381
+ const accounts = {};
382
+ for (const a of store.listAccounts()) accounts[a.name] = store.readAccountAuth(a.name);
383
+ if (Object.keys(accounts).length === 0) throw new Error('no accounts to export');
384
+ writeJSONAtomic(path.resolve(file), { version: 1, exportedAt: new Date().toISOString(), meta: store.loadMeta(), accounts }, 0o600);
385
+ out(`exported ${Object.keys(accounts).length} account(s) to ${file}`);
386
+ out('WARNING: this file contains login tokens — treat it like a password.');
387
+ }
388
+
389
+ function cmdRestore(args) {
390
+ const file = requireArg(args, 0, 'backup file path');
391
+ const data = readJSONSafe(path.resolve(file));
392
+ if (!data || data.version !== 1 || !data.accounts) throw new Error('not a codexswitch backup file');
393
+ store.ensureDirs();
394
+ const meta = store.loadMeta();
395
+ let n = 0;
396
+ for (const [name, auth] of Object.entries(data.accounts)) {
397
+ store.writeAccountAuth(name, auth);
398
+ const backup = (data.meta && data.meta.accounts && data.meta.accounts[name]) || {};
399
+ meta.accounts[name] = { ...backup, ...(meta.accounts[name] || {}) };
400
+ n++;
401
+ }
402
+ if (!meta.active && data.meta && data.meta.active) meta.active = data.meta.active;
403
+ store.saveMeta(meta);
404
+ out(`restored ${n} account(s) from ${file}`);
405
+ }
406
+
407
+ function cmdNames() {
408
+ for (const a of store.listAccounts()) out(a.name);
409
+ }
410
+
411
+ const COMMANDS =
412
+ 'login import add-key list use current next run exec order model remove rename ' +
413
+ 'enable disable priority clear-limit cooldown threshold patterns export restore sync completion help';
414
+
415
+ function cmdCompletion(args) {
416
+ const shell = args[0];
417
+ if (shell !== 'bash' && shell !== 'zsh') {
418
+ throw new Error('usage: codexswitch completion <bash|zsh> (append the output to your shell rc file)');
419
+ }
420
+ const script = `# codexswitch completion (${shell})
421
+ ${shell === 'zsh' ? 'autoload -Uz bashcompinit && bashcompinit\n' : ''}_codexswitch() {
422
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
423
+ if [ "\$COMP_CWORD" -eq 1 ]; then
424
+ COMPREPLY=( \$(compgen -W "${COMMANDS}" -- "\$cur") )
425
+ else
426
+ COMPREPLY=( \$(compgen -W "\$(codexswitch names 2>/dev/null)" -- "\$cur") )
427
+ fi
428
+ }
429
+ complete -F _codexswitch codexswitch cxs`;
430
+ out(script);
431
+ }
432
+
433
+ // Final argv for "codex exec": inject --skip-git-repo-check so it works in
434
+ // non-git folders, and the configured default model — unless the user
435
+ // already passed their own flags.
436
+ function buildExecArgs(rest, meta) {
437
+ const args = [...rest];
438
+ if (!args.includes('--skip-git-repo-check')) args.unshift('--skip-git-repo-check');
439
+ const hasModel = args.some((a) => a === '-m' || a === '--model' || a.startsWith('--model='));
440
+ if (meta.model && !hasModel) args.unshift('-m', meta.model);
441
+ return args;
442
+ }
443
+
233
444
  async function cmdRun(args) {
234
445
  let name = null;
235
446
  let rest = args;
@@ -244,28 +455,43 @@ async function cmdRun(args) {
244
455
  name = meta.active && store.accountExists(meta.active) ? meta.active : picked.name;
245
456
  }
246
457
  if (rest[0] === '--') rest = rest.slice(1);
458
+ const meta = store.loadMeta();
459
+ if (rest[0] === 'exec') {
460
+ rest = ['exec', ...buildExecArgs(rest.slice(1), meta)];
461
+ } else if (meta.model && rest.length === 0) {
462
+ rest = ['-m', meta.model];
463
+ }
247
464
  out(`[codexswitch] running codex as "${name}"`);
465
+ const startTs = Date.now();
248
466
  const res = await runner.runCodex(name, rest);
467
+ runner.recordUsage(name, res.profile, startTs);
249
468
  return res.code;
250
469
  }
251
470
 
471
+ const RESUME_PROMPT = 'Continue the previous task from exactly where it left off.';
472
+
252
473
  async function cmdExec(args) {
253
474
  let explicit = null;
475
+ let allowResume = true;
254
476
  const rest = [];
255
477
  for (let i = 0; i < args.length; i++) {
256
478
  if (args[i] === '-a' || args[i] === '--account') {
257
479
  explicit = requireArg(args, ++i, 'account name after --account');
480
+ } else if (args[i] === '--no-resume') {
481
+ allowResume = false;
258
482
  } else {
259
483
  rest.push(args[i]);
260
484
  }
261
485
  }
262
486
  if (explicit && !store.accountExists(explicit)) throw new Error(`no such account: ${explicit}`);
487
+ if (rest[0] === 'resume') allowResume = false; // user drives resume themselves
263
488
 
264
489
  const meta = store.loadMeta();
265
490
  const total = store.listAccounts().length;
266
491
  if (total === 0) throw new Error('no accounts — add one with "codexswitch login"');
267
492
 
268
493
  const tried = [];
494
+ let useResume = false;
269
495
  for (let attempt = 0; attempt < total; attempt++) {
270
496
  let name;
271
497
  if (explicit && attempt === 0) {
@@ -276,24 +502,52 @@ async function cmdExec(args) {
276
502
  name = picked.name;
277
503
  }
278
504
  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 });
505
+ console.error(
506
+ `[codexswitch] exec as "${name}"${attempt > 0 ? ` (attempt ${attempt + 1}${useResume ? ', resuming session' : ''})` : ''}`
507
+ );
508
+ // On rotation, continue the same session with the next account instead
509
+ // of restarting the whole prompt — the session files are shared.
510
+ const codexArgs = useResume
511
+ ? ['exec', 'resume', '--last', ...buildExecArgs([], meta), RESUME_PROMPT]
512
+ : ['exec', ...buildExecArgs(rest, meta)];
513
+ const startTs = Date.now();
514
+ const res = await runner.runCodex(name, codexArgs, { capture: true });
515
+ runner.recordUsage(name, res.profile, startTs);
281
516
  if (res.code === 0) {
282
517
  store.clearLimited(name);
518
+ warnIfOverThreshold(name);
283
519
  return 0;
284
520
  }
285
- if (runner.looksRateLimited(res.output)) {
521
+ if (runner.looksRateLimited(res.output, meta.limitPatterns)) {
286
522
  const until = Date.now() + runner.limitCooldownMs(res.output, meta.cooldownMinutes);
287
523
  store.markLimited(name, until);
288
- console.error(`[codexswitch] "${name}" hit a usage/rate limit (paused until ${fmtDate(until)}) — rotating`);
524
+ if (allowResume && !useResume && runner.sessionTouchedSince(res.profile, startTs)) {
525
+ useResume = true;
526
+ }
527
+ console.error(
528
+ `[codexswitch] "${name}" hit a usage/rate limit (paused until ${fmtDate(until)}) — rotating${useResume ? ' and resuming the session' : ''}`
529
+ );
289
530
  continue;
290
531
  }
291
532
  return res.code; // real failure, don't burn other accounts on it
292
533
  }
293
- console.error('[codexswitch] all accounts are rate-limited or disabled');
534
+ console.error('[codexswitch] all accounts are rate-limited, over threshold, or disabled');
294
535
  return 2;
295
536
  }
296
537
 
538
+ function warnIfOverThreshold(name) {
539
+ const meta = store.loadMeta();
540
+ const account = store.listAccounts().find((a) => a.name === name);
541
+ if (!account) return;
542
+ const blocked = store.overThreshold(account, meta);
543
+ if (blocked) {
544
+ const pct = blocked === '5h' ? account.usage.p5h.pct : account.usage.weekly.pct;
545
+ console.error(
546
+ `[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`
547
+ );
548
+ }
549
+ }
550
+
297
551
  async function main(argv) {
298
552
  const [cmd, ...args] = argv;
299
553
  switch (cmd) {
@@ -333,10 +587,29 @@ async function main(argv) {
333
587
  return 0;
334
588
  case 'priority':
335
589
  return cmdPriority(args), 0;
590
+ case 'order':
591
+ return cmdOrder(args), 0;
592
+ case 'model':
593
+ return cmdModel(args), 0;
594
+ case 'add-key':
595
+ case 'apikey':
596
+ return cmdAddKey(args), 0;
597
+ case 'threshold':
598
+ return cmdThreshold(args), 0;
599
+ case 'patterns':
600
+ return cmdPatterns(args), 0;
601
+ case 'export':
602
+ return cmdExport(args), 0;
603
+ case 'restore':
604
+ return cmdRestore(args), 0;
605
+ case 'names':
606
+ return cmdNames(), 0;
607
+ case 'completion':
608
+ return cmdCompletion(args), 0;
336
609
  case 'clear-limit': {
337
610
  const name = requireArg(args, 0, 'account name');
338
- store.clearLimited(name);
339
- out(`cleared rate-limit record for "${name}"`);
611
+ store.clearLimited(name, { includeUsage: true });
612
+ out(`cleared rate-limit and usage records for "${name}"`);
340
613
  return 0;
341
614
  }
342
615
  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,106 @@ 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;
202
288
 
203
- function looksRateLimited(output) {
204
- return LIMIT_RE.test(output || '');
289
+ function looksRateLimited(output, extraPatterns = []) {
290
+ if (LIMIT_RE.test(output || '')) return true;
291
+ for (const p of extraPatterns) {
292
+ try {
293
+ if (new RegExp(p, 'i').test(output || '')) return true;
294
+ } catch {
295
+ /* invalid user pattern — ignore */
296
+ }
297
+ }
298
+ return false;
205
299
  }
206
300
 
207
301
  // Try to extract "try again in 2 hours 30 minutes" style hints; fall back
@@ -220,4 +314,7 @@ module.exports = {
220
314
  runCodex,
221
315
  looksRateLimited,
222
316
  limitCooldownMs,
317
+ scanUsage,
318
+ recordUsage,
319
+ sessionTouchedSince,
223
320
  };
package/src/store.js CHANGED
@@ -30,6 +30,9 @@ function loadMeta() {
30
30
  if (!meta.accounts) meta.accounts = {};
31
31
  if (!('active' in meta)) meta.active = null;
32
32
  if (!meta.cooldownMinutes) meta.cooldownMinutes = 60;
33
+ if (!meta.threshold5h) meta.threshold5h = 95;
34
+ if (!meta.thresholdWeekly) meta.thresholdWeekly = 95;
35
+ if (!Array.isArray(meta.limitPatterns)) meta.limitPatterns = [];
33
36
  return meta;
34
37
  }
35
38
 
@@ -83,6 +86,7 @@ function listAccounts() {
83
86
  priority: m.priority ?? 0,
84
87
  disabled: !!m.disabled,
85
88
  limitedUntil: m.limitedUntil || null,
89
+ usage: m.usage || null,
86
90
  active: meta.active === name,
87
91
  };
88
92
  })
@@ -122,24 +126,78 @@ function markLimited(name, untilTs) {
122
126
  saveMeta(meta);
123
127
  }
124
128
 
125
- function clearLimited(name) {
129
+ function clearLimited(name, { includeUsage = false } = {}) {
126
130
  const meta = loadMeta();
127
- if (meta.accounts[name] && meta.accounts[name].limitedUntil) {
128
- delete meta.accounts[name].limitedUntil;
131
+ const m = meta.accounts[name];
132
+ if (m && (m.limitedUntil || (includeUsage && m.usage))) {
133
+ delete m.limitedUntil;
134
+ if (includeUsage) delete m.usage;
129
135
  saveMeta(meta);
130
136
  }
131
137
  }
132
138
 
139
+ function saveUsage(name, usage) {
140
+ const meta = loadMeta();
141
+ if (!meta.accounts[name]) meta.accounts[name] = {};
142
+ meta.accounts[name].usage = usage;
143
+ saveMeta(meta);
144
+ }
145
+
146
+ // An account is "over threshold" when its last recorded 5h/weekly usage
147
+ // exceeds the configured percentage and the window has not reset yet.
148
+ // Returns the blocking window ('5h' | 'weekly') or null.
149
+ function overThreshold(account, meta, now = Date.now()) {
150
+ const u = account.usage;
151
+ if (!u) return null;
152
+ const checks = [
153
+ ['5h', u.p5h, meta.threshold5h],
154
+ ['weekly', u.weekly, meta.thresholdWeekly],
155
+ ];
156
+ for (const [label, win, threshold] of checks) {
157
+ if (!win || typeof win.pct !== 'number') continue;
158
+ if (win.pct < threshold) continue;
159
+ // Trust the snapshot only while its window can still be in effect.
160
+ if (win.resetAt) {
161
+ if (win.resetAt > now) return label;
162
+ } else if (win.windowMinutes && u.at + win.windowMinutes * 60000 > now) {
163
+ return label;
164
+ }
165
+ }
166
+ return null;
167
+ }
168
+
169
+ function isUsable(account, meta, now = Date.now()) {
170
+ if (account.disabled) return false;
171
+ if (account.limitedUntil && account.limitedUntil > now) return false;
172
+ if (overThreshold(account, meta, now)) return false;
173
+ return true;
174
+ }
175
+
133
176
  // Pick the best usable account: enabled, not currently rate-limited,
134
- // lowest priority number first. `exclude` skips accounts already tried.
177
+ // lowest priority number first (the order set by "order"/"priority").
178
+ // The active account only wins ties within the same priority, so an
179
+ // explicit order is always respected. `exclude` skips accounts already tried.
135
180
  function pickAccount(exclude = []) {
136
181
  const now = Date.now();
137
- const usable = listAccounts().filter(
138
- (a) => !a.disabled && !exclude.includes(a.name) && (!a.limitedUntil || a.limitedUntil <= now)
139
- );
182
+ const meta = loadMeta();
183
+ const usable = listAccounts().filter((a) => !exclude.includes(a.name) && isUsable(a, meta, now));
140
184
  if (usable.length === 0) return null;
141
- const active = usable.find((a) => a.active);
142
- return active || usable[0];
185
+ const group = usable.filter((a) => a.priority === usable[0].priority);
186
+ return group.find((a) => a.active) || group[0];
187
+ }
188
+
189
+ // Next usable account after the active one, following priority order and
190
+ // wrapping around — so repeated "next" cycles through the configured order.
191
+ function nextAccount() {
192
+ const all = listAccounts();
193
+ const now = Date.now();
194
+ const meta = loadMeta();
195
+ const start = all.findIndex((a) => a.active);
196
+ for (let i = 1; i <= all.length; i++) {
197
+ const cand = all[(start + i) % all.length];
198
+ if (!cand.active && isUsable(cand, meta, now)) return cand;
199
+ }
200
+ return null;
143
201
  }
144
202
 
145
203
  module.exports = {
@@ -155,7 +213,11 @@ module.exports = {
155
213
  syncBackFrom,
156
214
  markLimited,
157
215
  clearLimited,
216
+ saveUsage,
217
+ overThreshold,
218
+ isUsable,
158
219
  pickAccount,
220
+ nextAccount,
159
221
  ensureDirs() {
160
222
  const p = paths();
161
223
  ensureDir(p.home);