@carjms/codexswitch 0.6.3 → 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/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,18 +53,25 @@ 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
- | write (edit files in cwd) | full (no sandbox)
67
+ | write (edit files in cwd) | write+net (also allow
68
+ ssh/curl/network) | full (no sandbox)
58
69
  threshold [5h%] [wk%] rotate early when recorded usage reaches these percents
59
70
  of the 5-hour / weekly window (default 95, one value = both)
60
71
  cooldown [minutes] show/set rate-limit cooldown (default 60)
61
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]
62
75
 
63
76
  Maintenance
64
77
  sync save tokens refreshed by codex back into the store
@@ -154,7 +167,7 @@ function cmdLogin(args) {
154
167
  function cmdList() {
155
168
  const accounts = store.listAccounts();
156
169
  if (accounts.length === 0) {
157
- out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
170
+ printEmptyAccounts();
158
171
  return;
159
172
  }
160
173
  const now = Date.now();
@@ -169,10 +182,10 @@ function cmdList() {
169
182
  const status = a.disabled
170
183
  ? ui.red('disabled')
171
184
  : a.limitedUntil && a.limitedUntil > now
172
- ? ui.yellow(`limited ${fmtRemaining(a.limitedUntil)}`)
185
+ ? ui.yellow(`paused ${fmtRemaining(a.limitedUntil)}`)
173
186
  : over
174
187
  ? ui.yellow(`over-${over}`)
175
- : ui.green('ok');
188
+ : ui.green('ready');
176
189
  return [
177
190
  a.active ? ui.green('●') : '',
178
191
  a.active ? ui.bold(a.name) : a.name,
@@ -186,7 +199,19 @@ function cmdList() {
186
199
  ];
187
200
  });
188
201
  out(table(rows, ['', 'name', 'email', 'plan', 'prio', 'status', '5h', 'week', 'token refreshed']));
189
- 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.'));
190
215
  }
191
216
 
192
217
  function cmdUse(args) {
@@ -271,6 +296,12 @@ function cmdRename(args) {
271
296
  if (store.accountExists(to)) throw new Error(`account "${to}" already exists`);
272
297
  store.writeAccountAuth(to, auth);
273
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
+ }
274
305
  const meta = store.loadMeta();
275
306
  meta.accounts[to] = meta.accounts[from] || { priority: 0 };
276
307
  delete meta.accounts[from];
@@ -376,6 +407,13 @@ function cmdModel(args) {
376
407
  function usageLines(accounts, meta, { cursor = -1 } = {}) {
377
408
  const now = Date.now();
378
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('');
379
417
  const bar = (pct, threshold) => {
380
418
  const width = 20;
381
419
  const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
@@ -395,7 +433,7 @@ function usageLines(accounts, meta, { cursor = -1 } = {}) {
395
433
  const state = a.disabled ? ui.red(' [disabled]') : a.limitedUntil && a.limitedUntil > now ? ui.yellow(` [limited ${fmtRemaining(a.limitedUntil)}]`) : '';
396
434
  lines.push(`${cursor >= 0 ? sel : ''}${mark} ${ui.bold(a.name)}${ui.dim(` (${a.plan || a.email || '-'})`)}${state}`);
397
435
  if (!a.usage) {
398
- 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}"`));
399
437
  } else {
400
438
  lines.push(gauge('5h ', a.usage.p5h, meta.threshold5h));
401
439
  lines.push(gauge('weekly', a.usage.weekly, meta.thresholdWeekly));
@@ -403,7 +441,8 @@ function usageLines(accounts, meta, { cursor = -1 } = {}) {
403
441
  }
404
442
  });
405
443
  const next = store.pickAccount();
406
- 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}%`));
407
446
  return lines;
408
447
  }
409
448
 
@@ -415,7 +454,7 @@ function cmdUsage(args) {
415
454
  if (accounts.length === 0) throw new Error(`no such account: ${args[0]}`);
416
455
  }
417
456
  if (accounts.length === 0) {
418
- out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
457
+ printEmptyAccounts();
419
458
  return;
420
459
  }
421
460
  for (const l of usageLines(accounts, meta)) out(l);
@@ -482,6 +521,93 @@ function cmdLog(args) {
482
521
  }
483
522
  }
484
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
+
485
611
  // Live interactive dashboard: usage gauges + recent activity, refreshed
486
612
  // every 5s. Keyboard: up/down select, s switch, e enable/disable, p probe,
487
613
  // r refresh, q quit.
@@ -645,8 +771,8 @@ function cmdNames() {
645
771
  }
646
772
 
647
773
  const COMMANDS =
648
- 'login import add-key list usage watch probe log chat server use current next run exec order model remove rename ' +
649
- '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';
650
776
 
651
777
  function cmdCompletion(args) {
652
778
  const shell = args[0];
@@ -693,12 +819,27 @@ function injectExecFlags(rest, meta) {
693
819
  a === '--full-auto' ||
694
820
  a === '--dangerously-bypass-approvals-and-sandbox'
695
821
  );
696
- if (meta.sandbox && !hasSandbox) args.unshift('--sandbox', meta.sandbox);
822
+ if (meta.sandbox && !hasSandbox) {
823
+ if (meta.sandbox === 'workspace-write+net') {
824
+ // workspace-write plus outbound network (ssh, curl, npm install...)
825
+ args.unshift('--sandbox', 'workspace-write', '-c', 'sandbox_workspace_write.network_access=true');
826
+ } else {
827
+ args.unshift('--sandbox', meta.sandbox);
828
+ }
829
+ }
697
830
  args.unshift(...reasoningFlags(meta, args));
698
831
  return args;
699
832
  }
700
833
 
701
- const SANDBOX_MODES = { 'read-only': 'read-only', write: 'workspace-write', 'workspace-write': 'workspace-write', full: 'danger-full-access', 'danger-full-access': 'danger-full-access' };
834
+ const SANDBOX_MODES = {
835
+ 'read-only': 'read-only',
836
+ write: 'workspace-write',
837
+ 'workspace-write': 'workspace-write',
838
+ 'write+net': 'workspace-write+net',
839
+ net: 'workspace-write+net',
840
+ full: 'danger-full-access',
841
+ 'danger-full-access': 'danger-full-access',
842
+ };
702
843
 
703
844
  // codex exec defaults to a read-only sandbox; this setting lets it write.
704
845
  function cmdSandbox(args) {
@@ -714,14 +855,15 @@ function cmdSandbox(args) {
714
855
  return;
715
856
  }
716
857
  const mode = SANDBOX_MODES[args[0]];
717
- if (!mode) throw new Error('usage: codexswitch sandbox <read-only|write|full>');
858
+ if (!mode) throw new Error('usage: codexswitch sandbox <read-only|write|write+net|full>');
718
859
  meta.sandbox = mode;
719
860
  store.saveMeta(meta);
720
- out(
721
- mode === 'workspace-write'
722
- ? ui.ok('sandbox set to workspace-write — codex can edit files in the working directory')
723
- : ui.warn('sandbox set to danger-full-access — codex can touch anything, use with care')
724
- );
861
+ const messages = {
862
+ 'workspace-write': ui.ok('sandbox set to workspace-write — codex can edit files in the working directory'),
863
+ 'workspace-write+net': ui.ok('sandbox set to workspace-write + network — codex can also use ssh/curl/npm'),
864
+ 'danger-full-access': ui.warn('sandbox set to danger-full-access — codex can touch anything, use with care'),
865
+ };
866
+ out(messages[mode]);
725
867
  }
726
868
 
727
869
  // -c overrides implementing "cxs reasoning hide|concise|show".
@@ -756,9 +898,157 @@ function cmdReasoning(args) {
756
898
  );
757
899
  }
758
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
+
759
1041
  // EXPERIMENTAL: foreground proxy server for per-request rotation.
760
1042
  async function cmdServer(args) {
761
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
+ }
762
1052
  let port = proxy.DEFAULT_PORT;
763
1053
  for (let i = 0; i < args.length; i++) {
764
1054
  if (args[i] === '--port' || args[i] === '-p') port = parseInt(args[++i], 10);
@@ -790,13 +1080,18 @@ async function cmdServer(args) {
790
1080
  async function cmdRun(args) {
791
1081
  store.syncBack(); // pick up tokens refreshed by plain codex before overlaying
792
1082
  let proxyPort = null;
1083
+ const requireProxy = args.includes('--require-proxy');
1084
+ args = args.filter((a) => a !== '--require-proxy');
793
1085
  if (args.includes('--proxy')) {
794
1086
  const proxy = require('./proxy.js');
795
1087
  args = args.filter((a) => a !== '--proxy');
796
1088
  const state = readJSONSafe(path.join(store.paths().home, 'proxy.json'));
797
1089
  proxyPort = (state && state.port) || proxy.DEFAULT_PORT;
798
1090
  if (!(await proxy.ping(proxyPort))) {
799
- 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;
800
1095
  }
801
1096
  }
802
1097
  let name = null;
@@ -812,9 +1107,10 @@ async function cmdRun(args) {
812
1107
  name = meta.active && store.accountExists(meta.active) ? meta.active : picked.name;
813
1108
  }
814
1109
  if (rest[0] === '--') rest = rest.slice(1);
1110
+ const rawExecArgs = rest[0] === 'exec' ? rest.slice(1) : null;
815
1111
  const meta = store.loadMeta();
816
1112
  if (rest[0] === 'exec') {
817
- rest = ['exec', ...buildExecArgs(rest.slice(1), meta)];
1113
+ rest = ['exec', ...buildExecArgs(withMemoryPrompt(rest.slice(1), name, meta), meta)];
818
1114
  } else if (rest.length === 0) {
819
1115
  // interactive TUI launch — apply the same defaults
820
1116
  if (meta.model) rest = ['-m', meta.model];
@@ -822,8 +1118,30 @@ async function cmdRun(args) {
822
1118
  }
823
1119
  out(ui.info(`running codex as ${ui.bold(`"${name}"`)}${proxyPort ? ui.dim(` via proxy :${proxyPort}`) : ''}`));
824
1120
  const startTs = Date.now();
1121
+ const before = rawExecArgs ? workspaceSnapshot() : null;
825
1122
  const res = await runner.runCodex(name, rest, { proxyPort });
826
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
+ }
827
1145
  return res.code;
828
1146
  }
829
1147
 
@@ -855,9 +1173,45 @@ async function cmdExec(args) {
855
1173
 
856
1174
  store.syncBack(); // pick up tokens refreshed by plain codex before overlaying
857
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
+ };
858
1184
  const total = store.listAccounts().length;
859
1185
  if (total === 0) throw new Error('no accounts — add one with "codexswitch login"');
860
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
+
861
1215
  const tried = [];
862
1216
  let useResume = false;
863
1217
  for (let attempt = 0; attempt < total; attempt++) {
@@ -870,25 +1224,47 @@ async function cmdExec(args) {
870
1224
  name = picked.name;
871
1225
  }
872
1226
  tried.push(name);
873
- console.error(
874
- ui.info(`exec as ${ui.bold(`"${name}"`)}${ui.dim(attempt > 0 ? ` (attempt ${attempt + 1}${useResume ? ', resuming session' : ''})` : '')}`)
875
- );
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
+ }
876
1235
  // On rotation, continue the same session with the next account instead
877
1236
  // of restarting the whole prompt — the session files are shared.
878
- const codexArgs = useResume
879
- ? ['exec', 'resume', '--last', ...buildExecArgs([], meta), '--', RESUME_PROMPT]
880
- : ['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)];
881
1242
  const startTs = Date.now();
882
- 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();
883
1248
  runner.recordUsage(name, res.profile, startTs);
884
1249
  if (res.code === 0) {
1250
+ runRecord.attempts.push({ account: name, status: 'done', durationMs: Date.now() - startTs });
885
1251
  store.clearLimited(name);
886
- console.error(ui.ok(`done as ${ui.bold(`"${name}"`)}`));
1252
+ if (!compact) console.error(ui.ok(`done as ${ui.bold(`"${name}"`)}`));
887
1253
  store.logEvent('exec', `done as "${name}"${useResume ? ' (resumed session)' : ''}`);
888
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}`)));
889
1264
  return 0;
890
1265
  }
891
1266
  if (runner.looksRateLimited(res.output, meta.limitPatterns)) {
1267
+ runRecord.attempts.push({ account: name, status: 'rate-limited', durationMs: Date.now() - startTs });
892
1268
  const until = Date.now() + runner.limitCooldownMs(res.output, meta.cooldownMinutes);
893
1269
  store.markLimited(name, until);
894
1270
  if (allowResume && !useResume && runner.sessionTouchedSince(res.profile, startTs)) {
@@ -901,6 +1277,7 @@ async function cmdExec(args) {
901
1277
  continue;
902
1278
  }
903
1279
  if (runner.looksAuthFailed(res.output)) {
1280
+ runRecord.attempts.push({ account: name, status: 'auth-failed', durationMs: Date.now() - startTs });
904
1281
  // A revoked token won't heal by itself — take the account out of
905
1282
  // rotation and keep the task going on the next one.
906
1283
  setFlag(name, { disabled: true });
@@ -910,9 +1287,19 @@ async function cmdExec(args) {
910
1287
  store.logEvent('auth', `"${name}" disabled (revoked login)`);
911
1288
  continue;
912
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();
913
1297
  return res.code; // real failure, don't burn other accounts on it
914
1298
  }
915
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}`)));
916
1303
  return 2;
917
1304
  }
918
1305
 
@@ -920,21 +1307,32 @@ async function cmdExec(args) {
920
1307
  // Every turn goes through the rotation engine, and turns 2+ resume the same
921
1308
  // codex session — so the conversation continues even when the account under
922
1309
  // it changes between (or during) turns.
923
- async function cmdChat() {
1310
+ async function cmdChat({ welcome = true } = {}) {
924
1311
  const readline = require('readline');
925
1312
  runner.assertCodexAvailable();
926
1313
  if (store.listAccounts().length === 0) {
927
1314
  throw new Error('no accounts — add one with "codexswitch login"');
928
1315
  }
929
- const meta = store.loadMeta();
930
- out(`${ui.bold('codexswitch chat')} ${ui.dim(`· model ${meta.model || '(codex default)'} · rotation on limits`)}`);
931
- 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'));
932
1330
 
933
1331
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
934
1332
  rl.setPrompt(ui.enabled ? `${ui.cyan('cxs')} ${ui.dim('›')} ` : 'cxs › ');
935
1333
  // Buffer lines ourselves: input can arrive while a turn is still running
936
1334
  // (type-ahead, piped input) and must not be dropped or hit a closed rl.
937
- const pending = [];
1335
+ const pending = welcomePrompt ? [welcomePrompt] : [];
938
1336
  const waiters = [];
939
1337
  let closed = false;
940
1338
  rl.on('line', (l) => {
@@ -954,6 +1352,7 @@ async function cmdChat() {
954
1352
  };
955
1353
 
956
1354
  let inSession = false;
1355
+ let turn = 0;
957
1356
  for (;;) {
958
1357
  const raw = await ask();
959
1358
  if (raw == null) break; // EOF (Ctrl-D or piped input ended)
@@ -965,7 +1364,9 @@ async function cmdChat() {
965
1364
  try {
966
1365
  if (cmd === 'quit' || cmd === 'exit' || cmd === 'q') break;
967
1366
  else if (cmd === 'help')
968
- 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]] : []);
969
1370
  else if (cmd === 'usage') cmdUsage([]);
970
1371
  else if (cmd === 'list') cmdList();
971
1372
  else if (cmd === 'use') cmdUse(rest);
@@ -973,9 +1374,33 @@ async function cmdChat() {
973
1374
  else if (cmd === 'model') cmdModel(rest);
974
1375
  else if (cmd === 'sandbox') cmdSandbox(rest);
975
1376
  else if (cmd === 'reasoning') cmdReasoning(rest);
1377
+ else if (cmd === 'output') cmdOutput(rest);
1378
+ else if (cmd === 'memory') cmdMemory(rest);
976
1379
  else if (cmd === 'new') {
977
1380
  inSession = false;
1381
+ turn = 0;
978
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
+ }
979
1404
  } else out(ui.warn(`unknown command /${cmd} — try /help`));
980
1405
  } catch (e) {
981
1406
  out(ui.fail(e.message));
@@ -985,11 +1410,14 @@ async function cmdChat() {
985
1410
 
986
1411
  // "--" marks the prompt as positional so lines starting with "-" are
987
1412
  // never parsed as codex options.
1413
+ turn++;
1414
+ out(ui.info(`turn ${turn} · ${inSession ? 'continuing session' : 'new session'}`));
988
1415
  const code = await cmdExec(inSession ? ['resume', '--last', '--', line] : ['--', line]);
989
1416
  if (code === 0) inSession = true;
990
1417
  else if (code === 2) out(ui.fail('all accounts exhausted — try again later or /use a specific account'));
991
1418
  }
992
1419
  rl.close();
1420
+ out(ui.dim(`\nsession ended · ${turn} turn${turn === 1 ? '' : 's'}${turn > 0 ? ' · review with "codexswitch history"' : ''}`));
993
1421
  return 0;
994
1422
  }
995
1423
 
@@ -1010,6 +1438,11 @@ async function main(argv) {
1010
1438
  const [cmd, ...args] = argv;
1011
1439
  switch (cmd) {
1012
1440
  case undefined:
1441
+ if (store.listAccounts().length === 0) {
1442
+ printEmptyAccounts();
1443
+ return 0;
1444
+ }
1445
+ return cmdChat();
1013
1446
  case 'help':
1014
1447
  case '--help':
1015
1448
  case '-h':
@@ -1039,6 +1472,9 @@ async function main(argv) {
1039
1472
  case 'log':
1040
1473
  case 'activity':
1041
1474
  return cmdLog(args), 0;
1475
+ case 'history':
1476
+ case 'work':
1477
+ return cmdHistory(args), 0;
1042
1478
  case 'use':
1043
1479
  return cmdUse(args), 0;
1044
1480
  case 'current':
@@ -1067,8 +1503,12 @@ async function main(argv) {
1067
1503
  return cmdModel(args), 0;
1068
1504
  case 'reasoning':
1069
1505
  return cmdReasoning(args), 0;
1506
+ case 'output':
1507
+ return cmdOutput(args), 0;
1070
1508
  case 'sandbox':
1071
1509
  return cmdSandbox(args), 0;
1510
+ case 'memory':
1511
+ return cmdMemory(args), 0;
1072
1512
  case 'add-key':
1073
1513
  case 'apikey':
1074
1514
  return cmdAddKey(args), 0;
@@ -1100,7 +1540,7 @@ async function main(argv) {
1100
1540
  return cmdExec(args);
1101
1541
  case 'chat':
1102
1542
  case 'repl':
1103
- return cmdChat();
1543
+ return cmdChat({ welcome: !args.includes('--simple') });
1104
1544
  case 'server':
1105
1545
  case 'proxy':
1106
1546
  return cmdServer(args);