@jojonax/codex-copilot 1.4.3 → 1.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jojonax/codex-copilot",
3
- "version": "1.4.3",
3
+ "version": "1.5.1",
4
4
  "description": "PRD-driven automated development orchestrator for CodeX / Cursor",
5
5
  "bin": {
6
6
  "codex-copilot": "./bin/cli.js"
@@ -248,9 +248,9 @@ export async function evolve(projectDir) {
248
248
 
249
249
  // Execute via AI provider
250
250
  log.step(`Invoking AI to analyze gaps and plan Round ${nextRound}...`);
251
- const ok = await provider.executePrompt(providerId, promptPath, projectDir);
251
+ const result = await provider.executePrompt(providerId, promptPath, projectDir);
252
252
 
253
- if (!ok) {
253
+ if (!result.ok) {
254
254
  log.error('AI invocation failed. You can manually edit the prompt and retry.');
255
255
  closePrompt();
256
256
  process.exit(1);
@@ -101,12 +101,42 @@ export async function init(projectDir) {
101
101
 
102
102
  // ===== Select AI Provider =====
103
103
  log.step('Select AI coding tool...');
104
+
105
+ // Check versions of installed CLI tools (non-blocking)
106
+ log.info('Checking tool versions...');
107
+ let versionCache = {};
108
+ try {
109
+ versionCache = provider.checkAllVersions();
110
+ const updatable = Object.entries(versionCache).filter(([, v]) => v.updateAvailable);
111
+ if (updatable.length > 0) {
112
+ for (const [id, v] of updatable) {
113
+ log.warn(`${provider.getProvider(id).name}: v${v.current} → v${v.latest} update available`);
114
+ }
115
+ } else if (Object.keys(versionCache).length > 0) {
116
+ log.info('All detected tools are up to date ✓');
117
+ }
118
+ } catch {
119
+ // Version check failed — continue normally
120
+ }
121
+
104
122
  log.blank();
105
- const providerChoices = provider.buildProviderChoices();
123
+ const providerChoices = provider.buildProviderChoices(versionCache);
106
124
  const selectedProvider = await select('Which AI tool will you use?', providerChoices);
107
125
  const providerId = selectedProvider.value;
108
126
  const providerInfo = provider.getProvider(providerId);
109
127
  log.info(`Selected: ${providerInfo.name}`);
128
+
129
+ // Offer update if newer version available
130
+ const vi = versionCache[providerId];
131
+ if (vi && vi.updateAvailable) {
132
+ log.blank();
133
+ log.warn(`${providerInfo.name} v${vi.current} → v${vi.latest} available`);
134
+ const doUpdate = await confirm(`Update ${providerInfo.name} now?`);
135
+ if (doUpdate) {
136
+ provider.updateProvider(providerId);
137
+ }
138
+ }
139
+
110
140
  log.blank();
111
141
 
112
142
  // ===== Create .codex-copilot directory =====
@@ -194,8 +224,8 @@ You are an efficient automated development agent responsible for completing feat
194
224
  const autoparse = await confirm(`Auto-invoke ${providerInfo.name} to decompose PRD?`);
195
225
  if (autoparse) {
196
226
  log.step(`Invoking ${providerInfo.name} to decompose PRD...`);
197
- const ok = await provider.executePrompt(providerId, promptPath, projectDir);
198
- if (ok) {
227
+ const result = await provider.executePrompt(providerId, promptPath, projectDir);
228
+ if (result.ok) {
199
229
  log.info('PRD decomposition complete!');
200
230
  }
201
231
  } else {
@@ -14,6 +14,8 @@ import { closePrompt } from '../utils/prompt.js';
14
14
  import { createCheckpoint } from '../utils/checkpoint.js';
15
15
  import { provider } from '../utils/provider.js';
16
16
 
17
+ const maxRateLimitRetries = 3;
18
+
17
19
  function readJSON(path) {
18
20
  return JSON.parse(readFileSync(path, 'utf-8'));
19
21
  }
@@ -54,6 +56,7 @@ export async function run(projectDir) {
54
56
  const pollInterval = config.review_poll_interval || 60;
55
57
  const waitTimeout = config.review_wait_timeout || 600;
56
58
  const isPrivate = github.isPrivateRepo(projectDir); // Cache once
59
+ const weeklyQuotaThreshold = config.weekly_quota_threshold || 97;
57
60
 
58
61
  const providerInfo = provider.getProvider(providerId);
59
62
  log.info(`AI Provider: ${providerInfo ? providerInfo.name : providerId}`);
@@ -85,7 +88,7 @@ export async function run(projectDir) {
85
88
  log.info(`Base branch: ${baseBranch}`);
86
89
 
87
90
  // ===== Pre-flight: ensure base branch is committed & pushed =====
88
- await ensureBaseReady(projectDir, baseBranch);
91
+ await ensureBaseReady(projectDir, baseBranch, isPrivate);
89
92
 
90
93
  // Show resume info if resuming mid-task
91
94
  if (state.phase && state.current_task > 0) {
@@ -131,6 +134,23 @@ export async function run(projectDir) {
131
134
  log.title(`━━━ Task ${task.id}/${tasks.total}: ${task.title} ━━━`);
132
135
  log.blank();
133
136
 
137
+ // ===== Quota pre-check (Codex CLI only) =====
138
+ if (providerId === 'codex-cli' || providerId === 'codex-desktop') {
139
+ const quota = provider.checkQuotaBeforeExecution(weeklyQuotaThreshold);
140
+ if (!quota.ok) {
141
+ log.blank();
142
+ log.error(`⚠ Weekly quota at ${quota.quota7d}% (threshold: ${weeklyQuotaThreshold}%) — stopping to preserve remaining quota`);
143
+ log.info('Run `codex-copilot usage` to check quota details');
144
+ log.info('Run `codex-copilot run` again when quota resets');
145
+ writeJSON(tasksPath, tasks);
146
+ closePrompt();
147
+ process.exit(0);
148
+ }
149
+ if (quota.warning) {
150
+ log.warn(`⚠ Weekly quota at ${quota.quota7d}% — approaching limit`);
151
+ }
152
+ }
153
+
134
154
  // Check dependencies — completed, skipped, and blocked all satisfy dependencies.
135
155
  // Blocked tasks are treated as "done for now" to prevent cascade-skipping;
136
156
  // the user can retry them later with `codex-copilot retry`.
@@ -304,14 +324,34 @@ async function developPhase(projectDir, task, baseBranch, checkpoint, providerId
304
324
  log.dim('⏩ Prompt already generated');
305
325
  }
306
326
 
307
- // Step 3: Execute via AI Provider
327
+ // Step 3: Execute via AI Provider (with rate limit auto-retry)
308
328
  if (!checkpoint.isStepDone(task.id, 'develop', 'codex_complete')) {
309
329
  const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
310
- const ok = await provider.executePrompt(providerId, promptPath, projectDir);
311
- if (ok) {
312
- log.info('Development complete');
313
- checkpoint.saveStep(task.id, 'develop', 'codex_complete', { branch: task.branch });
314
- } else {
330
+ let rateLimitRetries = 0;
331
+
332
+ while (rateLimitRetries < maxRateLimitRetries) {
333
+ const result = await provider.executePrompt(providerId, promptPath, projectDir);
334
+ if (result.ok) {
335
+ log.info('Development complete');
336
+ checkpoint.saveStep(task.id, 'develop', 'codex_complete', { branch: task.branch });
337
+ break;
338
+ }
339
+
340
+ if (result.rateLimited && result.retryAt) {
341
+ rateLimitRetries++;
342
+ log.warn(`Rate limit hit (attempt ${rateLimitRetries}/${maxRateLimitRetries})`);
343
+ if (rateLimitRetries >= maxRateLimitRetries) {
344
+ log.error('Max rate limit retries reached — marking task as blocked');
345
+ task.status = 'blocked';
346
+ task.block_reason = 'rate_limited';
347
+ return;
348
+ }
349
+ await waitForRateLimitReset(result.retryAt, result.retryAtStr);
350
+ log.info('Rate limit reset — resuming development...');
351
+ continue;
352
+ }
353
+
354
+ // Non-rate-limit failure
315
355
  log.error('AI development failed — marking task as blocked');
316
356
  task.status = 'blocked';
317
357
  task.block_reason = 'dev_failed';
@@ -625,11 +665,29 @@ ${feedback}
625
665
  4. Do NOT run git add or git commit — the automation handles committing
626
666
  `;
627
667
 
628
- // Save to file and execute via provider
668
+ // Save to file and execute via provider (with rate limit auto-retry)
629
669
  const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
630
670
  writeFileSync(promptPath, fixPrompt);
631
671
 
632
- await provider.executePrompt(providerId, promptPath, projectDir);
672
+ let rateLimitRetries = 0;
673
+ while (rateLimitRetries < 3) {
674
+ const result = await provider.executePrompt(providerId, promptPath, projectDir);
675
+ if (result.ok) {
676
+ break;
677
+ }
678
+ if (result.rateLimited && result.retryAt) {
679
+ rateLimitRetries++;
680
+ if (rateLimitRetries >= 3) {
681
+ log.warn('Max rate limit retries reached during fix phase');
682
+ break;
683
+ }
684
+ log.warn(`Rate limit hit during fix — waiting for reset...`);
685
+ await waitForRateLimitReset(result.retryAt, result.retryAtStr);
686
+ log.info('Rate limit reset — retrying fix...');
687
+ continue;
688
+ }
689
+ break; // Non-rate-limit failure
690
+ }
633
691
  log.info('Fix complete');
634
692
  }
635
693
 
@@ -682,7 +740,8 @@ async function mergePhase(projectDir, task, prInfo, baseBranch, checkpoint) {
682
740
  // ──────────────────────────────────────────────
683
741
  // Pre-flight: ensure base branch has commits & is pushed
684
742
  // ──────────────────────────────────────────────
685
- async function ensureBaseReady(projectDir, baseBranch) {
743
+ async function ensureBaseReady(projectDir, baseBranch, isPrivate = false) {
744
+ const skipCI = isPrivate ? ' [skip ci]' : '';
686
745
  // Check if the repo has any commits at all
687
746
  const hasCommits = git.execSafe('git rev-parse HEAD', projectDir);
688
747
  if (!hasCommits.ok) {
@@ -690,7 +749,7 @@ async function ensureBaseReady(projectDir, baseBranch) {
690
749
  log.warn('No commits found in repository');
691
750
  if (!git.isClean(projectDir)) {
692
751
  log.info('Creating initial commit from existing code...');
693
- git.commitAll(projectDir, 'chore: initial commit');
752
+ git.commitAll(projectDir, `chore: initial commit${skipCI}`);
694
753
  log.info('✅ Initial commit created');
695
754
  } else {
696
755
  log.warn('Repository is empty — no files to commit');
@@ -701,7 +760,7 @@ async function ensureBaseReady(projectDir, baseBranch) {
701
760
  const currentBranch = git.currentBranch(projectDir);
702
761
  if (currentBranch === baseBranch && !git.isClean(projectDir)) {
703
762
  log.info('Uncommitted changes on base branch, committing first...');
704
- git.commitAll(projectDir, 'chore: save current progress before automation');
763
+ git.commitAll(projectDir, `chore: save current progress before automation${skipCI}`);
705
764
  log.info('✅ Base branch changes committed');
706
765
  }
707
766
  }
@@ -756,5 +815,41 @@ function sleep(ms) {
756
815
  return new Promise(resolve => setTimeout(resolve, ms));
757
816
  }
758
817
 
818
+ /**
819
+ * Wait for rate limit reset with countdown display.
820
+ * @param {Date} retryAt - When to resume
821
+ * @param {string} retryAtStr - Human-readable time string
822
+ */
823
+ async function waitForRateLimitReset(retryAt, retryAtStr) {
824
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
825
+ let spinIdx = 0;
826
+
827
+ log.blank();
828
+ log.info(`⏳ Rate limited — auto-resuming at ${retryAtStr}`);
829
+ log.blank();
830
+
831
+ while (true) {
832
+ const now = Date.now();
833
+ const remaining = retryAt.getTime() - now;
834
+ if (remaining <= 0) break;
835
+
836
+ const mins = Math.floor(remaining / 60000);
837
+ const secs = Math.floor((remaining % 60000) / 1000);
838
+ const frame = SPINNER[spinIdx % SPINNER.length];
839
+ spinIdx++;
840
+
841
+ process.stdout.write(
842
+ `\r\x1b[K \x1b[33m${frame}\x1b[0m Waiting for rate limit reset... \x1b[1m${mins}m ${secs}s\x1b[0m remaining`
843
+ );
844
+
845
+ await sleep(1000);
846
+ }
847
+
848
+ process.stdout.write('\r\x1b[K');
849
+ log.info('✅ Rate limit reset — waiting 3min buffer before resuming...');
850
+ // Add 3-minute buffer to ensure the limit is actually lifted
851
+ await sleep(180_000);
852
+ }
853
+
759
854
 
760
855
 
@@ -237,29 +237,61 @@ export async function usage() {
237
237
  log.info(` Total (${codexSessions.length} sessions): ${formatTokens(totalTokens)} tokens`);
238
238
  log.blank();
239
239
 
240
+ // Collect all breakdowns first to compute per-session quota delta
241
+ const allBreakdowns = codexSessions.map(s => ({
242
+ session: s,
243
+ breakdown: getCodexTokenBreakdown(s.rolloutPath),
244
+ })).filter(b => b.breakdown);
245
+
240
246
  // Token breakdown per session
241
- for (const session of codexSessions) {
242
- const breakdown = getCodexTokenBreakdown(session.rolloutPath);
243
- if (!breakdown) continue;
247
+ for (let idx = 0; idx < allBreakdowns.length; idx++) {
248
+ const { session, breakdown } = allBreakdowns[idx];
244
249
 
245
250
  const title = session.title.length > 50 ? session.title.substring(0, 47) + '...' : session.title;
246
251
  console.log(`\x1b[2m ┌ ${title}\x1b[0m`);
247
252
 
253
+ // Effective (non-cached) tokens and cache rate
254
+ const effectiveInput = Math.max(0, breakdown.input - breakdown.cached);
255
+ const cacheRate = breakdown.input > 0
256
+ ? ((breakdown.cached / breakdown.input) * 100).toFixed(0)
257
+ : '0';
258
+ const effectiveTotal = effectiveInput + breakdown.output;
259
+
248
260
  const bRows = [
249
261
  ['Input', formatTokens(breakdown.input)],
250
262
  [' ↳ Cached', formatTokens(breakdown.cached)],
251
263
  ['Output', formatTokens(breakdown.output)],
252
264
  [' ↳ Reasoning', formatTokens(breakdown.reasoning)],
253
265
  ['Total', formatTokens(breakdown.total)],
266
+ ['Effective', `${formatTokens(effectiveTotal)} (cache ${cacheRate}%)`],
254
267
  ];
255
268
  for (const [label, val] of bRows) {
256
- console.log(`\x1b[2m │ ${padRight(label, 16)} ${padLeft(val, 10)}\x1b[0m`);
269
+ console.log(`\x1b[2m │ ${padRight(label, 16)} ${padLeft(val, 24)}\x1b[0m`);
257
270
  }
258
271
 
259
272
  if (breakdown.rateLimits) {
260
273
  const rl = breakdown.rateLimits;
261
274
  const resetPrimary = rl.primaryResets ? new Date(rl.primaryResets * 1000).toLocaleString() : 'N/A';
262
- console.log(`\x1b[2m │ Quota: ${rl.primaryUsed ?? '?'}% (5h) / ${rl.secondaryUsed ?? '?'}% (7d) Resets: ${resetPrimary}\x1b[0m`);
275
+
276
+ // Estimate this session's quota contribution by comparing with next (older) session
277
+ // Sessions are sorted newest-first, so next entry is the previous session
278
+ let quotaNote5h = '';
279
+ let quotaNote7d = '';
280
+ const nextBreakdown = idx < allBreakdowns.length - 1 ? allBreakdowns[idx + 1].breakdown : null;
281
+ if (nextBreakdown?.rateLimits) {
282
+ const nrl = nextBreakdown.rateLimits;
283
+ // Only compute delta if same reset window (same resets_at timestamp)
284
+ if (nrl.primaryResets === rl.primaryResets && rl.primaryUsed != null && nrl.primaryUsed != null) {
285
+ const delta5h = rl.primaryUsed - nrl.primaryUsed;
286
+ quotaNote5h = delta5h >= 0 ? ` (this session: +${delta5h}%)` : '';
287
+ }
288
+ if (nrl.secondaryResets === rl.secondaryResets && rl.secondaryUsed != null && nrl.secondaryUsed != null) {
289
+ const delta7d = rl.secondaryUsed - nrl.secondaryUsed;
290
+ quotaNote7d = delta7d >= 0 ? ` (this session: +${delta7d}%)` : '';
291
+ }
292
+ }
293
+
294
+ console.log(`\x1b[2m │ Quota: ${rl.primaryUsed ?? '?'}% (5h)${quotaNote5h} / ${rl.secondaryUsed ?? '?'}% (7d)${quotaNote7d} Resets: ${resetPrimary}\x1b[0m`);
263
295
  }
264
296
  console.log(`\x1b[2m └──\x1b[0m`);
265
297
  }
@@ -10,12 +10,14 @@
10
10
  */
11
11
 
12
12
  import { execSync, spawn } from 'child_process';
13
- import { readFileSync, writeFileSync } from 'fs';
13
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
14
14
  import { resolve } from 'path';
15
15
  import { log } from './logger.js';
16
16
  import { ask } from './prompt.js';
17
17
  import { automator } from './automator.js';
18
18
 
19
+ const HOME = process.env.HOME || process.env.USERPROFILE || '~';
20
+
19
21
  // ──────────────────────────────────────────────
20
22
  // Provider Registry — each provider is unique
21
23
  // ──────────────────────────────────────────────
@@ -31,6 +33,12 @@ const PROVIDERS = {
31
33
  buildCommand: (promptPath, cwd) =>
32
34
  `cat ${shellEscape(promptPath)} | codex exec --full-auto -`,
33
35
  description: 'OpenAI Codex CLI — pipes prompt via stdin',
36
+ version: {
37
+ command: 'codex --version',
38
+ parse: (output) => output.match(/[\d.]+/)?.[0],
39
+ latest: { type: 'brew-cask', name: 'codex' },
40
+ update: 'brew upgrade --cask codex',
41
+ },
34
42
  },
35
43
 
36
44
  'claude-code': {
@@ -45,6 +53,12 @@ const PROVIDERS = {
45
53
  return `claude -p "$(cat ${escaped})" --allowedTools "Bash(git*),Read,Write,Edit"`;
46
54
  },
47
55
  description: 'Anthropic Claude Code CLI — -p mode with tool permissions',
56
+ version: {
57
+ command: 'claude --version',
58
+ parse: (output) => output.match(/[\d.]+/)?.[0],
59
+ latest: { type: 'npm', name: '@anthropic-ai/claude-code' },
60
+ update: 'npm update -g @anthropic-ai/claude-code',
61
+ },
48
62
  },
49
63
 
50
64
  'cursor-agent': {
@@ -57,6 +71,7 @@ const PROVIDERS = {
57
71
  return `cursor-agent -p "$(cat ${escaped})"`;
58
72
  },
59
73
  description: 'Cursor Agent CLI — headless -p mode',
74
+ // No standard package manager — skip version check
60
75
  },
61
76
 
62
77
  'gemini-cli': {
@@ -69,6 +84,12 @@ const PROVIDERS = {
69
84
  return `gemini -p "$(cat ${escaped})"`;
70
85
  },
71
86
  description: 'Google Gemini CLI — non-interactive -p mode',
87
+ version: {
88
+ command: 'gemini --version',
89
+ parse: (output) => output.match(/[\d.]+/)?.[0],
90
+ latest: { type: 'brew', name: 'gemini-cli' },
91
+ update: 'brew upgrade gemini-cli',
92
+ },
72
93
  },
73
94
 
74
95
  // ─── IDE Providers (clipboard + manual) ───
@@ -136,15 +157,28 @@ export function detectAvailable() {
136
157
  * Groups CLIs first (with detection status), then IDEs
137
158
  * @returns {{ label: string, value: string }[]}
138
159
  */
139
- export function buildProviderChoices() {
160
+ export function buildProviderChoices(versionCache = {}) {
140
161
  const detected = detectAvailable();
141
162
  const choices = [];
142
163
 
143
- // CLI providers first with detection indicator
164
+ // CLI providers first with detection indicator + version info
144
165
  for (const [id, prov] of Object.entries(PROVIDERS)) {
145
166
  if (prov.type === 'cli') {
146
167
  const installed = detected.includes(id);
147
- const tag = installed ? ' ✓ detected' : '';
168
+ let tag = installed ? ' ✓ detected' : '';
169
+
170
+ // Append version info if available in cache
171
+ const vi = versionCache[id];
172
+ if (vi && vi.current) {
173
+ if (vi.updateAvailable) {
174
+ tag += ` (v${vi.current} → v${vi.latest} available)`;
175
+ } else if (vi.latest) {
176
+ tag += ` (v${vi.current} ✓ latest)`;
177
+ } else {
178
+ tag += ` (v${vi.current})`;
179
+ }
180
+ }
181
+
148
182
  choices.push({
149
183
  label: `${prov.name}${tag} — ${prov.description}`,
150
184
  value: id,
@@ -178,20 +212,22 @@ export function buildProviderChoices() {
178
212
  * @param {string} providerId - Provider ID from config
179
213
  * @param {string} promptPath - Absolute path to prompt file
180
214
  * @param {string} cwd - Working directory
181
- * @returns {Promise<boolean>} true if execution succeeded
215
+ * @returns {Promise<{ ok: boolean, rateLimited?: boolean, retryAt?: Date }>}
182
216
  */
183
217
  export async function executePrompt(providerId, promptPath, cwd) {
184
218
  const prov = PROVIDERS[providerId];
185
219
 
186
220
  if (!prov) {
187
221
  log.warn(`Unknown provider '${providerId}', falling back to clipboard mode`);
188
- return await clipboardFallback(promptPath);
222
+ const ok = await clipboardFallback(promptPath);
223
+ return { ok };
189
224
  }
190
225
 
191
226
  if (prov.type === 'cli') {
192
227
  return await executeCLI(prov, providerId, promptPath, cwd);
193
228
  } else {
194
- return await executeIDE(prov, providerId, promptPath);
229
+ const ok = await executeIDE(prov, providerId, promptPath);
230
+ return { ok };
195
231
  }
196
232
  }
197
233
 
@@ -210,7 +246,8 @@ async function executeCLI(prov, providerId, promptPath, cwd) {
210
246
  execSync(cmd, { stdio: 'pipe' });
211
247
  } catch {
212
248
  log.warn(`${prov.name} not found in PATH, falling back to clipboard mode`);
213
- return await clipboardFallback(promptPath);
249
+ const ok = await clipboardFallback(promptPath);
250
+ return { ok };
214
251
  }
215
252
  }
216
253
 
@@ -227,6 +264,7 @@ async function executeCLI(prov, providerId, promptPath, cwd) {
227
264
  let lastFile = '';
228
265
  let statusText = command.substring(0, 80);
229
266
  let lineBuffer = '';
267
+ let stderrBuffer = ''; // Capture stderr for rate limit detection
230
268
  const FILE_EXT = /(?:^|\s|['"|(/])([a-zA-Z0-9_.\/-]+\.(?:rs|ts|js|jsx|tsx|py|go|toml|yaml|yml|json|md|css|html|sh|sql|prisma|vue|svelte))\b/;
231
269
 
232
270
  // Spinner animation — gives a dynamic, alive feel
@@ -247,16 +285,20 @@ async function executeCLI(prov, providerId, promptPath, cwd) {
247
285
  }
248
286
 
249
287
  child.stdout.on('data', (data) => {
250
- lineBuffer += data.toString();
288
+ const text = data.toString();
289
+ lineBuffer += text;
290
+ stderrBuffer += text; // Also check stdout for rate limit messages
251
291
  const lines = lineBuffer.split('\n');
252
292
  lineBuffer = lines.pop();
253
293
  for (const line of lines) processLine(line);
254
294
  });
255
295
 
256
296
  child.stderr.on('data', (data) => {
257
- const text = data.toString().trim();
258
- if (text && !text.includes('\u2588') && !text.includes('progress')) {
259
- for (const line of text.split('\n').slice(0, 3)) {
297
+ const text = data.toString();
298
+ stderrBuffer += text;
299
+ const trimmed = text.trim();
300
+ if (trimmed && !trimmed.includes('\u2588') && !trimmed.includes('progress')) {
301
+ for (const line of trimmed.split('\n').slice(0, 3)) {
260
302
  if (line.trim()) log.dim(` ${line.substring(0, 120)}`);
261
303
  }
262
304
  }
@@ -267,10 +309,17 @@ async function executeCLI(prov, providerId, promptPath, cwd) {
267
309
  process.stdout.write('\r\x1b[K');
268
310
  if (code === 0) {
269
311
  log.info(`${prov.name} execution complete`);
270
- resolvePromise(true);
312
+ resolvePromise({ ok: true });
271
313
  } else {
272
- log.warn(`${prov.name} exited with code ${code}`);
273
- resolvePromise(false);
314
+ // Check for rate limit error in captured output
315
+ const rateLimitInfo = parseRateLimitError(stderrBuffer);
316
+ if (rateLimitInfo) {
317
+ log.warn(`${prov.name} hit rate limit — retry at ${rateLimitInfo.retryAtStr}`);
318
+ resolvePromise({ ok: false, rateLimited: true, retryAt: rateLimitInfo.retryAt, retryAtStr: rateLimitInfo.retryAtStr });
319
+ } else {
320
+ log.warn(`${prov.name} exited with code ${code}`);
321
+ resolvePromise({ ok: false });
322
+ }
274
323
  }
275
324
  });
276
325
 
@@ -278,7 +327,7 @@ async function executeCLI(prov, providerId, promptPath, cwd) {
278
327
  clearInterval(spinTimer);
279
328
  process.stdout.write('\r\x1b[K');
280
329
  log.warn(`${prov.name} execution failed: ${err.message}`);
281
- resolvePromise(false);
330
+ resolvePromise({ ok: false });
282
331
  });
283
332
  });
284
333
  }
@@ -458,7 +507,263 @@ IMPORTANT: Output ONLY a single word on the first line: either PASS or FIX. No o
458
507
  return null; // Ambiguous — caller decides fallback
459
508
  }
460
509
 
510
+ // ──────────────────────────────────────────────
511
+ // Version Check & Auto-Update
512
+ // ──────────────────────────────────────────────
513
+
514
+ /**
515
+ * Compare two semver-like version strings.
516
+ * Returns true if latest > current.
517
+ */
518
+ function isNewerVersion(current, latest) {
519
+ if (!current || !latest) return false;
520
+ const a = current.split('.').map(Number);
521
+ const b = latest.split('.').map(Number);
522
+ const len = Math.max(a.length, b.length);
523
+ for (let i = 0; i < len; i++) {
524
+ const av = a[i] || 0;
525
+ const bv = b[i] || 0;
526
+ if (bv > av) return true;
527
+ if (bv < av) return false;
528
+ }
529
+ return false;
530
+ }
531
+
532
+ /**
533
+ * Get the locally installed version of a provider.
534
+ * @returns {string|null} version string or null
535
+ */
536
+ function getLocalVersion(providerId) {
537
+ const prov = PROVIDERS[providerId];
538
+ if (!prov?.version) return null;
539
+ try {
540
+ const output = execSync(prov.version.command, {
541
+ encoding: 'utf-8',
542
+ stdio: ['pipe', 'pipe', 'pipe'],
543
+ timeout: 5000,
544
+ }).trim();
545
+ return prov.version.parse(output);
546
+ } catch {
547
+ return null;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Fetch the latest available version from the package registry.
553
+ * @returns {string|null} latest version string or null
554
+ */
555
+ function fetchLatestVersion(providerId) {
556
+ const prov = PROVIDERS[providerId];
557
+ if (!prov?.version?.latest) return null;
558
+
559
+ const { type, name } = prov.version.latest;
560
+ try {
561
+ if (type === 'npm') {
562
+ return execSync(`npm view ${name} version`, {
563
+ encoding: 'utf-8',
564
+ stdio: ['pipe', 'pipe', 'pipe'],
565
+ timeout: 10000,
566
+ }).trim();
567
+ }
568
+ if (type === 'brew') {
569
+ const json = execSync(`brew info ${name} --json=v2`, {
570
+ encoding: 'utf-8',
571
+ stdio: ['pipe', 'pipe', 'pipe'],
572
+ timeout: 10000,
573
+ });
574
+ const data = JSON.parse(json);
575
+ return data.formulae?.[0]?.versions?.stable || null;
576
+ }
577
+ if (type === 'brew-cask') {
578
+ const json = execSync(`brew info --cask ${name} --json=v2`, {
579
+ encoding: 'utf-8',
580
+ stdio: ['pipe', 'pipe', 'pipe'],
581
+ timeout: 10000,
582
+ });
583
+ const data = JSON.parse(json);
584
+ return data.casks?.[0]?.version || null;
585
+ }
586
+ } catch {
587
+ return null;
588
+ }
589
+ return null;
590
+ }
591
+
592
+ /**
593
+ * Check version status for a single provider.
594
+ * @param {string} providerId
595
+ * @returns {{ current: string|null, latest: string|null, updateAvailable: boolean, updateCommand: string|null }}
596
+ */
597
+ export function checkProviderVersion(providerId) {
598
+ const prov = PROVIDERS[providerId];
599
+ if (!prov?.version) {
600
+ return { current: null, latest: null, updateAvailable: false, updateCommand: null };
601
+ }
602
+
603
+ const current = getLocalVersion(providerId);
604
+ const latest = fetchLatestVersion(providerId);
605
+ const updateAvailable = isNewerVersion(current, latest);
606
+
607
+ return {
608
+ current,
609
+ latest,
610
+ updateAvailable,
611
+ updateCommand: updateAvailable ? prov.version.update : null,
612
+ };
613
+ }
614
+
615
+ /**
616
+ * Check versions for all installed CLI providers in parallel.
617
+ * Returns a map of providerId → version info.
618
+ * Non-blocking: failures are silently ignored.
619
+ */
620
+ export function checkAllVersions() {
621
+ const detected = detectAvailable();
622
+ const results = {};
623
+
624
+ for (const id of detected) {
625
+ const prov = PROVIDERS[id];
626
+ if (!prov?.version) continue;
627
+ try {
628
+ results[id] = checkProviderVersion(id);
629
+ } catch {
630
+ // Skip on failure — non-blocking
631
+ }
632
+ }
633
+
634
+ return results;
635
+ }
636
+
637
+ /**
638
+ * Execute the update command for a provider.
639
+ * @param {string} providerId
640
+ * @returns {boolean} true if update succeeded
641
+ */
642
+ export function updateProvider(providerId) {
643
+ const prov = PROVIDERS[providerId];
644
+ if (!prov?.version?.update) return false;
645
+
646
+ try {
647
+ log.info(`Updating ${prov.name}...`);
648
+ execSync(prov.version.update, {
649
+ encoding: 'utf-8',
650
+ stdio: 'inherit',
651
+ timeout: 120000, // 2 minute timeout for updates
652
+ });
653
+ log.info(`✅ ${prov.name} updated successfully`);
654
+ return true;
655
+ } catch (err) {
656
+ log.warn(`Failed to update ${prov.name}: ${(err.message || '').substring(0, 100)}`);
657
+ return false;
658
+ }
659
+ }
660
+
661
+ // ──────────────────────────────────────────────
662
+ // Rate Limit Detection
663
+ // ──────────────────────────────────────────────
664
+
665
+ /**
666
+ * Parse rate limit error from CLI output.
667
+ * Looks for: "try again at X:XX PM" or "try again at X:XX AM"
668
+ * @param {string} output - Combined stdout+stderr text
669
+ * @returns {{ retryAt: Date, retryAtStr: string } | null}
670
+ */
671
+ function parseRateLimitError(output) {
672
+ if (!output) return null;
673
+
674
+ // Match "usage limit" or "rate limit" patterns
675
+ const isRateLimited = /usage limit|rate limit|too many requests/i.test(output);
676
+ if (!isRateLimited) return null;
677
+
678
+ // Extract time: "try again at 6:06 PM" or "try again at 18:06"
679
+ const timeMatch = output.match(/try again at\s+(\d{1,2}:\d{2}(?::\d{2})?\s*(?:AM|PM)?)/i);
680
+ if (!timeMatch) {
681
+ // Rate limited but no specific time — estimate 30 minutes from now
682
+ const retryAt = new Date(Date.now() + 30 * 60 * 1000);
683
+ return { retryAt, retryAtStr: retryAt.toLocaleTimeString() };
684
+ }
685
+
686
+ const timeStr = timeMatch[1].trim();
687
+ const now = new Date();
688
+ const today = now.toISOString().split('T')[0]; // YYYY-MM-DD
689
+
690
+ // Parse the time string
691
+ let retryAt;
692
+ if (/AM|PM/i.test(timeStr)) {
693
+ // 12-hour format: "6:06 PM"
694
+ retryAt = new Date(`${today} ${timeStr}`);
695
+ } else {
696
+ // 24-hour format: "18:06"
697
+ retryAt = new Date(`${today}T${timeStr}`);
698
+ }
699
+
700
+ // If parsed time is in the past, it's tomorrow
701
+ if (retryAt <= now) {
702
+ retryAt.setDate(retryAt.getDate() + 1);
703
+ }
704
+
705
+ // Sanity check: if retry is more than 6 hours away, something's wrong
706
+ if (retryAt - now > 6 * 60 * 60 * 1000) {
707
+ retryAt = new Date(Date.now() + 30 * 60 * 1000);
708
+ }
709
+
710
+ return { retryAt, retryAtStr: timeStr };
711
+ }
712
+
713
+ // ──────────────────────────────────────────────
714
+ // Quota Pre-Check
715
+ // ──────────────────────────────────────────────
716
+
717
+ /**
718
+ * Check current Codex 7d quota usage before execution.
719
+ * Reads the latest rollout file for quota data.
720
+ *
721
+ * @param {number} threshold - Percentage at which to block (default 98)
722
+ * @returns {{ ok: boolean, quota5h: number|null, quota7d: number|null, warning: boolean }}
723
+ */
724
+ export function checkQuotaBeforeExecution(threshold = 97) {
725
+ try {
726
+ const sessionsDir = resolve(HOME, '.codex/sessions');
727
+ if (!existsSync(sessionsDir)) return { ok: true, quota5h: null, quota7d: null, warning: false };
728
+
729
+ // Find the most recent rollout file
730
+ const raw = execSync(
731
+ `find "${sessionsDir}" -name "rollout-*.jsonl" -type f | sort -r | head -1`,
732
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
733
+ ).trim();
734
+ if (!raw || !existsSync(raw)) return { ok: true, quota5h: null, quota7d: null, warning: false };
735
+
736
+ // Get the last token_count event with rate_limits
737
+ const line = execSync(
738
+ `grep '"rate_limits"' "${raw}" | tail -1`,
739
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
740
+ ).trim();
741
+ if (!line) return { ok: true, quota5h: null, quota7d: null, warning: false };
742
+
743
+ const data = JSON.parse(line);
744
+ const limits = data?.payload?.rate_limits;
745
+ if (!limits) return { ok: true, quota5h: null, quota7d: null, warning: false };
746
+
747
+ const quota5h = limits.primary?.used_percent ?? null;
748
+ const quota7d = limits.secondary?.used_percent ?? null;
749
+
750
+ // Block if 7d quota exceeds threshold
751
+ if (quota7d !== null && quota7d >= threshold) {
752
+ return { ok: false, quota5h, quota7d, warning: false };
753
+ }
754
+
755
+ // Warn if 7d quota is high (>= 95%)
756
+ const warning = quota7d !== null && quota7d >= 95;
757
+ return { ok: true, quota5h, quota7d, warning };
758
+ } catch {
759
+ // Quota check failed — don't block execution
760
+ return { ok: true, quota5h: null, quota7d: null, warning: false };
761
+ }
762
+ }
763
+
461
764
  export const provider = {
462
765
  getProvider, getAllProviderIds, detectAvailable,
463
766
  buildProviderChoices, executePrompt, queryAI, classifyReview,
767
+ checkProviderVersion, checkAllVersions, updateProvider,
768
+ checkQuotaBeforeExecution,
464
769
  };