@alanwalk/codex-until 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -61
- package/bin/cli.cjs +36 -71
- package/bin/terminal.cjs +145 -0
- package/package.json +3 -4
- package/platform.cjs +45 -0
- package/watch-codex-quota.cjs +90 -8
package/README.md
CHANGED
|
@@ -1,76 +1,38 @@
|
|
|
1
|
-
#
|
|
1
|
+
# codex-until
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
监控 Codex 剩余额度,发现额度重置时结束本机所有 Codex 会话。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
支持 Windows、macOS、Linux。需要 Node.js 18+,以及已登录 ChatGPT 账号的官方 Codex CLI 或桌面应用。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## 使用
|
|
8
8
|
|
|
9
|
-
```
|
|
10
|
-
npx @alanwalk/codex-until
|
|
11
|
-
npx
|
|
12
|
-
npx
|
|
13
|
-
npx
|
|
14
|
-
npx
|
|
9
|
+
```sh
|
|
10
|
+
npx @alanwalk/codex-until # 打开交互面板
|
|
11
|
+
npx @alanwalk/codex-until -b # 后台运行
|
|
12
|
+
npx @alanwalk/codex-until status # 查看状态
|
|
13
|
+
npx @alanwalk/codex-until stop # 停止监控
|
|
14
|
+
npx @alanwalk/codex-until quota # 查询一次额度
|
|
15
|
+
npx @alanwalk/codex-until logs # 查看日志
|
|
15
16
|
```
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
面板显示剩余额度、下次重置时间和查询倒计时。上下键选择操作,左右键切换刷新间隔:`10s / 30s / 1m / 5m`,默认 `1m`。
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
- **转入后台**:关闭面板,继续监控。也可按 `b`。
|
|
21
|
+
- **停止并退出**:停止监控,保留 Codex 会话。也可按 `q` 或 `Ctrl+C`。
|
|
20
22
|
|
|
21
|
-
|
|
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`。
|
|
23
|
+
## 工作方式
|
|
55
24
|
|
|
56
|
-
|
|
25
|
+
通过官方 App Server 轻量查询额度,不发送模型请求。查询失败会自动退避;能取得 `Retry-After` 时遵守服务端等待时间。
|
|
57
26
|
|
|
58
|
-
|
|
59
|
-
2. 确认 npm 账号有 `@alanwalk` scope 的发布权限。若该包尚不存在,先在本机完成首次发布:
|
|
27
|
+
同一账号、同一额度窗口的剩余百分比上升,或重置时间推迟且已用比例未增加,就视为重置。首次查询只建立基线,重启监控会重新建立基线。
|
|
60
28
|
|
|
61
|
-
|
|
62
|
-
npm login
|
|
63
|
-
npm publish --access public
|
|
64
|
-
```
|
|
29
|
+
**检测到重置后会强制结束本机全部 Codex 进程及其子进程,正在执行的任务会中断。** 服务器修正额度也可能触发。
|
|
65
30
|
|
|
66
|
-
|
|
67
|
-
4. 按 [npm 官方 Trusted Publishing 文档](https://docs.npmjs.com/trusted-publishers/) 保存配置。上传源码本身不会触发发布;只有发布 GitHub Release 才触发。
|
|
31
|
+
## 路径
|
|
68
32
|
|
|
69
|
-
|
|
33
|
+
默认查找 Codex 桌面应用内的 CLI,找不到则使用 PATH 中的 `codex`。可用 `CODEX_QUOTA_CLI` 指定可执行文件完整路径。
|
|
70
34
|
|
|
71
|
-
|
|
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 依赖。
|
|
35
|
+
日志位置:
|
|
75
36
|
|
|
76
|
-
|
|
37
|
+
- Windows:`%LOCALAPPDATA%\codex-quota-guard\monitor.log`
|
|
38
|
+
- macOS / Linux:`~/.codex-until/monitor.log`
|
package/bin/cli.cjs
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const net = require('node:net');
|
|
3
|
-
const os = require('node:os');
|
|
4
3
|
const path = require('node:path');
|
|
5
4
|
const fs = require('node:fs');
|
|
6
|
-
const
|
|
7
|
-
const
|
|
5
|
+
const { runtimePaths } = require('../platform.cjs');
|
|
6
|
+
const { consoleMode } = require('./terminal.cjs');
|
|
8
7
|
const { spawn } = require('node:child_process');
|
|
9
8
|
const watch = require('../watch-codex-quota.cjs');
|
|
10
|
-
const
|
|
11
|
-
const pipe = `\\\\.\\pipe\\codex-quota-guard-${userKey}`;
|
|
9
|
+
const { address: pipe, windows } = runtimePaths();
|
|
12
10
|
|
|
13
11
|
function request(action) {
|
|
14
12
|
return new Promise((resolve, reject) => {
|
|
@@ -33,30 +31,54 @@ async function status() {
|
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
async function daemon() {
|
|
34
|
+
let ownsSocket = false;
|
|
35
|
+
let staleSocketRetried = false;
|
|
36
36
|
const server = net.createServer(socket => {
|
|
37
37
|
socket.setTimeout(2000, () => socket.destroy());
|
|
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
|
});
|
|
45
49
|
});
|
|
46
50
|
});
|
|
47
|
-
server.on('error', error => {
|
|
51
|
+
server.on('error', async error => {
|
|
52
|
+
if (!windows && error.code === 'EADDRINUSE' && !staleSocketRetried) {
|
|
53
|
+
try {
|
|
54
|
+
const existing = await status();
|
|
55
|
+
if (existing.running) { process.exit(0); return; }
|
|
56
|
+
fs.unlinkSync(pipe);
|
|
57
|
+
staleSocketRetried = true;
|
|
58
|
+
server.listen(pipe);
|
|
59
|
+
return;
|
|
60
|
+
} catch (failure) { console.error(failure.message); process.exit(1); return; }
|
|
61
|
+
}
|
|
48
62
|
if (error.code !== 'EADDRINUSE') console.error(error.message);
|
|
49
63
|
process.exit(error.code === 'EADDRINUSE' ? 0 : 1);
|
|
50
64
|
});
|
|
51
|
-
|
|
65
|
+
process.once('exit', () => {
|
|
66
|
+
if (ownsSocket && !windows) { try { fs.unlinkSync(pipe); } catch {} }
|
|
67
|
+
});
|
|
68
|
+
process.once('SIGTERM', () => process.exit(0));
|
|
69
|
+
server.on('listening', () => {
|
|
70
|
+
ownsSocket = true;
|
|
71
|
+
if (!windows) fs.chmodSync(pipe, 0o600);
|
|
52
72
|
watch.main().then(() => process.exit(process.exitCode || 0)).catch(error => {
|
|
53
73
|
console.error(error.message);
|
|
54
74
|
process.exit(1);
|
|
55
75
|
});
|
|
56
76
|
});
|
|
77
|
+
server.listen(pipe);
|
|
57
78
|
}
|
|
58
79
|
|
|
59
80
|
function parseOptions(args, interactive) {
|
|
81
|
+
if (args.length === 1 && args[0] === '--daemon') return { action: '--daemon', foreground: false };
|
|
60
82
|
const hasCommand = args.length > 0 && !args[0].startsWith('-');
|
|
61
83
|
const action = hasCommand ? args[0] : 'start';
|
|
62
84
|
if (['--help', '-h'].includes(args[0])) return { action: 'help', foreground: false };
|
|
@@ -69,66 +91,10 @@ function parseOptions(args, interactive) {
|
|
|
69
91
|
return { action, foreground: action === 'start' && interactive && !background };
|
|
70
92
|
}
|
|
71
93
|
|
|
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
94
|
async function main() {
|
|
129
95
|
const { action, foreground } = parseOptions(process.argv.slice(2), Boolean(process.stdin.isTTY && process.stdout.isTTY));
|
|
130
96
|
if (['help', '--help', '-h'].includes(action)) {
|
|
131
|
-
console.log(`Codex Until (Windows, Node >=18)
|
|
97
|
+
console.log(`Codex Until (Windows / macOS / Linux, Node >=18)
|
|
132
98
|
Usage: codex-until [start|stop|status|quota|logs]
|
|
133
99
|
No command defaults to start. Without -b, show b/q controls in a terminal.
|
|
134
100
|
Non-interactive invocations run in the background.
|
|
@@ -138,11 +104,10 @@ Non-interactive invocations run in the background.
|
|
|
138
104
|
status Print monitor status as JSON.
|
|
139
105
|
quota Query quota once without enabling monitoring.
|
|
140
106
|
logs Print the last 50 log lines.
|
|
141
|
-
Reset detection stops ALL local
|
|
107
|
+
Reset detection stops ALL local Codex processes and their children.
|
|
142
108
|
Restarting the monitor establishes a fresh baseline. No baseline is persisted.`);
|
|
143
109
|
return;
|
|
144
110
|
}
|
|
145
|
-
if (process.platform !== 'win32') throw new Error('This version supports Windows only.');
|
|
146
111
|
if (action === '--daemon') return daemon();
|
|
147
112
|
if (action === 'quota') return watch.main(true);
|
|
148
113
|
if (action === 'logs') {
|
|
@@ -158,8 +123,8 @@ Restarting the monitor establishes a fresh baseline. No baseline is persisted.`)
|
|
|
158
123
|
return;
|
|
159
124
|
}
|
|
160
125
|
if (before.running) {
|
|
161
|
-
|
|
162
|
-
|
|
126
|
+
if (foreground) await consoleMode({ status, request, logPath: watch.logPath });
|
|
127
|
+
else console.log(JSON.stringify({ ...before, changed: false }));
|
|
163
128
|
return;
|
|
164
129
|
}
|
|
165
130
|
fs.mkdirSync(path.dirname(watch.logPath), { recursive: true });
|
|
@@ -174,8 +139,8 @@ Restarting the monitor establishes a fresh baseline. No baseline is persisted.`)
|
|
|
174
139
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
175
140
|
const result = await status();
|
|
176
141
|
if (result.running) {
|
|
177
|
-
|
|
178
|
-
|
|
142
|
+
if (foreground) await consoleMode({ status, request, logPath: watch.logPath });
|
|
143
|
+
else console.log(JSON.stringify({ ...result, changed: true }));
|
|
179
144
|
return;
|
|
180
145
|
}
|
|
181
146
|
}
|
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.1
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|
|
@@ -12,11 +12,10 @@
|
|
|
12
12
|
"publishConfig": { "access": "public" },
|
|
13
13
|
"bin": { "codex-until": "bin/cli.cjs" },
|
|
14
14
|
"main": "watch-codex-quota.cjs",
|
|
15
|
-
"files": ["bin/", "watch-codex-quota.cjs", "README.md"],
|
|
15
|
+
"files": ["bin/", "platform.cjs", "watch-codex-quota.cjs", "README.md"],
|
|
16
16
|
"engines": { "node": ">=18" },
|
|
17
|
-
"os": ["win32"],
|
|
18
17
|
"scripts": {
|
|
19
|
-
"check": "node --check bin/cli.cjs && node --check watch-codex-quota.cjs && node bin/cli.cjs --help",
|
|
18
|
+
"check": "node --check bin/cli.cjs && node --check bin/terminal.cjs && node --check platform.cjs && node --check watch-codex-quota.cjs && node bin/cli.cjs --help",
|
|
20
19
|
"test": "node watch-codex-quota.test.cjs",
|
|
21
20
|
"prepublishOnly": "npm run check && npm test"
|
|
22
21
|
},
|
package/platform.cjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
|
|
6
|
+
function runtimePaths(platform = process.platform, env = process.env, home = os.homedir()) {
|
|
7
|
+
const windows = platform === 'win32';
|
|
8
|
+
const key = crypto.createHash('sha256').update(home).digest('hex').slice(0, 16);
|
|
9
|
+
const desktopCli = windows
|
|
10
|
+
? path.join(env.LOCALAPPDATA || '', 'Programs/OpenAI/Codex/bin/codex.exe')
|
|
11
|
+
: '/Applications/Codex.app/Contents/Resources/codex';
|
|
12
|
+
const logDirectory = windows ? path.join(env.LOCALAPPDATA || home, 'codex-quota-guard')
|
|
13
|
+
: path.join(home, '.codex-until');
|
|
14
|
+
let cli = env.CODEX_QUOTA_CLI;
|
|
15
|
+
if (!cli) {
|
|
16
|
+
if (fs.existsSync(desktopCli)) cli = desktopCli;
|
|
17
|
+
else if (windows) cli = 'codex.exe';
|
|
18
|
+
else cli = 'codex';
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
windows,
|
|
22
|
+
address: windows ? `\\\\.\\pipe\\codex-quota-guard-${key}` : path.join(os.tmpdir(), `codex-until-${key}.sock`),
|
|
23
|
+
logPath: path.join(logDirectory, 'monitor.log'),
|
|
24
|
+
cli,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function codexProcessIds(processList, ownPid = process.pid) {
|
|
29
|
+
const processes = processList.split('\n').flatMap(line => {
|
|
30
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
31
|
+
if (!match) return [];
|
|
32
|
+
return [{ pid: Number(match[1]), parent: Number(match[2]), name: path.posix.basename(match[3]) }];
|
|
33
|
+
});
|
|
34
|
+
const targets = new Set(processes.filter(item => /^(codex|codex-(aarch64|x86_64)-.+)$/i.test(item.name)).map(item => item.pid));
|
|
35
|
+
let added = true;
|
|
36
|
+
while (added) {
|
|
37
|
+
added = false;
|
|
38
|
+
for (const item of processes) {
|
|
39
|
+
if (targets.has(item.parent) && !targets.has(item.pid)) { targets.add(item.pid); added = true; }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return [...targets].reverse().filter(pid => pid !== ownPid && pid > 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { runtimePaths, codexProcessIds };
|
package/watch-codex-quota.cjs
CHANGED
|
@@ -2,9 +2,73 @@ const { spawn, spawnSync } = require('node:child_process');
|
|
|
2
2
|
const readline = require('node:readline');
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
|
-
const
|
|
5
|
+
const { runtimePaths, codexProcessIds } = require('./platform.cjs');
|
|
6
6
|
|
|
7
|
-
const logPath =
|
|
7
|
+
const { logPath } = runtimePaths();
|
|
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}`;
|
|
@@ -14,8 +78,7 @@ function log(message) {
|
|
|
14
78
|
|
|
15
79
|
function readQuota() {
|
|
16
80
|
return new Promise((resolve, reject) => {
|
|
17
|
-
const
|
|
18
|
-
const cli = process.env.CODEX_QUOTA_CLI || (fs.existsSync(desktopCli) ? desktopCli : 'codex.exe');
|
|
81
|
+
const { cli } = runtimePaths();
|
|
19
82
|
const child = spawn(cli, ['app-server', '--stdio'], {
|
|
20
83
|
windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
21
84
|
});
|
|
@@ -41,10 +104,10 @@ function readQuota() {
|
|
|
41
104
|
let message;
|
|
42
105
|
try { message = JSON.parse(line); } catch { return; }
|
|
43
106
|
if (message.id !== 1 && message.id !== 2) return;
|
|
44
|
-
if (message.error) return finish(
|
|
107
|
+
if (message.error) return finish(quotaError(message.error));
|
|
45
108
|
if (message.id === 1) {
|
|
46
109
|
send({ method: 'initialized', params: {} });
|
|
47
|
-
send({ id: 2, method: 'account/rateLimits/read', params:
|
|
110
|
+
send({ id: 2, method: 'account/rateLimits/read', params: quotaParams });
|
|
48
111
|
} else {
|
|
49
112
|
finish(null, message.result);
|
|
50
113
|
}
|
|
@@ -91,6 +154,18 @@ function resetReason(previous, current) {
|
|
|
91
154
|
}
|
|
92
155
|
|
|
93
156
|
function stopCodex() {
|
|
157
|
+
if (process.platform !== 'win32') {
|
|
158
|
+
const result = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8' });
|
|
159
|
+
if (result.error || result.status !== 0) throw new Error('Failed to enumerate Codex processes');
|
|
160
|
+
const failures = [];
|
|
161
|
+
for (const pid of codexProcessIds(result.stdout)) {
|
|
162
|
+
try { process.kill(pid, 'SIGKILL'); }
|
|
163
|
+
catch (error) { if (error.code !== 'ESRCH') failures.push(`${pid}: ${error.code}`); }
|
|
164
|
+
}
|
|
165
|
+
if (failures.length) throw new Error(`Failed to stop Codex: ${failures.join(', ')}`);
|
|
166
|
+
log('All local Codex processes and their child processes stopped.');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
94
169
|
// /T also stops tasks spawned by each Codex process.
|
|
95
170
|
const result = spawnSync('taskkill.exe', ['/F', '/T', '/IM', 'codex.exe'], {
|
|
96
171
|
windowsHide: true, encoding: 'utf8',
|
|
@@ -106,8 +181,11 @@ async function main(queryOnly = process.argv.includes('--once')) {
|
|
|
106
181
|
log('Started. Polling every 60 seconds. Ctrl+C stops this monitor.');
|
|
107
182
|
for (;;) {
|
|
108
183
|
const started = Date.now();
|
|
184
|
+
polling.querying = true;
|
|
185
|
+
polling.nextQueryAt = null;
|
|
109
186
|
try {
|
|
110
187
|
const current = getSnapshot(await readQuota());
|
|
188
|
+
recordSuccess();
|
|
111
189
|
log(Object.entries(current.windows).map(([name, window]) =>
|
|
112
190
|
`${name}: remaining ${100 - window.usedPercent}%, reset ${new Date(window.resetsAt * 1000).toISOString()}`
|
|
113
191
|
).join('; '));
|
|
@@ -122,10 +200,14 @@ async function main(queryOnly = process.argv.includes('--once')) {
|
|
|
122
200
|
} catch (error) {
|
|
123
201
|
log(`ERROR: ${error.message}`);
|
|
124
202
|
if (queryOnly) { process.exitCode = 1; return; }
|
|
203
|
+
const delay = recordFailure(error);
|
|
204
|
+
log(`BACKOFF: retry no earlier than ${new Date(polling.cooldownUntil).toISOString()} (${Math.ceil(delay / 1000)}s)`);
|
|
125
205
|
}
|
|
126
|
-
|
|
206
|
+
polling.querying = false;
|
|
207
|
+
await waitForNextQuery(started);
|
|
127
208
|
}
|
|
128
209
|
}
|
|
129
210
|
|
|
130
|
-
module.exports = { getSnapshot, resetReason, main, logPath
|
|
211
|
+
module.exports = { getSnapshot, resetReason, main, logPath, polling, setIntervalMs, waitForNextQuery,
|
|
212
|
+
quotaParams, quotaError, retryAfterMs, recordFailure, recordSuccess, queryDeadline };
|
|
131
213
|
if (require.main === module) main().catch(error => { console.error(error); process.exitCode = 1; });
|