@carjms/codexswitch 0.4.0 → 0.6.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,7 +205,8 @@ 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 watch` | 실시간 대시보드 5초마다 자동 갱신 + 최근 활동 표시 (q로 종료) |
208
+ | `cxs chat` | **대화형 입력창 (Claude Code 스타일)**: 턴이 로테이션을 거치고 같은 codex 세션을 이어가므로, 계정이 바뀌어도 대화가 유지됨. 내부 명령: `/usage /use /next /model /new /quit` |
209
+ | `cxs watch` | 실시간 인터랙티브 대시보드 — 5초마다 갱신; 키: `↑/↓` 선택, `s` 전환, `e` 활성/비활성, `p` 프로브, `q` 종료 |
209
210
  | `cxs probe [이름]` | 계정마다 최소 요청 1회를 보내 사용량 게이지 워밍업 (토큰 소량 소모) |
210
211
  | `cxs log [개수]` | 최근 활동 기록: 계정 전환, 한도 도달, 로테이션, 프로브 |
211
212
  | `cxs use <이름>` | 활성 계정 전환 |
@@ -222,6 +223,8 @@ cxs list # 계정별 5h/week 사용량 % 확인
222
223
  | `cxs restore <파일>` | 백업 파일에서 계정 복원 (다른 PC 이전용) |
223
224
  | `cxs completion <bash\|zsh>` | 셸 자동완성 스크립트 출력 |
224
225
  | `cxs <그 외 명령>` | codex로 그대로 전달 — `cxs resume`, `cxs goal ...`, `cxs apply` 등 codex의 모든 명령을 관리 계정으로 바로 사용 가능 |
226
+ | `cxs server [--port N]` | **실험 기능** 로컬 프록시(teamclaude 방식): 요청마다 인증을 교체하고, 429는 다음 계정으로 재시도, 응답 헤더에서 사용량을 실시간 수집 |
227
+ | `cxs run --proxy [인자]` | codex를 로컬 프록시 경유로 실행 — 대화형 세션 안에서도 요청 단위 로테이션 |
225
228
  | `cxs remove <이름>` | 계정 삭제 |
226
229
  | `cxs rename <옛이름> <새이름>` | 계정 이름 변경 |
227
230
  | `cxs disable / enable <이름>` | 로테이션에서 임시 제외 / 복귀 |
package/README.md CHANGED
@@ -207,7 +207,8 @@ 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 watch` | Live dashboard auto-refreshes every 5s with recent activity (q to quit) |
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 |
211
212
  | `cxs probe [name]` | Warm up the usage gauges with one minimal request per account (costs a few tokens) |
212
213
  | `cxs log [count]` | Recent activity: account switches, limits hit, rotations, probes |
213
214
  | `cxs use <name>` | Switch the active account |
@@ -224,6 +225,8 @@ cxs list # per-account 5h/week usage columns
224
225
  | `cxs restore <file>` | Restore accounts from a backup (for moving machines) |
225
226
  | `cxs completion <bash\|zsh>` | Print a shell completion script |
226
227
  | `cxs <anything else>` | Forwarded to codex under the managed account — `cxs resume`, `cxs goal ...`, `cxs apply` all work like their codex counterparts |
228
+ | `cxs server [--port N]` | **EXPERIMENTAL** local proxy (teamclaude-style): swaps credentials per request, retries 429s on the next account, and feeds usage gauges live from response headers |
229
+ | `cxs run --proxy [args]` | Run codex routed through the local proxy — per-request rotation even inside interactive sessions |
227
230
  | `cxs remove <name>` | Delete an account |
228
231
  | `cxs rename <old> <new>` | Rename an account |
229
232
  | `cxs disable / enable <name>` | Temporarily exclude from / restore to rotation |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carjms/codexswitch",
3
- "version": "0.4.0",
3
+ "version": "0.6.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
@@ -34,6 +34,10 @@ Accounts
34
34
  clear-limit <name> forget a recorded rate-limit for an account
35
35
 
36
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)
37
41
  run [name] [args...] run codex as <name> (or active account) in an isolated
38
42
  per-account CODEX_HOME; config/sessions are shared
39
43
  exec [args...] run "codex exec ..." and auto-rotate through accounts in
@@ -41,6 +45,9 @@ Running codex
41
45
  the same session (--skip-git-repo-check added automatically)
42
46
  -a, --account <n> start exec with a specific account
43
47
  --no-resume restart the prompt instead of resuming on rotation
48
+ server [--port N] EXPERIMENTAL local proxy: per-request account
49
+ rotation with live usage from response headers
50
+ run --proxy [args...] run codex routed through the local proxy
44
51
 
45
52
  Settings
46
53
  model [name|default] show/set the default model injected into run/exec
@@ -360,47 +367,54 @@ function cmdModel(args) {
360
367
  out(`default model set to "${meta.model}" (applied to run/exec)`);
361
368
  }
362
369
 
363
- // Per-account usage dashboard: gauge bars for the 5h / weekly windows with
364
- // reset countdowns, based on what codex recorded in its session files.
365
- function cmdUsage(args) {
366
- const meta = store.loadMeta();
367
- let accounts = store.listAccounts();
368
- if (args[0]) {
369
- accounts = accounts.filter((a) => a.name === args[0]);
370
- if (accounts.length === 0) throw new Error(`no such account: ${args[0]}`);
371
- }
372
- if (accounts.length === 0) {
373
- out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
374
- return;
375
- }
370
+ // Render the per-account usage view (gauge bars for the 5h / weekly windows
371
+ // with reset countdowns). `cursor` marks the selected row in watch mode.
372
+ function usageLines(accounts, meta, { cursor = -1 } = {}) {
376
373
  const now = Date.now();
374
+ const lines = [];
377
375
  const bar = (pct, threshold) => {
378
376
  const width = 20;
379
377
  const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
380
378
  const paint = pct >= threshold ? ui.red : pct >= 70 ? ui.yellow : ui.green;
381
379
  return `[${paint('█'.repeat(filled))}${ui.dim('░'.repeat(width - filled))}]`;
382
380
  };
383
- const line = (label, win, threshold) => {
381
+ const gauge = (label, win, threshold) => {
384
382
  if (!win || typeof win.pct !== 'number') return ` ${label} ${ui.dim('no data')}`;
385
383
  const pct = Math.round(win.pct);
386
384
  const reset = win.resetAt && win.resetAt > now ? ` resets in ${fmtRemaining(win.resetAt)}` : '';
387
385
  const overMark = win.pct >= threshold ? ` ${ui.red(`≥ threshold ${threshold}%`)}` : '';
388
386
  return ` ${label} ${bar(win.pct, threshold)} ${String(pct).padStart(3)}%${ui.dim(reset)}${overMark}`;
389
387
  };
390
- for (const a of accounts) {
388
+ accounts.forEach((a, i) => {
389
+ const sel = i === cursor ? ui.cyan('▶') : ' ';
391
390
  const mark = a.active ? ui.green('●') : ' ';
392
391
  const state = a.disabled ? ui.red(' [disabled]') : a.limitedUntil && a.limitedUntil > now ? ui.yellow(` [limited ${fmtRemaining(a.limitedUntil)}]`) : '';
393
- out(`${mark} ${ui.bold(a.name)}${ui.dim(` (${a.plan || a.email || '-'})`)}${state}`);
392
+ lines.push(`${cursor >= 0 ? sel : ''}${mark} ${ui.bold(a.name)}${ui.dim(` (${a.plan || a.email || '-'})`)}${state}`);
394
393
  if (!a.usage) {
395
- out(ui.dim(' no usage data yet — codex records it during runs (try "codexswitch run" once)'));
394
+ lines.push(ui.dim(' no usage data yet — codex records it during runs (try "codexswitch run" once)'));
396
395
  } else {
397
- out(line('5h ', a.usage.p5h, meta.threshold5h));
398
- out(line('weekly', a.usage.weekly, meta.thresholdWeekly));
399
- if (a.usage.at) out(ui.dim(` measured ${fmtDate(a.usage.at)}`));
396
+ lines.push(gauge('5h ', a.usage.p5h, meta.threshold5h));
397
+ lines.push(gauge('weekly', a.usage.weekly, meta.thresholdWeekly));
398
+ if (a.usage.at) lines.push(ui.dim(` measured ${fmtDate(a.usage.at)}`));
400
399
  }
401
- }
400
+ });
402
401
  const next = store.pickAccount();
403
- out(ui.dim(`\nrotation would pick: ${next ? next.name : '(none usable)'} · threshold 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`));
402
+ lines.push(ui.dim(`\nrotation would pick: ${next ? next.name : '(none usable)'} · threshold 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`));
403
+ return lines;
404
+ }
405
+
406
+ function cmdUsage(args) {
407
+ const meta = store.loadMeta();
408
+ let accounts = store.listAccounts();
409
+ if (args[0]) {
410
+ accounts = accounts.filter((a) => a.name === args[0]);
411
+ if (accounts.length === 0) throw new Error(`no such account: ${args[0]}`);
412
+ }
413
+ if (accounts.length === 0) {
414
+ out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
415
+ return;
416
+ }
417
+ for (const l of usageLines(accounts, meta)) out(l);
404
418
  }
405
419
 
406
420
  // Warm up quota measurements: send a minimal request per account so the
@@ -417,32 +431,38 @@ async function cmdProbe(args) {
417
431
  out(ui.info(ui.dim(`probing ${accounts.length} account(s) with a minimal request...`)));
418
432
  let failures = 0;
419
433
  for (const a of accounts) {
420
- const startTs = Date.now();
421
- const res = await runner.runCodex(
422
- a.name,
423
- ['exec', ...buildExecArgs(['Reply with exactly: ok'], meta)],
424
- { capture: true, silent: true }
425
- );
426
- const usage = runner.recordUsage(a.name, res.profile, startTs);
427
- if (res.code !== 0) {
428
- failures++;
429
- const why = runner.looksAuthFailed(res.output)
430
- ? `login revoked — fix with "codexswitch login ${a.name}"`
431
- : runner.looksRateLimited(res.output, meta.limitPatterns)
432
- ? 'rate limited'
433
- : `exit ${res.code}`;
434
- out(ui.fail(`${a.name}: ${why}`));
435
- store.logEvent('probe', `${a.name} failed: ${why}`);
436
- } else if (usage && usage.p5h) {
437
- out(ui.ok(`${a.name}: 5h ${Math.round(usage.p5h.pct)}%${usage.weekly ? ` / weekly ${Math.round(usage.weekly.pct)}%` : ''}`));
438
- store.logEvent('probe', `${a.name} measured`);
439
- } else {
440
- out(ui.warn(`${a.name}: reachable, but codex reported no usage data`));
441
- }
434
+ const line = await probeOne(a, meta);
435
+ out(line.text);
436
+ if (!line.ok) failures++;
442
437
  }
443
438
  return failures > 0 ? 1 : 0;
444
439
  }
445
440
 
441
+ // Probe a single account; returns a printable result line.
442
+ async function probeOne(a, meta) {
443
+ const startTs = Date.now();
444
+ const res = await runner.runCodex(
445
+ a.name,
446
+ ['exec', ...buildExecArgs(['Reply with exactly: ok'], meta)],
447
+ { capture: true, silent: true }
448
+ );
449
+ const usage = runner.recordUsage(a.name, res.profile, startTs);
450
+ if (res.code !== 0) {
451
+ const why = runner.looksAuthFailed(res.output)
452
+ ? `login revoked — fix with "codexswitch login ${a.name}"`
453
+ : runner.looksRateLimited(res.output, meta.limitPatterns)
454
+ ? 'rate limited'
455
+ : `exit ${res.code}`;
456
+ store.logEvent('probe', `${a.name} failed: ${why}`);
457
+ return { ok: false, text: ui.fail(`${a.name}: ${why}`) };
458
+ }
459
+ store.logEvent('probe', `${a.name} measured`);
460
+ if (usage && usage.p5h) {
461
+ return { ok: true, text: ui.ok(`${a.name}: 5h ${Math.round(usage.p5h.pct)}%${usage.weekly ? ` / weekly ${Math.round(usage.weekly.pct)}%` : ''}`) };
462
+ }
463
+ return { ok: true, text: ui.warn(`${a.name}: reachable, but codex reported no usage data`) };
464
+ }
465
+
446
466
  function cmdLog(args) {
447
467
  const count = args[0] ? parseInt(args[0], 10) : 20;
448
468
  if (Number.isNaN(count) || count < 1) throw new Error('usage: codexswitch log [count]');
@@ -458,35 +478,81 @@ function cmdLog(args) {
458
478
  }
459
479
  }
460
480
 
461
- // Live dashboard: re-renders the usage view every few seconds until "q".
481
+ // Live interactive dashboard: usage gauges + recent activity, refreshed
482
+ // every 5s. Keyboard: up/down select, s switch, e enable/disable, p probe,
483
+ // r refresh, q quit.
462
484
  async function cmdWatch() {
463
485
  if (!process.stdout.isTTY) {
464
486
  cmdUsage([]);
465
487
  return 0;
466
488
  }
489
+ let cursor = 0;
490
+ let message = '';
491
+ let busy = false;
467
492
  const render = () => {
493
+ const meta = store.loadMeta();
494
+ const accounts = store.listAccounts();
495
+ if (cursor >= accounts.length) cursor = Math.max(0, accounts.length - 1);
468
496
  process.stdout.write('\x1b[2J\x1b[H');
469
- out(`${ui.bold('codexswitch watch')}${ui.dim(` ${new Date().toLocaleTimeString()} · refreshes every 5s · q to quit`)}\n`);
470
- cmdUsage([]);
471
- const events = store.readEvents(5);
497
+ out(`${ui.bold('codexswitch watch')}${ui.dim(` ${new Date().toLocaleTimeString()} \u00b7 refreshes every 5s`)}\n`);
498
+ if (accounts.length === 0) {
499
+ out('no accounts yet \u2014 add one with "codexswitch login"');
500
+ } else {
501
+ for (const l of usageLines(accounts, meta, { cursor })) out(l);
502
+ }
503
+ const events = store.readEvents(4);
472
504
  if (events.length > 0) {
473
505
  out(ui.dim('\nrecent activity:'));
474
506
  for (const e of events) out(ui.dim(` ${e.at.replace('T', ' ').slice(11, 19)} ${e.type}: ${e.message}`));
475
507
  }
508
+ 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`);
509
+ if (message) out(message);
476
510
  };
477
511
  render();
478
- const timer = setInterval(render, 5000);
512
+ const timer = setInterval(() => {
513
+ if (!busy) render();
514
+ }, 5000);
479
515
  await new Promise((resolve) => {
480
516
  if (process.stdin.isTTY) process.stdin.setRawMode(true);
481
517
  process.stdin.resume();
482
- process.stdin.on('data', (b) => {
518
+ process.stdin.on('data', async (b) => {
519
+ if (busy) return;
483
520
  const s = b.toString();
521
+ const accounts = store.listAccounts();
522
+ const selected = accounts[cursor];
484
523
  if (s === 'q' || s === '\u0003') {
485
524
  clearInterval(timer);
486
525
  if (process.stdin.isTTY) process.stdin.setRawMode(false);
487
526
  process.stdin.pause();
488
527
  resolve();
528
+ return;
489
529
  }
530
+ if (s === '\x1b[A' || s === 'k') cursor = Math.max(0, cursor - 1);
531
+ else if (s === '\x1b[B' || s === 'j') cursor = Math.min(accounts.length - 1, cursor + 1);
532
+ else if (s === 'r') message = '';
533
+ else if (s === 's' && selected) {
534
+ try {
535
+ cmdUse([selected.name]);
536
+ message = ui.ok(`switched to "${selected.name}"`);
537
+ } catch (e) {
538
+ message = ui.fail(e.message);
539
+ }
540
+ } else if (s === 'e' && selected) {
541
+ setFlag(selected.name, { disabled: !selected.disabled });
542
+ message = selected.disabled ? ui.ok(`enabled "${selected.name}"`) : ui.warn(`disabled "${selected.name}"`);
543
+ } else if (s === 'p' && selected) {
544
+ busy = true;
545
+ message = ui.dim(`probing "${selected.name}"...`);
546
+ render();
547
+ try {
548
+ const result = await probeOne(selected, store.loadMeta());
549
+ message = result.text;
550
+ } catch (e) {
551
+ message = ui.fail(e.message);
552
+ }
553
+ busy = false;
554
+ }
555
+ render();
490
556
  });
491
557
  });
492
558
  return 0;
@@ -575,7 +641,7 @@ function cmdNames() {
575
641
  }
576
642
 
577
643
  const COMMANDS =
578
- 'login import add-key list usage watch probe log use current next run exec order model remove rename ' +
644
+ 'login import add-key list usage watch probe log chat server use current next run exec order model remove rename ' +
579
645
  'enable disable priority clear-limit cooldown threshold patterns export restore sync completion help';
580
646
 
581
647
  function cmdCompletion(args) {
@@ -600,6 +666,18 @@ complete -F _codexswitch codexswitch cxs`;
600
666
  // non-git folders, and the configured default model — unless the user
601
667
  // already passed their own flags.
602
668
  function buildExecArgs(rest, meta) {
669
+ // "exec resume [--last|<id>] ..." — the subcommand and its target must
670
+ // stay in front; flags are injected after them.
671
+ if (rest[0] === 'resume') {
672
+ const head = ['resume'];
673
+ let i = 1;
674
+ if (rest[i] === '--last' || (rest[i] && /^[0-9a-f][0-9a-f-]{7,}$/i.test(rest[i]))) head.push(rest[i++]);
675
+ return [...head, ...injectExecFlags(rest.slice(i), meta)];
676
+ }
677
+ return injectExecFlags(rest, meta);
678
+ }
679
+
680
+ function injectExecFlags(rest, meta) {
603
681
  const args = [...rest];
604
682
  if (!args.includes('--skip-git-repo-check')) args.unshift('--skip-git-repo-check');
605
683
  const hasModel = args.some((a) => a === '-m' || a === '--model' || a.startsWith('--model='));
@@ -607,8 +685,49 @@ function buildExecArgs(rest, meta) {
607
685
  return args;
608
686
  }
609
687
 
688
+ // EXPERIMENTAL: foreground proxy server for per-request rotation.
689
+ async function cmdServer(args) {
690
+ const proxy = require('./proxy.js');
691
+ let port = proxy.DEFAULT_PORT;
692
+ for (let i = 0; i < args.length; i++) {
693
+ if (args[i] === '--port' || args[i] === '-p') port = parseInt(args[++i], 10);
694
+ }
695
+ if (Number.isNaN(port) || port < 1 || port > 65535) throw new Error('invalid port');
696
+ if (store.listAccounts().length === 0) throw new Error('no accounts — add one with "codexswitch login"');
697
+ const log = (type, msg) => {
698
+ const paint = { rotate: ui.warn, error: ui.fail, ws: ui.info, req: (s) => ui.dim(` ${s}`) }[type] || ui.info;
699
+ out(paint(msg));
700
+ };
701
+ const server = await proxy.startServer({ port, log });
702
+ const stateFile = path.join(store.paths().home, 'proxy.json');
703
+ writeJSONAtomic(stateFile, { port, pid: process.pid, startedAt: Date.now() });
704
+ out(`${ui.bold('codexswitch proxy')} ${ui.yellow('(experimental)')} listening on ${ui.cyan(`http://127.0.0.1:${port}`)}`);
705
+ out(ui.dim('per-request account rotation · 429 retries on the next account · live usage from response headers'));
706
+ out(ui.dim(`use it with: codexswitch run --proxy (Ctrl-C to stop)\n`));
707
+ await new Promise((resolve) => {
708
+ const stop = () => {
709
+ server.close();
710
+ fs.rmSync(stateFile, { force: true });
711
+ resolve();
712
+ };
713
+ process.on('SIGINT', stop);
714
+ process.on('SIGTERM', stop);
715
+ });
716
+ return 0;
717
+ }
718
+
610
719
  async function cmdRun(args) {
611
720
  store.syncBack(); // pick up tokens refreshed by plain codex before overlaying
721
+ let proxyPort = null;
722
+ if (args.includes('--proxy')) {
723
+ const proxy = require('./proxy.js');
724
+ args = args.filter((a) => a !== '--proxy');
725
+ const state = readJSONSafe(path.join(store.paths().home, 'proxy.json'));
726
+ proxyPort = (state && state.port) || proxy.DEFAULT_PORT;
727
+ if (!(await proxy.ping(proxyPort))) {
728
+ throw new Error(`no proxy on port ${proxyPort} — start it first with "codexswitch server"`);
729
+ }
730
+ }
612
731
  let name = null;
613
732
  let rest = args;
614
733
  if (args[0] && store.accountExists(args[0])) {
@@ -628,9 +747,9 @@ async function cmdRun(args) {
628
747
  } else if (meta.model && rest.length === 0) {
629
748
  rest = ['-m', meta.model];
630
749
  }
631
- out(ui.info(`running codex as ${ui.bold(`"${name}"`)}`));
750
+ out(ui.info(`running codex as ${ui.bold(`"${name}"`)}${proxyPort ? ui.dim(` via proxy :${proxyPort}`) : ''}`));
632
751
  const startTs = Date.now();
633
- const res = await runner.runCodex(name, rest);
752
+ const res = await runner.runCodex(name, rest, { proxyPort });
634
753
  runner.recordUsage(name, res.profile, startTs);
635
754
  return res.code;
636
755
  }
@@ -716,6 +835,79 @@ async function cmdExec(args) {
716
835
  return 2;
717
836
  }
718
837
 
838
+ // Interactive chat: a Claude Code-style prompt loop on top of exec.
839
+ // Every turn goes through the rotation engine, and turns 2+ resume the same
840
+ // codex session — so the conversation continues even when the account under
841
+ // it changes between (or during) turns.
842
+ async function cmdChat() {
843
+ const readline = require('readline');
844
+ runner.assertCodexAvailable();
845
+ if (store.listAccounts().length === 0) {
846
+ throw new Error('no accounts — add one with "codexswitch login"');
847
+ }
848
+ const meta = store.loadMeta();
849
+ out(`${ui.bold('codexswitch chat')} ${ui.dim(`· model ${meta.model || '(codex default)'} · rotation on limits`)}`);
850
+ out(ui.dim('type a prompt, or /help for commands (/quit to exit)\n'));
851
+
852
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
853
+ rl.setPrompt(ui.enabled ? `${ui.cyan('cxs')} ${ui.dim('›')} ` : 'cxs › ');
854
+ // Buffer lines ourselves: input can arrive while a turn is still running
855
+ // (type-ahead, piped input) and must not be dropped or hit a closed rl.
856
+ const pending = [];
857
+ const waiters = [];
858
+ let closed = false;
859
+ rl.on('line', (l) => {
860
+ const w = waiters.shift();
861
+ if (w) w(l);
862
+ else pending.push(l);
863
+ });
864
+ rl.on('close', () => {
865
+ closed = true;
866
+ for (const w of waiters.splice(0)) w(null);
867
+ });
868
+ const ask = () => {
869
+ if (pending.length > 0) return Promise.resolve(pending.shift());
870
+ if (closed) return Promise.resolve(null);
871
+ rl.prompt();
872
+ return new Promise((resolve) => waiters.push(resolve));
873
+ };
874
+
875
+ let inSession = false;
876
+ for (;;) {
877
+ const raw = await ask();
878
+ if (raw == null) break; // EOF (Ctrl-D or piped input ended)
879
+ const line = raw.trim();
880
+ if (!line) continue;
881
+
882
+ if (line[0] === '/') {
883
+ const [cmd, ...rest] = line.slice(1).split(/\s+/);
884
+ try {
885
+ if (cmd === 'quit' || cmd === 'exit' || cmd === 'q') break;
886
+ else if (cmd === 'help')
887
+ out(ui.dim('/usage /list /use <name> /next /model [m] /new (fresh session) /quit'));
888
+ else if (cmd === 'usage') cmdUsage([]);
889
+ else if (cmd === 'list') cmdList();
890
+ else if (cmd === 'use') cmdUse(rest);
891
+ else if (cmd === 'next') cmdNext();
892
+ else if (cmd === 'model') cmdModel(rest);
893
+ else if (cmd === 'new') {
894
+ inSession = false;
895
+ out(ui.ok('starting a fresh session on the next prompt'));
896
+ } else out(ui.warn(`unknown command /${cmd} — try /help`));
897
+ } catch (e) {
898
+ out(ui.fail(e.message));
899
+ }
900
+ continue;
901
+ }
902
+
903
+ const code = await cmdExec(inSession ? ['resume', '--last', line] : [line]);
904
+ if (code === 0) inSession = true;
905
+ else if (code === 2) out(ui.fail('all accounts exhausted — try again later or /use a specific account'));
906
+ }
907
+ rl.close();
908
+ return 0;
909
+ }
910
+
719
911
  function warnIfOverThreshold(name) {
720
912
  const meta = store.loadMeta();
721
913
  const account = store.listAccounts().find((a) => a.name === name);
@@ -817,6 +1009,12 @@ async function main(argv) {
817
1009
  return cmdRun(args);
818
1010
  case 'exec':
819
1011
  return cmdExec(args);
1012
+ case 'chat':
1013
+ case 'repl':
1014
+ return cmdChat();
1015
+ case 'server':
1016
+ case 'proxy':
1017
+ return cmdServer(args);
820
1018
  default:
821
1019
  // Forward everything else to codex under the managed account, so
822
1020
  // codexswitch is a drop-in replacement: "cxs goal", "cxs resume", ...
package/src/proxy.js ADDED
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+
3
+ // EXPERIMENTAL local proxy (teamclaude-style): codex is pointed at
4
+ // http://127.0.0.1:<port>/backend-api via chatgpt_base_url, and every
5
+ // request gets its Authorization / chatgpt-account-id swapped to the
6
+ // account chosen by the rotation rules — so even interactive codex
7
+ // sessions rotate per request. 429 responses retry on the next account.
8
+ // Rate-limit response headers (x-codex-*) feed the usage gauges live.
9
+
10
+ const http = require('http');
11
+ const https = require('https');
12
+ const net = require('net');
13
+ const tls = require('tls');
14
+ const { URL } = require('url');
15
+ const store = require('./store.js');
16
+
17
+ const DEFAULT_PORT = 8437;
18
+ const MAX_BODY = 32 * 1024 * 1024;
19
+
20
+ function upstreamBase() {
21
+ return new URL(process.env.CODEX_SWITCH_UPSTREAM || 'https://chatgpt.com');
22
+ }
23
+
24
+ function pickAuth(exclude = []) {
25
+ const acct = store.pickAccount(exclude);
26
+ if (!acct) return null;
27
+ const auth = store.readAccountAuth(acct.name);
28
+ const tokens = auth.tokens || {};
29
+ return {
30
+ name: acct.name,
31
+ accountId: tokens.account_id || null,
32
+ token: tokens.access_token || auth.OPENAI_API_KEY || '',
33
+ };
34
+ }
35
+
36
+ // Parse x-codex-(primary|secondary)-* response headers into a usage
37
+ // snapshot compatible with the session-file scanner.
38
+ function usageFromHeaders(headers) {
39
+ const wins = { primary: {}, secondary: {} };
40
+ let found = false;
41
+ for (const [key, value] of Object.entries(headers)) {
42
+ const m = /^x-codex-(primary|secondary)-(.+)$/i.exec(key);
43
+ if (!m) continue;
44
+ found = true;
45
+ wins[m[1].toLowerCase()][m[2].toLowerCase()] = Number(value);
46
+ }
47
+ if (!found) return null;
48
+ const at = Date.now();
49
+ const win = (w) => {
50
+ const pct = w['used-percent'] ?? w['used_percent'];
51
+ if (typeof pct !== 'number' || Number.isNaN(pct)) return null;
52
+ const resets = w['resets-in-seconds'] ?? w['reset-after-seconds'] ?? w['resets_in_seconds'];
53
+ return {
54
+ pct,
55
+ resetAt: typeof resets === 'number' && !Number.isNaN(resets) ? at + resets * 1000 : null,
56
+ windowMinutes: w['window-minutes'] ?? null,
57
+ };
58
+ };
59
+ const p5h = win(wins.primary);
60
+ const weekly = win(wins.secondary);
61
+ if (!p5h && !weekly) return null;
62
+ return { p5h, weekly, at };
63
+ }
64
+
65
+ function swapHeaders(headers, acct, host) {
66
+ const h = {};
67
+ for (const [k, v] of Object.entries(headers)) {
68
+ const lk = k.toLowerCase();
69
+ if (lk === 'host' || lk === 'content-length' || lk === 'connection') continue;
70
+ h[k] = v;
71
+ }
72
+ h.host = host;
73
+ h.authorization = `Bearer ${acct.token}`;
74
+ if (acct.accountId) h['chatgpt-account-id'] = acct.accountId;
75
+ return h;
76
+ }
77
+
78
+ function readBody(req) {
79
+ return new Promise((resolve, reject) => {
80
+ const chunks = [];
81
+ let size = 0;
82
+ req.on('data', (c) => {
83
+ size += c.length;
84
+ if (size > MAX_BODY) {
85
+ reject(new Error('request body too large'));
86
+ req.destroy();
87
+ return;
88
+ }
89
+ chunks.push(c);
90
+ });
91
+ req.on('end', () => resolve(Buffer.concat(chunks)));
92
+ req.on('error', reject);
93
+ });
94
+ }
95
+
96
+ function forwardOnce(req, body, acct) {
97
+ return new Promise((resolve, reject) => {
98
+ const base = upstreamBase();
99
+ const mod = base.protocol === 'https:' ? https : http;
100
+ const up = mod.request(
101
+ {
102
+ hostname: base.hostname,
103
+ port: base.port || (base.protocol === 'https:' ? 443 : 80),
104
+ path: req.url,
105
+ method: req.method,
106
+ headers: { ...swapHeaders(req.headers, acct, base.host), 'content-length': body.length },
107
+ },
108
+ (upRes) => resolve(upRes)
109
+ );
110
+ up.on('error', reject);
111
+ up.end(body);
112
+ });
113
+ }
114
+
115
+ function startServer({ port = DEFAULT_PORT, log = () => {} } = {}) {
116
+ const server = http.createServer(async (req, res) => {
117
+ if (req.url === '/__codexswitch') {
118
+ res.writeHead(200, { 'content-type': 'application/json' });
119
+ res.end(JSON.stringify({ ok: true, accounts: store.listAccounts().length }));
120
+ return;
121
+ }
122
+ let body;
123
+ try {
124
+ body = await readBody(req);
125
+ } catch (e) {
126
+ res.writeHead(413).end(e.message);
127
+ return;
128
+ }
129
+ const meta = store.loadMeta();
130
+ const total = store.listAccounts().length;
131
+ const tried = [];
132
+ for (let attempt = 0; attempt < Math.max(1, total); attempt++) {
133
+ const acct = pickAuth(tried);
134
+ if (!acct) {
135
+ res.writeHead(503, { 'content-type': 'application/json' });
136
+ res.end(JSON.stringify({ error: 'codexswitch: no usable account' }));
137
+ return;
138
+ }
139
+ tried.push(acct.name);
140
+ let upRes;
141
+ try {
142
+ upRes = await forwardOnce(req, body, acct);
143
+ } catch (e) {
144
+ log('error', `${acct.name}: upstream ${e.message}`);
145
+ res.writeHead(502).end(`codexswitch: upstream error: ${e.message}`);
146
+ return;
147
+ }
148
+ const usage = usageFromHeaders(upRes.headers);
149
+ if (usage) store.saveUsage(acct.name, usage);
150
+ if (upRes.statusCode === 429 && tried.length < total) {
151
+ store.markLimited(acct.name, Date.now() + meta.cooldownMinutes * 60000);
152
+ store.logEvent('limit', `proxy: "${acct.name}" got 429 — rotating`);
153
+ log('rotate', `${acct.name} hit 429 -> trying next account`);
154
+ upRes.resume(); // discard
155
+ continue;
156
+ }
157
+ log('req', `${acct.name} ${req.method} ${req.url} -> ${upRes.statusCode}`);
158
+ res.writeHead(upRes.statusCode, upRes.headers);
159
+ upRes.pipe(res);
160
+ return;
161
+ }
162
+ res.writeHead(503).end('codexswitch: all accounts exhausted');
163
+ });
164
+
165
+ // WebSocket passthrough: swap auth on the handshake, then pipe raw bytes.
166
+ server.on('upgrade', (req, socket, head) => {
167
+ const acct = pickAuth();
168
+ if (!acct) {
169
+ socket.end('HTTP/1.1 503 Service Unavailable\r\n\r\n');
170
+ return;
171
+ }
172
+ const base = upstreamBase();
173
+ const isTls = base.protocol === 'https:';
174
+ const upPort = base.port || (isTls ? 443 : 80);
175
+ const upstream = isTls
176
+ ? tls.connect({ host: base.hostname, port: upPort, servername: base.hostname })
177
+ : net.connect({ host: base.hostname, port: upPort });
178
+ const onReady = () => {
179
+ const headers = swapHeaders(req.headers, acct, base.host);
180
+ headers.connection = 'Upgrade';
181
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
182
+ for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${v}`);
183
+ upstream.write(lines.join('\r\n') + '\r\n\r\n');
184
+ if (head && head.length) upstream.write(head);
185
+ upstream.pipe(socket);
186
+ socket.pipe(upstream);
187
+ log('ws', `${acct.name} ${req.url}`);
188
+ store.logEvent('exec', `proxy ws opened as "${acct.name}"`);
189
+ };
190
+ upstream.on(isTls ? 'secureConnect' : 'connect', onReady);
191
+ const drop = () => {
192
+ socket.destroy();
193
+ upstream.destroy();
194
+ };
195
+ upstream.on('error', drop);
196
+ socket.on('error', drop);
197
+ });
198
+
199
+ return new Promise((resolve, reject) => {
200
+ server.once('error', reject);
201
+ server.listen(port, '127.0.0.1', () => resolve(server));
202
+ });
203
+ }
204
+
205
+ // Is a codexswitch proxy answering on this port?
206
+ function ping(port) {
207
+ return new Promise((resolve) => {
208
+ const req = http.get({ host: '127.0.0.1', port, path: '/__codexswitch', timeout: 1500 }, (res) => {
209
+ res.resume();
210
+ resolve(res.statusCode === 200);
211
+ });
212
+ req.on('error', () => resolve(false));
213
+ req.on('timeout', () => {
214
+ req.destroy();
215
+ resolve(false);
216
+ });
217
+ });
218
+ }
219
+
220
+ module.exports = { startServer, ping, DEFAULT_PORT, usageFromHeaders };
package/src/runner.js CHANGED
@@ -60,9 +60,9 @@ function assertCodexAvailable() {
60
60
  // history are shared — except auth.json (per-account, copied from the store)
61
61
  // and sqlite databases (excluded: two codex processes writing the same
62
62
  // sqlite file through symlinks risks corruption; each profile keeps its own).
63
- function buildProfile(name) {
63
+ function buildProfile(name, dirName = name) {
64
64
  const p = store.paths();
65
- const profile = path.join(p.profilesDir, name);
65
+ const profile = path.join(p.profilesDir, dirName);
66
66
  ensureDir(profile);
67
67
 
68
68
  // Sessions must exist in the real CODEX_HOME before linking, so that every
@@ -109,6 +109,26 @@ function buildProfile(name) {
109
109
  return profile;
110
110
  }
111
111
 
112
+ // Proxy-mode profile: like a normal overlay, but config.toml is a patched
113
+ // copy pointing chatgpt_base_url at the local codexswitch proxy so every
114
+ // codex request flows through it (and rotates per request).
115
+ function buildProxyProfile(name, port) {
116
+ const p = store.paths();
117
+ const profile = buildProfile(name, `${name}.proxy`);
118
+ const cfgDest = path.join(profile, 'config.toml');
119
+ let cfg = '';
120
+ try {
121
+ cfg = fs.readFileSync(path.join(p.codexHome, 'config.toml'), 'utf8');
122
+ } catch {
123
+ /* no config yet */
124
+ }
125
+ cfg = cfg.replace(/^\s*chatgpt_base_url\s*=.*$/gm, '').trimEnd();
126
+ cfg += `\n\n# managed by codexswitch proxy mode\nchatgpt_base_url = "http://127.0.0.1:${port}/backend-api/"\n`;
127
+ fs.rmSync(cfgDest, { force: true });
128
+ fs.writeFileSync(cfgDest, cfg);
129
+ return profile;
130
+ }
131
+
112
132
  function shareable(entry) {
113
133
  if (entry === 'auth.json') return false;
114
134
  if (/\.sqlite(-wal|-shm|-journal)?$/.test(entry)) return false;
@@ -165,9 +185,10 @@ function refreshCopy(target, dest) {
165
185
  // capture=false: interactive, stdio inherited. capture=true: stream output
166
186
  // through while also collecting it so the caller can detect limit errors.
167
187
  // silent=true (with capture): collect without echoing (used by probe).
168
- function runCodex(name, args, { capture = false, silent = false } = {}) {
188
+ // proxyPort: route the run through the local codexswitch proxy.
189
+ function runCodex(name, args, { capture = false, silent = false, proxyPort = null } = {}) {
169
190
  assertCodexAvailable();
170
- const profile = buildProfile(name);
191
+ const profile = proxyPort ? buildProxyProfile(name, proxyPort) : buildProfile(name);
171
192
  const env = { ...process.env, CODEX_HOME: profile };
172
193
 
173
194
  return new Promise((resolve) => {
@@ -321,6 +342,7 @@ module.exports = {
321
342
  spawnCodexSync,
322
343
  assertCodexAvailable,
323
344
  buildProfile,
345
+ buildProxyProfile,
324
346
  runCodex,
325
347
  looksRateLimited,
326
348
  looksAuthFailed,