@alanwalk/codex-until 0.1.0 → 0.2.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/README.md +12 -39
- package/bin/cli.cjs +11 -62
- package/bin/terminal.cjs +145 -0
- package/package.json +2 -2
- package/watch-codex-quota.cjs +75 -4
package/README.md
CHANGED
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Windows Codex 额度监控命令行工具。零 npm 依赖,Node.js 18 或以上。
|
|
4
4
|
|
|
5
|
+
菜单第一项为“刷新间隔”,左右键切换 `10s / 30s / 1m / 5m`,默认 `1m`;后两项为“转入后台”和“停止监控并退出”。
|
|
6
|
+
|
|
7
|
+
使用官方轻量轮询参数 `excludeResetCreditDetails: true`,跳过不需要的重置次数详情查询。查询失败自动退避:若 App Server 错误数据或文本中包含有效的 `Retry-After`,至少等待服务端要求的时间;否则依次等待 1、2、4、8、16、30 分钟,之后保持最多每 30 分钟重试。等待不会短于所选刷新间隔,成功后恢复所选间隔。退避期间修改刷新间隔不能提前结束服务端等待。
|
|
8
|
+
|
|
9
|
+
App Server 当前并不保证透传 HTTP 响应头,因此不能保证总能获取 `Retry-After`;无法获得时使用上述退避。面板显示退避倒计时,保留上次成功数据和额度比较基线。
|
|
10
|
+
|
|
11
|
+
终端面板展示剩余额度、下次额度重置时间(按本地时区显示),并逐秒更新距下次查询的预计倒计时。后台默认每 60 秒查询额度,可在菜单中上下选中“刷新间隔”,再用左右键切换 `10s / 30s / 1m / 5m`。修改即时应用到同一后台进程,转入后台后保持所选间隔;重新启动恢复默认 `1m`。后台按所选间隔查询额度,查询期间显示“正在查询”。查询失败时保留上次成功数据并提示错误;详细记录通过 logs 命令查看。
|
|
12
|
+
|
|
5
13
|
需要已安装并通过 ChatGPT 账号登录的官方 Codex CLI / 桌面应用。工具复用本地登录状态,不打印 token。默认使用桌面应用 CLI,找不到时使用 PATH 中的 `codex.exe`;可通过 `CODEX_QUOTA_CLI` 指定完整可执行文件路径。
|
|
6
14
|
|
|
7
|
-
|
|
15
|
+
无需预先执行 `npm install`,直接运行:
|
|
8
16
|
|
|
9
17
|
```powershell
|
|
10
18
|
npx @alanwalk/codex-until
|
|
@@ -14,12 +22,12 @@ npx --yes @alanwalk/codex-until quota
|
|
|
14
22
|
npx --yes @alanwalk/codex-until logs
|
|
15
23
|
```
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
不带参数会立即开启监控:在交互终端中显示彩色状态面板、剩余额度进度条、查询倒计时和下次重置时间。使用方向键选择操作并回车确认,也可直接按 `b` 转入后台、按 `q` 停止监控并退出,`Ctrl+C` 同样停止监控。转入后台只是退出控制台,监控进程及本次比较基线保持不变。输入流关闭时也会退出控制台并保留后台监控。
|
|
18
26
|
|
|
19
|
-
`start`
|
|
27
|
+
`start` 与无参数执行相同,在交互终端默认显示状态面板和操作菜单;使用 `-b / --background` 直接进入后台:
|
|
20
28
|
|
|
21
29
|
```powershell
|
|
22
|
-
npx @alanwalk/codex-until #
|
|
30
|
+
npx @alanwalk/codex-until # 显示状态面板和菜单
|
|
23
31
|
npx @alanwalk/codex-until start # 同上
|
|
24
32
|
npx @alanwalk/codex-until -b # 直接后台运行
|
|
25
33
|
npx @alanwalk/codex-until start --background # 同上,可简写 -b
|
|
@@ -39,38 +47,3 @@ npx @alanwalk/codex-until start --background # 同上,可简写 -b
|
|
|
39
47
|
**触发后执行 `taskkill /F /T /IM codex.exe`,强制结束本机所有 Codex 进程及其子进程,包括其他账号的本地会话,正在执行的任务会被中断。远程主机不受影响。** 成功后监控退出;停止失败会记录错误,后续继续尝试。没有自动消费额度重置次数的功能。
|
|
40
48
|
|
|
41
49
|
日志保存在 `%LOCALAPPDATA%\codex-quota-guard\monitor.log`,不写入 npx 缓存中的包目录。日志不包含凭据。后台监控没有开机自启功能。
|
|
42
|
-
|
|
43
|
-
## 本地验证
|
|
44
|
-
|
|
45
|
-
```powershell
|
|
46
|
-
npm run prepublishOnly
|
|
47
|
-
npm pack
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
当前包名是 `@alanwalk/codex-until`,未确认 npm 名称可用性。包内使用 files 白名单,不包含本机日志、测试或登录凭据。无需构建、无需 npm install。npx 首次运行仍需联网下载并缓存包。
|
|
51
|
-
|
|
52
|
-
## GitHub Actions 自动发布 npm
|
|
53
|
-
|
|
54
|
-
沿用 [alanwalk/svn-merge-tool](https://github.com/alanwalk/svn-merge-tool/blob/main/.github/workflows/npm-publish.yml) 的发布方式:发布 GitHub Release 后,检查通过才发布 npm。配置文件为 `.github/workflows/npm-publish.yml`,Node.js 24,使用 npm Trusted Publishing(OIDC),无需配置 `NPM_TOKEN`。
|
|
55
|
-
|
|
56
|
-
### 首次配置
|
|
57
|
-
|
|
58
|
-
1. 将项目上传到你自己的 GitHub 仓库,包含 `.github/workflows/npm-publish.yml`。
|
|
59
|
-
2. 确认 npm 账号有 `@alanwalk` scope 的发布权限。若该包尚不存在,先在本机完成首次发布:
|
|
60
|
-
|
|
61
|
-
```powershell
|
|
62
|
-
npm login
|
|
63
|
-
npm publish --access public
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
3. 打开 npm 网站中 `@alanwalk/codex-until` 的 Settings → Trusted Publisher,选择 GitHub Actions,填写实际 GitHub 用户/组织、仓库名,以及工作流文件名 `npm-publish.yml`。当前 workflow 未使用 GitHub Environment,此项留空;如果页面提供 Allowed actions,允许直接执行 `npm publish`。
|
|
67
|
-
4. 按 [npm 官方 Trusted Publishing 文档](https://docs.npmjs.com/trusted-publishers/) 保存配置。上传源码本身不会触发发布;只有发布 GitHub Release 才触发。
|
|
68
|
-
|
|
69
|
-
### 后续发版
|
|
70
|
-
|
|
71
|
-
1. 修改代码,运行 `npm run prepublishOnly`。
|
|
72
|
-
2. 修改版本,例如运行 `npm version patch --no-git-tag-version`,然后提交并推送代码。
|
|
73
|
-
3. 在 GitHub 创建并发布 Release,tag 必须为 `v` 加 `package.json` 中的版本,例如 `v0.1.1`。tag 应指向准备发布的提交。
|
|
74
|
-
4. Actions 先在 Windows 上检查 tag、语法、帮助入口、判断测试及包内容,再在 Ubuntu 上使用 OIDC 执行 `npm publish`。全程无需安装 npm 依赖。
|
|
75
|
-
|
|
76
|
-
版本号必须是 npm 尚未发布的版本;相同版本不能重复发布。`package.json` 已设置 public access。Release 的 prerelease 标志不会自动切换 npm dist-tag,当前流程统一使用 `latest`,请只发布准备正式使用的版本。
|
package/bin/cli.cjs
CHANGED
|
@@ -4,7 +4,7 @@ const os = require('node:os');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const fs = require('node:fs');
|
|
6
6
|
const crypto = require('node:crypto');
|
|
7
|
-
const
|
|
7
|
+
const { consoleMode } = require('./terminal.cjs');
|
|
8
8
|
const { spawn } = require('node:child_process');
|
|
9
9
|
const watch = require('../watch-codex-quota.cjs');
|
|
10
10
|
const userKey = crypto.createHash('sha256').update(os.homedir()).digest('hex').slice(0, 16);
|
|
@@ -38,7 +38,11 @@ async function daemon() {
|
|
|
38
38
|
socket.on('error', () => {});
|
|
39
39
|
socket.once('data', data => {
|
|
40
40
|
const action = data.toString().trim();
|
|
41
|
-
|
|
41
|
+
if (action.startsWith('interval:')) {
|
|
42
|
+
try { watch.setIntervalMs(Number(action.slice('interval:'.length))); }
|
|
43
|
+
catch (error) { socket.end(JSON.stringify({ error: error.message }) + '\n'); return; }
|
|
44
|
+
}
|
|
45
|
+
const result = { running: action !== 'stop', pid: process.pid, logPath: watch.logPath, ...watch.polling };
|
|
42
46
|
socket.end(JSON.stringify(result) + '\n', () => {
|
|
43
47
|
if (action === 'stop') process.exit(0);
|
|
44
48
|
});
|
|
@@ -57,6 +61,7 @@ async function daemon() {
|
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
function parseOptions(args, interactive) {
|
|
64
|
+
if (args.length === 1 && args[0] === '--daemon') return { action: '--daemon', foreground: false };
|
|
60
65
|
const hasCommand = args.length > 0 && !args[0].startsWith('-');
|
|
61
66
|
const action = hasCommand ? args[0] : 'start';
|
|
62
67
|
if (['--help', '-h'].includes(args[0])) return { action: 'help', foreground: false };
|
|
@@ -69,62 +74,6 @@ function parseOptions(args, interactive) {
|
|
|
69
74
|
return { action, foreground: action === 'start' && interactive && !background };
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
async function consoleMode() {
|
|
73
|
-
console.log('监控中:输入 b 回车转入后台;输入 q 回车停止监控并退出。Ctrl+C 同样停止监控。');
|
|
74
|
-
const input = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
75
|
-
let lastLog = '';
|
|
76
|
-
let busy = false;
|
|
77
|
-
let closing = false;
|
|
78
|
-
return new Promise(resolve => {
|
|
79
|
-
const finish = async stop => {
|
|
80
|
-
if (closing) return;
|
|
81
|
-
closing = true;
|
|
82
|
-
clearInterval(timer);
|
|
83
|
-
try {
|
|
84
|
-
if (stop) {
|
|
85
|
-
const current = await status();
|
|
86
|
-
if (current.running) await request('stop');
|
|
87
|
-
console.log('监控已停止。Codex 会话保持运行。');
|
|
88
|
-
} else console.log('已退出控制台,监控继续在后台运行。');
|
|
89
|
-
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
90
|
-
input.close();
|
|
91
|
-
process.removeListener('SIGINT', interrupt);
|
|
92
|
-
resolve();
|
|
93
|
-
};
|
|
94
|
-
const interrupt = () => { void finish(true); };
|
|
95
|
-
const refresh = async () => {
|
|
96
|
-
if (busy || closing) return;
|
|
97
|
-
busy = true;
|
|
98
|
-
try {
|
|
99
|
-
if (fs.existsSync(watch.logPath)) {
|
|
100
|
-
const latest = fs.readFileSync(watch.logPath, 'utf8').trimEnd().split('\n').slice(-1)[0];
|
|
101
|
-
if (latest !== lastLog) { console.log(latest); lastLog = latest; }
|
|
102
|
-
}
|
|
103
|
-
if (!(await status()).running) {
|
|
104
|
-
console.log('后台监控已退出。');
|
|
105
|
-
closing = true;
|
|
106
|
-
clearInterval(timer);
|
|
107
|
-
input.close();
|
|
108
|
-
process.removeListener('SIGINT', interrupt);
|
|
109
|
-
resolve();
|
|
110
|
-
}
|
|
111
|
-
} catch (error) { console.error(error.message); }
|
|
112
|
-
finally { busy = false; }
|
|
113
|
-
};
|
|
114
|
-
const timer = setInterval(refresh, 1000);
|
|
115
|
-
input.on('line', line => {
|
|
116
|
-
const choice = line.trim().toLowerCase();
|
|
117
|
-
if (choice === 'b') void finish(false);
|
|
118
|
-
else if (choice === 'q') void finish(true);
|
|
119
|
-
else console.log('输入 b 转入后台,或 q 停止并退出。');
|
|
120
|
-
});
|
|
121
|
-
input.on('SIGINT', interrupt);
|
|
122
|
-
process.on('SIGINT', interrupt);
|
|
123
|
-
input.on('close', () => { if (!closing) void finish(false); });
|
|
124
|
-
void refresh();
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
77
|
async function main() {
|
|
129
78
|
const { action, foreground } = parseOptions(process.argv.slice(2), Boolean(process.stdin.isTTY && process.stdout.isTTY));
|
|
130
79
|
if (['help', '--help', '-h'].includes(action)) {
|
|
@@ -158,8 +107,8 @@ Restarting the monitor establishes a fresh baseline. No baseline is persisted.`)
|
|
|
158
107
|
return;
|
|
159
108
|
}
|
|
160
109
|
if (before.running) {
|
|
161
|
-
|
|
162
|
-
|
|
110
|
+
if (foreground) await consoleMode({ status, request, logPath: watch.logPath });
|
|
111
|
+
else console.log(JSON.stringify({ ...before, changed: false }));
|
|
163
112
|
return;
|
|
164
113
|
}
|
|
165
114
|
fs.mkdirSync(path.dirname(watch.logPath), { recursive: true });
|
|
@@ -174,8 +123,8 @@ Restarting the monitor establishes a fresh baseline. No baseline is persisted.`)
|
|
|
174
123
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
175
124
|
const result = await status();
|
|
176
125
|
if (result.running) {
|
|
177
|
-
|
|
178
|
-
|
|
126
|
+
if (foreground) await consoleMode({ status, request, logPath: watch.logPath });
|
|
127
|
+
else console.log(JSON.stringify({ ...result, changed: true }));
|
|
179
128
|
return;
|
|
180
129
|
}
|
|
181
130
|
}
|
package/bin/terminal.cjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const readline = require('node:readline');
|
|
3
|
+
|
|
4
|
+
const intervals = [10000, 30000, 60000, 300000];
|
|
5
|
+
const intervalLabels = ['10s', '30s', '1m', '5m'];
|
|
6
|
+
|
|
7
|
+
function renderPanel({ running, pid, lines, selected = 0, columns = 80, color = true,
|
|
8
|
+
now = Date.now(), intervalMs = 60000, nextQueryAt, querying, cooldownUntil }) {
|
|
9
|
+
const paint = (code, text) => color ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
10
|
+
const clean = text => text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f]/g, '');
|
|
11
|
+
const records = lines.map(clean);
|
|
12
|
+
const quota = [...records].reverse().find(line => /remaining [\d.]+%/.test(line));
|
|
13
|
+
const match = quota?.match(/^(\S+).*?remaining ([\d.]+)%, reset (\S+)/);
|
|
14
|
+
const width = Math.max(8, Math.min(28, columns - 20));
|
|
15
|
+
const latestRecord = records.at(-1) || '';
|
|
16
|
+
const lastAttempt = Date.parse(latestRecord.split(' ')[0]);
|
|
17
|
+
const deadline = nextQueryAt ?? lastAttempt + intervalMs;
|
|
18
|
+
const secondsUntilQuery = Math.max(0, Math.ceil((deadline - now) / 1000));
|
|
19
|
+
let queryStatus = '正在查询…';
|
|
20
|
+
if (!querying && Number.isFinite(deadline) && secondsUntilQuery > 0) {
|
|
21
|
+
queryStatus = `${secondsUntilQuery} 秒后查询`;
|
|
22
|
+
}
|
|
23
|
+
if (cooldownUntil > now) queryStatus = `退避等待 · ${Math.ceil((cooldownUntil - now) / 1000)} 秒后重试`;
|
|
24
|
+
const output = [
|
|
25
|
+
'', paint('36', ' ╭───────────────────────╮'),
|
|
26
|
+
paint('36', ' │') + paint('1;36', ' CODEX UNTIL ') + paint('36', '│'),
|
|
27
|
+
paint('36', ' ╰───────────────────────╯'), '',
|
|
28
|
+
` ${paint(running ? '32' : '33', running ? '● 正在监控' : '○ 监控已退出')} ${paint('90', `PID ${pid ?? '—'}`)}`,
|
|
29
|
+
` ${paint('36', queryStatus)}`,
|
|
30
|
+
];
|
|
31
|
+
if (match) {
|
|
32
|
+
const remaining = Math.max(0, Math.min(100, Number(match[2])));
|
|
33
|
+
const filled = Math.round(width * remaining / 100);
|
|
34
|
+
output.push('', ` 剩余额度 ${paint('1;36', `${remaining}%`)}`,
|
|
35
|
+
` ${paint('36', '━'.repeat(filled))}${paint('90', '─'.repeat(width - filled))}`,
|
|
36
|
+
` 下次重置 ${new Date(match[3]).toLocaleString('zh-CN', { hour12: false })}`);
|
|
37
|
+
} else output.push('', ' 剩余额度 —', ' 下次重置 —',
|
|
38
|
+
paint('90', ' 正在等待首次额度查询…'));
|
|
39
|
+
if (records.at(-1)?.includes('ERROR')) {
|
|
40
|
+
output.push(paint('33', ' 刷新失败,以上为上次成功数据。'));
|
|
41
|
+
}
|
|
42
|
+
output.push('', paint('90', ' 额度重置后,结束本机全部 Codex 会话。'), '');
|
|
43
|
+
const choices = intervalLabels.map((label, index) => intervals[index] === intervalMs ? `[${label}]` : label).join(' ');
|
|
44
|
+
for (const [index, label] of [`刷新间隔 ‹ ${choices} ›`, '转入后台,继续监控', '停止监控并退出'].entries()) {
|
|
45
|
+
output.push(index === selected ? paint('1;36', ` ❯ ${label}`) : paint('90', ` ${label}`));
|
|
46
|
+
}
|
|
47
|
+
output.push('');
|
|
48
|
+
return output.join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function consoleMode({ status, request, logPath }) {
|
|
52
|
+
const input = process.stdin;
|
|
53
|
+
const output = process.stdout;
|
|
54
|
+
let selected = 0;
|
|
55
|
+
let busy = false;
|
|
56
|
+
let closing = false;
|
|
57
|
+
let changingInterval = false;
|
|
58
|
+
let current = { running: true };
|
|
59
|
+
let lines = [];
|
|
60
|
+
let lastPanel = '';
|
|
61
|
+
const wasRaw = input.isRaw;
|
|
62
|
+
readline.emitKeypressEvents(input);
|
|
63
|
+
input.setRawMode(true);
|
|
64
|
+
input.resume();
|
|
65
|
+
output.write('\x1b[?1049h\x1b[?25l');
|
|
66
|
+
return new Promise(resolve => {
|
|
67
|
+
const draw = () => {
|
|
68
|
+
if (closing) return;
|
|
69
|
+
const panel = renderPanel({ ...current, lines, selected,
|
|
70
|
+
columns: output.columns || 80, color: !process.env.NO_COLOR });
|
|
71
|
+
if (panel === lastPanel) return;
|
|
72
|
+
lastPanel = panel;
|
|
73
|
+
output.write('\x1b[H\x1b[2J' + panel);
|
|
74
|
+
};
|
|
75
|
+
const finish = async (stop, message) => {
|
|
76
|
+
if (closing) return;
|
|
77
|
+
closing = true;
|
|
78
|
+
clearInterval(timer);
|
|
79
|
+
input.removeListener('keypress', onKey);
|
|
80
|
+
input.removeListener('end', onEnd);
|
|
81
|
+
output.removeListener('resize', draw);
|
|
82
|
+
process.removeListener('SIGINT', onInterrupt);
|
|
83
|
+
let resultMessage = message;
|
|
84
|
+
try {
|
|
85
|
+
if (stop) {
|
|
86
|
+
if ((await status()).running) await request('stop');
|
|
87
|
+
resultMessage = '监控已停止,Codex 会话继续运行。';
|
|
88
|
+
}
|
|
89
|
+
} catch (error) { resultMessage = `停止失败:${error.message}`; process.exitCode = 1; }
|
|
90
|
+
finally {
|
|
91
|
+
input.setRawMode(Boolean(wasRaw));
|
|
92
|
+
input.pause();
|
|
93
|
+
output.write('\x1b[?25h\x1b[?1049l');
|
|
94
|
+
}
|
|
95
|
+
console.log(resultMessage || '已转入后台,监控继续运行。');
|
|
96
|
+
resolve();
|
|
97
|
+
};
|
|
98
|
+
const changeInterval = async direction => {
|
|
99
|
+
if (changingInterval || closing) return;
|
|
100
|
+
changingInterval = true;
|
|
101
|
+
try {
|
|
102
|
+
const currentIndex = intervals.indexOf(current.intervalMs ?? 60000);
|
|
103
|
+
const nextIndex = (currentIndex + direction + intervals.length) % intervals.length;
|
|
104
|
+
const result = await request(`interval:${intervals[nextIndex]}`);
|
|
105
|
+
if (result.error) throw new Error(result.error);
|
|
106
|
+
if (!Number.isFinite(result.intervalMs)) throw new Error('当前后台版本不支持切换间隔,请停止后重新启动监控。');
|
|
107
|
+
current = result;
|
|
108
|
+
draw();
|
|
109
|
+
} catch (error) { lines = [...lines, `ERROR: ${error.message}`]; draw(); }
|
|
110
|
+
finally { changingInterval = false; }
|
|
111
|
+
};
|
|
112
|
+
const onKey = (text, key = {}) => {
|
|
113
|
+
if (key.name === 'up') { selected = (selected + 2) % 3; draw(); }
|
|
114
|
+
else if (key.name === 'down') { selected = (selected + 1) % 3; draw(); }
|
|
115
|
+
else if (selected === 0 && key.name === 'left') void changeInterval(-1);
|
|
116
|
+
else if (selected === 0 && key.name === 'right') void changeInterval(1);
|
|
117
|
+
else if (key.name === 'return' && selected !== 0) void finish(selected === 2);
|
|
118
|
+
else if (text === 'b') void finish(false);
|
|
119
|
+
else if (text === 'q' || (key.ctrl && key.name === 'c')) void finish(true);
|
|
120
|
+
};
|
|
121
|
+
const onEnd = () => { void finish(false); };
|
|
122
|
+
const onInterrupt = () => { void finish(true); };
|
|
123
|
+
const refresh = async () => {
|
|
124
|
+
if (busy || closing) return;
|
|
125
|
+
busy = true;
|
|
126
|
+
try {
|
|
127
|
+
if (fs.existsSync(logPath)) lines = fs.readFileSync(logPath, 'utf8').trimEnd().split('\n').slice(-150);
|
|
128
|
+
current = await status();
|
|
129
|
+
if (closing) return;
|
|
130
|
+
draw();
|
|
131
|
+
if (!current.running) await finish(false, '后台监控已退出,请用 logs 查看原因。');
|
|
132
|
+
} catch (error) { lines = [...lines, `ERROR: ${error.message}`]; draw(); }
|
|
133
|
+
finally { busy = false; }
|
|
134
|
+
};
|
|
135
|
+
const timer = setInterval(refresh, 1000);
|
|
136
|
+
input.on('keypress', onKey);
|
|
137
|
+
input.on('end', onEnd);
|
|
138
|
+
output.on('resize', draw);
|
|
139
|
+
process.on('SIGINT', onInterrupt);
|
|
140
|
+
draw();
|
|
141
|
+
void refresh();
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { consoleMode, renderPanel };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alanwalk/codex-until",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Stop local Codex sessions when quota resets during monitoring.",
|
|
5
5
|
"homepage": "https://github.com/alanwalk/codex-until#readme",
|
|
6
6
|
"repository": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"engines": { "node": ">=18" },
|
|
17
17
|
"os": ["win32"],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"check": "node --check bin/cli.cjs && node --check watch-codex-quota.cjs && node bin/cli.cjs --help",
|
|
19
|
+
"check": "node --check bin/cli.cjs && node --check bin/terminal.cjs && node --check watch-codex-quota.cjs && node bin/cli.cjs --help",
|
|
20
20
|
"test": "node watch-codex-quota.test.cjs",
|
|
21
21
|
"prepublishOnly": "npm run check && npm test"
|
|
22
22
|
},
|
package/watch-codex-quota.cjs
CHANGED
|
@@ -5,6 +5,70 @@ const path = require('node:path');
|
|
|
5
5
|
const os = require('node:os');
|
|
6
6
|
|
|
7
7
|
const logPath = path.join(process.env.LOCALAPPDATA || os.homedir(), 'codex-quota-guard', 'monitor.log');
|
|
8
|
+
const polling = { intervalMs: 60000, nextQueryAt: null, querying: false, cooldownUntil: null, failureCount: 0 };
|
|
9
|
+
const quotaParams = { excludeResetCreditDetails: true };
|
|
10
|
+
function quotaError(error) {
|
|
11
|
+
const result = new Error(error.message || 'Quota query failed');
|
|
12
|
+
result.data = error.data;
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function retryAfterMs(error, now = Date.now()) {
|
|
17
|
+
const data = error.data;
|
|
18
|
+
const headers = data?.headers ?? data?.response?.headers;
|
|
19
|
+
let value = headers?.['retry-after'] ?? headers?.['Retry-After'] ?? data?.retryAfter ?? data?.retry_after;
|
|
20
|
+
if (value == null) {
|
|
21
|
+
const match = error.message.match(/retry-after["']?\s*[=:]\s*["']?([^\r\n"'}]+)/i);
|
|
22
|
+
if (match) value = match[1].trim();
|
|
23
|
+
}
|
|
24
|
+
if (value == null || String(value).trim() === '') return null;
|
|
25
|
+
const seconds = Number(value);
|
|
26
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
27
|
+
const date = Date.parse(String(value));
|
|
28
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function recordFailure(error, now = Date.now()) {
|
|
32
|
+
polling.failureCount += 1;
|
|
33
|
+
const requestedWait = retryAfterMs(error, now);
|
|
34
|
+
const backoff = Math.min(1800000, 60000 * 2 ** Math.min(5, polling.failureCount - 1));
|
|
35
|
+
const delay = Math.max(polling.intervalMs, requestedWait ?? backoff);
|
|
36
|
+
polling.cooldownUntil = now + delay;
|
|
37
|
+
return delay;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function recordSuccess() {
|
|
41
|
+
polling.failureCount = 0;
|
|
42
|
+
polling.cooldownUntil = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function queryDeadline(requested) {
|
|
46
|
+
return Math.max(requested, polling.cooldownUntil || 0);
|
|
47
|
+
}
|
|
48
|
+
let reschedule = null;
|
|
49
|
+
function setIntervalMs(intervalMs) {
|
|
50
|
+
if (![10000, 30000, 60000, 300000].includes(intervalMs)) throw new Error('Unsupported polling interval');
|
|
51
|
+
polling.intervalMs = intervalMs;
|
|
52
|
+
if (reschedule) reschedule(Date.now() + intervalMs);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function waitForNextQuery(started) {
|
|
56
|
+
return new Promise(resolve => {
|
|
57
|
+
let timer;
|
|
58
|
+
reschedule = nextQueryAt => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
polling.nextQueryAt = queryDeadline(nextQueryAt);
|
|
61
|
+
const remaining = Math.max(0, polling.nextQueryAt - Date.now());
|
|
62
|
+
// Long Retry-After values must not overflow Node's timer and cause an immediate retry.
|
|
63
|
+
timer = setTimeout(() => {
|
|
64
|
+
if (Date.now() < polling.nextQueryAt) { reschedule(polling.nextQueryAt); return; }
|
|
65
|
+
reschedule = null;
|
|
66
|
+
resolve();
|
|
67
|
+
}, Math.min(remaining, 2147483647));
|
|
68
|
+
};
|
|
69
|
+
reschedule(started + polling.intervalMs);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
8
72
|
function log(message) {
|
|
9
73
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
10
74
|
const line = `${new Date().toISOString()} ${message}`;
|
|
@@ -41,10 +105,10 @@ function readQuota() {
|
|
|
41
105
|
let message;
|
|
42
106
|
try { message = JSON.parse(line); } catch { return; }
|
|
43
107
|
if (message.id !== 1 && message.id !== 2) return;
|
|
44
|
-
if (message.error) return finish(
|
|
108
|
+
if (message.error) return finish(quotaError(message.error));
|
|
45
109
|
if (message.id === 1) {
|
|
46
110
|
send({ method: 'initialized', params: {} });
|
|
47
|
-
send({ id: 2, method: 'account/rateLimits/read', params:
|
|
111
|
+
send({ id: 2, method: 'account/rateLimits/read', params: quotaParams });
|
|
48
112
|
} else {
|
|
49
113
|
finish(null, message.result);
|
|
50
114
|
}
|
|
@@ -106,8 +170,11 @@ async function main(queryOnly = process.argv.includes('--once')) {
|
|
|
106
170
|
log('Started. Polling every 60 seconds. Ctrl+C stops this monitor.');
|
|
107
171
|
for (;;) {
|
|
108
172
|
const started = Date.now();
|
|
173
|
+
polling.querying = true;
|
|
174
|
+
polling.nextQueryAt = null;
|
|
109
175
|
try {
|
|
110
176
|
const current = getSnapshot(await readQuota());
|
|
177
|
+
recordSuccess();
|
|
111
178
|
log(Object.entries(current.windows).map(([name, window]) =>
|
|
112
179
|
`${name}: remaining ${100 - window.usedPercent}%, reset ${new Date(window.resetsAt * 1000).toISOString()}`
|
|
113
180
|
).join('; '));
|
|
@@ -122,10 +189,14 @@ async function main(queryOnly = process.argv.includes('--once')) {
|
|
|
122
189
|
} catch (error) {
|
|
123
190
|
log(`ERROR: ${error.message}`);
|
|
124
191
|
if (queryOnly) { process.exitCode = 1; return; }
|
|
192
|
+
const delay = recordFailure(error);
|
|
193
|
+
log(`BACKOFF: retry no earlier than ${new Date(polling.cooldownUntil).toISOString()} (${Math.ceil(delay / 1000)}s)`);
|
|
125
194
|
}
|
|
126
|
-
|
|
195
|
+
polling.querying = false;
|
|
196
|
+
await waitForNextQuery(started);
|
|
127
197
|
}
|
|
128
198
|
}
|
|
129
199
|
|
|
130
|
-
module.exports = { getSnapshot, resetReason, main, logPath
|
|
200
|
+
module.exports = { getSnapshot, resetReason, main, logPath, polling, setIntervalMs, waitForNextQuery,
|
|
201
|
+
quotaParams, quotaError, retryAfterMs, recordFailure, recordSuccess, queryDeadline };
|
|
131
202
|
if (require.main === module) main().catch(error => { console.error(error); process.exitCode = 1; });
|