@alanwalk/codex-until 0.2.1 → 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 +8 -2
- package/bin/cli.cjs +22 -8
- package/bin/terminal.cjs +32 -10
- package/package.json +1 -1
- package/platform.cjs +16 -4
- package/watch-codex-quota.cjs +16 -3
package/README.md
CHANGED
|
@@ -15,8 +15,12 @@ npx @alanwalk/codex-until quota # 查询一次额度
|
|
|
15
15
|
npx @alanwalk/codex-until logs # 查看日志
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
`status` 输出一次当前状态面板,不显示操作菜单;未运行时只显示标题和“未启动”。
|
|
19
|
+
|
|
18
20
|
面板显示剩余额度、下次重置时间和查询倒计时。上下键选择操作,左右键切换刷新间隔:`10s / 30s / 1m / 5m`,默认 `1m`。
|
|
19
21
|
|
|
22
|
+
每次打开面板会主动刷新一次,返回前显示等待,不展示上次额度。已有查询会复用,限流退避期间仍按要求等待。`status` 只查看现有状态,不触发刷新。
|
|
23
|
+
|
|
20
24
|
- **转入后台**:关闭面板,继续监控。也可按 `b`。
|
|
21
25
|
- **停止并退出**:停止监控,保留 Codex 会话。也可按 `q` 或 `Ctrl+C`。
|
|
22
26
|
|
|
@@ -34,5 +38,7 @@ npx @alanwalk/codex-until logs # 查看日志
|
|
|
34
38
|
|
|
35
39
|
日志位置:
|
|
36
40
|
|
|
37
|
-
- Windows:`%LOCALAPPDATA
|
|
38
|
-
- macOS / Linux:`~/.codex-until
|
|
41
|
+
- Windows:`%LOCALAPPDATA%/.cache/codex-until/`
|
|
42
|
+
- macOS / Linux:`~/.cache/codex-until/`
|
|
43
|
+
|
|
44
|
+
每次启动单独创建日志,按本地启动时间命名,例如 `codex-until-2026-10-15_16-50-30-123.log`。`logs` 查看当前监控日志;未运行时查看最近一次日志。
|
package/bin/cli.cjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
const net = require('node:net');
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const fs = require('node:fs');
|
|
5
|
-
const { runtimePaths } = require('../platform.cjs');
|
|
6
|
-
const { consoleMode } = require('./terminal.cjs');
|
|
5
|
+
const { runtimePaths, latestLog } = require('../platform.cjs');
|
|
6
|
+
const { consoleMode, renderPanel, monitorLines } = require('./terminal.cjs');
|
|
7
7
|
const { spawn } = require('node:child_process');
|
|
8
8
|
const watch = require('../watch-codex-quota.cjs');
|
|
9
9
|
const { address: pipe, windows } = runtimePaths();
|
|
@@ -38,11 +38,14 @@ async function daemon() {
|
|
|
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
|
});
|
|
@@ -101,7 +104,7 @@ Non-interactive invocations run in the background.
|
|
|
101
104
|
start Start polling every 60 seconds; repeated calls are safe.
|
|
102
105
|
--background / -b: start directly in the background.
|
|
103
106
|
stop Stop monitoring only. Codex sessions remain running.
|
|
104
|
-
status
|
|
107
|
+
status Show the current status panel once, without a menu.
|
|
105
108
|
quota Query quota once without enabling monitoring.
|
|
106
109
|
logs Print the last 50 log lines.
|
|
107
110
|
Reset detection stops ALL local Codex processes and their children.
|
|
@@ -111,12 +114,19 @@ Restarting the monitor establishes a fresh baseline. No baseline is persisted.`)
|
|
|
111
114
|
if (action === '--daemon') return daemon();
|
|
112
115
|
if (action === 'quota') return watch.main(true);
|
|
113
116
|
if (action === 'logs') {
|
|
114
|
-
|
|
115
|
-
|
|
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'));
|
|
116
121
|
}
|
|
117
122
|
if (!['start', 'stop', 'status'].includes(action)) throw new Error(`Unknown command: ${action}. Use --help.`);
|
|
118
123
|
const before = await status();
|
|
119
|
-
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
|
+
}
|
|
120
130
|
if (action === 'stop') {
|
|
121
131
|
const result = before.running ? await request('stop') : before;
|
|
122
132
|
console.log(JSON.stringify({ ...result, changed: before.running }));
|
|
@@ -124,13 +134,17 @@ Restarting the monitor establishes a fresh baseline. No baseline is persisted.`)
|
|
|
124
134
|
}
|
|
125
135
|
if (before.running) {
|
|
126
136
|
if (foreground) await consoleMode({ status, request, logPath: watch.logPath });
|
|
127
|
-
else
|
|
137
|
+
else {
|
|
138
|
+
const refreshed = await request('refresh');
|
|
139
|
+
console.log(JSON.stringify({ ...refreshed, quota: null, changed: false }));
|
|
140
|
+
}
|
|
128
141
|
return;
|
|
129
142
|
}
|
|
130
143
|
fs.mkdirSync(path.dirname(watch.logPath), { recursive: true });
|
|
131
144
|
const output = fs.openSync(watch.logPath, 'a');
|
|
132
145
|
const child = spawn(process.execPath, [__filename, '--daemon'], {
|
|
133
146
|
detached: true, windowsHide: true, stdio: ['ignore', 'ignore', output],
|
|
147
|
+
env: { ...process.env, CODEX_UNTIL_LOG_PATH: watch.logPath },
|
|
134
148
|
});
|
|
135
149
|
fs.closeSync(output);
|
|
136
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
package/platform.cjs
CHANGED
|
@@ -3,14 +3,25 @@ const os = require('node:os');
|
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const crypto = require('node:crypto');
|
|
5
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
|
+
|
|
6
17
|
function runtimePaths(platform = process.platform, env = process.env, home = os.homedir()) {
|
|
7
18
|
const windows = platform === 'win32';
|
|
8
19
|
const key = crypto.createHash('sha256').update(home).digest('hex').slice(0, 16);
|
|
9
20
|
const desktopCli = windows
|
|
10
21
|
? path.join(env.LOCALAPPDATA || '', 'Programs/OpenAI/Codex/bin/codex.exe')
|
|
11
22
|
: '/Applications/Codex.app/Contents/Resources/codex';
|
|
12
|
-
const
|
|
13
|
-
|
|
23
|
+
const cacheRoot = windows ? env.LOCALAPPDATA || home : home;
|
|
24
|
+
const logDirectory = path.join(cacheRoot, '.cache', 'codex-until');
|
|
14
25
|
let cli = env.CODEX_QUOTA_CLI;
|
|
15
26
|
if (!cli) {
|
|
16
27
|
if (fs.existsSync(desktopCli)) cli = desktopCli;
|
|
@@ -20,7 +31,8 @@ function runtimePaths(platform = process.platform, env = process.env, home = os.
|
|
|
20
31
|
return {
|
|
21
32
|
windows,
|
|
22
33
|
address: windows ? `\\\\.\\pipe\\codex-quota-guard-${key}` : path.join(os.tmpdir(), `codex-until-${key}.sock`),
|
|
23
|
-
|
|
34
|
+
logDirectory,
|
|
35
|
+
logPath: path.join(logDirectory, logFileName()),
|
|
24
36
|
cli,
|
|
25
37
|
};
|
|
26
38
|
}
|
|
@@ -42,4 +54,4 @@ function codexProcessIds(processList, ownPid = process.pid) {
|
|
|
42
54
|
return [...targets].reverse().filter(pid => pid !== ownPid && pid > 1);
|
|
43
55
|
}
|
|
44
56
|
|
|
45
|
-
module.exports = { runtimePaths, codexProcessIds };
|
|
57
|
+
module.exports = { runtimePaths, codexProcessIds, logFileName, latestLog };
|
package/watch-codex-quota.cjs
CHANGED
|
@@ -4,8 +4,10 @@ const fs = require('node:fs');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { runtimePaths, codexProcessIds } = require('./platform.cjs');
|
|
6
6
|
|
|
7
|
-
const
|
|
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;
|
|
@@ -182,9 +191,13 @@ async function main(queryOnly = process.argv.includes('--once')) {
|
|
|
182
191
|
for (;;) {
|
|
183
192
|
const started = Date.now();
|
|
184
193
|
polling.querying = true;
|
|
194
|
+
polling.querySequence += 1;
|
|
185
195
|
polling.nextQueryAt = null;
|
|
186
196
|
try {
|
|
187
197
|
const current = getSnapshot(await readQuota());
|
|
198
|
+
polling.quota = current.windows;
|
|
199
|
+
polling.lastSuccessfulAt = Date.now();
|
|
200
|
+
polling.successfulSequence = polling.querySequence;
|
|
188
201
|
recordSuccess();
|
|
189
202
|
log(Object.entries(current.windows).map(([name, window]) =>
|
|
190
203
|
`${name}: remaining ${100 - window.usedPercent}%, reset ${new Date(window.resetsAt * 1000).toISOString()}`
|
|
@@ -209,5 +222,5 @@ async function main(queryOnly = process.argv.includes('--once')) {
|
|
|
209
222
|
}
|
|
210
223
|
|
|
211
224
|
module.exports = { getSnapshot, resetReason, main, logPath, polling, setIntervalMs, waitForNextQuery,
|
|
212
|
-
quotaParams, quotaError, retryAfterMs, recordFailure, recordSuccess, queryDeadline };
|
|
225
|
+
quotaParams, quotaError, retryAfterMs, recordFailure, recordSuccess, queryDeadline, requestRefresh };
|
|
213
226
|
if (require.main === module) main().catch(error => { console.error(error); process.exitCode = 1; });
|