@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/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 };