@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 CHANGED
@@ -1,49 +1,44 @@
1
- # @alanwalk/codex-until
1
+ # codex-until
2
2
 
3
- Windows Codex 额度监控命令行工具。零 npm 依赖,Node.js 18 或以上。
3
+ 监控 Codex 剩余额度,发现额度重置时结束本机所有 Codex 会话。
4
4
 
5
- 菜单第一项为“刷新间隔”,左右键切换 `10s / 30s / 1m / 5m`,默认 `1m`;后两项为“转入后台”和“停止监控并退出”。
5
+ 支持 Windows、macOS、Linux。需要 Node.js 18+,以及已登录 ChatGPT 账号的官方 Codex CLI 或桌面应用。
6
6
 
7
- 使用官方轻量轮询参数 `excludeResetCreditDetails: true`,跳过不需要的重置次数详情查询。查询失败自动退避:若 App Server 错误数据或文本中包含有效的 `Retry-After`,至少等待服务端要求的时间;否则依次等待 1、2、4、8、16、30 分钟,之后保持最多每 30 分钟重试。等待不会短于所选刷新间隔,成功后恢复所选间隔。退避期间修改刷新间隔不能提前结束服务端等待。
7
+ ## 使用
8
8
 
9
- App Server 当前并不保证透传 HTTP 响应头,因此不能保证总能获取 `Retry-After`;无法获得时使用上述退避。面板显示退避倒计时,保留上次成功数据和额度比较基线。
10
-
11
- 终端面板展示剩余额度、下次额度重置时间(按本地时区显示),并逐秒更新距下次查询的预计倒计时。后台默认每 60 秒查询额度,可在菜单中上下选中“刷新间隔”,再用左右键切换 `10s / 30s / 1m / 5m`。修改即时应用到同一后台进程,转入后台后保持所选间隔;重新启动恢复默认 `1m`。后台按所选间隔查询额度,查询期间显示“正在查询”。查询失败时保留上次成功数据并提示错误;详细记录通过 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 # 查看日志
16
+ ```
12
17
 
13
- 需要已安装并通过 ChatGPT 账号登录的官方 Codex CLI / 桌面应用。工具复用本地登录状态,不打印 token。默认使用桌面应用 CLI,找不到时使用 PATH 中的 `codex.exe`;可通过 `CODEX_QUOTA_CLI` 指定完整可执行文件路径。
18
+ `status` 输出一次当前状态面板,不显示操作菜单;未运行时只显示标题和“未启动”。
14
19
 
15
- 无需预先执行 `npm install`,直接运行:
20
+ 面板显示剩余额度、下次重置时间和查询倒计时。上下键选择操作,左右键切换刷新间隔:`10s / 30s / 1m / 5m`,默认 `1m`。
16
21
 
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
23
- ```
22
+ 每次打开面板会主动刷新一次,返回前显示等待,不展示上次额度。已有查询会复用,限流退避期间仍按要求等待。`status` 只查看现有状态,不触发刷新。
24
23
 
25
- 不带参数会立即开启监控:在交互终端中显示彩色状态面板、剩余额度进度条、查询倒计时和下次重置时间。使用方向键选择操作并回车确认,也可直接按 `b` 转入后台、按 `q` 停止监控并退出,`Ctrl+C` 同样停止监控。转入后台只是退出控制台,监控进程及本次比较基线保持不变。输入流关闭时也会退出控制台并保留后台监控。
24
+ - **转入后台**:关闭面板,继续监控。也可按 `b`。
25
+ - **停止并退出**:停止监控,保留 Codex 会话。也可按 `q` 或 `Ctrl+C`。
26
26
 
27
- `start` 与无参数执行相同,在交互终端默认显示状态面板和操作菜单;使用 `-b / --background` 直接进入后台:
27
+ ## 工作方式
28
28
 
29
- ```powershell
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
- 通过自动化工具等非交互环境无参数执行时,默认后台启动,不等待输入。重复启动不会产生多个监控,前台模式可连接已有监控。`stop` 只停止监控,不停止 Codex。`status` 输出 JSON;表示后台监控进程可响应,不代表最近的额度请求一定成功,请结合 `logs` 查看查询结果。`--help` 显示帮助;添加 `--yes`(例如 `npx --yes @alanwalk/codex-until`)可跳过 npx 首次下载确认。
31
+ 同一账号、同一额度窗口的剩余百分比上升,或重置时间推迟且已用比例未增加,就视为重置。首次查询只建立基线,重启监控会重新建立基线。
37
32
 
38
- ## 重置判断
33
+ **检测到重置后会强制结束本机全部 Codex 进程及其子进程,正在执行的任务会中断。** 服务器修正额度也可能触发。
39
34
 
40
- 首次成功查询只建立内存基线。后续对比上次成功查询,在账号、窗口名称和窗口时长相同的前提下,任一窗口满足以下条件即触发:
35
+ ## 路径
41
36
 
42
- - 已用百分比下降,即剩余额度上升,哪怕只上升 1%。
43
- - 重置时间推迟,且已用百分比没有增加。
37
+ 默认查找 Codex 桌面应用内的 CLI,找不到则使用 PATH 中的 `codex`。可用 `CODEX_QUOTA_CLI` 指定可执行文件完整路径。
44
38
 
45
- 查询失败记录日志并重试,不清空上一次成功结果。停止再启动重新建立基线,不比较监控停机期间的变化。服务端额度修正也可能满足上述规则。轮询存在约一分钟延迟,另加网络请求耗时。
39
+ 日志位置:
46
40
 
47
- **触发后执行 `taskkill /F /T /IM codex.exe`,强制结束本机所有 Codex 进程及其子进程,包括其他账号的本地会话,正在执行的任务会被中断。远程主机不受影响。** 成功后监控退出;停止失败会记录错误,后续继续尝试。没有自动消费额度重置次数的功能。
41
+ - Windows:`%LOCALAPPDATA%/.cache/codex-until/`
42
+ - macOS / Linux:`~/.cache/codex-until/`
48
43
 
49
- 日志保存在 `%LOCALAPPDATA%\codex-quota-guard\monitor.log`,不写入 npx 缓存中的包目录。日志不包含凭据。后台监控没有开机自启功能。
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 crypto = require('node:crypto');
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 userKey = crypto.createHash('sha256').update(os.homedir()).digest('hex').slice(0, 16);
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, ...watch.polling };
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
- server.listen(pipe, () => {
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 Print monitor status as JSON.
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 codex.exe processes and their children.
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
- if (!fs.existsSync(watch.logPath)) return console.log('No logs yet.');
99
- return console.log(fs.readFileSync(watch.logPath, 'utf8').trimEnd().split('\n').slice(-50).join('\n'));
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') return console.log(JSON.stringify(before));
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 console.log(JSON.stringify({ ...before, changed: false }));
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
- const match = quota?.match(/^(\S+).*?remaining ([\d.]+)%, reset (\S+)/);
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 ? '● 正在监控' : '○ 监控已退出')} ${paint('90', `PID ${pid ?? '—'}`)}`,
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.0",
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 };
@@ -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 os = require('node:os');
5
+ const { runtimePaths, codexProcessIds } = require('./platform.cjs');
6
6
 
7
- const logPath = path.join(process.env.LOCALAPPDATA || os.homedir(), 'codex-quota-guard', 'monitor.log');
8
- const polling = { intervalMs: 60000, nextQueryAt: null, querying: false, cooldownUntil: null, failureCount: 0 };
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 desktopCli = path.join(process.env.LOCALAPPDATA || '', 'Programs/OpenAI/Codex/bin/codex.exe');
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; });