@carjms/codexswitch 0.3.5 → 0.5.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/README.ko.md CHANGED
@@ -205,13 +205,17 @@ cxs list # 계정별 5h/week 사용량 % 확인
205
205
  | `cxs add-key <이름> [키]` | OpenAI API 키 계정 등록 (키 생략 시 `$OPENAI_API_KEY` 사용) |
206
206
  | `cxs list` | 계정 목록: 활성 표시, 이메일, 플랜, 우선순위, 한도 상태, 사용량 % |
207
207
  | `cxs usage [이름]` | 계정별 사용량 대시보드: 5시간/주간 게이지 바, 리셋 카운트다운, 다음 로테이션 계정 (별칭: `status`) |
208
+ | `cxs chat` | **대화형 입력창 (Claude Code 스타일)**: 매 턴이 로테이션을 거치고 같은 codex 세션을 이어가므로, 계정이 바뀌어도 대화가 유지됨. 내부 명령: `/usage /use /next /model /new /quit` |
209
+ | `cxs watch` | 실시간 인터랙티브 대시보드 — 5초마다 갱신; 키: `↑/↓` 선택, `s` 전환, `e` 활성/비활성, `p` 프로브, `q` 종료 |
210
+ | `cxs probe [이름]` | 계정마다 최소 요청 1회를 보내 사용량 게이지 워밍업 (토큰 소량 소모) |
211
+ | `cxs log [개수]` | 최근 활동 기록: 계정 전환, 한도 도달, 로테이션, 프로브 |
208
212
  | `cxs use <이름>` | 활성 계정 전환 |
209
213
  | `cxs current` | 현재 활성 계정 확인 |
210
214
  | `cxs next` | 설정된 순서의 다음 계정으로 전환 (끝에 오면 처음으로 순환) |
211
215
  | `cxs run [이름] [인자...]` | 전환 없이 특정 계정으로 codex 실행 (격리 환경) |
212
216
  | `cxs exec [인자...]` | `codex exec` + 한도 도달 시 순서대로 자동 로테이션 — 다음 계정이 같은 세션을 이어받음. git 저장소가 아닌 폴더에서도 동작 |
213
217
  | `cxs exec -a <이름> ...` | 특정 계정부터 exec 시작 (`--no-resume`: 이어받기 대신 처음부터 재시도) |
214
- | `cxs order [이름들...]` | 로테이션 순서를 번에 설정 (인자 없으면 현재 순서 표시) |
218
+ | `cxs order [이름들...]` | 로테이션 순서 고정(pin); 나열하지 않은 계정은 **주간 리셋이 빠른 순**으로 자동 소진 (use-or-lose) |
215
219
  | `cxs model [모델명]` | `run`/`exec`에 자동 적용할 기본 모델 설정 (`default`로 초기화) |
216
220
  | `cxs threshold [5h%] [주간%]` | 사용량이 이 퍼센트에 도달하면 다음 계정으로 전환 (기본 95, 값 하나면 둘 다) |
217
221
  | `cxs patterns [add/remove]` | 한도 감지에 쓸 커스텀 정규식 패턴 추가/삭제 |
@@ -222,7 +226,7 @@ cxs list # 계정별 5h/week 사용량 % 확인
222
226
  | `cxs remove <이름>` | 계정 삭제 |
223
227
  | `cxs rename <옛이름> <새이름>` | 계정 이름 변경 |
224
228
  | `cxs disable / enable <이름>` | 로테이션에서 임시 제외 / 복귀 |
225
- | `cxs priority <이름> <숫자>` | 계정 하나의 우선순위만 변경 (낮을수록 먼저, 기본 0) |
229
+ | `cxs priority <이름> <숫자\|auto>` | 계정 하나의 순위 고정, `auto`면 고정 해제(리셋 빠른 자동 로테이션) |
226
230
  | `cxs clear-limit <이름>` | 기록된 한도 상태 수동 해제 |
227
231
  | `cxs cooldown [분]` | 한도 감지 시 기본 대기 시간 조회/설정 (기본 60분) |
228
232
  | `cxs sync` | codex가 갱신한 토큰을 저장소에 반영 |
package/README.md CHANGED
@@ -207,13 +207,17 @@ cxs list # per-account 5h/week usage columns
207
207
  | `cxs add-key <name> [key]` | Register an OpenAI API-key account (falls back to `$OPENAI_API_KEY`) |
208
208
  | `cxs list` | List accounts: active marker, email, plan, priority, limit status, usage % |
209
209
  | `cxs usage [name]` | Per-account usage dashboard: 5h/weekly gauge bars, reset countdowns, next rotation pick (alias: `status`) |
210
+ | `cxs chat` | **Interactive prompt loop (Claude Code-style)**: each turn runs through rotation and resumes the same codex session, so the conversation survives account switches. Slash commands inside: `/usage /use /next /model /new /quit` |
211
+ | `cxs watch` | Live interactive dashboard — refreshes every 5s; keys: `↑/↓` select, `s` switch, `e` enable/disable, `p` probe, `q` quit |
212
+ | `cxs probe [name]` | Warm up the usage gauges with one minimal request per account (costs a few tokens) |
213
+ | `cxs log [count]` | Recent activity: account switches, limits hit, rotations, probes |
210
214
  | `cxs use <name>` | Switch the active account |
211
215
  | `cxs current` | Show the active account |
212
216
  | `cxs next` | Switch to the next account in rotation order (wraps around) |
213
217
  | `cxs run [name] [args...]` | Run codex as a specific account without switching (isolated env) |
214
218
  | `cxs exec [args...]` | `codex exec` + automatic rotation on usage limits — the next account resumes the same session; works outside git repos |
215
219
  | `cxs exec -a <name> ...` | Start exec with a specific account (`--no-resume`: restart instead of resuming) |
216
- | `cxs order [names...]` | Set the rotation order in one command (no args: show it) |
220
+ | `cxs order [names...]` | Pin the rotation order; unlisted accounts rotate by **soonest weekly reset** (use-or-lose) |
217
221
  | `cxs model [name]` | Set the default model injected into `run`/`exec` (`default` to reset) |
218
222
  | `cxs threshold [5h%] [wk%]` | Rotate to the next account when usage reaches these percents (default 95; one value sets both) |
219
223
  | `cxs patterns [add/remove]` | Custom regex patterns treated as rate-limit errors |
@@ -224,7 +228,7 @@ cxs list # per-account 5h/week usage columns
224
228
  | `cxs remove <name>` | Delete an account |
225
229
  | `cxs rename <old> <new>` | Rename an account |
226
230
  | `cxs disable / enable <name>` | Temporarily exclude from / restore to rotation |
227
- | `cxs priority <name> <n>` | Change one account's priority (lower = preferred, default 0) |
231
+ | `cxs priority <name> <n\|auto>` | Pin one account's priority, or `auto` to unpin (use-or-lose rotation) |
228
232
  | `cxs clear-limit <name>` | Manually clear a recorded rate-limit |
229
233
  | `cxs cooldown [minutes]` | Show/set the default cooldown after a limit is detected (default 60) |
230
234
  | `cxs sync` | Save tokens refreshed by codex back into the store |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carjms/codexswitch",
3
- "version": "0.3.5",
3
+ "version": "0.5.0",
4
4
  "description": "Multi-account manager for OpenAI Codex CLI with quota-based rotation (inspired by KarpelesLab/teamclaude)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -17,6 +17,10 @@ Accounts
17
17
  list list stored accounts (alias: accounts)
18
18
  usage [name] per-account 5h/weekly usage gauges with reset times
19
19
  (alias: status)
20
+ watch live usage dashboard, refreshes every 5s (q to quit)
21
+ probe [name] warm up quota gauges with a minimal request per
22
+ account (costs a few tokens; alias: warmup)
23
+ log [count] recent activity: switches, limits, rotations
20
24
  use <name> make <name> the active account in ~/.codex
21
25
  current show the active account
22
26
  next switch to the next account in rotation order (wraps around)
@@ -24,11 +28,16 @@ Accounts
24
28
  rename <old> <new> rename a stored account
25
29
  enable <name> re-enable a disabled account
26
30
  disable <name> temporarily exclude an account from rotation
27
- order [name...] show or set the rotation order in one command
28
- priority <name> <n> set one account's priority (lower = preferred, default 0)
31
+ order [name...] pin the rotation order in one command; accounts not
32
+ listed rotate by soonest weekly reset (use-or-lose)
33
+ priority <name> <n|auto> pin one account's priority, or "auto" to unpin
29
34
  clear-limit <name> forget a recorded rate-limit for an account
30
35
 
31
36
  Running codex
37
+ chat interactive prompt loop (Claude Code-style): each turn
38
+ runs through rotation and resumes the same session,
39
+ so the conversation survives account switches
40
+ (/usage /use /next /model /new /quit inside)
32
41
  run [name] [args...] run codex as <name> (or active account) in an isolated
33
42
  per-account CODEX_HOME; config/sessions are shared
34
43
  exec [args...] run "codex exec ..." and auto-rotate through accounts in
@@ -86,7 +95,7 @@ function storeAccount(name, auth) {
86
95
  const existed = store.accountExists(name);
87
96
  store.writeAccountAuth(name, auth);
88
97
  const meta = store.loadMeta();
89
- if (!meta.accounts[name]) meta.accounts[name] = { priority: 0, addedAt: Date.now() };
98
+ if (!meta.accounts[name]) meta.accounts[name] = { addedAt: Date.now() };
90
99
  // fresh credentials fix whatever got the account disabled or limited
91
100
  delete meta.accounts[name].disabled;
92
101
  delete meta.accounts[name].limitedUntil;
@@ -162,7 +171,7 @@ function cmdList() {
162
171
  a.active ? ui.bold(a.name) : a.name,
163
172
  ui.dim(a.email || '-'),
164
173
  a.plan,
165
- a.priority,
174
+ a.priority == null ? ui.dim('auto') : a.priority,
166
175
  status,
167
176
  pct(a.usage && a.usage.p5h, meta.threshold5h),
168
177
  pct(a.usage && a.usage.weekly, meta.thresholdWeekly),
@@ -182,9 +191,10 @@ function cmdUse(args) {
182
191
  writeJSONAtomic(p.authPath, auth, 0o600);
183
192
  const meta = store.loadMeta();
184
193
  meta.active = name;
185
- if (!meta.accounts[name]) meta.accounts[name] = { priority: 0 };
194
+ if (!meta.accounts[name]) meta.accounts[name] = {};
186
195
  meta.accounts[name].lastUsed = Date.now();
187
196
  store.saveMeta(meta);
197
+ store.logEvent('switch', `now using "${name}"`);
188
198
  const info = authInfo(auth);
189
199
  out(ui.ok(`now using ${ui.bold(`"${name}"`)}${info.email ? ui.dim(` (${info.email}, ${info.plan || 'unknown plan'})`) : ''}`));
190
200
  }
@@ -271,10 +281,18 @@ function setFlag(name, patch) {
271
281
 
272
282
  function cmdPriority(args) {
273
283
  const name = requireArg(args, 0, 'account name');
274
- const n = parseInt(requireArg(args, 1, 'priority number'), 10);
275
- if (Number.isNaN(n)) throw new Error('priority must be a number');
284
+ const value = requireArg(args, 1, 'priority number or "auto"');
285
+ if (value === 'auto') {
286
+ const meta = store.loadMeta();
287
+ if (meta.accounts[name]) delete meta.accounts[name].priority;
288
+ store.saveMeta(meta);
289
+ out(ui.ok(`"${name}" unpinned — rotates by soonest weekly reset (use-or-lose)`));
290
+ return;
291
+ }
292
+ const n = parseInt(value, 10);
293
+ if (Number.isNaN(n)) throw new Error('priority must be a number or "auto"');
276
294
  setFlag(name, { priority: n });
277
- out(`priority of "${name}" set to ${n}`);
295
+ out(ui.ok(`priority of "${name}" pinned to ${n}`));
278
296
  }
279
297
 
280
298
  function cmdCooldown(args) {
@@ -317,13 +335,16 @@ function cmdOrder(args) {
317
335
  for (const name of args) {
318
336
  meta.accounts[name] = { ...(meta.accounts[name] || {}), priority: prio++ };
319
337
  }
338
+ // accounts not listed become "auto" and rotate by soonest weekly reset
320
339
  for (const a of accounts) {
321
340
  if (args.includes(a.name)) continue;
322
- meta.accounts[a.name] = { ...(meta.accounts[a.name] || {}), priority: prio++ };
341
+ if (meta.accounts[a.name]) delete meta.accounts[a.name].priority;
323
342
  }
324
343
  store.saveMeta(meta);
325
- out('rotation order set:');
326
- store.listAccounts().forEach((a, i) => out(`${i + 1}. ${a.name}`));
344
+ out(ui.ok('rotation order set:'));
345
+ store.listAccounts().forEach((a, i) =>
346
+ out(`${i + 1}. ${a.name}${a.priority == null ? ui.dim(' (auto)') : ''}`)
347
+ );
327
348
  }
328
349
 
329
350
  function cmdModel(args) {
@@ -343,47 +364,195 @@ function cmdModel(args) {
343
364
  out(`default model set to "${meta.model}" (applied to run/exec)`);
344
365
  }
345
366
 
346
- // Per-account usage dashboard: gauge bars for the 5h / weekly windows with
347
- // reset countdowns, based on what codex recorded in its session files.
348
- function cmdUsage(args) {
349
- const meta = store.loadMeta();
350
- let accounts = store.listAccounts();
351
- if (args[0]) {
352
- accounts = accounts.filter((a) => a.name === args[0]);
353
- if (accounts.length === 0) throw new Error(`no such account: ${args[0]}`);
354
- }
355
- if (accounts.length === 0) {
356
- out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
357
- return;
358
- }
367
+ // Render the per-account usage view (gauge bars for the 5h / weekly windows
368
+ // with reset countdowns). `cursor` marks the selected row in watch mode.
369
+ function usageLines(accounts, meta, { cursor = -1 } = {}) {
359
370
  const now = Date.now();
371
+ const lines = [];
360
372
  const bar = (pct, threshold) => {
361
373
  const width = 20;
362
374
  const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
363
375
  const paint = pct >= threshold ? ui.red : pct >= 70 ? ui.yellow : ui.green;
364
376
  return `[${paint('█'.repeat(filled))}${ui.dim('░'.repeat(width - filled))}]`;
365
377
  };
366
- const line = (label, win, threshold) => {
378
+ const gauge = (label, win, threshold) => {
367
379
  if (!win || typeof win.pct !== 'number') return ` ${label} ${ui.dim('no data')}`;
368
380
  const pct = Math.round(win.pct);
369
381
  const reset = win.resetAt && win.resetAt > now ? ` resets in ${fmtRemaining(win.resetAt)}` : '';
370
382
  const overMark = win.pct >= threshold ? ` ${ui.red(`≥ threshold ${threshold}%`)}` : '';
371
383
  return ` ${label} ${bar(win.pct, threshold)} ${String(pct).padStart(3)}%${ui.dim(reset)}${overMark}`;
372
384
  };
373
- for (const a of accounts) {
385
+ accounts.forEach((a, i) => {
386
+ const sel = i === cursor ? ui.cyan('▶') : ' ';
374
387
  const mark = a.active ? ui.green('●') : ' ';
375
388
  const state = a.disabled ? ui.red(' [disabled]') : a.limitedUntil && a.limitedUntil > now ? ui.yellow(` [limited ${fmtRemaining(a.limitedUntil)}]`) : '';
376
- out(`${mark} ${ui.bold(a.name)}${ui.dim(` (${a.plan || a.email || '-'})`)}${state}`);
389
+ lines.push(`${cursor >= 0 ? sel : ''}${mark} ${ui.bold(a.name)}${ui.dim(` (${a.plan || a.email || '-'})`)}${state}`);
377
390
  if (!a.usage) {
378
- out(ui.dim(' no usage data yet — codex records it during runs (try "codexswitch run" once)'));
391
+ lines.push(ui.dim(' no usage data yet — codex records it during runs (try "codexswitch run" once)'));
379
392
  } else {
380
- out(line('5h ', a.usage.p5h, meta.threshold5h));
381
- out(line('weekly', a.usage.weekly, meta.thresholdWeekly));
382
- if (a.usage.at) out(ui.dim(` measured ${fmtDate(a.usage.at)}`));
393
+ lines.push(gauge('5h ', a.usage.p5h, meta.threshold5h));
394
+ lines.push(gauge('weekly', a.usage.weekly, meta.thresholdWeekly));
395
+ if (a.usage.at) lines.push(ui.dim(` measured ${fmtDate(a.usage.at)}`));
383
396
  }
384
- }
397
+ });
385
398
  const next = store.pickAccount();
386
- out(ui.dim(`\nrotation would pick: ${next ? next.name : '(none usable)'} · threshold 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`));
399
+ lines.push(ui.dim(`\nrotation would pick: ${next ? next.name : '(none usable)'} · threshold 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`));
400
+ return lines;
401
+ }
402
+
403
+ function cmdUsage(args) {
404
+ const meta = store.loadMeta();
405
+ let accounts = store.listAccounts();
406
+ if (args[0]) {
407
+ accounts = accounts.filter((a) => a.name === args[0]);
408
+ if (accounts.length === 0) throw new Error(`no such account: ${args[0]}`);
409
+ }
410
+ if (accounts.length === 0) {
411
+ out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
412
+ return;
413
+ }
414
+ for (const l of usageLines(accounts, meta)) out(l);
415
+ }
416
+
417
+ // Warm up quota measurements: send a minimal request per account so the
418
+ // usage gauges (and use-or-lose ordering) have data. Costs a few tokens.
419
+ async function cmdProbe(args) {
420
+ runner.assertCodexAvailable();
421
+ const meta = store.loadMeta();
422
+ let accounts = store.listAccounts().filter((a) => !a.disabled);
423
+ if (args[0]) {
424
+ accounts = accounts.filter((a) => a.name === args[0]);
425
+ if (accounts.length === 0) throw new Error(`no such account: ${args[0]}`);
426
+ }
427
+ if (accounts.length === 0) throw new Error('no enabled accounts to probe');
428
+ out(ui.info(ui.dim(`probing ${accounts.length} account(s) with a minimal request...`)));
429
+ let failures = 0;
430
+ for (const a of accounts) {
431
+ const line = await probeOne(a, meta);
432
+ out(line.text);
433
+ if (!line.ok) failures++;
434
+ }
435
+ return failures > 0 ? 1 : 0;
436
+ }
437
+
438
+ // Probe a single account; returns a printable result line.
439
+ async function probeOne(a, meta) {
440
+ const startTs = Date.now();
441
+ const res = await runner.runCodex(
442
+ a.name,
443
+ ['exec', ...buildExecArgs(['Reply with exactly: ok'], meta)],
444
+ { capture: true, silent: true }
445
+ );
446
+ const usage = runner.recordUsage(a.name, res.profile, startTs);
447
+ if (res.code !== 0) {
448
+ const why = runner.looksAuthFailed(res.output)
449
+ ? `login revoked — fix with "codexswitch login ${a.name}"`
450
+ : runner.looksRateLimited(res.output, meta.limitPatterns)
451
+ ? 'rate limited'
452
+ : `exit ${res.code}`;
453
+ store.logEvent('probe', `${a.name} failed: ${why}`);
454
+ return { ok: false, text: ui.fail(`${a.name}: ${why}`) };
455
+ }
456
+ store.logEvent('probe', `${a.name} measured`);
457
+ if (usage && usage.p5h) {
458
+ return { ok: true, text: ui.ok(`${a.name}: 5h ${Math.round(usage.p5h.pct)}%${usage.weekly ? ` / weekly ${Math.round(usage.weekly.pct)}%` : ''}`) };
459
+ }
460
+ return { ok: true, text: ui.warn(`${a.name}: reachable, but codex reported no usage data`) };
461
+ }
462
+
463
+ function cmdLog(args) {
464
+ const count = args[0] ? parseInt(args[0], 10) : 20;
465
+ if (Number.isNaN(count) || count < 1) throw new Error('usage: codexswitch log [count]');
466
+ const events = store.readEvents(count);
467
+ if (events.length === 0) {
468
+ out('no activity yet');
469
+ return;
470
+ }
471
+ const color = { limit: ui.yellow, auth: ui.red, probe: ui.cyan, switch: ui.green, exec: ui.green };
472
+ for (const e of events) {
473
+ const paint = color[e.type] || ((s) => s);
474
+ out(`${ui.dim(e.at.replace('T', ' ').slice(0, 19))} ${paint(e.type.padEnd(6))} ${e.message}`);
475
+ }
476
+ }
477
+
478
+ // Live interactive dashboard: usage gauges + recent activity, refreshed
479
+ // every 5s. Keyboard: up/down select, s switch, e enable/disable, p probe,
480
+ // r refresh, q quit.
481
+ async function cmdWatch() {
482
+ if (!process.stdout.isTTY) {
483
+ cmdUsage([]);
484
+ return 0;
485
+ }
486
+ let cursor = 0;
487
+ let message = '';
488
+ let busy = false;
489
+ const render = () => {
490
+ const meta = store.loadMeta();
491
+ const accounts = store.listAccounts();
492
+ if (cursor >= accounts.length) cursor = Math.max(0, accounts.length - 1);
493
+ process.stdout.write('\x1b[2J\x1b[H');
494
+ out(`${ui.bold('codexswitch watch')}${ui.dim(` ${new Date().toLocaleTimeString()} \u00b7 refreshes every 5s`)}\n`);
495
+ if (accounts.length === 0) {
496
+ out('no accounts yet \u2014 add one with "codexswitch login"');
497
+ } else {
498
+ for (const l of usageLines(accounts, meta, { cursor })) out(l);
499
+ }
500
+ const events = store.readEvents(4);
501
+ if (events.length > 0) {
502
+ out(ui.dim('\nrecent activity:'));
503
+ for (const e of events) out(ui.dim(` ${e.at.replace('T', ' ').slice(11, 19)} ${e.type}: ${e.message}`));
504
+ }
505
+ out(`\n${ui.dim('keys:')} ${ui.cyan('\u2191/\u2193')} select ${ui.cyan('s')} switch ${ui.cyan('e')} enable/disable ${ui.cyan('p')} probe ${ui.cyan('r')} refresh ${ui.cyan('q')} quit`);
506
+ if (message) out(message);
507
+ };
508
+ render();
509
+ const timer = setInterval(() => {
510
+ if (!busy) render();
511
+ }, 5000);
512
+ await new Promise((resolve) => {
513
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
514
+ process.stdin.resume();
515
+ process.stdin.on('data', async (b) => {
516
+ if (busy) return;
517
+ const s = b.toString();
518
+ const accounts = store.listAccounts();
519
+ const selected = accounts[cursor];
520
+ if (s === 'q' || s === '\u0003') {
521
+ clearInterval(timer);
522
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
523
+ process.stdin.pause();
524
+ resolve();
525
+ return;
526
+ }
527
+ if (s === '\x1b[A' || s === 'k') cursor = Math.max(0, cursor - 1);
528
+ else if (s === '\x1b[B' || s === 'j') cursor = Math.min(accounts.length - 1, cursor + 1);
529
+ else if (s === 'r') message = '';
530
+ else if (s === 's' && selected) {
531
+ try {
532
+ cmdUse([selected.name]);
533
+ message = ui.ok(`switched to "${selected.name}"`);
534
+ } catch (e) {
535
+ message = ui.fail(e.message);
536
+ }
537
+ } else if (s === 'e' && selected) {
538
+ setFlag(selected.name, { disabled: !selected.disabled });
539
+ message = selected.disabled ? ui.ok(`enabled "${selected.name}"`) : ui.warn(`disabled "${selected.name}"`);
540
+ } else if (s === 'p' && selected) {
541
+ busy = true;
542
+ message = ui.dim(`probing "${selected.name}"...`);
543
+ render();
544
+ try {
545
+ const result = await probeOne(selected, store.loadMeta());
546
+ message = result.text;
547
+ } catch (e) {
548
+ message = ui.fail(e.message);
549
+ }
550
+ busy = false;
551
+ }
552
+ render();
553
+ });
554
+ });
555
+ return 0;
387
556
  }
388
557
 
389
558
  // Rotate-early thresholds: how full the 5h / weekly window may get before
@@ -469,7 +638,7 @@ function cmdNames() {
469
638
  }
470
639
 
471
640
  const COMMANDS =
472
- 'login import add-key list usage use current next run exec order model remove rename ' +
641
+ 'login import add-key list usage watch probe log chat use current next run exec order model remove rename ' +
473
642
  'enable disable priority clear-limit cooldown threshold patterns export restore sync completion help';
474
643
 
475
644
  function cmdCompletion(args) {
@@ -494,6 +663,18 @@ complete -F _codexswitch codexswitch cxs`;
494
663
  // non-git folders, and the configured default model — unless the user
495
664
  // already passed their own flags.
496
665
  function buildExecArgs(rest, meta) {
666
+ // "exec resume [--last|<id>] ..." — the subcommand and its target must
667
+ // stay in front; flags are injected after them.
668
+ if (rest[0] === 'resume') {
669
+ const head = ['resume'];
670
+ let i = 1;
671
+ if (rest[i] === '--last' || (rest[i] && /^[0-9a-f][0-9a-f-]{7,}$/i.test(rest[i]))) head.push(rest[i++]);
672
+ return [...head, ...injectExecFlags(rest.slice(i), meta)];
673
+ }
674
+ return injectExecFlags(rest, meta);
675
+ }
676
+
677
+ function injectExecFlags(rest, meta) {
497
678
  const args = [...rest];
498
679
  if (!args.includes('--skip-git-repo-check')) args.unshift('--skip-git-repo-check');
499
680
  const hasModel = args.some((a) => a === '-m' || a === '--model' || a.startsWith('--model='));
@@ -578,6 +759,7 @@ async function cmdExec(args) {
578
759
  if (res.code === 0) {
579
760
  store.clearLimited(name);
580
761
  console.error(ui.ok(`done as ${ui.bold(`"${name}"`)}`));
762
+ store.logEvent('exec', `done as "${name}"${useResume ? ' (resumed session)' : ''}`);
581
763
  warnIfOverThreshold(name);
582
764
  return 0;
583
765
  }
@@ -590,6 +772,7 @@ async function cmdExec(args) {
590
772
  console.error(
591
773
  ui.warn(`"${name}" hit a usage/rate limit (paused until ${fmtDate(until)}) — rotating${useResume ? ' and resuming the session' : ''}`)
592
774
  );
775
+ store.logEvent('limit', `"${name}" paused until ${fmtDate(until)}`);
593
776
  continue;
594
777
  }
595
778
  if (runner.looksAuthFailed(res.output)) {
@@ -599,6 +782,7 @@ async function cmdExec(args) {
599
782
  console.error(
600
783
  ui.fail(`"${name}" has a revoked/invalid login — disabled. Fix it with: ${ui.bold(`codexswitch login ${name}`)}`)
601
784
  );
785
+ store.logEvent('auth', `"${name}" disabled (revoked login)`);
602
786
  continue;
603
787
  }
604
788
  return res.code; // real failure, don't burn other accounts on it
@@ -607,6 +791,79 @@ async function cmdExec(args) {
607
791
  return 2;
608
792
  }
609
793
 
794
+ // Interactive chat: a Claude Code-style prompt loop on top of exec.
795
+ // Every turn goes through the rotation engine, and turns 2+ resume the same
796
+ // codex session — so the conversation continues even when the account under
797
+ // it changes between (or during) turns.
798
+ async function cmdChat() {
799
+ const readline = require('readline');
800
+ runner.assertCodexAvailable();
801
+ if (store.listAccounts().length === 0) {
802
+ throw new Error('no accounts — add one with "codexswitch login"');
803
+ }
804
+ const meta = store.loadMeta();
805
+ out(`${ui.bold('codexswitch chat')} ${ui.dim(`· model ${meta.model || '(codex default)'} · rotation on limits`)}`);
806
+ out(ui.dim('type a prompt, or /help for commands (/quit to exit)\n'));
807
+
808
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
809
+ rl.setPrompt(ui.enabled ? `${ui.cyan('cxs')} ${ui.dim('›')} ` : 'cxs › ');
810
+ // Buffer lines ourselves: input can arrive while a turn is still running
811
+ // (type-ahead, piped input) and must not be dropped or hit a closed rl.
812
+ const pending = [];
813
+ const waiters = [];
814
+ let closed = false;
815
+ rl.on('line', (l) => {
816
+ const w = waiters.shift();
817
+ if (w) w(l);
818
+ else pending.push(l);
819
+ });
820
+ rl.on('close', () => {
821
+ closed = true;
822
+ for (const w of waiters.splice(0)) w(null);
823
+ });
824
+ const ask = () => {
825
+ if (pending.length > 0) return Promise.resolve(pending.shift());
826
+ if (closed) return Promise.resolve(null);
827
+ rl.prompt();
828
+ return new Promise((resolve) => waiters.push(resolve));
829
+ };
830
+
831
+ let inSession = false;
832
+ for (;;) {
833
+ const raw = await ask();
834
+ if (raw == null) break; // EOF (Ctrl-D or piped input ended)
835
+ const line = raw.trim();
836
+ if (!line) continue;
837
+
838
+ if (line[0] === '/') {
839
+ const [cmd, ...rest] = line.slice(1).split(/\s+/);
840
+ try {
841
+ if (cmd === 'quit' || cmd === 'exit' || cmd === 'q') break;
842
+ else if (cmd === 'help')
843
+ out(ui.dim('/usage /list /use <name> /next /model [m] /new (fresh session) /quit'));
844
+ else if (cmd === 'usage') cmdUsage([]);
845
+ else if (cmd === 'list') cmdList();
846
+ else if (cmd === 'use') cmdUse(rest);
847
+ else if (cmd === 'next') cmdNext();
848
+ else if (cmd === 'model') cmdModel(rest);
849
+ else if (cmd === 'new') {
850
+ inSession = false;
851
+ out(ui.ok('starting a fresh session on the next prompt'));
852
+ } else out(ui.warn(`unknown command /${cmd} — try /help`));
853
+ } catch (e) {
854
+ out(ui.fail(e.message));
855
+ }
856
+ continue;
857
+ }
858
+
859
+ const code = await cmdExec(inSession ? ['resume', '--last', line] : [line]);
860
+ if (code === 0) inSession = true;
861
+ else if (code === 2) out(ui.fail('all accounts exhausted — try again later or /use a specific account'));
862
+ }
863
+ rl.close();
864
+ return 0;
865
+ }
866
+
610
867
  function warnIfOverThreshold(name) {
611
868
  const meta = store.loadMeta();
612
869
  const account = store.listAccounts().find((a) => a.name === name);
@@ -644,6 +901,15 @@ async function main(argv) {
644
901
  case 'usage':
645
902
  case 'status':
646
903
  return cmdUsage(args), 0;
904
+ case 'watch':
905
+ case 'dashboard':
906
+ return cmdWatch();
907
+ case 'probe':
908
+ case 'warmup':
909
+ return cmdProbe(args);
910
+ case 'log':
911
+ case 'activity':
912
+ return cmdLog(args), 0;
647
913
  case 'use':
648
914
  return cmdUse(args), 0;
649
915
  case 'current':
@@ -699,6 +965,9 @@ async function main(argv) {
699
965
  return cmdRun(args);
700
966
  case 'exec':
701
967
  return cmdExec(args);
968
+ case 'chat':
969
+ case 'repl':
970
+ return cmdChat();
702
971
  default:
703
972
  // Forward everything else to codex under the managed account, so
704
973
  // codexswitch is a drop-in replacement: "cxs goal", "cxs resume", ...
package/src/runner.js CHANGED
@@ -164,7 +164,8 @@ function refreshCopy(target, dest) {
164
164
  // Run codex for one account inside its overlay profile.
165
165
  // capture=false: interactive, stdio inherited. capture=true: stream output
166
166
  // through while also collecting it so the caller can detect limit errors.
167
- function runCodex(name, args, { capture = false } = {}) {
167
+ // silent=true (with capture): collect without echoing (used by probe).
168
+ function runCodex(name, args, { capture = false, silent = false } = {}) {
168
169
  assertCodexAvailable();
169
170
  const profile = buildProfile(name);
170
171
  const env = { ...process.env, CODEX_HOME: profile };
@@ -182,7 +183,7 @@ function runCodex(name, args, { capture = false } = {}) {
182
183
  stream.on('data', (chunk) => {
183
184
  out += chunk.toString('utf8');
184
185
  if (out.length > 262144) out = out.slice(-131072);
185
- dest.write(chunk);
186
+ if (!silent) dest.write(chunk);
186
187
  });
187
188
  };
188
189
  collect(child.stdout, process.stdout);
package/src/store.js CHANGED
@@ -36,6 +36,34 @@ function loadMeta() {
36
36
  return meta;
37
37
  }
38
38
 
39
+ // Append an event to the activity log (rotations, limits, auth failures...).
40
+ function logEvent(type, message) {
41
+ try {
42
+ ensureDir(paths().home);
43
+ fs.appendFileSync(
44
+ path.join(paths().home, 'activity.log'),
45
+ JSON.stringify({ at: new Date().toISOString(), type, message }) + '\n'
46
+ );
47
+ } catch {
48
+ /* logging must never break the tool */
49
+ }
50
+ }
51
+
52
+ function readEvents(count = 20) {
53
+ try {
54
+ const lines = fs.readFileSync(path.join(paths().home, 'activity.log'), 'utf8').trim().split('\n');
55
+ return lines.slice(-count).map((l) => {
56
+ try {
57
+ return JSON.parse(l);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }).filter(Boolean);
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
39
67
  function saveMeta(meta) {
40
68
  writeJSONAtomic(paths().metaPath, meta);
41
69
  }
@@ -83,14 +111,27 @@ function listAccounts() {
83
111
  return {
84
112
  name,
85
113
  ...authInfo(auth),
86
- priority: m.priority ?? 0,
114
+ priority: m.priority ?? null, // null = auto (use-or-lose ordering)
87
115
  disabled: !!m.disabled,
88
116
  limitedUntil: m.limitedUntil || null,
89
117
  usage: m.usage || null,
90
118
  active: meta.active === name,
91
119
  };
92
120
  })
93
- .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
121
+ .sort(rotationCompare);
122
+ }
123
+
124
+ // Rotation order: pinned accounts (explicit priority) come first in pin
125
+ // order; auto accounts follow, spending the one whose weekly quota resets
126
+ // soonest first (use-or-lose — quota about to refresh anyway gets burned
127
+ // first). Ties prefer the active account to avoid needless switching.
128
+ function rotationCompare(a, b) {
129
+ const aPinned = a.priority != null;
130
+ const bPinned = b.priority != null;
131
+ if (aPinned !== bPinned) return aPinned ? -1 : 1;
132
+ if (aPinned && a.priority !== b.priority) return a.priority - b.priority;
133
+ const reset = (x) => (x.usage && x.usage.weekly && x.usage.weekly.resetAt) || Infinity;
134
+ return reset(a) - reset(b) || Number(b.active) - Number(a.active) || a.name.localeCompare(b.name);
94
135
  }
95
136
 
96
137
  // Copy refreshed tokens from a live auth.json back into the store, so a
@@ -187,8 +228,10 @@ function pickAccount(exclude = []) {
187
228
  const meta = loadMeta();
188
229
  const usable = listAccounts().filter((a) => !exclude.includes(a.name) && isUsable(a, meta, now));
189
230
  if (usable.length === 0) return null;
190
- const group = usable.filter((a) => a.priority === usable[0].priority);
191
- return group.find((a) => a.active) || group[0];
231
+ // listAccounts is already in rotation order (pins first, then use-or-lose,
232
+ // active as tiebreaker) but a pinned tie should still stick to active.
233
+ const top = usable.filter((a) => a.priority != null && a.priority === usable[0].priority);
234
+ return top.find((a) => a.active) || usable[0];
192
235
  }
193
236
 
194
237
  // Next usable account after the active one, following priority order and
@@ -223,6 +266,8 @@ module.exports = {
223
266
  isUsable,
224
267
  pickAccount,
225
268
  nextAccount,
269
+ logEvent,
270
+ readEvents,
226
271
  ensureDirs() {
227
272
  const p = paths();
228
273
  ensureDir(p.home);