@alanwalk/codex-until 0.2.0 → 0.2.2
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 +27 -32
- package/bin/cli.cjs +46 -16
- package/bin/terminal.cjs +32 -10
- package/package.json +3 -4
- package/platform.cjs +57 -0
- package/watch-codex-quota.cjs +30 -6
package/README.md
CHANGED
|
@@ -1,49 +1,44 @@
|
|
|
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
|
-
|
|
11
|
-
|
|
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 # 查看日志
|
|
16
|
+
```
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
`status` 输出一次当前状态面板,不显示操作菜单;未运行时只显示标题和“未启动”。
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
面板显示剩余额度、下次重置时间和查询倒计时。上下键选择操作,左右键切换刷新间隔:`10s / 30s / 1m / 5m`,默认 `1m`。
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
npx @alanwalk/codex-until
|
|
19
|
-
npx --yes @alanwalk/codex-until status
|
|
20
|
-
npx --yes @alanwalk/codex-until stop
|
|
21
|
-
npx --yes @alanwalk/codex-until quota
|
|
22
|
-
npx --yes @alanwalk/codex-until logs
|
|
23
|
-
```
|
|
22
|
+
每次打开面板会主动刷新一次,返回前显示等待,不展示上次额度。已有查询会复用,限流退避期间仍按要求等待。`status` 只查看现有状态,不触发刷新。
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
- **转入后台**:关闭面板,继续监控。也可按 `b`。
|
|
25
|
+
- **停止并退出**:停止监控,保留 Codex 会话。也可按 `q` 或 `Ctrl+C`。
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
## 工作方式
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
npx @alanwalk/codex-until # 显示状态面板和菜单
|
|
31
|
-
npx @alanwalk/codex-until start # 同上
|
|
32
|
-
npx @alanwalk/codex-until -b # 直接后台运行
|
|
33
|
-
npx @alanwalk/codex-until start --background # 同上,可简写 -b
|
|
34
|
-
```
|
|
29
|
+
通过官方 App Server 轻量查询额度,不发送模型请求。查询失败会自动退避;能取得 `Retry-After` 时遵守服务端等待时间。
|
|
35
30
|
|
|
36
|
-
|
|
31
|
+
同一账号、同一额度窗口的剩余百分比上升,或重置时间推迟且已用比例未增加,就视为重置。首次查询只建立基线,重启监控会重新建立基线。
|
|
37
32
|
|
|
38
|
-
|
|
33
|
+
**检测到重置后会强制结束本机全部 Codex 进程及其子进程,正在执行的任务会中断。** 服务器修正额度也可能触发。
|
|
39
34
|
|
|
40
|
-
|
|
35
|
+
## 路径
|
|
41
36
|
|
|
42
|
-
|
|
43
|
-
- 重置时间推迟,且已用百分比没有增加。
|
|
37
|
+
默认查找 Codex 桌面应用内的 CLI,找不到则使用 PATH 中的 `codex`。可用 `CODEX_QUOTA_CLI` 指定可执行文件完整路径。
|
|
44
38
|
|
|
45
|
-
|
|
39
|
+
日志位置:
|
|
46
40
|
|
|
47
|
-
|
|
41
|
+
- Windows:`%LOCALAPPDATA%/.cache/codex-until/`
|
|
42
|
+
- macOS / Linux:`~/.cache/codex-until/`
|
|
48
43
|
|
|
49
|
-
|
|
44
|
+
每次启动单独创建日志,按本地启动时间命名,例如 `codex-until-2026-10-15_16-50-30-123.log`。`logs` 查看当前监控日志;未运行时查看最近一次日志。
|
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 { consoleMode } = require('./terminal.cjs');
|
|
5
|
+
const { runtimePaths, latestLog } = require('../platform.cjs');
|
|
6
|
+
const { consoleMode, renderPanel, monitorLines } = 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,31 +31,53 @@ 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
|
+
let requiredSequence;
|
|
42
|
+
if (action === 'refresh') requiredSequence = watch.requestRefresh();
|
|
41
43
|
if (action.startsWith('interval:')) {
|
|
42
44
|
try { watch.setIntervalMs(Number(action.slice('interval:'.length))); }
|
|
43
45
|
catch (error) { socket.end(JSON.stringify({ error: error.message }) + '\n'); return; }
|
|
44
46
|
}
|
|
45
|
-
const result = { running: action !== 'stop', pid: process.pid, logPath: watch.logPath,
|
|
47
|
+
const result = { running: action !== 'stop', pid: process.pid, logPath: watch.logPath,
|
|
48
|
+
...watch.polling, requiredSequence };
|
|
46
49
|
socket.end(JSON.stringify(result) + '\n', () => {
|
|
47
50
|
if (action === 'stop') process.exit(0);
|
|
48
51
|
});
|
|
49
52
|
});
|
|
50
53
|
});
|
|
51
|
-
server.on('error', error => {
|
|
54
|
+
server.on('error', async error => {
|
|
55
|
+
if (!windows && error.code === 'EADDRINUSE' && !staleSocketRetried) {
|
|
56
|
+
try {
|
|
57
|
+
const existing = await status();
|
|
58
|
+
if (existing.running) { process.exit(0); return; }
|
|
59
|
+
fs.unlinkSync(pipe);
|
|
60
|
+
staleSocketRetried = true;
|
|
61
|
+
server.listen(pipe);
|
|
62
|
+
return;
|
|
63
|
+
} catch (failure) { console.error(failure.message); process.exit(1); return; }
|
|
64
|
+
}
|
|
52
65
|
if (error.code !== 'EADDRINUSE') console.error(error.message);
|
|
53
66
|
process.exit(error.code === 'EADDRINUSE' ? 0 : 1);
|
|
54
67
|
});
|
|
55
|
-
|
|
68
|
+
process.once('exit', () => {
|
|
69
|
+
if (ownsSocket && !windows) { try { fs.unlinkSync(pipe); } catch {} }
|
|
70
|
+
});
|
|
71
|
+
process.once('SIGTERM', () => process.exit(0));
|
|
72
|
+
server.on('listening', () => {
|
|
73
|
+
ownsSocket = true;
|
|
74
|
+
if (!windows) fs.chmodSync(pipe, 0o600);
|
|
56
75
|
watch.main().then(() => process.exit(process.exitCode || 0)).catch(error => {
|
|
57
76
|
console.error(error.message);
|
|
58
77
|
process.exit(1);
|
|
59
78
|
});
|
|
60
79
|
});
|
|
80
|
+
server.listen(pipe);
|
|
61
81
|
}
|
|
62
82
|
|
|
63
83
|
function parseOptions(args, interactive) {
|
|
@@ -77,30 +97,36 @@ function parseOptions(args, interactive) {
|
|
|
77
97
|
async function main() {
|
|
78
98
|
const { action, foreground } = parseOptions(process.argv.slice(2), Boolean(process.stdin.isTTY && process.stdout.isTTY));
|
|
79
99
|
if (['help', '--help', '-h'].includes(action)) {
|
|
80
|
-
console.log(`Codex Until (Windows, Node >=18)
|
|
100
|
+
console.log(`Codex Until (Windows / macOS / Linux, Node >=18)
|
|
81
101
|
Usage: codex-until [start|stop|status|quota|logs]
|
|
82
102
|
No command defaults to start. Without -b, show b/q controls in a terminal.
|
|
83
103
|
Non-interactive invocations run in the background.
|
|
84
104
|
start Start polling every 60 seconds; repeated calls are safe.
|
|
85
105
|
--background / -b: start directly in the background.
|
|
86
106
|
stop Stop monitoring only. Codex sessions remain running.
|
|
87
|
-
status
|
|
107
|
+
status Show the current status panel once, without a menu.
|
|
88
108
|
quota Query quota once without enabling monitoring.
|
|
89
109
|
logs Print the last 50 log lines.
|
|
90
|
-
Reset detection stops ALL local
|
|
110
|
+
Reset detection stops ALL local Codex processes and their children.
|
|
91
111
|
Restarting the monitor establishes a fresh baseline. No baseline is persisted.`);
|
|
92
112
|
return;
|
|
93
113
|
}
|
|
94
|
-
if (process.platform !== 'win32') throw new Error('This version supports Windows only.');
|
|
95
114
|
if (action === '--daemon') return daemon();
|
|
96
115
|
if (action === 'quota') return watch.main(true);
|
|
97
116
|
if (action === 'logs') {
|
|
98
|
-
|
|
99
|
-
|
|
117
|
+
const current = await status();
|
|
118
|
+
const logPath = current.running ? current.logPath : latestLog(runtimePaths().logDirectory);
|
|
119
|
+
if (!logPath || !fs.existsSync(logPath)) return console.log('No logs yet.');
|
|
120
|
+
return console.log(fs.readFileSync(logPath, 'utf8').trimEnd().split('\n').slice(-50).join('\n'));
|
|
100
121
|
}
|
|
101
122
|
if (!['start', 'stop', 'status'].includes(action)) throw new Error(`Unknown command: ${action}. Use --help.`);
|
|
102
123
|
const before = await status();
|
|
103
|
-
if (action === 'status')
|
|
124
|
+
if (action === 'status') {
|
|
125
|
+
const lines = before.running ? monitorLines(before, watch.logPath) : [];
|
|
126
|
+
console.log(renderPanel({ ...before, lines, showMenu: false,
|
|
127
|
+
columns: process.stdout.columns || 80, color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR) }));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
104
130
|
if (action === 'stop') {
|
|
105
131
|
const result = before.running ? await request('stop') : before;
|
|
106
132
|
console.log(JSON.stringify({ ...result, changed: before.running }));
|
|
@@ -108,13 +134,17 @@ Restarting the monitor establishes a fresh baseline. No baseline is persisted.`)
|
|
|
108
134
|
}
|
|
109
135
|
if (before.running) {
|
|
110
136
|
if (foreground) await consoleMode({ status, request, logPath: watch.logPath });
|
|
111
|
-
else
|
|
137
|
+
else {
|
|
138
|
+
const refreshed = await request('refresh');
|
|
139
|
+
console.log(JSON.stringify({ ...refreshed, quota: null, changed: false }));
|
|
140
|
+
}
|
|
112
141
|
return;
|
|
113
142
|
}
|
|
114
143
|
fs.mkdirSync(path.dirname(watch.logPath), { recursive: true });
|
|
115
144
|
const output = fs.openSync(watch.logPath, 'a');
|
|
116
145
|
const child = spawn(process.execPath, [__filename, '--daemon'], {
|
|
117
146
|
detached: true, windowsHide: true, stdio: ['ignore', 'ignore', output],
|
|
147
|
+
env: { ...process.env, CODEX_UNTIL_LOG_PATH: watch.logPath },
|
|
118
148
|
});
|
|
119
149
|
fs.closeSync(output);
|
|
120
150
|
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
|
package/bin/terminal.cjs
CHANGED
|
@@ -4,13 +4,28 @@ const readline = require('node:readline');
|
|
|
4
4
|
const intervals = [10000, 30000, 60000, 300000];
|
|
5
5
|
const intervalLabels = ['10s', '30s', '1m', '5m'];
|
|
6
6
|
|
|
7
|
+
function monitorLines(state, fallbackPath) {
|
|
8
|
+
// Older daemons only expose a log path; new daemons return quota directly.
|
|
9
|
+
if (state.quota !== undefined) return [];
|
|
10
|
+
const logPath = state.logPath || fallbackPath;
|
|
11
|
+
return logPath && fs.existsSync(logPath)
|
|
12
|
+
? fs.readFileSync(logPath, 'utf8').trimEnd().split('\n').slice(-150) : [];
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
function renderPanel({ running, pid, lines, selected = 0, columns = 80, color = true,
|
|
8
|
-
now = Date.now(), intervalMs = 60000, nextQueryAt, querying, cooldownUntil
|
|
16
|
+
now = Date.now(), intervalMs = 60000, nextQueryAt, querying, cooldownUntil, showMenu = true,
|
|
17
|
+
quota: snapshot, lastSuccessfulAt, lastError, minimumSequence = 0, successfulSequence = 0 }) {
|
|
9
18
|
const paint = (code, text) => color ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
10
19
|
const clean = text => text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f]/g, '');
|
|
11
20
|
const records = lines.map(clean);
|
|
12
21
|
const quota = [...records].reverse().find(line => /remaining [\d.]+%/.test(line));
|
|
13
|
-
|
|
22
|
+
let match = quota?.match(/^(\S+).*?remaining ([\d.]+)%, reset (\S+)/);
|
|
23
|
+
if (snapshot !== undefined) {
|
|
24
|
+
const fresh = successfulSequence >= minimumSequence;
|
|
25
|
+
const window = fresh ? snapshot?.primary ?? snapshot?.secondary : null;
|
|
26
|
+
match = window ? [null, new Date(lastSuccessfulAt).toISOString(), String(100 - window.usedPercent),
|
|
27
|
+
new Date(window.resetsAt * 1000).toISOString()] : null;
|
|
28
|
+
}
|
|
14
29
|
const width = Math.max(8, Math.min(28, columns - 20));
|
|
15
30
|
const latestRecord = records.at(-1) || '';
|
|
16
31
|
const lastAttempt = Date.parse(latestRecord.split(' ')[0]);
|
|
@@ -25,9 +40,10 @@ function renderPanel({ running, pid, lines, selected = 0, columns = 80, color =
|
|
|
25
40
|
'', paint('36', ' ╭───────────────────────╮'),
|
|
26
41
|
paint('36', ' │') + paint('1;36', ' CODEX UNTIL ') + paint('36', '│'),
|
|
27
42
|
paint('36', ' ╰───────────────────────╯'), '',
|
|
28
|
-
` ${paint(running ? '32' : '33', running ? '● 正在监控' : '○
|
|
29
|
-
` ${paint('36', queryStatus)}`,
|
|
43
|
+
` ${paint(running ? '32' : '33', running ? '● 正在监控' : '○ 未启动')}${running ? ' ' + paint('90', `PID ${pid ?? '—'}`) : ''}`,
|
|
30
44
|
];
|
|
45
|
+
if (!running) return output.join('\n') + '\n';
|
|
46
|
+
output.push(` ${paint('36', queryStatus)}`);
|
|
31
47
|
if (match) {
|
|
32
48
|
const remaining = Math.max(0, Math.min(100, Number(match[2])));
|
|
33
49
|
const filled = Math.round(width * remaining / 100);
|
|
@@ -35,11 +51,12 @@ function renderPanel({ running, pid, lines, selected = 0, columns = 80, color =
|
|
|
35
51
|
` ${paint('36', '━'.repeat(filled))}${paint('90', '─'.repeat(width - filled))}`,
|
|
36
52
|
` 下次重置 ${new Date(match[3]).toLocaleString('zh-CN', { hour12: false })}`);
|
|
37
53
|
} else output.push('', ' 剩余额度 —', ' 下次重置 —',
|
|
38
|
-
paint('90', '
|
|
39
|
-
if (records.at(-1)?.includes('ERROR')) {
|
|
40
|
-
output.push(paint('33', ' 刷新失败,以上为上次成功数据。'));
|
|
54
|
+
paint('90', ' 正在等待本次额度查询…'));
|
|
55
|
+
if (lastError || records.at(-1)?.includes('ERROR')) {
|
|
56
|
+
output.push(paint('33', match ? ' 刷新失败,以上为上次成功数据。' : ' 本次查询失败,等待重试。'));
|
|
41
57
|
}
|
|
42
58
|
output.push('', paint('90', ' 额度重置后,结束本机全部 Codex 会话。'), '');
|
|
59
|
+
if (!showMenu) return output.join('\n');
|
|
43
60
|
const choices = intervalLabels.map((label, index) => intervals[index] === intervalMs ? `[${label}]` : label).join(' ');
|
|
44
61
|
for (const [index, label] of [`刷新间隔 ‹ ${choices} ›`, '转入后台,继续监控', '停止监控并退出'].entries()) {
|
|
45
62
|
output.push(index === selected ? paint('1;36', ` ❯ ${label}`) : paint('90', ` ${label}`));
|
|
@@ -49,6 +66,11 @@ function renderPanel({ running, pid, lines, selected = 0, columns = 80, color =
|
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
async function consoleMode({ status, request, logPath }) {
|
|
69
|
+
const initial = await request('refresh');
|
|
70
|
+
if (!Number.isFinite(initial.requiredSequence)) {
|
|
71
|
+
throw new Error('当前后台版本不支持启动时刷新,请先 stop,再重新启动。');
|
|
72
|
+
}
|
|
73
|
+
const minimumSequence = initial.requiredSequence;
|
|
52
74
|
const input = process.stdin;
|
|
53
75
|
const output = process.stdout;
|
|
54
76
|
let selected = 0;
|
|
@@ -67,7 +89,7 @@ async function consoleMode({ status, request, logPath }) {
|
|
|
67
89
|
const draw = () => {
|
|
68
90
|
if (closing) return;
|
|
69
91
|
const panel = renderPanel({ ...current, lines, selected,
|
|
70
|
-
columns: output.columns || 80, color: !process.env.NO_COLOR });
|
|
92
|
+
minimumSequence, columns: output.columns || 80, color: !process.env.NO_COLOR });
|
|
71
93
|
if (panel === lastPanel) return;
|
|
72
94
|
lastPanel = panel;
|
|
73
95
|
output.write('\x1b[H\x1b[2J' + panel);
|
|
@@ -124,8 +146,8 @@ async function consoleMode({ status, request, logPath }) {
|
|
|
124
146
|
if (busy || closing) return;
|
|
125
147
|
busy = true;
|
|
126
148
|
try {
|
|
127
|
-
if (fs.existsSync(logPath)) lines = fs.readFileSync(logPath, 'utf8').trimEnd().split('\n').slice(-150);
|
|
128
149
|
current = await status();
|
|
150
|
+
lines = monitorLines(current, logPath);
|
|
129
151
|
if (closing) return;
|
|
130
152
|
draw();
|
|
131
153
|
if (!current.running) await finish(false, '后台监控已退出,请用 logs 查看原因。');
|
|
@@ -142,4 +164,4 @@ async function consoleMode({ status, request, logPath }) {
|
|
|
142
164
|
});
|
|
143
165
|
}
|
|
144
166
|
|
|
145
|
-
module.exports = { consoleMode, renderPanel };
|
|
167
|
+
module.exports = { consoleMode, renderPanel, monitorLines };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alanwalk/codex-until",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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 bin/terminal.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,57 @@
|
|
|
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 logFileName(startedAt = new Date()) {
|
|
7
|
+
const pad = (value, width = 2) => String(value).padStart(width, '0');
|
|
8
|
+
return `codex-until-${startedAt.getFullYear()}-${pad(startedAt.getMonth() + 1)}-${pad(startedAt.getDate())}_${pad(startedAt.getHours())}-${pad(startedAt.getMinutes())}-${pad(startedAt.getSeconds())}-${pad(startedAt.getMilliseconds(), 3)}.log`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function latestLog(logDirectory) {
|
|
12
|
+
if (!fs.existsSync(logDirectory)) return null;
|
|
13
|
+
const names = fs.readdirSync(logDirectory).filter(name => /^codex-until-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}\.log$/.test(name)).sort();
|
|
14
|
+
return names.length ? path.join(logDirectory, names.at(-1)) : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function runtimePaths(platform = process.platform, env = process.env, home = os.homedir()) {
|
|
18
|
+
const windows = platform === 'win32';
|
|
19
|
+
const key = crypto.createHash('sha256').update(home).digest('hex').slice(0, 16);
|
|
20
|
+
const desktopCli = windows
|
|
21
|
+
? path.join(env.LOCALAPPDATA || '', 'Programs/OpenAI/Codex/bin/codex.exe')
|
|
22
|
+
: '/Applications/Codex.app/Contents/Resources/codex';
|
|
23
|
+
const cacheRoot = windows ? env.LOCALAPPDATA || home : home;
|
|
24
|
+
const logDirectory = path.join(cacheRoot, '.cache', 'codex-until');
|
|
25
|
+
let cli = env.CODEX_QUOTA_CLI;
|
|
26
|
+
if (!cli) {
|
|
27
|
+
if (fs.existsSync(desktopCli)) cli = desktopCli;
|
|
28
|
+
else if (windows) cli = 'codex.exe';
|
|
29
|
+
else cli = 'codex';
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
windows,
|
|
33
|
+
address: windows ? `\\\\.\\pipe\\codex-quota-guard-${key}` : path.join(os.tmpdir(), `codex-until-${key}.sock`),
|
|
34
|
+
logDirectory,
|
|
35
|
+
logPath: path.join(logDirectory, logFileName()),
|
|
36
|
+
cli,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function codexProcessIds(processList, ownPid = process.pid) {
|
|
41
|
+
const processes = processList.split('\n').flatMap(line => {
|
|
42
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
43
|
+
if (!match) return [];
|
|
44
|
+
return [{ pid: Number(match[1]), parent: Number(match[2]), name: path.posix.basename(match[3]) }];
|
|
45
|
+
});
|
|
46
|
+
const targets = new Set(processes.filter(item => /^(codex|codex-(aarch64|x86_64)-.+)$/i.test(item.name)).map(item => item.pid));
|
|
47
|
+
let added = true;
|
|
48
|
+
while (added) {
|
|
49
|
+
added = false;
|
|
50
|
+
for (const item of processes) {
|
|
51
|
+
if (targets.has(item.parent) && !targets.has(item.pid)) { targets.add(item.pid); added = true; }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...targets].reverse().filter(pid => pid !== ownPid && pid > 1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { runtimePaths, codexProcessIds, logFileName, latestLog };
|
package/watch-codex-quota.cjs
CHANGED
|
@@ -2,10 +2,12 @@ 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 =
|
|
8
|
-
const polling = { intervalMs: 60000, nextQueryAt: null, querying: false, cooldownUntil: null,
|
|
7
|
+
const logPath = process.env.CODEX_UNTIL_LOG_PATH || runtimePaths().logPath;
|
|
8
|
+
const polling = { intervalMs: 60000, nextQueryAt: null, querying: false, cooldownUntil: null,
|
|
9
|
+
failureCount: 0, quota: null, lastSuccessfulAt: null, lastError: null,
|
|
10
|
+
querySequence: 0, successfulSequence: 0 };
|
|
9
11
|
const quotaParams = { excludeResetCreditDetails: true };
|
|
10
12
|
function quotaError(error) {
|
|
11
13
|
const result = new Error(error.message || 'Quota query failed');
|
|
@@ -29,6 +31,7 @@ function retryAfterMs(error, now = Date.now()) {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
function recordFailure(error, now = Date.now()) {
|
|
34
|
+
polling.lastError = error.message;
|
|
32
35
|
polling.failureCount += 1;
|
|
33
36
|
const requestedWait = retryAfterMs(error, now);
|
|
34
37
|
const backoff = Math.min(1800000, 60000 * 2 ** Math.min(5, polling.failureCount - 1));
|
|
@@ -38,6 +41,7 @@ function recordFailure(error, now = Date.now()) {
|
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
function recordSuccess() {
|
|
44
|
+
polling.lastError = null;
|
|
41
45
|
polling.failureCount = 0;
|
|
42
46
|
polling.cooldownUntil = null;
|
|
43
47
|
}
|
|
@@ -46,6 +50,11 @@ function queryDeadline(requested) {
|
|
|
46
50
|
return Math.max(requested, polling.cooldownUntil || 0);
|
|
47
51
|
}
|
|
48
52
|
let reschedule = null;
|
|
53
|
+
function requestRefresh(now = Date.now()) {
|
|
54
|
+
const requiredSequence = polling.querying ? polling.querySequence : polling.querySequence + 1;
|
|
55
|
+
if (!polling.querying && reschedule) reschedule(now);
|
|
56
|
+
return requiredSequence;
|
|
57
|
+
}
|
|
49
58
|
function setIntervalMs(intervalMs) {
|
|
50
59
|
if (![10000, 30000, 60000, 300000].includes(intervalMs)) throw new Error('Unsupported polling interval');
|
|
51
60
|
polling.intervalMs = intervalMs;
|
|
@@ -78,8 +87,7 @@ function log(message) {
|
|
|
78
87
|
|
|
79
88
|
function readQuota() {
|
|
80
89
|
return new Promise((resolve, reject) => {
|
|
81
|
-
const
|
|
82
|
-
const cli = process.env.CODEX_QUOTA_CLI || (fs.existsSync(desktopCli) ? desktopCli : 'codex.exe');
|
|
90
|
+
const { cli } = runtimePaths();
|
|
83
91
|
const child = spawn(cli, ['app-server', '--stdio'], {
|
|
84
92
|
windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
85
93
|
});
|
|
@@ -155,6 +163,18 @@ function resetReason(previous, current) {
|
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
function stopCodex() {
|
|
166
|
+
if (process.platform !== 'win32') {
|
|
167
|
+
const result = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8' });
|
|
168
|
+
if (result.error || result.status !== 0) throw new Error('Failed to enumerate Codex processes');
|
|
169
|
+
const failures = [];
|
|
170
|
+
for (const pid of codexProcessIds(result.stdout)) {
|
|
171
|
+
try { process.kill(pid, 'SIGKILL'); }
|
|
172
|
+
catch (error) { if (error.code !== 'ESRCH') failures.push(`${pid}: ${error.code}`); }
|
|
173
|
+
}
|
|
174
|
+
if (failures.length) throw new Error(`Failed to stop Codex: ${failures.join(', ')}`);
|
|
175
|
+
log('All local Codex processes and their child processes stopped.');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
158
178
|
// /T also stops tasks spawned by each Codex process.
|
|
159
179
|
const result = spawnSync('taskkill.exe', ['/F', '/T', '/IM', 'codex.exe'], {
|
|
160
180
|
windowsHide: true, encoding: 'utf8',
|
|
@@ -171,9 +191,13 @@ async function main(queryOnly = process.argv.includes('--once')) {
|
|
|
171
191
|
for (;;) {
|
|
172
192
|
const started = Date.now();
|
|
173
193
|
polling.querying = true;
|
|
194
|
+
polling.querySequence += 1;
|
|
174
195
|
polling.nextQueryAt = null;
|
|
175
196
|
try {
|
|
176
197
|
const current = getSnapshot(await readQuota());
|
|
198
|
+
polling.quota = current.windows;
|
|
199
|
+
polling.lastSuccessfulAt = Date.now();
|
|
200
|
+
polling.successfulSequence = polling.querySequence;
|
|
177
201
|
recordSuccess();
|
|
178
202
|
log(Object.entries(current.windows).map(([name, window]) =>
|
|
179
203
|
`${name}: remaining ${100 - window.usedPercent}%, reset ${new Date(window.resetsAt * 1000).toISOString()}`
|
|
@@ -198,5 +222,5 @@ async function main(queryOnly = process.argv.includes('--once')) {
|
|
|
198
222
|
}
|
|
199
223
|
|
|
200
224
|
module.exports = { getSnapshot, resetReason, main, logPath, polling, setIntervalMs, waitForNextQuery,
|
|
201
|
-
quotaParams, quotaError, retryAfterMs, recordFailure, recordSuccess, queryDeadline };
|
|
225
|
+
quotaParams, quotaError, retryAfterMs, recordFailure, recordSuccess, queryDeadline, requestRefresh };
|
|
202
226
|
if (require.main === module) main().catch(error => { console.error(error); process.exitCode = 1; });
|