@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 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%\codex-quota-guard\monitor.log`
38
- - macOS / Linux:`~/.codex-until/monitor.log`
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, ...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
  });
@@ -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 Print monitor status as JSON.
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
- if (!fs.existsSync(watch.logPath)) return console.log('No logs yet.');
115
- 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'));
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') 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
+ }
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 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
+ }
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
- 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.1",
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": {
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 logDirectory = windows ? path.join(env.LOCALAPPDATA || home, 'codex-quota-guard')
13
- : path.join(home, '.codex-until');
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
- logPath: path.join(logDirectory, 'monitor.log'),
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 };
@@ -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 { logPath } = runtimePaths();
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;
@@ -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; });