@alanwalk/codex-until 0.0.0-stage → 0.1.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 +75 -2
- package/bin/cli.cjs +186 -0
- package/package.json +22 -4
- package/watch-codex-quota.cjs +131 -0
package/README.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @alanwalk/codex-until
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Windows Codex 额度监控命令行工具。零 npm 依赖,Node.js 18 或以上。
|
|
4
|
+
|
|
5
|
+
需要已安装并通过 ChatGPT 账号登录的官方 Codex CLI / 桌面应用。工具复用本地登录状态,不打印 token。默认使用桌面应用 CLI,找不到时使用 PATH 中的 `codex.exe`;可通过 `CODEX_QUOTA_CLI` 指定完整可执行文件路径。
|
|
6
|
+
|
|
7
|
+
发布到 npm 后,无需预先执行 `npm install`,直接运行:
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
npx @alanwalk/codex-until
|
|
11
|
+
npx --yes @alanwalk/codex-until status
|
|
12
|
+
npx --yes @alanwalk/codex-until stop
|
|
13
|
+
npx --yes @alanwalk/codex-until quota
|
|
14
|
+
npx --yes @alanwalk/codex-until logs
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
不带参数会立即开启监控:在交互终端中显示最新日志,输入 `b` 并回车转入后台,输入 `q` 并回车停止监控并退出,`Ctrl+C` 同样停止监控。转入后台只是退出控制台,监控进程及本次比较基线保持不变。输入流关闭时也会退出控制台并保留后台监控。
|
|
18
|
+
|
|
19
|
+
`start` 与无参数执行相同,在交互终端默认显示日志和 b/q 选项;使用 `-b / --background` 直接进入后台:
|
|
20
|
+
|
|
21
|
+
```powershell
|
|
22
|
+
npx @alanwalk/codex-until # 显示日志及 b/q 选项
|
|
23
|
+
npx @alanwalk/codex-until start # 同上
|
|
24
|
+
npx @alanwalk/codex-until -b # 直接后台运行
|
|
25
|
+
npx @alanwalk/codex-until start --background # 同上,可简写 -b
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
通过自动化工具等非交互环境无参数执行时,默认后台启动,不等待输入。重复启动不会产生多个监控,前台模式可连接已有监控。`stop` 只停止监控,不停止 Codex。`status` 输出 JSON;表示后台监控进程可响应,不代表最近的额度请求一定成功,请结合 `logs` 查看查询结果。`--help` 显示帮助;添加 `--yes`(例如 `npx --yes @alanwalk/codex-until`)可跳过 npx 首次下载确认。
|
|
29
|
+
|
|
30
|
+
## 重置判断
|
|
31
|
+
|
|
32
|
+
首次成功查询只建立内存基线。后续对比上次成功查询,在账号、窗口名称和窗口时长相同的前提下,任一窗口满足以下条件即触发:
|
|
33
|
+
|
|
34
|
+
- 已用百分比下降,即剩余额度上升,哪怕只上升 1%。
|
|
35
|
+
- 重置时间推迟,且已用百分比没有增加。
|
|
36
|
+
|
|
37
|
+
查询失败记录日志并重试,不清空上一次成功结果。停止再启动重新建立基线,不比较监控停机期间的变化。服务端额度修正也可能满足上述规则。轮询存在约一分钟延迟,另加网络请求耗时。
|
|
38
|
+
|
|
39
|
+
**触发后执行 `taskkill /F /T /IM codex.exe`,强制结束本机所有 Codex 进程及其子进程,包括其他账号的本地会话,正在执行的任务会被中断。远程主机不受影响。** 成功后监控退出;停止失败会记录错误,后续继续尝试。没有自动消费额度重置次数的功能。
|
|
40
|
+
|
|
41
|
+
日志保存在 `%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
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const net = require('node:net');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const readline = require('node:readline');
|
|
8
|
+
const { spawn } = require('node:child_process');
|
|
9
|
+
const watch = require('../watch-codex-quota.cjs');
|
|
10
|
+
const userKey = crypto.createHash('sha256').update(os.homedir()).digest('hex').slice(0, 16);
|
|
11
|
+
const pipe = `\\\\.\\pipe\\codex-quota-guard-${userKey}`;
|
|
12
|
+
|
|
13
|
+
function request(action) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const socket = net.connect(pipe);
|
|
16
|
+
let response = '';
|
|
17
|
+
socket.setTimeout(2000, () => socket.destroy(new Error('Monitor response timed out')));
|
|
18
|
+
socket.on('connect', () => socket.write(action + '\n'));
|
|
19
|
+
socket.on('data', chunk => { response += chunk; });
|
|
20
|
+
socket.on('end', () => {
|
|
21
|
+
try { resolve(JSON.parse(response)); } catch { reject(new Error('Invalid monitor response')); }
|
|
22
|
+
});
|
|
23
|
+
socket.on('error', reject);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function status() {
|
|
28
|
+
try { return await request('status'); }
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (['ENOENT', 'ECONNREFUSED'].includes(error.code)) return { running: false, logPath: watch.logPath };
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function daemon() {
|
|
36
|
+
const server = net.createServer(socket => {
|
|
37
|
+
socket.setTimeout(2000, () => socket.destroy());
|
|
38
|
+
socket.on('error', () => {});
|
|
39
|
+
socket.once('data', data => {
|
|
40
|
+
const action = data.toString().trim();
|
|
41
|
+
const result = { running: action !== 'stop', pid: process.pid, logPath: watch.logPath };
|
|
42
|
+
socket.end(JSON.stringify(result) + '\n', () => {
|
|
43
|
+
if (action === 'stop') process.exit(0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
server.on('error', error => {
|
|
48
|
+
if (error.code !== 'EADDRINUSE') console.error(error.message);
|
|
49
|
+
process.exit(error.code === 'EADDRINUSE' ? 0 : 1);
|
|
50
|
+
});
|
|
51
|
+
server.listen(pipe, () => {
|
|
52
|
+
watch.main().then(() => process.exit(process.exitCode || 0)).catch(error => {
|
|
53
|
+
console.error(error.message);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseOptions(args, interactive) {
|
|
60
|
+
const hasCommand = args.length > 0 && !args[0].startsWith('-');
|
|
61
|
+
const action = hasCommand ? args[0] : 'start';
|
|
62
|
+
if (['--help', '-h'].includes(args[0])) return { action: 'help', foreground: false };
|
|
63
|
+
const flags = hasCommand ? args.slice(1) : args;
|
|
64
|
+
if (flags.some(flag => !['--background', '-b'].includes(flag))) {
|
|
65
|
+
throw new Error('Unknown option. Use --help.');
|
|
66
|
+
}
|
|
67
|
+
const background = flags.some(flag => ['--background', '-b'].includes(flag));
|
|
68
|
+
if (flags.length && action !== 'start') throw new Error('Mode options are only supported by start.');
|
|
69
|
+
return { action, foreground: action === 'start' && interactive && !background };
|
|
70
|
+
}
|
|
71
|
+
|
|
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
|
+
async function main() {
|
|
129
|
+
const { action, foreground } = parseOptions(process.argv.slice(2), Boolean(process.stdin.isTTY && process.stdout.isTTY));
|
|
130
|
+
if (['help', '--help', '-h'].includes(action)) {
|
|
131
|
+
console.log(`Codex Until (Windows, Node >=18)
|
|
132
|
+
Usage: codex-until [start|stop|status|quota|logs]
|
|
133
|
+
No command defaults to start. Without -b, show b/q controls in a terminal.
|
|
134
|
+
Non-interactive invocations run in the background.
|
|
135
|
+
start Start polling every 60 seconds; repeated calls are safe.
|
|
136
|
+
--background / -b: start directly in the background.
|
|
137
|
+
stop Stop monitoring only. Codex sessions remain running.
|
|
138
|
+
status Print monitor status as JSON.
|
|
139
|
+
quota Query quota once without enabling monitoring.
|
|
140
|
+
logs Print the last 50 log lines.
|
|
141
|
+
Reset detection stops ALL local codex.exe processes and their children.
|
|
142
|
+
Restarting the monitor establishes a fresh baseline. No baseline is persisted.`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (process.platform !== 'win32') throw new Error('This version supports Windows only.');
|
|
146
|
+
if (action === '--daemon') return daemon();
|
|
147
|
+
if (action === 'quota') return watch.main(true);
|
|
148
|
+
if (action === 'logs') {
|
|
149
|
+
if (!fs.existsSync(watch.logPath)) return console.log('No logs yet.');
|
|
150
|
+
return console.log(fs.readFileSync(watch.logPath, 'utf8').trimEnd().split('\n').slice(-50).join('\n'));
|
|
151
|
+
}
|
|
152
|
+
if (!['start', 'stop', 'status'].includes(action)) throw new Error(`Unknown command: ${action}. Use --help.`);
|
|
153
|
+
const before = await status();
|
|
154
|
+
if (action === 'status') return console.log(JSON.stringify(before));
|
|
155
|
+
if (action === 'stop') {
|
|
156
|
+
const result = before.running ? await request('stop') : before;
|
|
157
|
+
console.log(JSON.stringify({ ...result, changed: before.running }));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (before.running) {
|
|
161
|
+
console.log(JSON.stringify({ ...before, changed: false }));
|
|
162
|
+
if (foreground) await consoleMode();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
fs.mkdirSync(path.dirname(watch.logPath), { recursive: true });
|
|
166
|
+
const output = fs.openSync(watch.logPath, 'a');
|
|
167
|
+
const child = spawn(process.execPath, [__filename, '--daemon'], {
|
|
168
|
+
detached: true, windowsHide: true, stdio: ['ignore', 'ignore', output],
|
|
169
|
+
});
|
|
170
|
+
fs.closeSync(output);
|
|
171
|
+
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
|
|
172
|
+
child.unref();
|
|
173
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
174
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
175
|
+
const result = await status();
|
|
176
|
+
if (result.running) {
|
|
177
|
+
console.log(JSON.stringify({ ...result, changed: true }));
|
|
178
|
+
if (foreground) await consoleMode();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`Monitor failed to start. See ${watch.logPath}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { parseOptions };
|
|
186
|
+
if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; });
|
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alanwalk/codex-until",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stop local Codex sessions when quota resets during monitoring.",
|
|
5
|
+
"homepage": "https://github.com/alanwalk/codex-until#readme",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/alanwalk/codex-until.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": { "url": "https://github.com/alanwalk/codex-until/issues" },
|
|
11
|
+
"license": "UNLICENSED",
|
|
12
|
+
"publishConfig": { "access": "public" },
|
|
13
|
+
"bin": { "codex-until": "bin/cli.cjs" },
|
|
14
|
+
"main": "watch-codex-quota.cjs",
|
|
15
|
+
"files": ["bin/", "watch-codex-quota.cjs", "README.md"],
|
|
16
|
+
"engines": { "node": ">=18" },
|
|
17
|
+
"os": ["win32"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"check": "node --check bin/cli.cjs && node --check watch-codex-quota.cjs && node bin/cli.cjs --help",
|
|
20
|
+
"test": "node watch-codex-quota.test.cjs",
|
|
21
|
+
"prepublishOnly": "npm run check && npm test"
|
|
22
|
+
},
|
|
23
|
+
"keywords": ["codex", "quota", "cli", "monitor"]
|
|
24
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
2
|
+
const readline = require('node:readline');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
|
|
7
|
+
const logPath = path.join(process.env.LOCALAPPDATA || os.homedir(), 'codex-quota-guard', 'monitor.log');
|
|
8
|
+
function log(message) {
|
|
9
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
10
|
+
const line = `${new Date().toISOString()} ${message}`;
|
|
11
|
+
fs.appendFileSync(logPath, line + '\n');
|
|
12
|
+
console.log(line);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readQuota() {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const desktopCli = path.join(process.env.LOCALAPPDATA || '', 'Programs/OpenAI/Codex/bin/codex.exe');
|
|
18
|
+
const cli = process.env.CODEX_QUOTA_CLI || (fs.existsSync(desktopCli) ? desktopCli : 'codex.exe');
|
|
19
|
+
const child = spawn(cli, ['app-server', '--stdio'], {
|
|
20
|
+
windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
21
|
+
});
|
|
22
|
+
const send = message => child.stdin.write(JSON.stringify(message) + '\n');
|
|
23
|
+
const cleanup = () => child.kill();
|
|
24
|
+
process.once('exit', cleanup);
|
|
25
|
+
let settled = false;
|
|
26
|
+
const finish = (error, result) => {
|
|
27
|
+
if (settled) return;
|
|
28
|
+
settled = true;
|
|
29
|
+
process.removeListener('exit', cleanup);
|
|
30
|
+
clearTimeout(timeout);
|
|
31
|
+
child.kill();
|
|
32
|
+
if (error) reject(error);
|
|
33
|
+
else resolve(result);
|
|
34
|
+
};
|
|
35
|
+
const timeout = setTimeout(() => finish(new Error('Quota query timed out')), 30000);
|
|
36
|
+
child.stderr.resume();
|
|
37
|
+
child.on('error', error => finish(error));
|
|
38
|
+
child.stdin.on('error', error => finish(error));
|
|
39
|
+
child.on('exit', () => finish(new Error('Quota query process exited')));
|
|
40
|
+
readline.createInterface({ input: child.stdout }).on('line', line => {
|
|
41
|
+
let message;
|
|
42
|
+
try { message = JSON.parse(line); } catch { return; }
|
|
43
|
+
if (message.id !== 1 && message.id !== 2) return;
|
|
44
|
+
if (message.error) return finish(new Error(message.error.message));
|
|
45
|
+
if (message.id === 1) {
|
|
46
|
+
send({ method: 'initialized', params: {} });
|
|
47
|
+
send({ id: 2, method: 'account/rateLimits/read', params: {} });
|
|
48
|
+
} else {
|
|
49
|
+
finish(null, message.result);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
send({ id: 1, method: 'initialize', params: {
|
|
53
|
+
clientInfo: { name: 'quota_watch', version: '1.0.0' },
|
|
54
|
+
} });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getSnapshot(result) {
|
|
59
|
+
const limits = result.rateLimitsByLimitId?.codex ?? result.rateLimits;
|
|
60
|
+
if (!result.accountId || !limits || limits.limitId !== 'codex') {
|
|
61
|
+
throw new Error('Missing Codex account or quota');
|
|
62
|
+
}
|
|
63
|
+
const windows = {};
|
|
64
|
+
for (const name of ['primary', 'secondary']) {
|
|
65
|
+
const window = limits[name];
|
|
66
|
+
if (!window) continue;
|
|
67
|
+
if (!Number.isFinite(window.usedPercent) || window.usedPercent < 0 ||
|
|
68
|
+
window.usedPercent > 100 || !Number.isFinite(window.resetsAt) ||
|
|
69
|
+
!Number.isFinite(window.windowDurationMins)) {
|
|
70
|
+
throw new Error('Invalid quota window');
|
|
71
|
+
}
|
|
72
|
+
windows[name] = window;
|
|
73
|
+
}
|
|
74
|
+
if (!Object.keys(windows).length) throw new Error('No quota windows returned');
|
|
75
|
+
return { accountId: result.accountId, windows };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resetReason(previous, current) {
|
|
79
|
+
if (!previous || previous.accountId !== current.accountId) return null;
|
|
80
|
+
for (const [name, window] of Object.entries(current.windows)) {
|
|
81
|
+
const before = previous.windows[name];
|
|
82
|
+
if (!before || before.windowDurationMins !== window.windowDurationMins) continue;
|
|
83
|
+
if (window.usedPercent < before.usedPercent) {
|
|
84
|
+
return `${name}: used ${before.usedPercent}% -> ${window.usedPercent}%`;
|
|
85
|
+
}
|
|
86
|
+
if (window.resetsAt > before.resetsAt && window.usedPercent <= before.usedPercent) {
|
|
87
|
+
return `${name}: reset time advanced`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function stopCodex() {
|
|
94
|
+
// /T also stops tasks spawned by each Codex process.
|
|
95
|
+
const result = spawnSync('taskkill.exe', ['/F', '/T', '/IM', 'codex.exe'], {
|
|
96
|
+
windowsHide: true, encoding: 'utf8',
|
|
97
|
+
});
|
|
98
|
+
if (result.error || result.status !== 0) {
|
|
99
|
+
throw new Error(`Failed to stop Codex: ${result.error?.message ?? result.stderr.trim()}`);
|
|
100
|
+
}
|
|
101
|
+
log('All local codex.exe processes and their child processes stopped.');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function main(queryOnly = process.argv.includes('--once')) {
|
|
105
|
+
let previous = null;
|
|
106
|
+
log('Started. Polling every 60 seconds. Ctrl+C stops this monitor.');
|
|
107
|
+
for (;;) {
|
|
108
|
+
const started = Date.now();
|
|
109
|
+
try {
|
|
110
|
+
const current = getSnapshot(await readQuota());
|
|
111
|
+
log(Object.entries(current.windows).map(([name, window]) =>
|
|
112
|
+
`${name}: remaining ${100 - window.usedPercent}%, reset ${new Date(window.resetsAt * 1000).toISOString()}`
|
|
113
|
+
).join('; '));
|
|
114
|
+
if (queryOnly) return;
|
|
115
|
+
const reason = resetReason(previous, current);
|
|
116
|
+
if (reason) {
|
|
117
|
+
log(`RESET DETECTED: ${reason}. Stopping all local Codex sessions.`);
|
|
118
|
+
stopCodex();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
previous = current;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
log(`ERROR: ${error.message}`);
|
|
124
|
+
if (queryOnly) { process.exitCode = 1; return; }
|
|
125
|
+
}
|
|
126
|
+
await new Promise(resolve => setTimeout(resolve, Math.max(0, 60000 - (Date.now() - started))));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { getSnapshot, resetReason, main, logPath };
|
|
131
|
+
if (require.main === module) main().catch(error => { console.error(error); process.exitCode = 1; });
|