@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 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` | **대화형 입력창 (Claude Code 스타일)**: 매 턴이 로테이션을 거치고 같은 codex 세션을 이어가므로, 계정이 바뀌어도 대화가 유지됨. 내부 명령: `/usage /use /next /model /new /quit` |
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 방식): 요청마다 인증을 교체하고, 429는 다음 계정으로 재시도, 응답 헤더에서 사용량을 실시간 수집 |
229
- | `cxs run --proxy [인자]` | codex를 로컬 프록시 경유로 실행 대화형 세션 안에서도 요청 단위 로테이션 |
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` | **Interactive prompt loop (Claude Code-style)**: each turn runs through rotation and resumes the same codex session, so the conversation survives account switches. Slash commands inside: `/usage /use /next /model /new /quit` |
211
+ | `cxs 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): swaps credentials per request, retries 429s on the next account, and feeds usage gauges live from response headers |
231
- | `cxs run --proxy [args]` | Run codex routed through the local proxy — per-request rotation even inside interactive sessions |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carjms/codexswitch",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "description": "Multi-account manager for OpenAI Codex CLI with quota-based rotation (inspired by KarpelesLab/teamclaude)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -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 interactive prompt loop (Claude Code-style): each turn
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 inside)
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
- run --proxy [args...] run codex routed through the local proxy
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
- out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
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(`limited ${fmtRemaining(a.limitedUntil)}`)
185
+ ? ui.yellow(`paused ${fmtRemaining(a.limitedUntil)}`)
174
186
  : over
175
187
  ? ui.yellow(`over-${over}`)
176
- : ui.green('ok');
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
- out(ui.dim(`(rotate threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}% — change with "codexswitch threshold")`));
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(' no usage data yet — codex records it during runs (try "codexswitch run" once)'));
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(`\nrotation would pick: ${next ? next.name : '(none usable)'} · threshold 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`));
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
- out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
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
- console.error(
891
- ui.info(`exec as ${ui.bold(`"${name}"`)}${ui.dim(attempt > 0 ? ` (attempt ${attempt + 1}${useResume ? ', resuming session' : ''})` : '')}`)
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 codexArgs = useResume
896
- ? ['exec', 'resume', '--last', ...buildExecArgs([], meta), '--', RESUME_PROMPT]
897
- : ['exec', ...buildExecArgs(rest, meta)];
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 res = await runner.runCodex(name, codexArgs, { capture: true });
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
- const meta = store.loadMeta();
947
- out(`${ui.bold('codexswitch chat')} ${ui.dim(`· model ${meta.model || '(codex default)'} · rotation on limits`)}`);
948
- out(ui.dim('type a prompt, or /help for commands (/quit to exit)\n'));
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] /new (fresh session) /quit'));
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 && tried.length < total) {
151
- store.markLimited(acct.name, Date.now() + meta.cooldownMinutes * 60000);
152
- store.logEvent('limit', `proxy: "${acct.name}" got 429 — rotating`);
153
- log('rotate', `${acct.name} hit 429 -> trying next account`);
154
- upRes.resume(); // discard
155
- continue;
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 { visible, dim } = require('./ui.js');
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) => visible(r[i]).length)));
96
- const pad = (c, w) => c + ' '.repeat(Math.max(0, w - visible(c).length));
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 };