@alanwalk/codex-until 0.2.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 -34
- package/bin/cli.cjs +25 -9
- package/package.json +3 -4
- package/platform.cjs +45 -0
- package/watch-codex-quota.cjs +15 -4
package/README.md
CHANGED
|
@@ -1,49 +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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
```powershell
|
|
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
|
|
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 # 查看日志
|
|
23
16
|
```
|
|
24
17
|
|
|
25
|
-
|
|
18
|
+
面板显示剩余额度、下次重置时间和查询倒计时。上下键选择操作,左右键切换刷新间隔:`10s / 30s / 1m / 5m`,默认 `1m`。
|
|
26
19
|
|
|
27
|
-
|
|
20
|
+
- **转入后台**:关闭面板,继续监控。也可按 `b`。
|
|
21
|
+
- **停止并退出**:停止监控,保留 Codex 会话。也可按 `q` 或 `Ctrl+C`。
|
|
28
22
|
|
|
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
|
-
```
|
|
23
|
+
## 工作方式
|
|
35
24
|
|
|
36
|
-
|
|
25
|
+
通过官方 App Server 轻量查询额度,不发送模型请求。查询失败会自动退避;能取得 `Retry-After` 时遵守服务端等待时间。
|
|
37
26
|
|
|
38
|
-
|
|
27
|
+
同一账号、同一额度窗口的剩余百分比上升,或重置时间推迟且已用比例未增加,就视为重置。首次查询只建立基线,重启监控会重新建立基线。
|
|
39
28
|
|
|
40
|
-
|
|
29
|
+
**检测到重置后会强制结束本机全部 Codex 进程及其子进程,正在执行的任务会中断。** 服务器修正额度也可能触发。
|
|
41
30
|
|
|
42
|
-
|
|
43
|
-
- 重置时间推迟,且已用百分比没有增加。
|
|
31
|
+
## 路径
|
|
44
32
|
|
|
45
|
-
|
|
33
|
+
默认查找 Codex 桌面应用内的 CLI,找不到则使用 PATH 中的 `codex`。可用 `CODEX_QUOTA_CLI` 指定可执行文件完整路径。
|
|
46
34
|
|
|
47
|
-
|
|
35
|
+
日志位置:
|
|
48
36
|
|
|
49
|
-
|
|
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
|
|
5
|
+
const { runtimePaths } = require('../platform.cjs');
|
|
7
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,6 +31,8 @@ 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', () => {});
|
|
@@ -48,16 +48,33 @@ async function daemon() {
|
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
|
-
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
|
+
}
|
|
52
62
|
if (error.code !== 'EADDRINUSE') console.error(error.message);
|
|
53
63
|
process.exit(error.code === 'EADDRINUSE' ? 0 : 1);
|
|
54
64
|
});
|
|
55
|
-
|
|
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);
|
|
56
72
|
watch.main().then(() => process.exit(process.exitCode || 0)).catch(error => {
|
|
57
73
|
console.error(error.message);
|
|
58
74
|
process.exit(1);
|
|
59
75
|
});
|
|
60
76
|
});
|
|
77
|
+
server.listen(pipe);
|
|
61
78
|
}
|
|
62
79
|
|
|
63
80
|
function parseOptions(args, interactive) {
|
|
@@ -77,7 +94,7 @@ function parseOptions(args, interactive) {
|
|
|
77
94
|
async function main() {
|
|
78
95
|
const { action, foreground } = parseOptions(process.argv.slice(2), Boolean(process.stdin.isTTY && process.stdout.isTTY));
|
|
79
96
|
if (['help', '--help', '-h'].includes(action)) {
|
|
80
|
-
console.log(`Codex Until (Windows, Node >=18)
|
|
97
|
+
console.log(`Codex Until (Windows / macOS / Linux, Node >=18)
|
|
81
98
|
Usage: codex-until [start|stop|status|quota|logs]
|
|
82
99
|
No command defaults to start. Without -b, show b/q controls in a terminal.
|
|
83
100
|
Non-interactive invocations run in the background.
|
|
@@ -87,11 +104,10 @@ Non-interactive invocations run in the background.
|
|
|
87
104
|
status Print monitor status as JSON.
|
|
88
105
|
quota Query quota once without enabling monitoring.
|
|
89
106
|
logs Print the last 50 log lines.
|
|
90
|
-
Reset detection stops ALL local
|
|
107
|
+
Reset detection stops ALL local Codex processes and their children.
|
|
91
108
|
Restarting the monitor establishes a fresh baseline. No baseline is persisted.`);
|
|
92
109
|
return;
|
|
93
110
|
}
|
|
94
|
-
if (process.platform !== 'win32') throw new Error('This version supports Windows only.');
|
|
95
111
|
if (action === '--daemon') return daemon();
|
|
96
112
|
if (action === 'quota') return watch.main(true);
|
|
97
113
|
if (action === 'logs') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alanwalk/codex-until",
|
|
3
|
-
"version": "0.2.
|
|
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 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,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,9 @@ 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
8
|
const polling = { intervalMs: 60000, nextQueryAt: null, querying: false, cooldownUntil: null, failureCount: 0 };
|
|
9
9
|
const quotaParams = { excludeResetCreditDetails: true };
|
|
10
10
|
function quotaError(error) {
|
|
@@ -78,8 +78,7 @@ function log(message) {
|
|
|
78
78
|
|
|
79
79
|
function readQuota() {
|
|
80
80
|
return new Promise((resolve, reject) => {
|
|
81
|
-
const
|
|
82
|
-
const cli = process.env.CODEX_QUOTA_CLI || (fs.existsSync(desktopCli) ? desktopCli : 'codex.exe');
|
|
81
|
+
const { cli } = runtimePaths();
|
|
83
82
|
const child = spawn(cli, ['app-server', '--stdio'], {
|
|
84
83
|
windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
85
84
|
});
|
|
@@ -155,6 +154,18 @@ function resetReason(previous, current) {
|
|
|
155
154
|
}
|
|
156
155
|
|
|
157
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
|
+
}
|
|
158
169
|
// /T also stops tasks spawned by each Codex process.
|
|
159
170
|
const result = spawnSync('taskkill.exe', ['/F', '/T', '/IM', 'codex.exe'], {
|
|
160
171
|
windowsHide: true, encoding: 'utf8',
|