@jojonax/codex-copilot 1.4.3 → 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 +1 -1
- package/src/commands/evolve.js +2 -2
- package/src/commands/init.js +33 -3
- package/src/commands/run.js +106 -12
- package/src/commands/usage.js +37 -5
- package/src/utils/provider.js +321 -16
package/package.json
CHANGED
package/src/commands/evolve.js
CHANGED
|
@@ -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
|
|
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);
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
|
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 {
|
package/src/commands/run.js
CHANGED
|
@@ -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`.
|
|
@@ -304,14 +323,34 @@ async function developPhase(projectDir, task, baseBranch, checkpoint, providerId
|
|
|
304
323
|
log.dim('⏩ Prompt already generated');
|
|
305
324
|
}
|
|
306
325
|
|
|
307
|
-
// Step 3: Execute via AI Provider
|
|
326
|
+
// Step 3: Execute via AI Provider (with rate limit auto-retry)
|
|
308
327
|
if (!checkpoint.isStepDone(task.id, 'develop', 'codex_complete')) {
|
|
309
328
|
const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
315
354
|
log.error('AI development failed — marking task as blocked');
|
|
316
355
|
task.status = 'blocked';
|
|
317
356
|
task.block_reason = 'dev_failed';
|
|
@@ -625,11 +664,29 @@ ${feedback}
|
|
|
625
664
|
4. Do NOT run git add or git commit — the automation handles committing
|
|
626
665
|
`;
|
|
627
666
|
|
|
628
|
-
// Save to file and execute via provider
|
|
667
|
+
// Save to file and execute via provider (with rate limit auto-retry)
|
|
629
668
|
const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
|
|
630
669
|
writeFileSync(promptPath, fixPrompt);
|
|
631
670
|
|
|
632
|
-
|
|
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
|
+
}
|
|
633
690
|
log.info('Fix complete');
|
|
634
691
|
}
|
|
635
692
|
|
|
@@ -682,7 +739,8 @@ async function mergePhase(projectDir, task, prInfo, baseBranch, checkpoint) {
|
|
|
682
739
|
// ──────────────────────────────────────────────
|
|
683
740
|
// Pre-flight: ensure base branch has commits & is pushed
|
|
684
741
|
// ──────────────────────────────────────────────
|
|
685
|
-
async function ensureBaseReady(projectDir, baseBranch) {
|
|
742
|
+
async function ensureBaseReady(projectDir, baseBranch, isPrivate = false) {
|
|
743
|
+
const skipCI = isPrivate ? ' [skip ci]' : '';
|
|
686
744
|
// Check if the repo has any commits at all
|
|
687
745
|
const hasCommits = git.execSafe('git rev-parse HEAD', projectDir);
|
|
688
746
|
if (!hasCommits.ok) {
|
|
@@ -690,7 +748,7 @@ async function ensureBaseReady(projectDir, baseBranch) {
|
|
|
690
748
|
log.warn('No commits found in repository');
|
|
691
749
|
if (!git.isClean(projectDir)) {
|
|
692
750
|
log.info('Creating initial commit from existing code...');
|
|
693
|
-
git.commitAll(projectDir,
|
|
751
|
+
git.commitAll(projectDir, `chore: initial commit${skipCI}`);
|
|
694
752
|
log.info('✅ Initial commit created');
|
|
695
753
|
} else {
|
|
696
754
|
log.warn('Repository is empty — no files to commit');
|
|
@@ -701,7 +759,7 @@ async function ensureBaseReady(projectDir, baseBranch) {
|
|
|
701
759
|
const currentBranch = git.currentBranch(projectDir);
|
|
702
760
|
if (currentBranch === baseBranch && !git.isClean(projectDir)) {
|
|
703
761
|
log.info('Uncommitted changes on base branch, committing first...');
|
|
704
|
-
git.commitAll(projectDir,
|
|
762
|
+
git.commitAll(projectDir, `chore: save current progress before automation${skipCI}`);
|
|
705
763
|
log.info('✅ Base branch changes committed');
|
|
706
764
|
}
|
|
707
765
|
}
|
|
@@ -756,5 +814,41 @@ function sleep(ms) {
|
|
|
756
814
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
757
815
|
}
|
|
758
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
|
+
|
|
759
853
|
|
|
760
854
|
|
package/src/commands/usage.js
CHANGED
|
@@ -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 (
|
|
242
|
-
const breakdown =
|
|
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,
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/provider.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
};
|