@jojonax/codex-copilot 1.4.2 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jojonax/codex-copilot",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
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 {
@@ -54,6 +54,8 @@ export async function run(projectDir) {
54
54
  const pollInterval = config.review_poll_interval || 60;
55
55
  const waitTimeout = config.review_wait_timeout || 600;
56
56
  const isPrivate = github.isPrivateRepo(projectDir); // Cache once
57
+ const weeklyQuotaThreshold = config.weekly_quota_threshold || 97;
58
+ const maxRateLimitRetries = 3;
57
59
 
58
60
  const providerInfo = provider.getProvider(providerId);
59
61
  log.info(`AI Provider: ${providerInfo ? providerInfo.name : providerId}`);
@@ -85,7 +87,7 @@ export async function run(projectDir) {
85
87
  log.info(`Base branch: ${baseBranch}`);
86
88
 
87
89
  // ===== Pre-flight: ensure base branch is committed & pushed =====
88
- await ensureBaseReady(projectDir, baseBranch);
90
+ await ensureBaseReady(projectDir, baseBranch, isPrivate);
89
91
 
90
92
  // Show resume info if resuming mid-task
91
93
  if (state.phase && state.current_task > 0) {
@@ -131,6 +133,23 @@ export async function run(projectDir) {
131
133
  log.title(`━━━ Task ${task.id}/${tasks.total}: ${task.title} ━━━`);
132
134
  log.blank();
133
135
 
136
+ // ===== Quota pre-check (Codex CLI only) =====
137
+ if (providerId === 'codex-cli' || providerId === 'codex-desktop') {
138
+ const quota = provider.checkQuotaBeforeExecution(weeklyQuotaThreshold);
139
+ if (!quota.ok) {
140
+ log.blank();
141
+ log.error(`⚠ Weekly quota at ${quota.quota7d}% (threshold: ${weeklyQuotaThreshold}%) — stopping to preserve remaining quota`);
142
+ log.info('Run `codex-copilot usage` to check quota details');
143
+ log.info('Run `codex-copilot run` again when quota resets');
144
+ writeJSON(tasksPath, tasks);
145
+ closePrompt();
146
+ process.exit(0);
147
+ }
148
+ if (quota.warning) {
149
+ log.warn(`⚠ Weekly quota at ${quota.quota7d}% — approaching limit`);
150
+ }
151
+ }
152
+
134
153
  // Check dependencies — completed, skipped, and blocked all satisfy dependencies.
135
154
  // Blocked tasks are treated as "done for now" to prevent cascade-skipping;
136
155
  // the user can retry them later with `codex-copilot retry`.
@@ -156,6 +175,8 @@ export async function run(projectDir) {
156
175
 
157
176
  // Mark task as in_progress
158
177
  task.status = 'in_progress';
178
+ const isRetry = (task.retry_count || 0) > 0;
179
+ const retryStartedAt = isRetry ? new Date().toISOString() : null;
159
180
  writeJSON(tasksPath, tasks);
160
181
 
161
182
  // ===== Phase 1: Develop =====
@@ -185,10 +206,19 @@ export async function run(projectDir) {
185
206
 
186
207
  // ===== Phase 3: Review loop =====
187
208
  if (!checkpoint.isStepDone(task.id, 'merge', 'merged')) {
209
+ // On retry: request fresh re-review since old reviews may be stale
210
+ if (isRetry && prInfo?.number) {
211
+ log.info('🔄 Retry: requesting fresh re-review on existing PR...');
212
+ try {
213
+ github.requestReReview(projectDir, prInfo.number);
214
+ } catch { /* ignore — review bots may not be configured */ }
215
+ }
216
+
188
217
  await reviewLoop(projectDir, task, prInfo, {
189
218
  maxRounds: maxReviewRounds,
190
219
  pollInterval,
191
220
  waitTimeout,
221
+ retryStartedAt, // Only count reviews after this timestamp
192
222
  }, checkpoint, providerId, isPrivate);
193
223
  } else {
194
224
  log.dim('⏩ Skipping review phase (already completed)');
@@ -198,7 +228,18 @@ export async function run(projectDir) {
198
228
  if (!checkpoint.isStepDone(task.id, 'merge', 'merged')) {
199
229
  await mergePhase(projectDir, task, prInfo, baseBranch, checkpoint);
200
230
  } else {
201
- log.dim('⏩ Skipping merge phase (already merged)');
231
+ // Checkpoint says merged verify against GitHub
232
+ if (prInfo?.number) {
233
+ const prState = github.getPRState(projectDir, prInfo.number);
234
+ if (prState !== 'merged') {
235
+ log.warn(`⚠ Checkpoint says merged but PR #${prInfo.number} is ${prState} — re-entering merge`);
236
+ await mergePhase(projectDir, task, prInfo, baseBranch, checkpoint);
237
+ } else {
238
+ log.dim('⏩ Skipping merge phase (PR confirmed merged on GitHub)');
239
+ }
240
+ } else {
241
+ log.dim('⏩ Skipping merge phase (already merged)');
242
+ }
202
243
  }
203
244
 
204
245
  // Check if task was blocked during review/merge
@@ -282,14 +323,34 @@ async function developPhase(projectDir, task, baseBranch, checkpoint, providerId
282
323
  log.dim('⏩ Prompt already generated');
283
324
  }
284
325
 
285
- // Step 3: Execute via AI Provider
326
+ // Step 3: Execute via AI Provider (with rate limit auto-retry)
286
327
  if (!checkpoint.isStepDone(task.id, 'develop', 'codex_complete')) {
287
328
  const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
288
- const ok = await provider.executePrompt(providerId, promptPath, projectDir);
289
- if (ok) {
290
- log.info('Development complete');
291
- checkpoint.saveStep(task.id, 'develop', 'codex_complete', { branch: task.branch });
292
- } else {
329
+ let rateLimitRetries = 0;
330
+
331
+ while (rateLimitRetries < maxRateLimitRetries) {
332
+ const result = await provider.executePrompt(providerId, promptPath, projectDir);
333
+ if (result.ok) {
334
+ log.info('Development complete');
335
+ checkpoint.saveStep(task.id, 'develop', 'codex_complete', { branch: task.branch });
336
+ break;
337
+ }
338
+
339
+ if (result.rateLimited && result.retryAt) {
340
+ rateLimitRetries++;
341
+ log.warn(`Rate limit hit (attempt ${rateLimitRetries}/${maxRateLimitRetries})`);
342
+ if (rateLimitRetries >= maxRateLimitRetries) {
343
+ log.error('Max rate limit retries reached — marking task as blocked');
344
+ task.status = 'blocked';
345
+ task.block_reason = 'rate_limited';
346
+ return;
347
+ }
348
+ await waitForRateLimitReset(result.retryAt, result.retryAtStr);
349
+ log.info('Rate limit reset — resuming development...');
350
+ continue;
351
+ }
352
+
353
+ // Non-rate-limit failure
293
354
  log.error('AI development failed — marking task as blocked');
294
355
  task.status = 'blocked';
295
356
  task.block_reason = 'dev_failed';
@@ -357,7 +418,7 @@ async function prPhase(projectDir, task, baseBranch, checkpoint, isPrivate) {
357
418
  // ──────────────────────────────────────────────
358
419
  // Phase 3: Review loop
359
420
  // ──────────────────────────────────────────────
360
- async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout }, checkpoint, providerId, isPrivate) {
421
+ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout, retryStartedAt }, checkpoint, providerId, isPrivate) {
361
422
  const HARD_MAX_ROUNDS = 5;
362
423
  const MAX_POLL_RETRIES = 3;
363
424
  let maxRounds = Math.min(_maxRounds, HARD_MAX_ROUNDS);
@@ -388,8 +449,19 @@ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pol
388
449
  // and fast bot responses after fix pushes.
389
450
  const existingReviews = github.getReviews(projectDir, prInfo.number);
390
451
  const existingComments = github.getIssueComments(projectDir, prInfo.number);
391
- const hasReview = existingReviews.some(r => r.state !== 'PENDING');
392
- const hasBotComment = existingComments.some(c =>
452
+
453
+ // On retry: only count reviews posted AFTER the retry started
454
+ const isReviewFresh = (item) => {
455
+ if (!retryStartedAt) return true; // Not a retry — all reviews are valid
456
+ const itemDate = item.submitted_at || item.created_at || item.updated_at;
457
+ return itemDate && new Date(itemDate) > new Date(retryStartedAt);
458
+ };
459
+
460
+ const freshReviews = existingReviews.filter(isReviewFresh);
461
+ const freshComments = existingComments.filter(isReviewFresh);
462
+
463
+ const hasReview = freshReviews.some(r => r.state !== 'PENDING');
464
+ const hasBotComment = freshComments.some(c =>
393
465
  c.user?.type === 'Bot' || c.user?.login?.includes('bot')
394
466
  );
395
467
 
@@ -592,11 +664,29 @@ ${feedback}
592
664
  4. Do NOT run git add or git commit — the automation handles committing
593
665
  `;
594
666
 
595
- // Save to file and execute via provider
667
+ // Save to file and execute via provider (with rate limit auto-retry)
596
668
  const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
597
669
  writeFileSync(promptPath, fixPrompt);
598
670
 
599
- await provider.executePrompt(providerId, promptPath, projectDir);
671
+ let rateLimitRetries = 0;
672
+ while (rateLimitRetries < 3) {
673
+ const result = await provider.executePrompt(providerId, promptPath, projectDir);
674
+ if (result.ok) {
675
+ break;
676
+ }
677
+ if (result.rateLimited && result.retryAt) {
678
+ rateLimitRetries++;
679
+ if (rateLimitRetries >= 3) {
680
+ log.warn('Max rate limit retries reached during fix phase');
681
+ break;
682
+ }
683
+ log.warn(`Rate limit hit during fix — waiting for reset...`);
684
+ await waitForRateLimitReset(result.retryAt, result.retryAtStr);
685
+ log.info('Rate limit reset — retrying fix...');
686
+ continue;
687
+ }
688
+ break; // Non-rate-limit failure
689
+ }
600
690
  log.info('Fix complete');
601
691
  }
602
692
 
@@ -649,7 +739,8 @@ async function mergePhase(projectDir, task, prInfo, baseBranch, checkpoint) {
649
739
  // ──────────────────────────────────────────────
650
740
  // Pre-flight: ensure base branch has commits & is pushed
651
741
  // ──────────────────────────────────────────────
652
- async function ensureBaseReady(projectDir, baseBranch) {
742
+ async function ensureBaseReady(projectDir, baseBranch, isPrivate = false) {
743
+ const skipCI = isPrivate ? ' [skip ci]' : '';
653
744
  // Check if the repo has any commits at all
654
745
  const hasCommits = git.execSafe('git rev-parse HEAD', projectDir);
655
746
  if (!hasCommits.ok) {
@@ -657,7 +748,7 @@ async function ensureBaseReady(projectDir, baseBranch) {
657
748
  log.warn('No commits found in repository');
658
749
  if (!git.isClean(projectDir)) {
659
750
  log.info('Creating initial commit from existing code...');
660
- git.commitAll(projectDir, 'chore: initial commit');
751
+ git.commitAll(projectDir, `chore: initial commit${skipCI}`);
661
752
  log.info('✅ Initial commit created');
662
753
  } else {
663
754
  log.warn('Repository is empty — no files to commit');
@@ -668,7 +759,7 @@ async function ensureBaseReady(projectDir, baseBranch) {
668
759
  const currentBranch = git.currentBranch(projectDir);
669
760
  if (currentBranch === baseBranch && !git.isClean(projectDir)) {
670
761
  log.info('Uncommitted changes on base branch, committing first...');
671
- git.commitAll(projectDir, 'chore: save current progress before automation');
762
+ git.commitAll(projectDir, `chore: save current progress before automation${skipCI}`);
672
763
  log.info('✅ Base branch changes committed');
673
764
  }
674
765
  }
@@ -723,5 +814,41 @@ function sleep(ms) {
723
814
  return new Promise(resolve => setTimeout(resolve, ms));
724
815
  }
725
816
 
817
+ /**
818
+ * Wait for rate limit reset with countdown display.
819
+ * @param {Date} retryAt - When to resume
820
+ * @param {string} retryAtStr - Human-readable time string
821
+ */
822
+ async function waitForRateLimitReset(retryAt, retryAtStr) {
823
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
824
+ let spinIdx = 0;
825
+
826
+ log.blank();
827
+ log.info(`⏳ Rate limited — auto-resuming at ${retryAtStr}`);
828
+ log.blank();
829
+
830
+ while (true) {
831
+ const now = Date.now();
832
+ const remaining = retryAt.getTime() - now;
833
+ if (remaining <= 0) break;
834
+
835
+ const mins = Math.floor(remaining / 60000);
836
+ const secs = Math.floor((remaining % 60000) / 1000);
837
+ const frame = SPINNER[spinIdx % SPINNER.length];
838
+ spinIdx++;
839
+
840
+ process.stdout.write(
841
+ `\r\x1b[K \x1b[33m${frame}\x1b[0m Waiting for rate limit reset... \x1b[1m${mins}m ${secs}s\x1b[0m remaining`
842
+ );
843
+
844
+ await sleep(1000);
845
+ }
846
+
847
+ process.stdout.write('\r\x1b[K');
848
+ log.info('✅ Rate limit reset — waiting 3min buffer before resuming...');
849
+ // Add 3-minute buffer to ensure the limit is actually lifted
850
+ await sleep(180_000);
851
+ }
852
+
726
853
 
727
854
 
@@ -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
  }
@@ -383,7 +383,7 @@ export const github = {
383
383
  ensureRemoteBranch, hasCommitsBetween,
384
384
  getReviews, getReviewComments, getIssueComments,
385
385
  getLatestReviewState, mergePR, collectReviewFeedback,
386
- isPrivateRepo, requestReReview, closePR, deleteBranch,
386
+ isPrivateRepo, requestReReview, closePR, deleteBranch, getPRState,
387
387
  };
388
388
 
389
389
  /**
@@ -412,3 +412,18 @@ export function deleteBranch(cwd, branch) {
412
412
  return false;
413
413
  }
414
414
  }
415
+
416
+ /**
417
+ * Get the state of a PR: 'open', 'closed', or 'merged'
418
+ */
419
+ export function getPRState(cwd, prNumber) {
420
+ try {
421
+ const num = validatePRNumber(prNumber);
422
+ const output = gh(`pr view ${num} --json state,mergedAt`, cwd);
423
+ const data = JSON.parse(output);
424
+ if (data.mergedAt) return 'merged';
425
+ return (data.state || 'OPEN').toLowerCase();
426
+ } catch {
427
+ return 'unknown';
428
+ }
429
+ }
@@ -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
  };