@carjms/codexswitch 0.6.4 → 0.7.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 +9 -3
- package/README.md +9 -3
- package/package.json +1 -1
- package/src/cli.js +452 -29
- package/src/proxy.js +52 -7
- package/src/store.js +78 -0
- package/src/util.js +30 -3
- package/src/welcome.js +184 -0
package/README.ko.md
CHANGED
|
@@ -200,15 +200,18 @@ cxs list # 계정별 5h/week 사용량 % 확인
|
|
|
200
200
|
|
|
201
201
|
| 명령 | 설명 |
|
|
202
202
|
|---|---|
|
|
203
|
+
| `cxs` | **기본 지속형 프롬프트**: 2열 시작 대시보드에서 계정·프로젝트·최근 작업을 확인하고 계속 입력. 두 번째 요청부터 같은 Codex 세션을 자동으로 이어감 |
|
|
203
204
|
| `cxs login [이름]` | 새 계정 로그인 후 저장 (기존 로그인 유지, 이름 생략 시 이메일 사용) |
|
|
204
205
|
| `cxs import [이름]` | 현재 `~/.codex`에 로그인된 계정을 저장소로 가져오기 |
|
|
205
206
|
| `cxs add-key <이름> [키]` | OpenAI API 키 계정 등록 (키 생략 시 `$OPENAI_API_KEY` 사용) |
|
|
206
207
|
| `cxs list` | 계정 목록: 활성 표시, 이메일, 플랜, 우선순위, 한도 상태, 사용량 % |
|
|
207
208
|
| `cxs usage [이름]` | 계정별 사용량 대시보드: 5시간/주간 게이지 바, 리셋 카운트다운, 다음 로테이션 계정 (별칭: `status`) |
|
|
208
|
-
| `cxs chat` |
|
|
209
|
+
| `cxs chat [--simple]` | `cxs`와 같은 지속형 입력창. `--simple`은 전체 화면 시작 대시보드를 생략. `/paste`로 여러 줄 입력(`/end`로 완료) |
|
|
209
210
|
| `cxs watch` | 실시간 인터랙티브 대시보드 — 5초마다 갱신; 키: `↑/↓` 선택, `s` 전환, `e` 활성/비활성, `p` 프로브, `q` 종료 |
|
|
210
211
|
| `cxs probe [이름]` | 계정마다 최소 요청 1회를 보내 사용량 게이지 워밍업 (토큰 소량 소모) |
|
|
211
212
|
| `cxs log [개수]` | 최근 활동 기록: 계정 전환, 한도 도달, 로테이션, 프로브 |
|
|
213
|
+
| `cxs history [개수]` | 실제 작업 이력: 요청, 성공 여부, 계정 전환 흐름, 소요 시간, 변경 파일 |
|
|
214
|
+
| `cxs history show <ID\|latest>` | 요청 원문과 계정별 시도 결과, 실행 후 Git 변경 파일 상세 확인 |
|
|
212
215
|
| `cxs use <이름>` | 활성 계정 전환 |
|
|
213
216
|
| `cxs current` | 현재 활성 계정 확인 |
|
|
214
217
|
| `cxs next` | 설정된 순서의 다음 계정으로 전환 (끝에 오면 처음으로 순환) |
|
|
@@ -219,14 +222,17 @@ cxs list # 계정별 5h/week 사용량 % 확인
|
|
|
219
222
|
| `cxs model [모델명]` | `run`/`exec`에 자동 적용할 기본 모델 설정 (`default`로 초기화) |
|
|
220
223
|
| `cxs threshold [5h%] [주간%]` | 사용량이 이 퍼센트에 도달하면 다음 계정으로 전환 (기본 95, 값 하나면 둘 다) |
|
|
221
224
|
| `cxs reasoning <show\|concise\|hide>` | 실행 중 모델 추론("생각") 출력량 조절 — `hide`는 완전 숨김, `concise`는 한 줄 요약 |
|
|
225
|
+
| `cxs output <auto\|compact\|raw>` | 출력 가독성. `auto`는 터미널에서 중간 diff를 접고 최종 답변·작업 요약만 표시하며, 파이프에서는 raw 유지 |
|
|
226
|
+
| `cxs memory [명령]` | 계정 간 기억 관리. `shared`는 모든 계정이 공유, `isolated`는 계정별 분리, `off`는 주입 중단. `add/show/path` 지원 |
|
|
222
227
|
| `cxs sandbox <read-only\|write\|write+net\|full>` | exec/chat의 파일 접근 권한 — `write`는 작업 폴더 수정, `write+net`은 ssh/curl 등 네트워크까지 허용, `full`은 샌드박스 해제 |
|
|
223
228
|
| `cxs patterns [add/remove]` | 한도 감지에 쓸 커스텀 정규식 패턴 추가/삭제 |
|
|
224
229
|
| `cxs export <파일>` | 전체 계정·설정 백업 (⚠️ 토큰 포함 — 비밀번호처럼 취급) |
|
|
225
230
|
| `cxs restore <파일>` | 백업 파일에서 계정 복원 (다른 PC 이전용) |
|
|
226
231
|
| `cxs completion <bash\|zsh>` | 셸 자동완성 스크립트 출력 |
|
|
227
232
|
| `cxs <그 외 명령>` | codex로 그대로 전달 — `cxs resume`, `cxs goal ...`, `cxs apply` 등 codex의 모든 명령을 관리 계정으로 바로 사용 가능 |
|
|
228
|
-
| `cxs server [--port N]` | **실험 기능** 로컬 프록시(teamclaude 방식):
|
|
229
|
-
| `cxs
|
|
233
|
+
| `cxs server [--port N]` | **실험 기능** 로컬 프록시(teamclaude 방식): 할당량 429만 계정 전환하고 짧은 분당 제한은 같은 계정에서 재시도, 사용량 실시간 수집 |
|
|
234
|
+
| `cxs server status` | 프록시 연결 상태와 요청 단위 로테이션 활성 여부 확인 |
|
|
235
|
+
| `cxs run --proxy [인자]` | 프록시가 있으면 경유하고 없으면 직접 실행. 반드시 필요하면 `--require-proxy` 사용 |
|
|
230
236
|
| `cxs remove <이름>` | 계정 삭제 |
|
|
231
237
|
| `cxs rename <옛이름> <새이름>` | 계정 이름 변경 |
|
|
232
238
|
| `cxs disable / enable <이름>` | 로테이션에서 임시 제외 / 복귀 |
|
package/README.md
CHANGED
|
@@ -202,15 +202,18 @@ cxs list # per-account 5h/week usage columns
|
|
|
202
202
|
|
|
203
203
|
| Command | Description |
|
|
204
204
|
|---|---|
|
|
205
|
+
| `cxs` | **Default persistent prompt**: two-column account/project/history dashboard, then continuous input; turns 2+ continue the same Codex session |
|
|
205
206
|
| `cxs login [name]` | Log in to a new account and store it (existing login untouched; defaults to the email as the name) |
|
|
206
207
|
| `cxs import [name]` | Import the account currently in `~/.codex` |
|
|
207
208
|
| `cxs add-key <name> [key]` | Register an OpenAI API-key account (falls back to `$OPENAI_API_KEY`) |
|
|
208
209
|
| `cxs list` | List accounts: active marker, email, plan, priority, limit status, usage % |
|
|
209
210
|
| `cxs usage [name]` | Per-account usage dashboard: 5h/weekly gauge bars, reset countdowns, next rotation pick (alias: `status`) |
|
|
210
|
-
| `cxs chat` |
|
|
211
|
+
| `cxs chat [--simple]` | Launch the same persistent prompt; `--simple` skips the full-screen landing. Use `/paste` for multiline input |
|
|
211
212
|
| `cxs watch` | Live interactive dashboard — refreshes every 5s; keys: `↑/↓` select, `s` switch, `e` enable/disable, `p` probe, `q` quit |
|
|
212
213
|
| `cxs probe [name]` | Warm up the usage gauges with one minimal request per account (costs a few tokens) |
|
|
213
214
|
| `cxs log [count]` | Recent activity: account switches, limits hit, rotations, probes |
|
|
215
|
+
| `cxs history [count]` | Work history: request, result, account flow, duration, and changed files |
|
|
216
|
+
| `cxs history show <ID\|latest>` | Full request, per-account attempts, and Git workspace changes for one run |
|
|
214
217
|
| `cxs use <name>` | Switch the active account |
|
|
215
218
|
| `cxs current` | Show the active account |
|
|
216
219
|
| `cxs next` | Switch to the next account in rotation order (wraps around) |
|
|
@@ -221,14 +224,17 @@ cxs list # per-account 5h/week usage columns
|
|
|
221
224
|
| `cxs model [name]` | Set the default model injected into `run`/`exec` (`default` to reset) |
|
|
222
225
|
| `cxs threshold [5h%] [wk%]` | Rotate to the next account when usage reaches these percents (default 95; one value sets both) |
|
|
223
226
|
| `cxs reasoning <show\|concise\|hide>` | How much model reasoning ("thinking") to print during runs — `hide` for clean output, `concise` for one-line summaries |
|
|
227
|
+
| `cxs output <auto\|compact\|raw>` | Output readability: `auto` folds intermediate diffs in a TTY and shows the final result plus work summary; pipes stay raw |
|
|
228
|
+
| `cxs memory [command]` | Manage persistent context: `shared` across accounts, per-account `isolated`, or `off`; supports `add/show/path` |
|
|
224
229
|
| `cxs sandbox <read-only\|write\|write+net\|full>` | File access for exec/chat — `write` edits files in cwd, `write+net` also allows ssh/curl/network, `full` disables the sandbox |
|
|
225
230
|
| `cxs patterns [add/remove]` | Custom regex patterns treated as rate-limit errors |
|
|
226
231
|
| `cxs export <file>` | Back up all accounts + settings (⚠️ contains tokens — treat like a password) |
|
|
227
232
|
| `cxs restore <file>` | Restore accounts from a backup (for moving machines) |
|
|
228
233
|
| `cxs completion <bash\|zsh>` | Print a shell completion script |
|
|
229
234
|
| `cxs <anything else>` | Forwarded to codex under the managed account — `cxs resume`, `cxs goal ...`, `cxs apply` all work like their codex counterparts |
|
|
230
|
-
| `cxs server [--port N]` | **EXPERIMENTAL** local proxy (teamclaude-style):
|
|
231
|
-
| `cxs
|
|
235
|
+
| `cxs server [--port N]` | **EXPERIMENTAL** local proxy (teamclaude-style): rotates on quota 429s, retries short throttles on the same account, and collects live usage |
|
|
236
|
+
| `cxs server status` | Check proxy reachability and whether per-request rotation is active |
|
|
237
|
+
| `cxs run --proxy [args]` | Use the proxy when available and fall back to direct mode; add `--require-proxy` for strict mode |
|
|
232
238
|
| `cxs remove <name>` | Delete an account |
|
|
233
239
|
| `cxs rename <old> <new>` | Rename an account |
|
|
234
240
|
| `cxs disable / enable <name>` | Temporarily exclude from / restore to rotation |
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
6
7
|
const { readJSONSafe, writeJSONAtomic, authInfo, ensureDir, fmtDate, fmtRemaining, table } = require('./util.js');
|
|
7
8
|
const store = require('./store.js');
|
|
8
9
|
const runner = require('./runner.js');
|
|
@@ -10,6 +11,8 @@ const ui = require('./ui.js');
|
|
|
10
11
|
|
|
11
12
|
const HELP = `codexswitch — multi-account manager for the OpenAI Codex CLI
|
|
12
13
|
|
|
14
|
+
Run without a command to open the persistent interactive prompt.
|
|
15
|
+
|
|
13
16
|
Accounts
|
|
14
17
|
login [name] log in to a new account (isolated "codex login") and store it
|
|
15
18
|
import [name] import the account currently in ~/.codex/auth.json
|
|
@@ -21,6 +24,8 @@ Accounts
|
|
|
21
24
|
probe [name] warm up quota gauges with a minimal request per
|
|
22
25
|
account (costs a few tokens; alias: warmup)
|
|
23
26
|
log [count] recent activity: switches, limits, rotations
|
|
27
|
+
history [count] work history: request, result, account, duration, files
|
|
28
|
+
history show <id> full details (use "latest" for the newest run)
|
|
24
29
|
use <name> make <name> the active account in ~/.codex
|
|
25
30
|
current show the active account
|
|
26
31
|
next switch to the next account in rotation order (wraps around)
|
|
@@ -34,10 +39,11 @@ Accounts
|
|
|
34
39
|
clear-limit <name> forget a recorded rate-limit for an account
|
|
35
40
|
|
|
36
41
|
Running codex
|
|
37
|
-
chat
|
|
42
|
+
chat [--simple] interactive prompt loop with a two-column welcome
|
|
43
|
+
dashboard (--simple skips the full-screen landing): each turn
|
|
38
44
|
runs through rotation and resumes the same session,
|
|
39
45
|
so the conversation survives account switches
|
|
40
|
-
(/usage /use /next /model /new /quit
|
|
46
|
+
(/status /usage /memory /use /next /model /new /quit)
|
|
41
47
|
run [name] [args...] run codex as <name> (or active account) in an isolated
|
|
42
48
|
per-account CODEX_HOME; config/sessions are shared
|
|
43
49
|
exec [args...] run "codex exec ..." and auto-rotate through accounts in
|
|
@@ -47,12 +53,16 @@ Running codex
|
|
|
47
53
|
--no-resume restart the prompt instead of resuming on rotation
|
|
48
54
|
server [--port N] EXPERIMENTAL local proxy: per-request account
|
|
49
55
|
rotation with live usage from response headers
|
|
50
|
-
|
|
56
|
+
server status show whether the local proxy is reachable
|
|
57
|
+
run --proxy [args...] use proxy when available, otherwise run directly
|
|
58
|
+
--require-proxy fail instead of falling back when proxy is unavailable
|
|
51
59
|
|
|
52
60
|
Settings
|
|
53
61
|
model [name|default] show/set the default model injected into run/exec
|
|
54
62
|
reasoning [mode] how much model reasoning to print during runs:
|
|
55
63
|
show (codex default) | concise | hide
|
|
64
|
+
output [mode] exec/chat display: auto (compact on TTY, raw when
|
|
65
|
+
piped) | compact (result + summary) | raw
|
|
56
66
|
sandbox [mode] file access for exec/chat: read-only (codex default)
|
|
57
67
|
| write (edit files in cwd) | write+net (also allow
|
|
58
68
|
ssh/curl/network) | full (no sandbox)
|
|
@@ -60,6 +70,8 @@ Settings
|
|
|
60
70
|
of the 5-hour / weekly window (default 95, one value = both)
|
|
61
71
|
cooldown [minutes] show/set rate-limit cooldown (default 60)
|
|
62
72
|
patterns [add|remove] extra regex patterns treated as rate-limit errors
|
|
73
|
+
memory [command] cross-account memory for exec/chat: status, shared,
|
|
74
|
+
isolated, off, add <text>, show [account], path [account]
|
|
63
75
|
|
|
64
76
|
Maintenance
|
|
65
77
|
sync save tokens refreshed by codex back into the store
|
|
@@ -155,7 +167,7 @@ function cmdLogin(args) {
|
|
|
155
167
|
function cmdList() {
|
|
156
168
|
const accounts = store.listAccounts();
|
|
157
169
|
if (accounts.length === 0) {
|
|
158
|
-
|
|
170
|
+
printEmptyAccounts();
|
|
159
171
|
return;
|
|
160
172
|
}
|
|
161
173
|
const now = Date.now();
|
|
@@ -170,10 +182,10 @@ function cmdList() {
|
|
|
170
182
|
const status = a.disabled
|
|
171
183
|
? ui.red('disabled')
|
|
172
184
|
: a.limitedUntil && a.limitedUntil > now
|
|
173
|
-
? ui.yellow(`
|
|
185
|
+
? ui.yellow(`paused ${fmtRemaining(a.limitedUntil)}`)
|
|
174
186
|
: over
|
|
175
187
|
? ui.yellow(`over-${over}`)
|
|
176
|
-
: ui.green('
|
|
188
|
+
: ui.green('ready');
|
|
177
189
|
return [
|
|
178
190
|
a.active ? ui.green('●') : '',
|
|
179
191
|
a.active ? ui.bold(a.name) : a.name,
|
|
@@ -187,7 +199,19 @@ function cmdList() {
|
|
|
187
199
|
];
|
|
188
200
|
});
|
|
189
201
|
out(table(rows, ['', 'name', 'email', 'plan', 'prio', 'status', '5h', 'week', 'token refreshed']));
|
|
190
|
-
|
|
202
|
+
const ready = accounts.filter((a) => store.isUsable(a, meta, now));
|
|
203
|
+
const next = store.pickAccount();
|
|
204
|
+
out(ui.dim(`\n${ready.length}/${accounts.length} ready · next: ${next ? next.name : 'none'} · threshold: 5h ${meta.threshold5h}% / week ${meta.thresholdWeekly}%`));
|
|
205
|
+
if (!next) out(ui.warn('No account can run now. Check details with "codexswitch usage".'));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function printEmptyAccounts() {
|
|
209
|
+
out(ui.bold('No accounts yet'));
|
|
210
|
+
out(' 1. Import the account already signed in to Codex:');
|
|
211
|
+
out(` ${ui.cyan('codexswitch import')}`);
|
|
212
|
+
out(' 2. Add another account in an isolated login:');
|
|
213
|
+
out(` ${ui.cyan('codexswitch login <name>')}`);
|
|
214
|
+
out(ui.dim('\nYour current Codex login is never replaced during step 2.'));
|
|
191
215
|
}
|
|
192
216
|
|
|
193
217
|
function cmdUse(args) {
|
|
@@ -272,6 +296,12 @@ function cmdRename(args) {
|
|
|
272
296
|
if (store.accountExists(to)) throw new Error(`account "${to}" already exists`);
|
|
273
297
|
store.writeAccountAuth(to, auth);
|
|
274
298
|
fs.rmSync(store.accountPath(from), { force: true });
|
|
299
|
+
const oldMemory = store.memoryPath('isolated', from);
|
|
300
|
+
const newMemory = store.memoryPath('isolated', to);
|
|
301
|
+
if (fs.existsSync(oldMemory)) {
|
|
302
|
+
ensureDir(path.dirname(newMemory));
|
|
303
|
+
fs.renameSync(oldMemory, newMemory);
|
|
304
|
+
}
|
|
275
305
|
const meta = store.loadMeta();
|
|
276
306
|
meta.accounts[to] = meta.accounts[from] || { priority: 0 };
|
|
277
307
|
delete meta.accounts[from];
|
|
@@ -377,6 +407,13 @@ function cmdModel(args) {
|
|
|
377
407
|
function usageLines(accounts, meta, { cursor = -1 } = {}) {
|
|
378
408
|
const now = Date.now();
|
|
379
409
|
const lines = [];
|
|
410
|
+
const usable = accounts.filter((a) => store.isUsable(a, meta, now));
|
|
411
|
+
const active = accounts.find((a) => a.active);
|
|
412
|
+
lines.push(
|
|
413
|
+
`${ui.bold('Usage overview')} ${ui.green(`${usable.length} ready`)} / ${accounts.length}` +
|
|
414
|
+
`${active ? ui.dim(` · active ${active.name}`) : ''}`
|
|
415
|
+
);
|
|
416
|
+
lines.push('');
|
|
380
417
|
const bar = (pct, threshold) => {
|
|
381
418
|
const width = 20;
|
|
382
419
|
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
|
|
@@ -396,7 +433,7 @@ function usageLines(accounts, meta, { cursor = -1 } = {}) {
|
|
|
396
433
|
const state = a.disabled ? ui.red(' [disabled]') : a.limitedUntil && a.limitedUntil > now ? ui.yellow(` [limited ${fmtRemaining(a.limitedUntil)}]`) : '';
|
|
397
434
|
lines.push(`${cursor >= 0 ? sel : ''}${mark} ${ui.bold(a.name)}${ui.dim(` (${a.plan || a.email || '-'})`)}${state}`);
|
|
398
435
|
if (!a.usage) {
|
|
399
|
-
lines.push(ui.dim(
|
|
436
|
+
lines.push(ui.dim(` no usage data yet — measure it with "codexswitch probe ${a.name}"`));
|
|
400
437
|
} else {
|
|
401
438
|
lines.push(gauge('5h ', a.usage.p5h, meta.threshold5h));
|
|
402
439
|
lines.push(gauge('weekly', a.usage.weekly, meta.thresholdWeekly));
|
|
@@ -404,7 +441,8 @@ function usageLines(accounts, meta, { cursor = -1 } = {}) {
|
|
|
404
441
|
}
|
|
405
442
|
});
|
|
406
443
|
const next = store.pickAccount();
|
|
407
|
-
lines.push(ui.dim(
|
|
444
|
+
lines.push(`\n${ui.dim('Next account')} ${next ? ui.bold(next.name) : ui.red('none usable')}`);
|
|
445
|
+
lines.push(ui.dim(`Thresholds 5h ${meta.threshold5h}% · week ${meta.thresholdWeekly}%`));
|
|
408
446
|
return lines;
|
|
409
447
|
}
|
|
410
448
|
|
|
@@ -416,7 +454,7 @@ function cmdUsage(args) {
|
|
|
416
454
|
if (accounts.length === 0) throw new Error(`no such account: ${args[0]}`);
|
|
417
455
|
}
|
|
418
456
|
if (accounts.length === 0) {
|
|
419
|
-
|
|
457
|
+
printEmptyAccounts();
|
|
420
458
|
return;
|
|
421
459
|
}
|
|
422
460
|
for (const l of usageLines(accounts, meta)) out(l);
|
|
@@ -483,6 +521,93 @@ function cmdLog(args) {
|
|
|
483
521
|
}
|
|
484
522
|
}
|
|
485
523
|
|
|
524
|
+
function shortText(value, max = 56) {
|
|
525
|
+
const oneLine = String(value || '').replace(/\s+/g, ' ').trim();
|
|
526
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function formatDuration(ms) {
|
|
530
|
+
if (!Number.isFinite(ms)) return '-';
|
|
531
|
+
if (ms < 1000) return `${ms}ms`;
|
|
532
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)}s`;
|
|
533
|
+
return `${Math.floor(ms / 60000)}m${Math.round((ms % 60000) / 1000)}s`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function cmdHistory(args) {
|
|
537
|
+
if (args[0] === 'show') {
|
|
538
|
+
const id = requireArg(args, 1, 'history id (or "latest")');
|
|
539
|
+
const run = store.findRun(id);
|
|
540
|
+
if (!run) throw new Error(`no history entry matching "${id}"`);
|
|
541
|
+
out(`${ui.bold(run.id)} ${run.status === 'done' ? ui.green('done') : ui.red(run.status)}`);
|
|
542
|
+
out(`${ui.dim('Started')} ${fmtDate(run.startedAt)}`);
|
|
543
|
+
out(`${ui.dim('Duration')} ${formatDuration(run.durationMs)}`);
|
|
544
|
+
out(`${ui.dim('Directory')} ${run.cwd || '-'}`);
|
|
545
|
+
out(`${ui.dim('Session')} ${run.session} · memory ${run.memory} · reasoning ${run.reasoning}`);
|
|
546
|
+
out(`\n${ui.bold('Request')}\n${run.prompt || ui.dim('(not captured)')}`);
|
|
547
|
+
out(`\n${ui.bold('Attempts')}`);
|
|
548
|
+
for (const attempt of run.attempts || []) {
|
|
549
|
+
const paint = attempt.status === 'done' ? ui.green : attempt.status === 'rate-limited' ? ui.yellow : ui.red;
|
|
550
|
+
out(` ${paint(attempt.status.padEnd(12))} ${attempt.account} · ${formatDuration(attempt.durationMs)}`);
|
|
551
|
+
}
|
|
552
|
+
out(`\n${ui.bold('Files changed during this run')}`);
|
|
553
|
+
if (run.touchedFiles && run.touchedFiles.length) run.touchedFiles.forEach((file) => out(` ${file}`));
|
|
554
|
+
else out(ui.dim(' no file changes detected'));
|
|
555
|
+
out(`\n${ui.bold('Git workspace after run')}`);
|
|
556
|
+
if (run.files && run.files.length) run.files.forEach((file) => out(` ${file}`));
|
|
557
|
+
else out(ui.dim(' clean'));
|
|
558
|
+
if (run.promptTruncated) out(ui.warn('\nRequest was truncated to the last 16 KiB in history.'));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const count = args[0] == null ? 20 : parseInt(args[0], 10);
|
|
562
|
+
if (Number.isNaN(count) || count < 1 || count > 200) {
|
|
563
|
+
throw new Error('usage: codexswitch history [1-200] | history show <id|latest>');
|
|
564
|
+
}
|
|
565
|
+
const runs = store.readRuns(count);
|
|
566
|
+
if (runs.length === 0) {
|
|
567
|
+
out('no work history yet — exec, run <account> exec, and chat runs appear here');
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const rows = runs.map((run) => [
|
|
571
|
+
run.id,
|
|
572
|
+
fmtDate(run.startedAt),
|
|
573
|
+
run.status === 'done' ? ui.green('done') : ui.red(run.status),
|
|
574
|
+
(run.attempts || []).map((a) => a.account).join(' → ') || '-',
|
|
575
|
+
formatDuration(run.durationMs),
|
|
576
|
+
shortText(run.prompt),
|
|
577
|
+
]);
|
|
578
|
+
out(table(rows, ['id', 'started', 'result', 'account flow', 'time', 'request']));
|
|
579
|
+
out(ui.dim('\nFull details: codexswitch history show <id>'));
|
|
580
|
+
out(ui.dim(`Stored locally in ${path.join(store.paths().home, 'history.jsonl')} (contains prompts)`));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function workspaceSnapshot() {
|
|
584
|
+
const result = spawnSync('git', ['status', '--short', '--untracked-files=all'], { cwd: process.cwd(), encoding: 'utf8' });
|
|
585
|
+
if (result.status !== 0 || !result.stdout) return {};
|
|
586
|
+
const snapshot = {};
|
|
587
|
+
for (const line of result.stdout.trim().split(/\r?\n/).filter(Boolean).slice(0, 100)) {
|
|
588
|
+
let file = line.slice(3);
|
|
589
|
+
if (file.includes(' -> ')) file = file.split(' -> ').pop();
|
|
590
|
+
if (file.startsWith('"')) {
|
|
591
|
+
try { file = JSON.parse(file); } catch { /* keep Git's displayed path */ }
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
const stat = fs.statSync(path.resolve(file), { bigint: true });
|
|
595
|
+
snapshot[line] = `${stat.size}:${stat.mtimeNs}`;
|
|
596
|
+
} catch {
|
|
597
|
+
snapshot[line] = 'missing';
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return snapshot;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function promptFromExecArgs(args) {
|
|
604
|
+
if (!args.length) return '';
|
|
605
|
+
const separator = args.lastIndexOf('--');
|
|
606
|
+
const value = args[args.length - 1];
|
|
607
|
+
if (separator < 0 && String(value).startsWith('-')) return '';
|
|
608
|
+
return String(value);
|
|
609
|
+
}
|
|
610
|
+
|
|
486
611
|
// Live interactive dashboard: usage gauges + recent activity, refreshed
|
|
487
612
|
// every 5s. Keyboard: up/down select, s switch, e enable/disable, p probe,
|
|
488
613
|
// r refresh, q quit.
|
|
@@ -646,8 +771,8 @@ function cmdNames() {
|
|
|
646
771
|
}
|
|
647
772
|
|
|
648
773
|
const COMMANDS =
|
|
649
|
-
'login import add-key list usage watch probe log chat server use current next run exec order model remove rename ' +
|
|
650
|
-
'enable disable priority clear-limit cooldown threshold reasoning sandbox patterns export restore sync completion help';
|
|
774
|
+
'login import add-key list usage watch probe log history chat server use current next run exec order model remove rename ' +
|
|
775
|
+
'enable disable priority clear-limit cooldown threshold reasoning output sandbox memory patterns export restore sync completion help';
|
|
651
776
|
|
|
652
777
|
function cmdCompletion(args) {
|
|
653
778
|
const shell = args[0];
|
|
@@ -773,9 +898,157 @@ function cmdReasoning(args) {
|
|
|
773
898
|
);
|
|
774
899
|
}
|
|
775
900
|
|
|
901
|
+
function cmdOutput(args) {
|
|
902
|
+
const meta = store.loadMeta();
|
|
903
|
+
if (args[0] == null) {
|
|
904
|
+
out(`output: ${meta.outputMode || 'auto'} ${ui.dim('(auto | compact | raw)')}`);
|
|
905
|
+
out(ui.dim('auto uses compact output in a terminal and raw output when piped'));
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const mode = args[0];
|
|
909
|
+
if (!['auto', 'compact', 'raw'].includes(mode)) throw new Error('usage: codexswitch output <auto|compact|raw>');
|
|
910
|
+
if (mode === 'auto') delete meta.outputMode;
|
|
911
|
+
else meta.outputMode = mode;
|
|
912
|
+
store.saveMeta(meta);
|
|
913
|
+
out(ui.ok(`output set to ${mode}`));
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function compactOutputEnabled(meta) {
|
|
917
|
+
const mode = meta.outputMode || 'auto';
|
|
918
|
+
return mode === 'compact' || (mode === 'auto' && process.stdout.isTTY);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function outputFileArg(args, file) {
|
|
922
|
+
if (args.some((a) => a === '-o' || a === '--output-last-message' || a.startsWith('--output-last-message='))) return args;
|
|
923
|
+
const result = [...args];
|
|
924
|
+
const resumeOffset = result[0] === 'resume' ? (result[1] === '--last' || /^[0-9a-f][0-9a-f-]{7,}$/i.test(result[1] || '') ? 2 : 1) : 0;
|
|
925
|
+
result.splice(resumeOffset, 0, '--output-last-message', file);
|
|
926
|
+
return result;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function existingOutputFile(args) {
|
|
930
|
+
for (let i = 0; i < args.length; i++) {
|
|
931
|
+
if ((args[i] === '-o' || args[i] === '--output-last-message') && args[i + 1]) return path.resolve(args[i + 1]);
|
|
932
|
+
if (args[i].startsWith('--output-last-message=')) return path.resolve(args[i].slice(args[i].indexOf('=') + 1));
|
|
933
|
+
}
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function startCompactProgress(account, attempt, session) {
|
|
938
|
+
const started = Date.now();
|
|
939
|
+
const label = () => `Working · ${account} · ${session} session · ${Math.round((Date.now() - started) / 1000)}s`;
|
|
940
|
+
if (!process.stdout.isTTY) {
|
|
941
|
+
out(ui.info(label()));
|
|
942
|
+
return () => {};
|
|
943
|
+
}
|
|
944
|
+
const draw = () => process.stderr.write(`\r\x1b[2K${ui.cyan('●')} ${label()}`);
|
|
945
|
+
draw();
|
|
946
|
+
const timer = setInterval(draw, 1000);
|
|
947
|
+
return () => {
|
|
948
|
+
clearInterval(timer);
|
|
949
|
+
process.stderr.write('\r\x1b[2K');
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function printCompactResult(message, run) {
|
|
954
|
+
out(`\n${ui.bold(ui.green('Result'))}`);
|
|
955
|
+
out(ui.dim('─'.repeat(48)));
|
|
956
|
+
out((message || '').trim() || ui.dim('(Codex returned no final message)'));
|
|
957
|
+
out(`\n${ui.bold('Work summary')}`);
|
|
958
|
+
out(ui.dim('─'.repeat(48)));
|
|
959
|
+
out(`${ui.green('✓')} ${run.status} · ${formatDuration(run.durationMs)} · ${(run.attempts || []).map((a) => a.account).join(' → ')}`);
|
|
960
|
+
if (run.touchedFiles && run.touchedFiles.length) {
|
|
961
|
+
out(`${ui.dim('Changed')} ${run.touchedFiles.length} file(s)`);
|
|
962
|
+
run.touchedFiles.slice(0, 12).forEach((file) => out(` ${file}`));
|
|
963
|
+
if (run.touchedFiles.length > 12) out(ui.dim(` … and ${run.touchedFiles.length - 12} more`));
|
|
964
|
+
} else {
|
|
965
|
+
out(ui.dim('Changed 0 files'));
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function memoryTarget(meta, requestedAccount = null) {
|
|
970
|
+
if (meta.memoryMode !== 'isolated') return null;
|
|
971
|
+
const picked = store.pickAccount();
|
|
972
|
+
const name = requestedAccount || meta.active || (picked && picked.name);
|
|
973
|
+
if (!name || !store.accountExists(name)) throw new Error('isolated memory needs a valid account (activate one with "codexswitch use")');
|
|
974
|
+
return name;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function cmdMemory(args) {
|
|
978
|
+
const meta = store.loadMeta();
|
|
979
|
+
const sub = args[0];
|
|
980
|
+
if (!sub || sub === 'status') {
|
|
981
|
+
const mode = meta.memoryMode || 'off';
|
|
982
|
+
const account = mode === 'isolated' ? memoryTarget(meta) : null;
|
|
983
|
+
const file = mode === 'off' ? null : store.memoryPath(mode, account);
|
|
984
|
+
const size = file ? Buffer.byteLength(store.readMemory(mode, account), 'utf8') : 0;
|
|
985
|
+
out(`memory: ${ui.bold(mode)}${account ? ` · account ${ui.bold(account)}` : ''}`);
|
|
986
|
+
out(ui.dim(file ? ` ${size} bytes · ${file}` : ' disabled · existing Codex session files are still shared'));
|
|
987
|
+
if (mode === 'off') out(` Enable: ${ui.cyan('codexswitch memory shared')} ${ui.dim('(shared across accounts)')}`);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (['shared', 'isolated', 'off'].includes(sub)) {
|
|
991
|
+
meta.memoryMode = sub;
|
|
992
|
+
store.saveMeta(meta);
|
|
993
|
+
if (sub === 'shared') out(ui.ok('shared memory enabled for exec/chat — all accounts read the same memory file'));
|
|
994
|
+
else if (sub === 'isolated') out(ui.ok('isolated memory enabled — each account reads only its own memory file'));
|
|
995
|
+
else out(ui.ok('memory injection disabled — stored memory files were kept'));
|
|
996
|
+
if (sub !== 'off') out(ui.dim(`add a note: codexswitch memory add "your note"`));
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
const mode = meta.memoryMode || 'off';
|
|
1000
|
+
if (mode === 'off') throw new Error('memory is off — choose "codexswitch memory shared" or "memory isolated" first');
|
|
1001
|
+
if (sub === 'add') {
|
|
1002
|
+
const account = memoryTarget(meta);
|
|
1003
|
+
const text = args.slice(1).join(' ');
|
|
1004
|
+
const file = store.appendMemory(mode, account, text);
|
|
1005
|
+
out(ui.ok(`memory added${account ? ` for "${account}"` : ''}`));
|
|
1006
|
+
out(ui.dim(file));
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (sub === 'show') {
|
|
1010
|
+
const target = memoryTarget(meta, args[1]);
|
|
1011
|
+
const text = store.readMemory(mode, target);
|
|
1012
|
+
out(text || ui.dim('(memory is empty)'));
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (sub === 'path') {
|
|
1016
|
+
out(store.memoryPath(mode, memoryTarget(meta, args[1])));
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
throw new Error('usage: codexswitch memory [status|shared|isolated|off|add <text>|show [account]|path [account]]');
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function withMemoryPrompt(args, accountName, meta) {
|
|
1023
|
+
const mode = meta.memoryMode || 'off';
|
|
1024
|
+
if (mode === 'off') return args;
|
|
1025
|
+
const memory = store.readMemory(mode, mode === 'isolated' ? accountName : null).trim();
|
|
1026
|
+
if (!memory) return args;
|
|
1027
|
+
const result = [...args];
|
|
1028
|
+
const separator = result.lastIndexOf('--');
|
|
1029
|
+
const promptIndex = separator >= 0 && separator < result.length - 1
|
|
1030
|
+
? result.length - 1
|
|
1031
|
+
: result.length > 0 && !String(result[result.length - 1]).startsWith('-')
|
|
1032
|
+
? result.length - 1
|
|
1033
|
+
: -1;
|
|
1034
|
+
if (promptIndex < 0) return result;
|
|
1035
|
+
result[promptIndex] =
|
|
1036
|
+
`<codexswitch-memory mode="${mode}">\n${memory}\n</codexswitch-memory>\n\n` +
|
|
1037
|
+
`<user-request>\n${result[promptIndex]}\n</user-request>`;
|
|
1038
|
+
return result;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
776
1041
|
// EXPERIMENTAL: foreground proxy server for per-request rotation.
|
|
777
1042
|
async function cmdServer(args) {
|
|
778
1043
|
const proxy = require('./proxy.js');
|
|
1044
|
+
if (args[0] === 'status') {
|
|
1045
|
+
const state = readJSONSafe(path.join(store.paths().home, 'proxy.json'));
|
|
1046
|
+
const port = (state && state.port) || proxy.DEFAULT_PORT;
|
|
1047
|
+
const running = await proxy.ping(port);
|
|
1048
|
+
out(running ? ui.ok(`proxy running on http://127.0.0.1:${port}`) : ui.warn(`proxy not reachable on port ${port}`));
|
|
1049
|
+
out(ui.dim(running ? `${store.listAccounts().length} account(s) available · per-request rotation active` : '"codexswitch run --proxy" will fall back to direct mode'));
|
|
1050
|
+
return 0;
|
|
1051
|
+
}
|
|
779
1052
|
let port = proxy.DEFAULT_PORT;
|
|
780
1053
|
for (let i = 0; i < args.length; i++) {
|
|
781
1054
|
if (args[i] === '--port' || args[i] === '-p') port = parseInt(args[++i], 10);
|
|
@@ -807,13 +1080,18 @@ async function cmdServer(args) {
|
|
|
807
1080
|
async function cmdRun(args) {
|
|
808
1081
|
store.syncBack(); // pick up tokens refreshed by plain codex before overlaying
|
|
809
1082
|
let proxyPort = null;
|
|
1083
|
+
const requireProxy = args.includes('--require-proxy');
|
|
1084
|
+
args = args.filter((a) => a !== '--require-proxy');
|
|
810
1085
|
if (args.includes('--proxy')) {
|
|
811
1086
|
const proxy = require('./proxy.js');
|
|
812
1087
|
args = args.filter((a) => a !== '--proxy');
|
|
813
1088
|
const state = readJSONSafe(path.join(store.paths().home, 'proxy.json'));
|
|
814
1089
|
proxyPort = (state && state.port) || proxy.DEFAULT_PORT;
|
|
815
1090
|
if (!(await proxy.ping(proxyPort))) {
|
|
816
|
-
throw new Error(`no proxy on port ${proxyPort} — start it first with "codexswitch server"`);
|
|
1091
|
+
if (requireProxy) throw new Error(`no proxy on port ${proxyPort} — start it first with "codexswitch server"`);
|
|
1092
|
+
out(ui.warn(`proxy unavailable on port ${proxyPort} — running directly with the selected account`));
|
|
1093
|
+
out(ui.dim('use --require-proxy to fail instead of falling back'));
|
|
1094
|
+
proxyPort = null;
|
|
817
1095
|
}
|
|
818
1096
|
}
|
|
819
1097
|
let name = null;
|
|
@@ -829,9 +1107,10 @@ async function cmdRun(args) {
|
|
|
829
1107
|
name = meta.active && store.accountExists(meta.active) ? meta.active : picked.name;
|
|
830
1108
|
}
|
|
831
1109
|
if (rest[0] === '--') rest = rest.slice(1);
|
|
1110
|
+
const rawExecArgs = rest[0] === 'exec' ? rest.slice(1) : null;
|
|
832
1111
|
const meta = store.loadMeta();
|
|
833
1112
|
if (rest[0] === 'exec') {
|
|
834
|
-
rest = ['exec', ...buildExecArgs(rest.slice(1), meta)];
|
|
1113
|
+
rest = ['exec', ...buildExecArgs(withMemoryPrompt(rest.slice(1), name, meta), meta)];
|
|
835
1114
|
} else if (rest.length === 0) {
|
|
836
1115
|
// interactive TUI launch — apply the same defaults
|
|
837
1116
|
if (meta.model) rest = ['-m', meta.model];
|
|
@@ -839,8 +1118,30 @@ async function cmdRun(args) {
|
|
|
839
1118
|
}
|
|
840
1119
|
out(ui.info(`running codex as ${ui.bold(`"${name}"`)}${proxyPort ? ui.dim(` via proxy :${proxyPort}`) : ''}`));
|
|
841
1120
|
const startTs = Date.now();
|
|
1121
|
+
const before = rawExecArgs ? workspaceSnapshot() : null;
|
|
842
1122
|
const res = await runner.runCodex(name, rest, { proxyPort });
|
|
843
1123
|
runner.recordUsage(name, res.profile, startTs);
|
|
1124
|
+
if (rawExecArgs) {
|
|
1125
|
+
const rawPrompt = promptFromExecArgs(rawExecArgs);
|
|
1126
|
+
const after = workspaceSnapshot();
|
|
1127
|
+
const id = `${startTs.toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
|
|
1128
|
+
store.logRun({
|
|
1129
|
+
id,
|
|
1130
|
+
startedAt: startTs,
|
|
1131
|
+
cwd: process.cwd(),
|
|
1132
|
+
prompt: rawPrompt.length > 16384 ? rawPrompt.slice(-16384) : rawPrompt,
|
|
1133
|
+
promptTruncated: rawPrompt.length > 16384,
|
|
1134
|
+
session: rawExecArgs[0] === 'resume' ? 'continue' : 'new',
|
|
1135
|
+
memory: meta.memoryMode || 'off',
|
|
1136
|
+
reasoning: meta.reasoning || 'show',
|
|
1137
|
+
status: res.code === 0 ? 'done' : `failed-${res.code}`,
|
|
1138
|
+
durationMs: Date.now() - startTs,
|
|
1139
|
+
attempts: [{ account: name, status: res.code === 0 ? 'done' : `exit-${res.code}`, durationMs: Date.now() - startTs }],
|
|
1140
|
+
files: Object.keys(after),
|
|
1141
|
+
touchedFiles: Object.keys({ ...before, ...after }).filter((file) => before[file] !== after[file]),
|
|
1142
|
+
});
|
|
1143
|
+
console.error(ui.info(ui.dim(`history ${id} · view: codexswitch history show ${id}`)));
|
|
1144
|
+
}
|
|
844
1145
|
return res.code;
|
|
845
1146
|
}
|
|
846
1147
|
|
|
@@ -872,9 +1173,45 @@ async function cmdExec(args) {
|
|
|
872
1173
|
|
|
873
1174
|
store.syncBack(); // pick up tokens refreshed by plain codex before overlaying
|
|
874
1175
|
const meta = store.loadMeta();
|
|
1176
|
+
const sessionMode = rest[0] === 'resume' ? 'continue' : 'new';
|
|
1177
|
+
const reasoningMode = meta.reasoning || 'show';
|
|
1178
|
+
const compact = compactOutputEnabled(meta);
|
|
1179
|
+
const userOutputFile = existingOutputFile(rest);
|
|
1180
|
+
const lastMessageFile = userOutputFile || path.join(os.tmpdir(), `codexswitch-result-${process.pid}-${Date.now()}.txt`);
|
|
1181
|
+
const cleanupResult = () => {
|
|
1182
|
+
if (!userOutputFile) fs.rmSync(lastMessageFile, { force: true });
|
|
1183
|
+
};
|
|
875
1184
|
const total = store.listAccounts().length;
|
|
876
1185
|
if (total === 0) throw new Error('no accounts — add one with "codexswitch login"');
|
|
877
1186
|
|
|
1187
|
+
const historyStarted = Date.now();
|
|
1188
|
+
const rawPrompt = promptFromExecArgs(rest);
|
|
1189
|
+
const promptTruncated = rawPrompt.length > 16384;
|
|
1190
|
+
const runRecord = {
|
|
1191
|
+
id: `${historyStarted.toString(36)}-${Math.random().toString(36).slice(2, 5)}`,
|
|
1192
|
+
startedAt: historyStarted,
|
|
1193
|
+
cwd: process.cwd(),
|
|
1194
|
+
prompt: promptTruncated ? rawPrompt.slice(-16384) : rawPrompt,
|
|
1195
|
+
promptTruncated,
|
|
1196
|
+
session: sessionMode,
|
|
1197
|
+
memory: meta.memoryMode || 'off',
|
|
1198
|
+
reasoning: reasoningMode,
|
|
1199
|
+
status: 'running',
|
|
1200
|
+
attempts: [],
|
|
1201
|
+
workspaceBefore: workspaceSnapshot(),
|
|
1202
|
+
};
|
|
1203
|
+
const finishHistory = (status) => {
|
|
1204
|
+
runRecord.status = status;
|
|
1205
|
+
runRecord.durationMs = Date.now() - historyStarted;
|
|
1206
|
+
const after = workspaceSnapshot();
|
|
1207
|
+
runRecord.files = Object.keys(after);
|
|
1208
|
+
runRecord.touchedFiles = Object.keys({ ...runRecord.workspaceBefore, ...after }).filter(
|
|
1209
|
+
(file) => runRecord.workspaceBefore[file] !== after[file]
|
|
1210
|
+
);
|
|
1211
|
+
delete runRecord.workspaceBefore;
|
|
1212
|
+
store.logRun(runRecord);
|
|
1213
|
+
};
|
|
1214
|
+
|
|
878
1215
|
const tried = [];
|
|
879
1216
|
let useResume = false;
|
|
880
1217
|
for (let attempt = 0; attempt < total; attempt++) {
|
|
@@ -887,25 +1224,47 @@ async function cmdExec(args) {
|
|
|
887
1224
|
name = picked.name;
|
|
888
1225
|
}
|
|
889
1226
|
tried.push(name);
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1227
|
+
if (!compact) {
|
|
1228
|
+
console.error(
|
|
1229
|
+
ui.info(
|
|
1230
|
+
`account ${ui.bold(`"${name}"`)}${ui.dim(` · session ${useResume || sessionMode === 'continue' ? 'continue' : 'new'} · reasoning ${reasoningMode}`)}` +
|
|
1231
|
+
`${ui.dim(attempt > 0 ? ` · attempt ${attempt + 1}` : '')}`
|
|
1232
|
+
)
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
893
1235
|
// On rotation, continue the same session with the next account instead
|
|
894
1236
|
// of restarting the whole prompt — the session files are shared.
|
|
895
|
-
const
|
|
896
|
-
? ['
|
|
897
|
-
:
|
|
1237
|
+
const memoryArgs = useResume
|
|
1238
|
+
? withMemoryPrompt(['resume', '--last', '--', RESUME_PROMPT], name, meta)
|
|
1239
|
+
: withMemoryPrompt(rest, name, meta);
|
|
1240
|
+
const displayArgs = compact ? outputFileArg(memoryArgs, lastMessageFile) : memoryArgs;
|
|
1241
|
+
const codexArgs = ['exec', ...buildExecArgs(displayArgs, meta)];
|
|
898
1242
|
const startTs = Date.now();
|
|
899
|
-
const
|
|
1243
|
+
const stopProgress = compact
|
|
1244
|
+
? startCompactProgress(name, attempt + 1, useResume || sessionMode === 'continue' ? 'continued' : 'new')
|
|
1245
|
+
: () => {};
|
|
1246
|
+
const res = await runner.runCodex(name, codexArgs, { capture: true, silent: compact });
|
|
1247
|
+
stopProgress();
|
|
900
1248
|
runner.recordUsage(name, res.profile, startTs);
|
|
901
1249
|
if (res.code === 0) {
|
|
1250
|
+
runRecord.attempts.push({ account: name, status: 'done', durationMs: Date.now() - startTs });
|
|
902
1251
|
store.clearLimited(name);
|
|
903
|
-
console.error(ui.ok(`done as ${ui.bold(`"${name}"`)}`));
|
|
1252
|
+
if (!compact) console.error(ui.ok(`done as ${ui.bold(`"${name}"`)}`));
|
|
904
1253
|
store.logEvent('exec', `done as "${name}"${useResume ? ' (resumed session)' : ''}`);
|
|
905
1254
|
warnIfOverThreshold(name);
|
|
1255
|
+
finishHistory('done');
|
|
1256
|
+
if (compact) {
|
|
1257
|
+
const finalMessage = (() => {
|
|
1258
|
+
try { return fs.readFileSync(lastMessageFile, 'utf8'); } catch { return ''; }
|
|
1259
|
+
})();
|
|
1260
|
+
printCompactResult(finalMessage, runRecord);
|
|
1261
|
+
}
|
|
1262
|
+
cleanupResult();
|
|
1263
|
+
console.error(ui.info(ui.dim(`history ${runRecord.id} · view: codexswitch history show ${runRecord.id}`)));
|
|
906
1264
|
return 0;
|
|
907
1265
|
}
|
|
908
1266
|
if (runner.looksRateLimited(res.output, meta.limitPatterns)) {
|
|
1267
|
+
runRecord.attempts.push({ account: name, status: 'rate-limited', durationMs: Date.now() - startTs });
|
|
909
1268
|
const until = Date.now() + runner.limitCooldownMs(res.output, meta.cooldownMinutes);
|
|
910
1269
|
store.markLimited(name, until);
|
|
911
1270
|
if (allowResume && !useResume && runner.sessionTouchedSince(res.profile, startTs)) {
|
|
@@ -918,6 +1277,7 @@ async function cmdExec(args) {
|
|
|
918
1277
|
continue;
|
|
919
1278
|
}
|
|
920
1279
|
if (runner.looksAuthFailed(res.output)) {
|
|
1280
|
+
runRecord.attempts.push({ account: name, status: 'auth-failed', durationMs: Date.now() - startTs });
|
|
921
1281
|
// A revoked token won't heal by itself — take the account out of
|
|
922
1282
|
// rotation and keep the task going on the next one.
|
|
923
1283
|
setFlag(name, { disabled: true });
|
|
@@ -927,9 +1287,19 @@ async function cmdExec(args) {
|
|
|
927
1287
|
store.logEvent('auth', `"${name}" disabled (revoked login)`);
|
|
928
1288
|
continue;
|
|
929
1289
|
}
|
|
1290
|
+
runRecord.attempts.push({ account: name, status: `exit-${res.code}`, durationMs: Date.now() - startTs });
|
|
1291
|
+
finishHistory(`failed-${res.code}`);
|
|
1292
|
+
if (compact) {
|
|
1293
|
+
console.error(ui.fail('Codex failed'));
|
|
1294
|
+
console.error(res.output.trim().split(/\r?\n/).slice(-12).join('\n'));
|
|
1295
|
+
}
|
|
1296
|
+
cleanupResult();
|
|
930
1297
|
return res.code; // real failure, don't burn other accounts on it
|
|
931
1298
|
}
|
|
932
1299
|
console.error(ui.fail('all accounts are rate-limited, over threshold, or disabled'));
|
|
1300
|
+
finishHistory('exhausted');
|
|
1301
|
+
cleanupResult();
|
|
1302
|
+
console.error(ui.info(ui.dim(`history ${runRecord.id} · view: codexswitch history show ${runRecord.id}`)));
|
|
933
1303
|
return 2;
|
|
934
1304
|
}
|
|
935
1305
|
|
|
@@ -937,21 +1307,32 @@ async function cmdExec(args) {
|
|
|
937
1307
|
// Every turn goes through the rotation engine, and turns 2+ resume the same
|
|
938
1308
|
// codex session — so the conversation continues even when the account under
|
|
939
1309
|
// it changes between (or during) turns.
|
|
940
|
-
async function cmdChat() {
|
|
1310
|
+
async function cmdChat({ welcome = true } = {}) {
|
|
941
1311
|
const readline = require('readline');
|
|
942
1312
|
runner.assertCodexAvailable();
|
|
943
1313
|
if (store.listAccounts().length === 0) {
|
|
944
1314
|
throw new Error('no accounts — add one with "codexswitch login"');
|
|
945
1315
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1316
|
+
let welcomePrompt = null;
|
|
1317
|
+
if (welcome) {
|
|
1318
|
+
const landing = await require('./welcome.js').readWelcomePrompt();
|
|
1319
|
+
if (landing.shown && landing.prompt == null) return 0;
|
|
1320
|
+
welcomePrompt = landing.prompt;
|
|
1321
|
+
}
|
|
1322
|
+
const chatStatus = () => {
|
|
1323
|
+
const currentMeta = store.loadMeta();
|
|
1324
|
+
const active = store.listAccounts().find((a) => a.active);
|
|
1325
|
+
const next = store.pickAccount();
|
|
1326
|
+
return `model ${currentMeta.model || 'default'} · reasoning ${currentMeta.reasoning || 'show'} · output ${currentMeta.outputMode || 'auto'} · memory ${currentMeta.memoryMode || 'off'} · account ${(active && active.name) || (next && next.name) || 'none'}`;
|
|
1327
|
+
};
|
|
1328
|
+
out(`${ui.bold('codexswitch chat')} ${ui.dim(`· ${chatStatus()}`)}`);
|
|
1329
|
+
out(ui.dim('Enter sends · /paste starts multiline input · /help lists commands\n'));
|
|
949
1330
|
|
|
950
1331
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
951
1332
|
rl.setPrompt(ui.enabled ? `${ui.cyan('cxs')} ${ui.dim('›')} ` : 'cxs › ');
|
|
952
1333
|
// Buffer lines ourselves: input can arrive while a turn is still running
|
|
953
1334
|
// (type-ahead, piped input) and must not be dropped or hit a closed rl.
|
|
954
|
-
const pending = [];
|
|
1335
|
+
const pending = welcomePrompt ? [welcomePrompt] : [];
|
|
955
1336
|
const waiters = [];
|
|
956
1337
|
let closed = false;
|
|
957
1338
|
rl.on('line', (l) => {
|
|
@@ -971,6 +1352,7 @@ async function cmdChat() {
|
|
|
971
1352
|
};
|
|
972
1353
|
|
|
973
1354
|
let inSession = false;
|
|
1355
|
+
let turn = 0;
|
|
974
1356
|
for (;;) {
|
|
975
1357
|
const raw = await ask();
|
|
976
1358
|
if (raw == null) break; // EOF (Ctrl-D or piped input ended)
|
|
@@ -982,7 +1364,9 @@ async function cmdChat() {
|
|
|
982
1364
|
try {
|
|
983
1365
|
if (cmd === 'quit' || cmd === 'exit' || cmd === 'q') break;
|
|
984
1366
|
else if (cmd === 'help')
|
|
985
|
-
out(ui.dim('/usage /list /use <name> /next /model [m] /sandbox [mode] /reasoning [mode] /
|
|
1367
|
+
out(ui.dim('/status /history [id] /usage /list /use <name> /next /model [m] /sandbox [mode] /reasoning [mode] /output [mode] /memory [command] /paste /new /quit'));
|
|
1368
|
+
else if (cmd === 'status') out(ui.info(chatStatus()));
|
|
1369
|
+
else if (cmd === 'history') cmdHistory(rest.length ? ['show', rest[0]] : []);
|
|
986
1370
|
else if (cmd === 'usage') cmdUsage([]);
|
|
987
1371
|
else if (cmd === 'list') cmdList();
|
|
988
1372
|
else if (cmd === 'use') cmdUse(rest);
|
|
@@ -990,9 +1374,33 @@ async function cmdChat() {
|
|
|
990
1374
|
else if (cmd === 'model') cmdModel(rest);
|
|
991
1375
|
else if (cmd === 'sandbox') cmdSandbox(rest);
|
|
992
1376
|
else if (cmd === 'reasoning') cmdReasoning(rest);
|
|
1377
|
+
else if (cmd === 'output') cmdOutput(rest);
|
|
1378
|
+
else if (cmd === 'memory') cmdMemory(rest);
|
|
993
1379
|
else if (cmd === 'new') {
|
|
994
1380
|
inSession = false;
|
|
1381
|
+
turn = 0;
|
|
995
1382
|
out(ui.ok('starting a fresh session on the next prompt'));
|
|
1383
|
+
} else if (cmd === 'paste' || cmd === 'multiline') {
|
|
1384
|
+
out(ui.info('multiline input · finish with /end on its own line · cancel with /cancel'));
|
|
1385
|
+
const parts = [];
|
|
1386
|
+
for (;;) {
|
|
1387
|
+
const pasted = await ask();
|
|
1388
|
+
if (pasted == null || pasted.trim() === '/cancel') {
|
|
1389
|
+
parts.length = 0;
|
|
1390
|
+
out(ui.warn('multiline input cancelled'));
|
|
1391
|
+
break;
|
|
1392
|
+
}
|
|
1393
|
+
if (pasted.trim() === '/end') break;
|
|
1394
|
+
parts.push(pasted);
|
|
1395
|
+
}
|
|
1396
|
+
if (parts.length > 0) {
|
|
1397
|
+
const prompt = parts.join('\n');
|
|
1398
|
+
turn++;
|
|
1399
|
+
out(ui.info(`turn ${turn} · ${inSession ? 'continuing session' : 'new session'} · ${prompt.split('\n').length} lines`));
|
|
1400
|
+
const code = await cmdExec(inSession ? ['resume', '--last', '--', prompt] : ['--', prompt]);
|
|
1401
|
+
if (code === 0) inSession = true;
|
|
1402
|
+
else if (code === 2) out(ui.fail('all accounts exhausted — try again later or /use a specific account'));
|
|
1403
|
+
}
|
|
996
1404
|
} else out(ui.warn(`unknown command /${cmd} — try /help`));
|
|
997
1405
|
} catch (e) {
|
|
998
1406
|
out(ui.fail(e.message));
|
|
@@ -1002,11 +1410,14 @@ async function cmdChat() {
|
|
|
1002
1410
|
|
|
1003
1411
|
// "--" marks the prompt as positional so lines starting with "-" are
|
|
1004
1412
|
// never parsed as codex options.
|
|
1413
|
+
turn++;
|
|
1414
|
+
out(ui.info(`turn ${turn} · ${inSession ? 'continuing session' : 'new session'}`));
|
|
1005
1415
|
const code = await cmdExec(inSession ? ['resume', '--last', '--', line] : ['--', line]);
|
|
1006
1416
|
if (code === 0) inSession = true;
|
|
1007
1417
|
else if (code === 2) out(ui.fail('all accounts exhausted — try again later or /use a specific account'));
|
|
1008
1418
|
}
|
|
1009
1419
|
rl.close();
|
|
1420
|
+
out(ui.dim(`\nsession ended · ${turn} turn${turn === 1 ? '' : 's'}${turn > 0 ? ' · review with "codexswitch history"' : ''}`));
|
|
1010
1421
|
return 0;
|
|
1011
1422
|
}
|
|
1012
1423
|
|
|
@@ -1027,6 +1438,11 @@ async function main(argv) {
|
|
|
1027
1438
|
const [cmd, ...args] = argv;
|
|
1028
1439
|
switch (cmd) {
|
|
1029
1440
|
case undefined:
|
|
1441
|
+
if (store.listAccounts().length === 0) {
|
|
1442
|
+
printEmptyAccounts();
|
|
1443
|
+
return 0;
|
|
1444
|
+
}
|
|
1445
|
+
return cmdChat();
|
|
1030
1446
|
case 'help':
|
|
1031
1447
|
case '--help':
|
|
1032
1448
|
case '-h':
|
|
@@ -1056,6 +1472,9 @@ async function main(argv) {
|
|
|
1056
1472
|
case 'log':
|
|
1057
1473
|
case 'activity':
|
|
1058
1474
|
return cmdLog(args), 0;
|
|
1475
|
+
case 'history':
|
|
1476
|
+
case 'work':
|
|
1477
|
+
return cmdHistory(args), 0;
|
|
1059
1478
|
case 'use':
|
|
1060
1479
|
return cmdUse(args), 0;
|
|
1061
1480
|
case 'current':
|
|
@@ -1084,8 +1503,12 @@ async function main(argv) {
|
|
|
1084
1503
|
return cmdModel(args), 0;
|
|
1085
1504
|
case 'reasoning':
|
|
1086
1505
|
return cmdReasoning(args), 0;
|
|
1506
|
+
case 'output':
|
|
1507
|
+
return cmdOutput(args), 0;
|
|
1087
1508
|
case 'sandbox':
|
|
1088
1509
|
return cmdSandbox(args), 0;
|
|
1510
|
+
case 'memory':
|
|
1511
|
+
return cmdMemory(args), 0;
|
|
1089
1512
|
case 'add-key':
|
|
1090
1513
|
case 'apikey':
|
|
1091
1514
|
return cmdAddKey(args), 0;
|
|
@@ -1117,7 +1540,7 @@ async function main(argv) {
|
|
|
1117
1540
|
return cmdExec(args);
|
|
1118
1541
|
case 'chat':
|
|
1119
1542
|
case 'repl':
|
|
1120
|
-
return cmdChat();
|
|
1543
|
+
return cmdChat({ welcome: !args.includes('--simple') });
|
|
1121
1544
|
case 'server':
|
|
1122
1545
|
case 'proxy':
|
|
1123
1546
|
return cmdServer(args);
|
package/src/proxy.js
CHANGED
|
@@ -16,6 +16,7 @@ const store = require('./store.js');
|
|
|
16
16
|
|
|
17
17
|
const DEFAULT_PORT = 8437;
|
|
18
18
|
const MAX_BODY = 32 * 1024 * 1024;
|
|
19
|
+
const RATE_LIMIT_ABSORB_MAX_MS = 60000;
|
|
19
20
|
|
|
20
21
|
function upstreamBase() {
|
|
21
22
|
return new URL(process.env.CODEX_SWITCH_UPSTREAM || 'https://chatgpt.com');
|
|
@@ -62,6 +63,29 @@ function usageFromHeaders(headers) {
|
|
|
62
63
|
return { p5h, weekly, at };
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
function retryAfterMs(headers, now = Date.now()) {
|
|
67
|
+
const value = headers['retry-after'];
|
|
68
|
+
if (value == null) return null;
|
|
69
|
+
const seconds = Number(value);
|
|
70
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
71
|
+
const date = Date.parse(value);
|
|
72
|
+
return Number.isNaN(date) ? null : Math.max(0, date - now);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Inspired by teamclaude: a quota rejection should rotate immediately, while
|
|
76
|
+
// a short per-minute throttle should stay on the same account to preserve its
|
|
77
|
+
// prompt cache and avoid cascading the burst across every account.
|
|
78
|
+
function classify429(headers, usage, meta) {
|
|
79
|
+
const rejected = Object.entries(headers).some(
|
|
80
|
+
([key, value]) => /(?:codex|quota|limit).*(?:status|state)/i.test(key) && /rejected|exhausted|quota/i.test(String(value))
|
|
81
|
+
);
|
|
82
|
+
const overQuota = [
|
|
83
|
+
usage && usage.p5h && [usage.p5h.pct, meta.threshold5h],
|
|
84
|
+
usage && usage.weekly && [usage.weekly.pct, meta.thresholdWeekly],
|
|
85
|
+
].some((pair) => pair && pair[0] >= pair[1]);
|
|
86
|
+
return rejected || overQuota ? 'quota' : 'rate';
|
|
87
|
+
}
|
|
88
|
+
|
|
65
89
|
function swapHeaders(headers, acct, host) {
|
|
66
90
|
const h = {};
|
|
67
91
|
for (const [k, v] of Object.entries(headers)) {
|
|
@@ -129,6 +153,7 @@ function startServer({ port = DEFAULT_PORT, log = () => {} } = {}) {
|
|
|
129
153
|
const meta = store.loadMeta();
|
|
130
154
|
const total = store.listAccounts().length;
|
|
131
155
|
const tried = [];
|
|
156
|
+
const absorbed = new Set();
|
|
132
157
|
for (let attempt = 0; attempt < Math.max(1, total); attempt++) {
|
|
133
158
|
const acct = pickAuth(tried);
|
|
134
159
|
if (!acct) {
|
|
@@ -147,12 +172,32 @@ function startServer({ port = DEFAULT_PORT, log = () => {} } = {}) {
|
|
|
147
172
|
}
|
|
148
173
|
const usage = usageFromHeaders(upRes.headers);
|
|
149
174
|
if (usage) store.saveUsage(acct.name, usage);
|
|
150
|
-
if (upRes.statusCode === 429
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
175
|
+
if (upRes.statusCode === 429) {
|
|
176
|
+
const kind = classify429(upRes.headers, usage, meta);
|
|
177
|
+
const retryMs = retryAfterMs(upRes.headers);
|
|
178
|
+
if (kind === 'rate') {
|
|
179
|
+
if (retryMs != null && retryMs <= RATE_LIMIT_ABSORB_MAX_MS && !absorbed.has(acct.name)) {
|
|
180
|
+
absorbed.add(acct.name);
|
|
181
|
+
log('rate', `${acct.name} throttled for ${Math.ceil(retryMs)}ms — retrying same account`);
|
|
182
|
+
upRes.resume();
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
184
|
+
tried.pop();
|
|
185
|
+
attempt--;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const pauseMs = retryMs == null ? Math.min(meta.cooldownMinutes * 60000, RATE_LIMIT_ABSORB_MAX_MS) : retryMs;
|
|
189
|
+
store.markLimited(acct.name, Date.now() + pauseMs);
|
|
190
|
+
store.logEvent('limit', `proxy: "${acct.name}" temporarily throttled — not rotating`);
|
|
191
|
+
log('rate', `${acct.name} temporarily throttled — preserving account cache`);
|
|
192
|
+
} else if (tried.length < total) {
|
|
193
|
+
store.markLimited(acct.name, Date.now() + meta.cooldownMinutes * 60000);
|
|
194
|
+
store.logEvent('limit', `proxy: "${acct.name}" quota exhausted — rotating`);
|
|
195
|
+
log('rotate', `${acct.name} quota exhausted -> trying next account`);
|
|
196
|
+
upRes.resume();
|
|
197
|
+
continue;
|
|
198
|
+
} else {
|
|
199
|
+
store.markLimited(acct.name, Date.now() + meta.cooldownMinutes * 60000);
|
|
200
|
+
}
|
|
156
201
|
}
|
|
157
202
|
log('req', `${acct.name} ${req.method} ${req.url} -> ${upRes.statusCode}`);
|
|
158
203
|
res.writeHead(upRes.statusCode, upRes.headers);
|
|
@@ -217,4 +262,4 @@ function ping(port) {
|
|
|
217
262
|
});
|
|
218
263
|
}
|
|
219
264
|
|
|
220
|
-
module.exports = { startServer, ping, DEFAULT_PORT, usageFromHeaders };
|
|
265
|
+
module.exports = { startServer, ping, DEFAULT_PORT, usageFromHeaders, retryAfterMs, classify429 };
|
package/src/store.js
CHANGED
|
@@ -19,6 +19,7 @@ function paths() {
|
|
|
19
19
|
codexHome,
|
|
20
20
|
accountsDir: path.join(home, 'accounts'),
|
|
21
21
|
profilesDir: path.join(home, 'profiles'),
|
|
22
|
+
memoryDir: path.join(home, 'memory'),
|
|
22
23
|
metaPath: path.join(home, 'meta.json'),
|
|
23
24
|
authPath: path.join(codexHome, 'auth.json'),
|
|
24
25
|
};
|
|
@@ -64,10 +65,80 @@ function readEvents(count = 20) {
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
// Structured work history is separate from the operational activity log.
|
|
69
|
+
// It intentionally contains user prompts, so the file is created private.
|
|
70
|
+
function logRun(record) {
|
|
71
|
+
try {
|
|
72
|
+
ensureDir(paths().home);
|
|
73
|
+
fs.appendFileSync(path.join(paths().home, 'history.jsonl'), JSON.stringify(record) + '\n', { mode: 0o600 });
|
|
74
|
+
} catch {
|
|
75
|
+
/* history must never break an actual Codex run */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readRuns(count = 20) {
|
|
80
|
+
try {
|
|
81
|
+
const lines = fs.readFileSync(path.join(paths().home, 'history.jsonl'), 'utf8').trim().split('\n');
|
|
82
|
+
return lines.slice(-count).map((line) => {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(line);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}).filter(Boolean).reverse();
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findRun(id) {
|
|
95
|
+
const runs = readRuns(1000);
|
|
96
|
+
if (id === 'latest') return runs[0] || null;
|
|
97
|
+
return runs.find((run) => run.id === id || run.id.startsWith(id)) || null;
|
|
98
|
+
}
|
|
99
|
+
|
|
67
100
|
function saveMeta(meta) {
|
|
68
101
|
writeJSONAtomic(paths().metaPath, meta);
|
|
69
102
|
}
|
|
70
103
|
|
|
104
|
+
function memoryPath(mode, accountName = null) {
|
|
105
|
+
const p = paths();
|
|
106
|
+
if (mode === 'shared') return path.join(p.memoryDir, 'shared.md');
|
|
107
|
+
if (mode === 'isolated') {
|
|
108
|
+
if (!accountName) throw new Error('isolated memory requires an account name');
|
|
109
|
+
// Reuse the account-name validation used for credential filenames.
|
|
110
|
+
accountPath(accountName);
|
|
111
|
+
return path.join(p.memoryDir, 'accounts', `${accountName}.md`);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readMemory(mode, accountName = null) {
|
|
117
|
+
const file = memoryPath(mode, accountName);
|
|
118
|
+
if (!file) return '';
|
|
119
|
+
try {
|
|
120
|
+
// Bound prompt growth even if the file was edited externally.
|
|
121
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
122
|
+
return text.length > 32768 ? text.slice(-32768) : text;
|
|
123
|
+
} catch {
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function appendMemory(mode, accountName, text) {
|
|
129
|
+
const file = memoryPath(mode, accountName);
|
|
130
|
+
if (!file) throw new Error('memory is off — enable it with "codexswitch memory shared" or "memory isolated"');
|
|
131
|
+
const clean = String(text || '').trim();
|
|
132
|
+
if (!clean) throw new Error('memory text cannot be empty');
|
|
133
|
+
ensureDir(path.dirname(file));
|
|
134
|
+
if (!fs.existsSync(file)) {
|
|
135
|
+
const title = mode === 'shared' ? '# Shared Codex memory\n' : `# Memory for ${accountName}\n`;
|
|
136
|
+
fs.writeFileSync(file, `${title}\n`, { mode: 0o600 });
|
|
137
|
+
}
|
|
138
|
+
fs.appendFileSync(file, `- ${clean.replace(/\n/g, '\n ')}\n`, { mode: 0o600 });
|
|
139
|
+
return file;
|
|
140
|
+
}
|
|
141
|
+
|
|
71
142
|
function accountPath(name) {
|
|
72
143
|
// Unicode letters/digits are fine (names become filenames); block path
|
|
73
144
|
// separators, traversal, and characters invalid on Windows filesystems.
|
|
@@ -268,10 +339,17 @@ module.exports = {
|
|
|
268
339
|
nextAccount,
|
|
269
340
|
logEvent,
|
|
270
341
|
readEvents,
|
|
342
|
+
logRun,
|
|
343
|
+
readRuns,
|
|
344
|
+
findRun,
|
|
345
|
+
memoryPath,
|
|
346
|
+
readMemory,
|
|
347
|
+
appendMemory,
|
|
271
348
|
ensureDirs() {
|
|
272
349
|
const p = paths();
|
|
273
350
|
ensureDir(p.home);
|
|
274
351
|
ensureDir(p.accountsDir);
|
|
275
352
|
ensureDir(p.profilesDir);
|
|
353
|
+
ensureDir(p.memoryDir);
|
|
276
354
|
},
|
|
277
355
|
};
|
package/src/util.js
CHANGED
|
@@ -89,11 +89,37 @@ function fmtRemaining(untilTs) {
|
|
|
89
89
|
return h > 0 ? `${h}h${m}m` : `${m}m`;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Terminal columns are not the same as JavaScript string length: Korean,
|
|
93
|
+
// Japanese, Chinese and emoji commonly occupy two columns. Keeping this
|
|
94
|
+
// small and dependency-free is sufficient for account names and table data.
|
|
95
|
+
function displayWidth(value) {
|
|
96
|
+
const text = require('./ui.js').visible(String(value));
|
|
97
|
+
let width = 0;
|
|
98
|
+
for (const ch of text) {
|
|
99
|
+
const cp = ch.codePointAt(0);
|
|
100
|
+
// Combining marks and variation selectors do not advance the cursor.
|
|
101
|
+
if (/\p{Mark}/u.test(ch) || (cp >= 0xfe00 && cp <= 0xfe0f)) continue;
|
|
102
|
+
const wide =
|
|
103
|
+
cp >= 0x1100 &&
|
|
104
|
+
(cp <= 0x115f ||
|
|
105
|
+
cp === 0x2329 || cp === 0x232a ||
|
|
106
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
107
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
108
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
109
|
+
(cp >= 0xfe10 && cp <= 0xfe6f) ||
|
|
110
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
111
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
112
|
+
(cp >= 0x20000 && cp <= 0x3fffd));
|
|
113
|
+
width += wide ? 2 : 1;
|
|
114
|
+
}
|
|
115
|
+
return width;
|
|
116
|
+
}
|
|
117
|
+
|
|
92
118
|
function table(rows, headers) {
|
|
93
|
-
const {
|
|
119
|
+
const { dim } = require('./ui.js');
|
|
94
120
|
const all = [headers, ...rows].map((r) => r.map((c) => String(c == null ? '-' : c)));
|
|
95
|
-
const widths = headers.map((_, i) => Math.max(...all.map((r) =>
|
|
96
|
-
const pad = (c, w) => c + ' '.repeat(Math.max(0, w -
|
|
121
|
+
const widths = headers.map((_, i) => Math.max(...all.map((r) => displayWidth(r[i]))));
|
|
122
|
+
const pad = (c, w) => c + ' '.repeat(Math.max(0, w - displayWidth(c)));
|
|
97
123
|
const line = (r) => r.map((c, i) => pad(c, widths[i])).join(' ').trimEnd();
|
|
98
124
|
return [dim(line(all[0])), dim(line(widths.map((w) => '-'.repeat(w)))), ...all.slice(1).map(line)].join('\n');
|
|
99
125
|
}
|
|
@@ -108,5 +134,6 @@ module.exports = {
|
|
|
108
134
|
parseDurationMs,
|
|
109
135
|
fmtDate,
|
|
110
136
|
fmtRemaining,
|
|
137
|
+
displayWidth,
|
|
111
138
|
table,
|
|
112
139
|
};
|
package/src/welcome.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
const store = require('./store.js');
|
|
6
|
+
const ui = require('./ui.js');
|
|
7
|
+
const { displayWidth, fmtRemaining } = require('./util.js');
|
|
8
|
+
const pkg = require('../package.json');
|
|
9
|
+
|
|
10
|
+
const MIN_WIDTH = 76;
|
|
11
|
+
const MIN_HEIGHT = 22;
|
|
12
|
+
|
|
13
|
+
function crop(value, width) {
|
|
14
|
+
const text = String(value == null ? '' : value);
|
|
15
|
+
if (displayWidth(text) <= width) return text;
|
|
16
|
+
let out = '';
|
|
17
|
+
for (const ch of text) {
|
|
18
|
+
if (displayWidth(out + ch + '…') > width) break;
|
|
19
|
+
out += ch;
|
|
20
|
+
}
|
|
21
|
+
return out + '…';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fit(value, width) {
|
|
25
|
+
const text = crop(value, width);
|
|
26
|
+
return text + ' '.repeat(Math.max(0, width - displayWidth(text)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function center(value, width) {
|
|
30
|
+
const text = crop(value, width);
|
|
31
|
+
const gap = Math.max(0, width - displayWidth(text));
|
|
32
|
+
return ' '.repeat(Math.floor(gap / 2)) + text + ' '.repeat(Math.ceil(gap / 2));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function percent(account) {
|
|
36
|
+
const win = account.usage && account.usage.p5h;
|
|
37
|
+
return win && typeof win.pct === 'number' ? `${Math.round(win.pct)}%` : '--';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function accountState(account, now = Date.now()) {
|
|
41
|
+
if (account.disabled) return 'disabled';
|
|
42
|
+
if (account.limitedUntil && account.limitedUntil > now) return `paused ${fmtRemaining(account.limitedUntil)}`;
|
|
43
|
+
return 'ready';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function projectPulse(cwd = process.cwd()) {
|
|
47
|
+
const branch = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf8' });
|
|
48
|
+
const status = spawnSync('git', ['status', '--short'], { cwd, encoding: 'utf8' });
|
|
49
|
+
return {
|
|
50
|
+
branch: branch.status === 0 && branch.stdout.trim() ? branch.stdout.trim() : '(not a git repository)',
|
|
51
|
+
changes: status.status === 0 && status.stdout.trim() ? status.stdout.trim().split(/\r?\n/).length : 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function welcomeState() {
|
|
56
|
+
const meta = store.loadMeta();
|
|
57
|
+
const accounts = store.listAccounts();
|
|
58
|
+
const active = accounts.find((a) => a.active) || store.pickAccount() || accounts[0] || null;
|
|
59
|
+
return {
|
|
60
|
+
version: pkg.version,
|
|
61
|
+
accounts,
|
|
62
|
+
active,
|
|
63
|
+
model: meta.model || 'default',
|
|
64
|
+
reasoning: meta.reasoning || 'show',
|
|
65
|
+
memory: meta.memoryMode || 'off',
|
|
66
|
+
output: meta.outputMode || 'auto',
|
|
67
|
+
history: store.readRuns(3),
|
|
68
|
+
project: projectPulse(),
|
|
69
|
+
cwd: process.cwd(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildWelcomeFrame({ width, height, input = '', state = welcomeState() }) {
|
|
74
|
+
if (width < MIN_WIDTH || height < MIN_HEIGHT) return null;
|
|
75
|
+
const inner = width - 2;
|
|
76
|
+
const leftW = Math.max(34, Math.floor(inner * 0.49));
|
|
77
|
+
const rightW = inner - leftW - 1;
|
|
78
|
+
const bodyH = height - 6;
|
|
79
|
+
const left = [];
|
|
80
|
+
const right = [];
|
|
81
|
+
|
|
82
|
+
const logo = [
|
|
83
|
+
' ██████╗██╗ ██╗███████╗',
|
|
84
|
+
'██╔════╝╚██╗██╔╝██╔════╝',
|
|
85
|
+
'██║ ╚███╔╝ ███████╗',
|
|
86
|
+
'██║ ██╔██╗ ╚════██║',
|
|
87
|
+
'╚██████╗██╔╝ ██╗███████║',
|
|
88
|
+
' ╚═════╝╚═╝ ╚═╝╚══════╝',
|
|
89
|
+
];
|
|
90
|
+
const topPad = Math.max(1, Math.floor((bodyH - 14) / 2));
|
|
91
|
+
for (let i = 0; i < topPad; i++) left.push('');
|
|
92
|
+
left.push(center(ui.bold(ui.cyan('CODEX SWITCH')), leftW));
|
|
93
|
+
left.push(center(ui.dim('rotate · continue · remember'), leftW));
|
|
94
|
+
left.push('');
|
|
95
|
+
logo.forEach((line) => left.push(center(ui.cyan(line), leftW)));
|
|
96
|
+
left.push('');
|
|
97
|
+
left.push(center(state.active ? `${ui.green('●')} ${ui.bold(state.active.name)} · ${accountState(state.active)} · ${percent(state.active)}` : ui.yellow('No usable account'), leftW));
|
|
98
|
+
left.push(center(ui.dim(`${state.accounts.length} account(s) · memory ${state.memory}`), leftW));
|
|
99
|
+
|
|
100
|
+
right.push(ui.bold(ui.cyan('Quick commands')));
|
|
101
|
+
right.push(ui.dim('/help /usage /history /output /memory /new'));
|
|
102
|
+
right.push(ui.dim('Enter send · /paste multiline · /quit exit'));
|
|
103
|
+
right.push(ui.dim('─'.repeat(Math.max(1, rightW))));
|
|
104
|
+
right.push(ui.bold(ui.cyan('Account pool')));
|
|
105
|
+
for (const account of state.accounts.slice(0, 5)) {
|
|
106
|
+
const mark = account.active ? ui.green('●') : '○';
|
|
107
|
+
right.push(`${mark} ${account.name} ${ui.dim(`${accountState(account)} · 5h ${percent(account)}`)}`);
|
|
108
|
+
}
|
|
109
|
+
if (state.accounts.length > 5) right.push(ui.dim(`… ${state.accounts.length - 5} more`));
|
|
110
|
+
right.push(ui.dim('─'.repeat(Math.max(1, rightW))));
|
|
111
|
+
right.push(ui.bold(ui.cyan('Project pulse')));
|
|
112
|
+
right.push(`branch ${ui.bold(state.project.branch)} · ${state.project.changes} change(s)`);
|
|
113
|
+
right.push(ui.dim(crop(state.cwd, rightW)));
|
|
114
|
+
right.push(ui.dim('─'.repeat(Math.max(1, rightW))));
|
|
115
|
+
right.push(ui.bold(ui.cyan('Session trail')));
|
|
116
|
+
if (state.history.length === 0) right.push(ui.dim('No saved work history'));
|
|
117
|
+
else state.history.forEach((run) => right.push(`${run.status === 'done' ? ui.green('✓') : ui.yellow('!')} ${crop(run.prompt || '(no prompt)', Math.max(8, rightW - 13))} ${ui.dim(run.id)}`));
|
|
118
|
+
|
|
119
|
+
while (left.length < bodyH) left.push('');
|
|
120
|
+
while (right.length < bodyH) right.push('');
|
|
121
|
+
const title = ` cxs v${state.version} · persistent Codex workspace `;
|
|
122
|
+
const topLeft = Math.max(0, leftW - displayWidth(title) - 1);
|
|
123
|
+
const lines = [`┌─${title}${'─'.repeat(topLeft)}┬${'─'.repeat(rightW)}┐`];
|
|
124
|
+
for (let i = 0; i < bodyH; i++) lines.push(`│${fit(left[i], leftW)}│${fit(right[i], rightW)}│`);
|
|
125
|
+
lines.push(`└${'─'.repeat(leftW)}┴${'─'.repeat(rightW)}┘`);
|
|
126
|
+
|
|
127
|
+
const status = state.active
|
|
128
|
+
? `${ui.green('●')} ${state.active.name} · model ${state.model} · reasoning ${state.reasoning} · ${crop(state.cwd, Math.max(8, width - 48))}`
|
|
129
|
+
: ui.yellow('No usable account');
|
|
130
|
+
lines.push(fit(status, width));
|
|
131
|
+
lines.push(`┌${'─'.repeat(width - 2)}┐`);
|
|
132
|
+
const prompt = input ? `› ${input}` : ui.dim('› Type your message… Enter: send · /help: commands · Ctrl-C: exit');
|
|
133
|
+
lines.push(`│${fit(prompt, width - 2)}│`);
|
|
134
|
+
lines.push(`└${'─'.repeat(width - 2)}┘`);
|
|
135
|
+
return lines.slice(0, height).map((line) => fit(line, width)).join('\r\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function readWelcomePrompt() {
|
|
139
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY || process.env.CXS_SIMPLE_UI === '1') return { prompt: null, shown: false };
|
|
140
|
+
if ((process.stdout.columns || 80) < MIN_WIDTH || (process.stdout.rows || 24) < MIN_HEIGHT) return { prompt: null, shown: false };
|
|
141
|
+
let input = '';
|
|
142
|
+
let done = false;
|
|
143
|
+
const draw = () => {
|
|
144
|
+
const frame = buildWelcomeFrame({
|
|
145
|
+
width: process.stdout.columns || 80,
|
|
146
|
+
height: process.stdout.rows || 24,
|
|
147
|
+
input,
|
|
148
|
+
});
|
|
149
|
+
if (frame) process.stdout.write(`\x1b[H${frame}`);
|
|
150
|
+
};
|
|
151
|
+
process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H\x1b[?25h');
|
|
152
|
+
process.stdin.setRawMode(true);
|
|
153
|
+
process.stdin.resume();
|
|
154
|
+
process.stdin.setEncoding('utf8');
|
|
155
|
+
draw();
|
|
156
|
+
const prompt = await new Promise((resolve) => {
|
|
157
|
+
const onResize = () => draw();
|
|
158
|
+
const finish = (value) => {
|
|
159
|
+
if (done) return;
|
|
160
|
+
done = true;
|
|
161
|
+
process.stdin.removeListener('data', onData);
|
|
162
|
+
process.stdout.removeListener('resize', onResize);
|
|
163
|
+
resolve(value);
|
|
164
|
+
};
|
|
165
|
+
const onData = (data) => {
|
|
166
|
+
if (data === '\u0003' || data === '\u0004' || data === '\u001b') return finish(null);
|
|
167
|
+
if (data === '\r' || data === '\n') {
|
|
168
|
+
if (input.trim()) finish(input.trim());
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (data === '\u007f' || data === '\b') input = Array.from(input).slice(0, -1).join('');
|
|
172
|
+
else if (!data.startsWith('\u001b')) input += Array.from(data).filter((ch) => ch >= ' ' && ch !== '\u007f').join('');
|
|
173
|
+
draw();
|
|
174
|
+
};
|
|
175
|
+
process.stdin.on('data', onData);
|
|
176
|
+
process.stdout.on('resize', onResize);
|
|
177
|
+
});
|
|
178
|
+
try { process.stdin.setRawMode(false); } catch { /* best effort */ }
|
|
179
|
+
process.stdin.pause();
|
|
180
|
+
process.stdout.write('\x1b[?25h\x1b[?1049l');
|
|
181
|
+
return { prompt, shown: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { buildWelcomeFrame, readWelcomePrompt, welcomeState, MIN_WIDTH, MIN_HEIGHT };
|