@carjms/codexswitch 0.3.4 → 0.4.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 +7 -3
- package/README.md +7 -3
- package/package.json +1 -1
- package/src/cli.js +179 -14
- package/src/runner.js +3 -2
- package/src/store.js +49 -4
- package/src/util.js +1 -0
package/README.ko.md
CHANGED
|
@@ -203,14 +203,18 @@ cxs list # 계정별 5h/week 사용량 % 확인
|
|
|
203
203
|
| `cxs login [이름]` | 새 계정 로그인 후 저장 (기존 로그인 유지, 이름 생략 시 이메일 사용) |
|
|
204
204
|
| `cxs import [이름]` | 현재 `~/.codex`에 로그인된 계정을 저장소로 가져오기 |
|
|
205
205
|
| `cxs add-key <이름> [키]` | OpenAI API 키 계정 등록 (키 생략 시 `$OPENAI_API_KEY` 사용) |
|
|
206
|
-
| `cxs list` | 계정 목록: 활성
|
|
206
|
+
| `cxs list` | 계정 목록: 활성 표시, 이메일, 플랜, 우선순위, 한도 상태, 사용량 % |
|
|
207
|
+
| `cxs usage [이름]` | 계정별 사용량 대시보드: 5시간/주간 게이지 바, 리셋 카운트다운, 다음 로테이션 계정 (별칭: `status`) |
|
|
208
|
+
| `cxs watch` | 실시간 대시보드 — 5초마다 자동 갱신 + 최근 활동 표시 (q로 종료) |
|
|
209
|
+
| `cxs probe [이름]` | 계정마다 최소 요청 1회를 보내 사용량 게이지 워밍업 (토큰 소량 소모) |
|
|
210
|
+
| `cxs log [개수]` | 최근 활동 기록: 계정 전환, 한도 도달, 로테이션, 프로브 |
|
|
207
211
|
| `cxs use <이름>` | 활성 계정 전환 |
|
|
208
212
|
| `cxs current` | 현재 활성 계정 확인 |
|
|
209
213
|
| `cxs next` | 설정된 순서의 다음 계정으로 전환 (끝에 오면 처음으로 순환) |
|
|
210
214
|
| `cxs run [이름] [인자...]` | 전환 없이 특정 계정으로 codex 실행 (격리 환경) |
|
|
211
215
|
| `cxs exec [인자...]` | `codex exec` + 한도 도달 시 순서대로 자동 로테이션 — 다음 계정이 같은 세션을 이어받음. git 저장소가 아닌 폴더에서도 동작 |
|
|
212
216
|
| `cxs exec -a <이름> ...` | 특정 계정부터 exec 시작 (`--no-resume`: 이어받기 대신 처음부터 재시도) |
|
|
213
|
-
| `cxs order [이름들...]` | 로테이션
|
|
217
|
+
| `cxs order [이름들...]` | 로테이션 순서 고정(pin); 나열하지 않은 계정은 **주간 리셋이 빠른 순**으로 자동 소진 (use-or-lose) |
|
|
214
218
|
| `cxs model [모델명]` | `run`/`exec`에 자동 적용할 기본 모델 설정 (`default`로 초기화) |
|
|
215
219
|
| `cxs threshold [5h%] [주간%]` | 사용량이 이 퍼센트에 도달하면 다음 계정으로 전환 (기본 95, 값 하나면 둘 다) |
|
|
216
220
|
| `cxs patterns [add/remove]` | 한도 감지에 쓸 커스텀 정규식 패턴 추가/삭제 |
|
|
@@ -221,7 +225,7 @@ cxs list # 계정별 5h/week 사용량 % 확인
|
|
|
221
225
|
| `cxs remove <이름>` | 계정 삭제 |
|
|
222
226
|
| `cxs rename <옛이름> <새이름>` | 계정 이름 변경 |
|
|
223
227
|
| `cxs disable / enable <이름>` | 로테이션에서 임시 제외 / 복귀 |
|
|
224
|
-
| `cxs priority <이름>
|
|
228
|
+
| `cxs priority <이름> <숫자\|auto>` | 계정 하나의 순위 고정, `auto`면 고정 해제(리셋 빠른 순 자동 로테이션) |
|
|
225
229
|
| `cxs clear-limit <이름>` | 기록된 한도 상태 수동 해제 |
|
|
226
230
|
| `cxs cooldown [분]` | 한도 감지 시 기본 대기 시간 조회/설정 (기본 60분) |
|
|
227
231
|
| `cxs sync` | codex가 갱신한 토큰을 저장소에 반영 |
|
package/README.md
CHANGED
|
@@ -205,14 +205,18 @@ cxs list # per-account 5h/week usage columns
|
|
|
205
205
|
| `cxs login [name]` | Log in to a new account and store it (existing login untouched; defaults to the email as the name) |
|
|
206
206
|
| `cxs import [name]` | Import the account currently in `~/.codex` |
|
|
207
207
|
| `cxs add-key <name> [key]` | Register an OpenAI API-key account (falls back to `$OPENAI_API_KEY`) |
|
|
208
|
-
| `cxs list` | List accounts: active marker
|
|
208
|
+
| `cxs list` | List accounts: active marker, email, plan, priority, limit status, usage % |
|
|
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) |
|
|
211
|
+
| `cxs probe [name]` | Warm up the usage gauges with one minimal request per account (costs a few tokens) |
|
|
212
|
+
| `cxs log [count]` | Recent activity: account switches, limits hit, rotations, probes |
|
|
209
213
|
| `cxs use <name>` | Switch the active account |
|
|
210
214
|
| `cxs current` | Show the active account |
|
|
211
215
|
| `cxs next` | Switch to the next account in rotation order (wraps around) |
|
|
212
216
|
| `cxs run [name] [args...]` | Run codex as a specific account without switching (isolated env) |
|
|
213
217
|
| `cxs exec [args...]` | `codex exec` + automatic rotation on usage limits — the next account resumes the same session; works outside git repos |
|
|
214
218
|
| `cxs exec -a <name> ...` | Start exec with a specific account (`--no-resume`: restart instead of resuming) |
|
|
215
|
-
| `cxs order [names...]` |
|
|
219
|
+
| `cxs order [names...]` | Pin the rotation order; unlisted accounts rotate by **soonest weekly reset** (use-or-lose) |
|
|
216
220
|
| `cxs model [name]` | Set the default model injected into `run`/`exec` (`default` to reset) |
|
|
217
221
|
| `cxs threshold [5h%] [wk%]` | Rotate to the next account when usage reaches these percents (default 95; one value sets both) |
|
|
218
222
|
| `cxs patterns [add/remove]` | Custom regex patterns treated as rate-limit errors |
|
|
@@ -223,7 +227,7 @@ cxs list # per-account 5h/week usage columns
|
|
|
223
227
|
| `cxs remove <name>` | Delete an account |
|
|
224
228
|
| `cxs rename <old> <new>` | Rename an account |
|
|
225
229
|
| `cxs disable / enable <name>` | Temporarily exclude from / restore to rotation |
|
|
226
|
-
| `cxs priority <name> <n>` |
|
|
230
|
+
| `cxs priority <name> <n\|auto>` | Pin one account's priority, or `auto` to unpin (use-or-lose rotation) |
|
|
227
231
|
| `cxs clear-limit <name>` | Manually clear a recorded rate-limit |
|
|
228
232
|
| `cxs cooldown [minutes]` | Show/set the default cooldown after a limit is detected (default 60) |
|
|
229
233
|
| `cxs sync` | Save tokens refreshed by codex back into the store |
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -14,7 +14,13 @@ Accounts
|
|
|
14
14
|
login [name] log in to a new account (isolated "codex login") and store it
|
|
15
15
|
import [name] import the account currently in ~/.codex/auth.json
|
|
16
16
|
add-key <name> [key] register an OpenAI API-key account (or read $OPENAI_API_KEY)
|
|
17
|
-
list list stored accounts (alias: accounts
|
|
17
|
+
list list stored accounts (alias: accounts)
|
|
18
|
+
usage [name] per-account 5h/weekly usage gauges with reset times
|
|
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
|
|
18
24
|
use <name> make <name> the active account in ~/.codex
|
|
19
25
|
current show the active account
|
|
20
26
|
next switch to the next account in rotation order (wraps around)
|
|
@@ -22,8 +28,9 @@ Accounts
|
|
|
22
28
|
rename <old> <new> rename a stored account
|
|
23
29
|
enable <name> re-enable a disabled account
|
|
24
30
|
disable <name> temporarily exclude an account from rotation
|
|
25
|
-
order [name...]
|
|
26
|
-
|
|
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
|
|
27
34
|
clear-limit <name> forget a recorded rate-limit for an account
|
|
28
35
|
|
|
29
36
|
Running codex
|
|
@@ -84,7 +91,7 @@ function storeAccount(name, auth) {
|
|
|
84
91
|
const existed = store.accountExists(name);
|
|
85
92
|
store.writeAccountAuth(name, auth);
|
|
86
93
|
const meta = store.loadMeta();
|
|
87
|
-
if (!meta.accounts[name]) meta.accounts[name] = {
|
|
94
|
+
if (!meta.accounts[name]) meta.accounts[name] = { addedAt: Date.now() };
|
|
88
95
|
// fresh credentials fix whatever got the account disabled or limited
|
|
89
96
|
delete meta.accounts[name].disabled;
|
|
90
97
|
delete meta.accounts[name].limitedUntil;
|
|
@@ -160,7 +167,7 @@ function cmdList() {
|
|
|
160
167
|
a.active ? ui.bold(a.name) : a.name,
|
|
161
168
|
ui.dim(a.email || '-'),
|
|
162
169
|
a.plan,
|
|
163
|
-
a.priority,
|
|
170
|
+
a.priority == null ? ui.dim('auto') : a.priority,
|
|
164
171
|
status,
|
|
165
172
|
pct(a.usage && a.usage.p5h, meta.threshold5h),
|
|
166
173
|
pct(a.usage && a.usage.weekly, meta.thresholdWeekly),
|
|
@@ -180,9 +187,10 @@ function cmdUse(args) {
|
|
|
180
187
|
writeJSONAtomic(p.authPath, auth, 0o600);
|
|
181
188
|
const meta = store.loadMeta();
|
|
182
189
|
meta.active = name;
|
|
183
|
-
if (!meta.accounts[name]) meta.accounts[name] = {
|
|
190
|
+
if (!meta.accounts[name]) meta.accounts[name] = {};
|
|
184
191
|
meta.accounts[name].lastUsed = Date.now();
|
|
185
192
|
store.saveMeta(meta);
|
|
193
|
+
store.logEvent('switch', `now using "${name}"`);
|
|
186
194
|
const info = authInfo(auth);
|
|
187
195
|
out(ui.ok(`now using ${ui.bold(`"${name}"`)}${info.email ? ui.dim(` (${info.email}, ${info.plan || 'unknown plan'})`) : ''}`));
|
|
188
196
|
}
|
|
@@ -269,10 +277,18 @@ function setFlag(name, patch) {
|
|
|
269
277
|
|
|
270
278
|
function cmdPriority(args) {
|
|
271
279
|
const name = requireArg(args, 0, 'account name');
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
280
|
+
const value = requireArg(args, 1, 'priority number or "auto"');
|
|
281
|
+
if (value === 'auto') {
|
|
282
|
+
const meta = store.loadMeta();
|
|
283
|
+
if (meta.accounts[name]) delete meta.accounts[name].priority;
|
|
284
|
+
store.saveMeta(meta);
|
|
285
|
+
out(ui.ok(`"${name}" unpinned — rotates by soonest weekly reset (use-or-lose)`));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const n = parseInt(value, 10);
|
|
289
|
+
if (Number.isNaN(n)) throw new Error('priority must be a number or "auto"');
|
|
274
290
|
setFlag(name, { priority: n });
|
|
275
|
-
out(`priority of "${name}"
|
|
291
|
+
out(ui.ok(`priority of "${name}" pinned to ${n}`));
|
|
276
292
|
}
|
|
277
293
|
|
|
278
294
|
function cmdCooldown(args) {
|
|
@@ -315,13 +331,16 @@ function cmdOrder(args) {
|
|
|
315
331
|
for (const name of args) {
|
|
316
332
|
meta.accounts[name] = { ...(meta.accounts[name] || {}), priority: prio++ };
|
|
317
333
|
}
|
|
334
|
+
// accounts not listed become "auto" and rotate by soonest weekly reset
|
|
318
335
|
for (const a of accounts) {
|
|
319
336
|
if (args.includes(a.name)) continue;
|
|
320
|
-
meta.accounts[a.name]
|
|
337
|
+
if (meta.accounts[a.name]) delete meta.accounts[a.name].priority;
|
|
321
338
|
}
|
|
322
339
|
store.saveMeta(meta);
|
|
323
|
-
out('rotation order set:');
|
|
324
|
-
store.listAccounts().forEach((a, i) =>
|
|
340
|
+
out(ui.ok('rotation order set:'));
|
|
341
|
+
store.listAccounts().forEach((a, i) =>
|
|
342
|
+
out(`${i + 1}. ${a.name}${a.priority == null ? ui.dim(' (auto)') : ''}`)
|
|
343
|
+
);
|
|
325
344
|
}
|
|
326
345
|
|
|
327
346
|
function cmdModel(args) {
|
|
@@ -341,6 +360,138 @@ function cmdModel(args) {
|
|
|
341
360
|
out(`default model set to "${meta.model}" (applied to run/exec)`);
|
|
342
361
|
}
|
|
343
362
|
|
|
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
|
+
}
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
const bar = (pct, threshold) => {
|
|
378
|
+
const width = 20;
|
|
379
|
+
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
|
|
380
|
+
const paint = pct >= threshold ? ui.red : pct >= 70 ? ui.yellow : ui.green;
|
|
381
|
+
return `[${paint('█'.repeat(filled))}${ui.dim('░'.repeat(width - filled))}]`;
|
|
382
|
+
};
|
|
383
|
+
const line = (label, win, threshold) => {
|
|
384
|
+
if (!win || typeof win.pct !== 'number') return ` ${label} ${ui.dim('no data')}`;
|
|
385
|
+
const pct = Math.round(win.pct);
|
|
386
|
+
const reset = win.resetAt && win.resetAt > now ? ` resets in ${fmtRemaining(win.resetAt)}` : '';
|
|
387
|
+
const overMark = win.pct >= threshold ? ` ${ui.red(`≥ threshold ${threshold}%`)}` : '';
|
|
388
|
+
return ` ${label} ${bar(win.pct, threshold)} ${String(pct).padStart(3)}%${ui.dim(reset)}${overMark}`;
|
|
389
|
+
};
|
|
390
|
+
for (const a of accounts) {
|
|
391
|
+
const mark = a.active ? ui.green('●') : ' ';
|
|
392
|
+
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}`);
|
|
394
|
+
if (!a.usage) {
|
|
395
|
+
out(ui.dim(' no usage data yet — codex records it during runs (try "codexswitch run" once)'));
|
|
396
|
+
} 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)}`));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const next = store.pickAccount();
|
|
403
|
+
out(ui.dim(`\nrotation would pick: ${next ? next.name : '(none usable)'} · threshold 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Warm up quota measurements: send a minimal request per account so the
|
|
407
|
+
// usage gauges (and use-or-lose ordering) have data. Costs a few tokens.
|
|
408
|
+
async function cmdProbe(args) {
|
|
409
|
+
runner.assertCodexAvailable();
|
|
410
|
+
const meta = store.loadMeta();
|
|
411
|
+
let accounts = store.listAccounts().filter((a) => !a.disabled);
|
|
412
|
+
if (args[0]) {
|
|
413
|
+
accounts = accounts.filter((a) => a.name === args[0]);
|
|
414
|
+
if (accounts.length === 0) throw new Error(`no such account: ${args[0]}`);
|
|
415
|
+
}
|
|
416
|
+
if (accounts.length === 0) throw new Error('no enabled accounts to probe');
|
|
417
|
+
out(ui.info(ui.dim(`probing ${accounts.length} account(s) with a minimal request...`)));
|
|
418
|
+
let failures = 0;
|
|
419
|
+
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
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return failures > 0 ? 1 : 0;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function cmdLog(args) {
|
|
447
|
+
const count = args[0] ? parseInt(args[0], 10) : 20;
|
|
448
|
+
if (Number.isNaN(count) || count < 1) throw new Error('usage: codexswitch log [count]');
|
|
449
|
+
const events = store.readEvents(count);
|
|
450
|
+
if (events.length === 0) {
|
|
451
|
+
out('no activity yet');
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const color = { limit: ui.yellow, auth: ui.red, probe: ui.cyan, switch: ui.green, exec: ui.green };
|
|
455
|
+
for (const e of events) {
|
|
456
|
+
const paint = color[e.type] || ((s) => s);
|
|
457
|
+
out(`${ui.dim(e.at.replace('T', ' ').slice(0, 19))} ${paint(e.type.padEnd(6))} ${e.message}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Live dashboard: re-renders the usage view every few seconds until "q".
|
|
462
|
+
async function cmdWatch() {
|
|
463
|
+
if (!process.stdout.isTTY) {
|
|
464
|
+
cmdUsage([]);
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
467
|
+
const render = () => {
|
|
468
|
+
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);
|
|
472
|
+
if (events.length > 0) {
|
|
473
|
+
out(ui.dim('\nrecent activity:'));
|
|
474
|
+
for (const e of events) out(ui.dim(` ${e.at.replace('T', ' ').slice(11, 19)} ${e.type}: ${e.message}`));
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
render();
|
|
478
|
+
const timer = setInterval(render, 5000);
|
|
479
|
+
await new Promise((resolve) => {
|
|
480
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
481
|
+
process.stdin.resume();
|
|
482
|
+
process.stdin.on('data', (b) => {
|
|
483
|
+
const s = b.toString();
|
|
484
|
+
if (s === 'q' || s === '\u0003') {
|
|
485
|
+
clearInterval(timer);
|
|
486
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
487
|
+
process.stdin.pause();
|
|
488
|
+
resolve();
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
return 0;
|
|
493
|
+
}
|
|
494
|
+
|
|
344
495
|
// Rotate-early thresholds: how full the 5h / weekly window may get before
|
|
345
496
|
// the account is skipped in rotation.
|
|
346
497
|
function cmdThreshold(args) {
|
|
@@ -424,7 +575,7 @@ function cmdNames() {
|
|
|
424
575
|
}
|
|
425
576
|
|
|
426
577
|
const COMMANDS =
|
|
427
|
-
'login import add-key list use current next run exec order model remove rename ' +
|
|
578
|
+
'login import add-key list usage watch probe log use current next run exec order model remove rename ' +
|
|
428
579
|
'enable disable priority clear-limit cooldown threshold patterns export restore sync completion help';
|
|
429
580
|
|
|
430
581
|
function cmdCompletion(args) {
|
|
@@ -533,6 +684,7 @@ async function cmdExec(args) {
|
|
|
533
684
|
if (res.code === 0) {
|
|
534
685
|
store.clearLimited(name);
|
|
535
686
|
console.error(ui.ok(`done as ${ui.bold(`"${name}"`)}`));
|
|
687
|
+
store.logEvent('exec', `done as "${name}"${useResume ? ' (resumed session)' : ''}`);
|
|
536
688
|
warnIfOverThreshold(name);
|
|
537
689
|
return 0;
|
|
538
690
|
}
|
|
@@ -545,6 +697,7 @@ async function cmdExec(args) {
|
|
|
545
697
|
console.error(
|
|
546
698
|
ui.warn(`"${name}" hit a usage/rate limit (paused until ${fmtDate(until)}) — rotating${useResume ? ' and resuming the session' : ''}`)
|
|
547
699
|
);
|
|
700
|
+
store.logEvent('limit', `"${name}" paused until ${fmtDate(until)}`);
|
|
548
701
|
continue;
|
|
549
702
|
}
|
|
550
703
|
if (runner.looksAuthFailed(res.output)) {
|
|
@@ -554,6 +707,7 @@ async function cmdExec(args) {
|
|
|
554
707
|
console.error(
|
|
555
708
|
ui.fail(`"${name}" has a revoked/invalid login — disabled. Fix it with: ${ui.bold(`codexswitch login ${name}`)}`)
|
|
556
709
|
);
|
|
710
|
+
store.logEvent('auth', `"${name}" disabled (revoked login)`);
|
|
557
711
|
continue;
|
|
558
712
|
}
|
|
559
713
|
return res.code; // real failure, don't burn other accounts on it
|
|
@@ -595,8 +749,19 @@ async function main(argv) {
|
|
|
595
749
|
return cmdImport(args), 0;
|
|
596
750
|
case 'list':
|
|
597
751
|
case 'accounts':
|
|
598
|
-
case 'status':
|
|
599
752
|
return cmdList(), 0;
|
|
753
|
+
case 'usage':
|
|
754
|
+
case 'status':
|
|
755
|
+
return cmdUsage(args), 0;
|
|
756
|
+
case 'watch':
|
|
757
|
+
case 'dashboard':
|
|
758
|
+
return cmdWatch();
|
|
759
|
+
case 'probe':
|
|
760
|
+
case 'warmup':
|
|
761
|
+
return cmdProbe(args);
|
|
762
|
+
case 'log':
|
|
763
|
+
case 'activity':
|
|
764
|
+
return cmdLog(args), 0;
|
|
600
765
|
case 'use':
|
|
601
766
|
return cmdUse(args), 0;
|
|
602
767
|
case 'current':
|
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
|
-
|
|
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 ??
|
|
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(
|
|
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
|
-
|
|
191
|
-
|
|
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);
|
package/src/util.js
CHANGED