unsnooze 1.19.0 → 1.19.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/CHANGELOG.md +218 -0
- package/README.md +41 -12
- package/bin/unsnooze.js +90 -64
- package/package.json +1 -1
- package/src/agents/codex.js +117 -12
- package/src/agents/opencode.js +2 -1
- package/src/cli.js +4 -0
- package/src/config.js +21 -0
- package/src/doctor.js +76 -0
- package/src/hook.js +6 -2
- package/src/install.js +104 -35
- package/src/launcher.js +52 -10
- package/src/monitor.js +11 -0
- package/src/multiplexers/headless.js +159 -11
- package/src/resumer.js +94 -11
- package/src/spawn.js +262 -9
- package/src/time-parser.js +6 -1
- package/src/usage.js +50 -21
- package/src/watcher.js +45 -12
- package/src/watchers/codex.js +202 -28
- package/src/which.js +63 -0
- package/src/wizard.js +8 -4
- package/src/workspace.js +4 -1
package/src/spawn.js
CHANGED
|
@@ -1,22 +1,78 @@
|
|
|
1
1
|
// Detached-process helpers shared by launcher, hook, and monitor.
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
existsSync, readFileSync, unlinkSync, writeFileSync, statSync, utimesSync, openSync, closeSync,
|
|
6
|
+
} from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
5
8
|
import { fileURLToPath } from 'node:url';
|
|
6
9
|
import { join, dirname } from 'node:path';
|
|
7
|
-
import { RESUMER_LOCK } from './config.js';
|
|
10
|
+
import { RESUMER_LOCK, DAEMON_ON_DEMAND, DAEMON_PIDFILE, STATE_DIR, ensureStateDir } from './config.js';
|
|
8
11
|
import { makeLogger } from './logger.js';
|
|
9
12
|
|
|
10
13
|
const log = makeLogger('spawn');
|
|
11
14
|
|
|
12
15
|
export const UNSNOOZE_BIN = join(dirname(dirname(fileURLToPath(import.meta.url))), 'bin', 'unsnooze.js');
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
// Variables that describe one agent session rather than the user. Claude Code
|
|
18
|
+
// sets the first group for every command and hook it runs, and CLAUDECODE is
|
|
19
|
+
// its nested-session guard: a claude started with it set refuses to run
|
|
20
|
+
// ("cannot be launched inside another Claude Code session"). The launcher sets
|
|
21
|
+
// the second group for the agent it wraps. A process that outlives the session
|
|
22
|
+
// that started it must carry neither, because every revival it launches
|
|
23
|
+
// inherits its environment. User configuration — CLAUDE_CONFIG_DIR,
|
|
24
|
+
// CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_GIT_BASH_PATH, TMUX — is kept.
|
|
25
|
+
const SESSION_ENV = new Set([
|
|
26
|
+
'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SSE_PORT',
|
|
27
|
+
'CLAUDE_CODE_SESSION_ID', 'CLAUDE_SESSION_ID', 'CLAUDE_PROJECT_DIR', 'CLAUDE_ENV_FILE',
|
|
28
|
+
'UNSNOOZE_ACTIVE', 'UNSNOOZE_MUX', 'UNSNOOZE_PANE', 'UNSNOOZE_PANE_OWNER', 'UNSNOOZE_LEASE_ID',
|
|
29
|
+
'UNSNOOZE_CWD',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// Windows environment names are case-insensitive, and a copied process.env
|
|
33
|
+
// keeps whatever case the variable was set with.
|
|
34
|
+
export function standaloneEnv(env = process.env) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const [key, value] of Object.entries(env)) {
|
|
37
|
+
if (!SESSION_ENV.has(key.toUpperCase())) out[key] = value;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// spawnDetached's options, apart so they can be tested without a child.
|
|
43
|
+
// `standalone` is for processes that outlive the session that started them —
|
|
44
|
+
// the daemon and a transient resumer: no session markers, and the home
|
|
45
|
+
// directory rather than the project the session happened to be in (on Windows
|
|
46
|
+
// a live process's working directory cannot be deleted or renamed, so a
|
|
47
|
+
// daemon started from a project would pin that folder until sign-out).
|
|
48
|
+
export function detachedSpawnOptions({ env = {}, standalone = false, logFd = null } = {}) {
|
|
49
|
+
return {
|
|
16
50
|
detached: true,
|
|
17
|
-
stdio: 'ignore',
|
|
18
|
-
env: { ...process.env, ...env },
|
|
19
|
-
|
|
51
|
+
stdio: logFd == null ? 'ignore' : ['ignore', logFd, logFd],
|
|
52
|
+
env: { ...(standalone ? standaloneEnv() : process.env), ...env },
|
|
53
|
+
...(standalone ? { cwd: homedir() } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// `logFile` appends the child's stdout/stderr to a file instead of discarding
|
|
58
|
+
// them: a daemon with no supervisor to capture its output would otherwise leave
|
|
59
|
+
// no trace of a crash.
|
|
60
|
+
export function spawnDetached(args, env = {}, { standalone = false, logFile = null, execPath = process.execPath } = {}) {
|
|
61
|
+
let logFd = null;
|
|
62
|
+
if (logFile) {
|
|
63
|
+
try { logFd = openSync(logFile, 'a', 0o600); } catch { /* no log, not no child */ }
|
|
64
|
+
}
|
|
65
|
+
let child;
|
|
66
|
+
try {
|
|
67
|
+
child = spawn(execPath, [UNSNOOZE_BIN, ...args], detachedSpawnOptions({ env, standalone, logFd }));
|
|
68
|
+
} finally {
|
|
69
|
+
// The child has its own copy of the descriptor.
|
|
70
|
+
if (logFd != null) { try { closeSync(logFd); } catch { /* already closed */ } }
|
|
71
|
+
}
|
|
72
|
+
// spawn() reports ENOENT, EACCES, EAGAIN and EMFILE as an 'error' event after
|
|
73
|
+
// it has returned. Unheard, that is an uncaught exception in the caller —
|
|
74
|
+
// the launcher, with the user's agent already running.
|
|
75
|
+
child.on('error', err => log(`could not start ${args[0]}: ${err.message}`));
|
|
20
76
|
child.unref();
|
|
21
77
|
return child.pid;
|
|
22
78
|
}
|
|
@@ -51,7 +107,9 @@ export function spawnResumerIfNeeded() {
|
|
|
51
107
|
if (Number.isFinite(pid) && pidAlive(pid)) return null;
|
|
52
108
|
}
|
|
53
109
|
} catch { /* unreadable lock — let the daemon sort it out */ }
|
|
54
|
-
|
|
110
|
+
// Standalone: it is spawned from inside a session (the hook, a pane's
|
|
111
|
+
// monitor) and outlives it, and a headless revival inherits its environment.
|
|
112
|
+
const pid = spawnDetached(['_resumer'], {}, { standalone: true });
|
|
55
113
|
log(`spawned resumer pid ${pid}`);
|
|
56
114
|
return pid;
|
|
57
115
|
}
|
|
@@ -63,7 +121,8 @@ export function stopResumer() {
|
|
|
63
121
|
try {
|
|
64
122
|
if (!existsSync(RESUMER_LOCK)) return { stopped: false, pid: null, reason: 'no-lock' };
|
|
65
123
|
pid = parseInt(readFileSync(RESUMER_LOCK, 'utf-8'), 10);
|
|
66
|
-
|
|
124
|
+
// A stale lock can name a recycled pid — this very process included.
|
|
125
|
+
if (Number.isFinite(pid) && pid !== process.pid && pidAlive(pid)) {
|
|
67
126
|
try { process.kill(pid, 'SIGTERM'); } catch { /* raced with exit */ }
|
|
68
127
|
log(`stopped resumer pid ${pid}`);
|
|
69
128
|
try { unlinkSync(RESUMER_LOCK); } catch { /* gone */ }
|
|
@@ -77,3 +136,197 @@ export function stopResumer() {
|
|
|
77
136
|
return { stopped: false, pid, reason: err.message };
|
|
78
137
|
}
|
|
79
138
|
}
|
|
139
|
+
|
|
140
|
+
// --- the on-demand daemon (native Windows) ----------------------------------
|
|
141
|
+
// launchd and systemd keep the daemon alive on macOS and Linux. Windows gets no
|
|
142
|
+
// equivalent, on purpose — see DAEMON_ON_DEMAND in config.js — so there the
|
|
143
|
+
// daemon is an ordinary background process that the wrappers and the
|
|
144
|
+
// StopFailure hook start whenever it is not running.
|
|
145
|
+
|
|
146
|
+
// How often a running daemon refreshes its pidfile, and how old the refresh may
|
|
147
|
+
// be before the file stops counting as proof of life. Five missed beats, so a
|
|
148
|
+
// busy moment is never mistaken for death. A machine waking from a long sleep
|
|
149
|
+
// can briefly look daemon-less; the worst that does is start a second daemon,
|
|
150
|
+
// which the first yields to on its next beat (claimDaemon).
|
|
151
|
+
export const DAEMON_HEARTBEAT_MS = 60_000;
|
|
152
|
+
export const DAEMON_STALE_MS = 5 * DAEMON_HEARTBEAT_MS;
|
|
153
|
+
|
|
154
|
+
function readPidfile(path) {
|
|
155
|
+
try { return parseInt(readFileSync(path, 'utf-8'), 10); } catch { return NaN; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The pid of the daemon that is demonstrably running, else null. A live pid
|
|
159
|
+
// alone proves nothing on Windows: pids are recycled quickly, and a sign-out or
|
|
160
|
+
// a crash leaves the file behind to name whichever process gets that pid next.
|
|
161
|
+
// The daemon touches the file every minute, so a stale one is ignored whoever
|
|
162
|
+
// its pid belongs to now.
|
|
163
|
+
export function runningDaemonPid({ path = DAEMON_PIDFILE, now = Date.now(), alive = pidAlive } = {}) {
|
|
164
|
+
const pid = readPidfile(path);
|
|
165
|
+
if (!alive(pid)) return null;
|
|
166
|
+
try {
|
|
167
|
+
return now - statSync(path).mtimeMs <= DAEMON_STALE_MS ? pid : null;
|
|
168
|
+
} catch {
|
|
169
|
+
return null; // vanished between the read and the stat
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// `wx` creates the file a moment before the claimant's pid is written into it.
|
|
174
|
+
// A file that young with no pid yet is somebody mid-claim, not a leftover.
|
|
175
|
+
const CLAIM_GRACE_MS = 2_000;
|
|
176
|
+
|
|
177
|
+
function midClaim(path, now = Date.now()) {
|
|
178
|
+
try {
|
|
179
|
+
return readFileSync(path, 'utf-8').trim() === '' && now - statSync(path).mtimeMs < CLAIM_GRACE_MS;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The daemon's half of the pidfile. Starters race — a wrapper and the hook can
|
|
186
|
+
// both find no daemon and both start one — so each new daemon claims the file,
|
|
187
|
+
// and a daemon that finds a live one already there stands down before doing
|
|
188
|
+
// anything. `wx` like the resumer lock; a stale file is replaced. Returns a
|
|
189
|
+
// release() for shutdown, or null when this daemon must not run.
|
|
190
|
+
//
|
|
191
|
+
// While held, the heartbeat keeps the claim fresh, and calls onLost() — the
|
|
192
|
+
// daemon should exit — once the file names another pid (this daemon looked
|
|
193
|
+
// dead through a long sleep and a newer one took over, or two claims raced:
|
|
194
|
+
// one daemon, whichever claimed last), once the file has been missing on two
|
|
195
|
+
// beats in a row (one unreadable moment, say an antivirus scan, is not proof),
|
|
196
|
+
// or once wanted() says the daemon is no longer asked for.
|
|
197
|
+
export function claimDaemon({
|
|
198
|
+
path = DAEMON_PIDFILE, pid = process.pid, running = runningDaemonPid,
|
|
199
|
+
heartbeatMs = DAEMON_HEARTBEAT_MS, onLost = () => {}, wanted = () => true,
|
|
200
|
+
lock = RESUMER_LOCK,
|
|
201
|
+
} = {}) {
|
|
202
|
+
ensureStateDir(dirname(path));
|
|
203
|
+
let claimed = false;
|
|
204
|
+
for (let attempt = 0; attempt < 2 && !claimed; attempt++) {
|
|
205
|
+
try {
|
|
206
|
+
writeFileSync(path, String(pid), { flag: 'wx', mode: 0o600 });
|
|
207
|
+
claimed = true;
|
|
208
|
+
} catch {
|
|
209
|
+
const previous = readPidfile(path);
|
|
210
|
+
const holder = running({ path });
|
|
211
|
+
if (holder === pid) claimed = true;
|
|
212
|
+
else if (holder) {
|
|
213
|
+
log(`daemon pid ${holder} is already running — pid ${pid} not starting another`);
|
|
214
|
+
return null;
|
|
215
|
+
} else if (midClaim(path)) {
|
|
216
|
+
log(`another daemon is claiming ${path} — pid ${pid} not starting another`);
|
|
217
|
+
return null;
|
|
218
|
+
} else {
|
|
219
|
+
// The previous daemon is gone: dead, or its pid now someone else's.
|
|
220
|
+
// TerminateProcess and sign-out skip its cleanup, so the resumer lock
|
|
221
|
+
// it held still names that pid — and on Windows, where the holder
|
|
222
|
+
// cannot be checked with ps, a recycled pid there would be honored
|
|
223
|
+
// forever and no resume would ever run again.
|
|
224
|
+
if (Number.isFinite(previous) && readPidfile(path) === previous && readPidfile(lock) === previous) {
|
|
225
|
+
try { unlinkSync(lock); } catch { /* released meanwhile */ }
|
|
226
|
+
log(`cleared the resumer lock left by daemon pid ${previous}`);
|
|
227
|
+
}
|
|
228
|
+
try { unlinkSync(path); } catch { /* raced with another claimant */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!claimed) {
|
|
233
|
+
log(`could not claim ${path} — pid ${pid} not starting`);
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
let misses = 0;
|
|
237
|
+
const beat = setInterval(() => {
|
|
238
|
+
const named = readPidfile(path);
|
|
239
|
+
let why = null;
|
|
240
|
+
if (!wanted()) why = 'it is no longer wanted';
|
|
241
|
+
else if (Number.isFinite(named) && named !== pid) why = `${path} now names pid ${named}`;
|
|
242
|
+
else if (!Number.isFinite(named) && ++misses >= 2) why = `${path} is gone`;
|
|
243
|
+
if (why) {
|
|
244
|
+
clearInterval(beat);
|
|
245
|
+
log(`daemon pid ${pid} standing down: ${why}`);
|
|
246
|
+
onLost();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (Number.isFinite(named)) {
|
|
250
|
+
misses = 0;
|
|
251
|
+
try { const t = new Date(); utimesSync(path, t, t); } catch { /* the next beat retries */ }
|
|
252
|
+
}
|
|
253
|
+
}, heartbeatMs);
|
|
254
|
+
beat.unref();
|
|
255
|
+
return () => {
|
|
256
|
+
clearInterval(beat);
|
|
257
|
+
if (readPidfile(path) === pid) {
|
|
258
|
+
try { unlinkSync(path); } catch { /* already gone */ }
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Is the on-demand daemon still asked for? `install --daemon` writes the
|
|
264
|
+
// marker and `uninstall` removes it.
|
|
265
|
+
export function onDemandWanted(marker = DAEMON_ON_DEMAND) {
|
|
266
|
+
return existsSync(marker);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Where the on-demand daemon's own stdout/stderr go — the file launchd writes
|
|
270
|
+
// them to elsewhere, which bin/unsnooze.js rotates when the daemon starts.
|
|
271
|
+
const DAEMON_LOG = join(STATE_DIR, 'daemon.log');
|
|
272
|
+
|
|
273
|
+
function spawnOnDemandDaemon(args) {
|
|
274
|
+
return spawnDetached(args, {}, { standalone: true, logFile: DAEMON_LOG });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Start the daemon unless it is already running — native Windows only, and
|
|
278
|
+
// only once `unsnooze install --daemon` has asked for it (the marker). The
|
|
279
|
+
// wrappers and the StopFailure hook call this, and between them they run
|
|
280
|
+
// whenever an agent does, so the daemon is back soon after a sign-in without
|
|
281
|
+
// anything registered to start at logon. Never throws: it sits on the launch
|
|
282
|
+
// path, where a failure must cost the daemon and nothing else.
|
|
283
|
+
//
|
|
284
|
+
// --on-demand ties the daemon to the marker: it exits when the marker goes
|
|
285
|
+
// (uninstall), where a daemon somebody runs by hand keeps running.
|
|
286
|
+
export function ensureDaemon({
|
|
287
|
+
platform = process.platform, marker = DAEMON_ON_DEMAND,
|
|
288
|
+
running = runningDaemonPid, spawner = spawnOnDemandDaemon,
|
|
289
|
+
} = {}) {
|
|
290
|
+
try {
|
|
291
|
+
if (platform !== 'win32' || !existsSync(marker) || running()) return null;
|
|
292
|
+
const pid = spawner(['daemon', '--on-demand']);
|
|
293
|
+
log(`daemon not running — started it on demand (pid ${pid})`);
|
|
294
|
+
return pid;
|
|
295
|
+
} catch (err) {
|
|
296
|
+
log(`could not start the daemon on demand: ${err.message}`);
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Stop the on-demand daemon (uninstall, and the restart `install --daemon`
|
|
302
|
+
// does). Only a daemon proven live by its heartbeat is signalled — a stale
|
|
303
|
+
// file's pid may be anybody's by now — and never this process, which a pid
|
|
304
|
+
// recycled inside the heartbeat window could be. The file goes either way, so
|
|
305
|
+
// a live daemon too stale to prove itself stands down at its next beats.
|
|
306
|
+
// Returns the pid that was signalled, or null.
|
|
307
|
+
export function stopDaemon({
|
|
308
|
+
path = DAEMON_PIDFILE, running = runningDaemonPid, lock = RESUMER_LOCK,
|
|
309
|
+
kill = (pid, signal) => process.kill(pid, signal),
|
|
310
|
+
} = {}) {
|
|
311
|
+
const found = running({ path });
|
|
312
|
+
const pid = found === process.pid ? null : found;
|
|
313
|
+
if (pid) {
|
|
314
|
+
try { kill(pid, 'SIGTERM'); } catch { /* raced with its exit */ }
|
|
315
|
+
log(`stopped daemon pid ${pid}`);
|
|
316
|
+
// On Windows that was TerminateProcess: nothing of the daemon's own ran,
|
|
317
|
+
// so a resumer lock it held still names it (see claimDaemon).
|
|
318
|
+
if (readPidfile(lock) === pid) {
|
|
319
|
+
try { unlinkSync(lock); } catch { /* already gone */ }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
try { unlinkSync(path); } catch { /* none to remove */ }
|
|
323
|
+
return pid;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// What `install --daemon` does on Windows, as launchctl unload+load does on
|
|
327
|
+
// macOS: a daemon already running keeps the code and PATH it started with, so
|
|
328
|
+
// re-running setup is how a user hands it new ones.
|
|
329
|
+
export function restartDaemon() {
|
|
330
|
+
stopDaemon();
|
|
331
|
+
return ensureDaemon();
|
|
332
|
+
}
|
package/src/time-parser.js
CHANGED
|
@@ -278,7 +278,12 @@ export function resetAtMs(parsed, {
|
|
|
278
278
|
}
|
|
279
279
|
return t; // unreachable in practice — 9 steps cover a week + DST repeat
|
|
280
280
|
}
|
|
281
|
-
|
|
281
|
+
// A clock time names a whole minute. Codex prints the reset as %-I:%M and
|
|
282
|
+
// drops the seconds, so a dated banner written at 7:36:15 that says "try
|
|
283
|
+
// again at 7:36 AM" means a reset later in that same minute — not 7:36
|
|
284
|
+
// tomorrow, a day late. Only a minute that had fully passed rolls.
|
|
285
|
+
const past = bannerAt != null ? t + 60_000 <= anchor : t <= anchor;
|
|
286
|
+
if (past) {
|
|
282
287
|
// Dated banner, or ambiguous (need both am/pm candidates as real
|
|
283
288
|
// next-occurrences): roll to the next day. Undated non-ambiguous
|
|
284
289
|
// past clock times stay past so the outer guard returns due-now
|
package/src/usage.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from './config.js';
|
|
16
16
|
import { readState, updateState } from './state.js';
|
|
17
17
|
import { getConfig } from './settings.js';
|
|
18
|
-
import { ROLLOUT_RE } from './agents/codex.js';
|
|
18
|
+
import { ROLLOUT_RE, rolloutId } from './agents/codex.js';
|
|
19
19
|
import { shouldUseTui, formatUsageTui, bar as tuiBar } from './tui.js';
|
|
20
20
|
import { shouldUseDashboard, runDashboard } from './dashboard/run.js';
|
|
21
21
|
|
|
@@ -343,18 +343,17 @@ export function calibrationStopCount(calibration, agent, limitType, pool = null)
|
|
|
343
343
|
return n;
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
-
// Clamp
|
|
347
|
-
//
|
|
348
|
-
|
|
346
|
+
// Clamp an exact reading to [0, 100]; fall back to the previous one when the
|
|
347
|
+
// current is unparseable. Nothing else: the value is the server's own number
|
|
348
|
+
// (Codex copies it out of a response header, the Claude statusline hands it
|
|
349
|
+
// over verbatim), and there is no client-side estimate in it to smooth. This
|
|
350
|
+
// used to average a >15pp jump with the previous sample "for one tick" — but
|
|
351
|
+
// the report recomputes from the last two samples, and at the moment a user is
|
|
352
|
+
// blocked no further sample ever arrives, so a real 99% sat at 64% for the rest
|
|
353
|
+
// of the window and never crossed the 80/95 warn thresholds (#20 follow-up).
|
|
354
|
+
export function clampUsedPercent(current, previous) {
|
|
349
355
|
if (!Number.isFinite(current)) return Number.isFinite(previous) ? previous : null;
|
|
350
|
-
|
|
351
|
-
if (v > 100) v = 100;
|
|
352
|
-
if (v < 0) v = 0;
|
|
353
|
-
if (Number.isFinite(previous) && v > previous + 15 && previous < 95) {
|
|
354
|
-
// Soft-cap upward spike: average with previous for one tick
|
|
355
|
-
v = (v + previous) / 2;
|
|
356
|
-
}
|
|
357
|
-
return v;
|
|
356
|
+
return Math.min(100, Math.max(0, current));
|
|
358
357
|
}
|
|
359
358
|
|
|
360
359
|
// Dominant model-pool among samples (for Max per-bucket calibration).
|
|
@@ -428,7 +427,7 @@ export function recordExactPctSample(store, exactClaude, { now = Date.now(), kee
|
|
|
428
427
|
if (last && last.pct === pct && now - last.at < 30_000) return store;
|
|
429
428
|
arr.push({
|
|
430
429
|
at: now,
|
|
431
|
-
pct:
|
|
430
|
+
pct: clampUsedPercent(pct, last?.pct),
|
|
432
431
|
resetsAtMs: normalizeResetsAtMs(exactClaude.fiveHour.resets_at),
|
|
433
432
|
});
|
|
434
433
|
store.exactPct.claude5h = arr
|
|
@@ -475,13 +474,23 @@ export function pctSpaceEta(history, {
|
|
|
475
474
|
};
|
|
476
475
|
}
|
|
477
476
|
|
|
478
|
-
|
|
477
|
+
// `rollout` is the uuid of the file the line came from (rolloutId()). A
|
|
478
|
+
// snapshot is only comparable with the previous snapshot of the SAME thread:
|
|
479
|
+
// Codex emits one token_count line per response per rate-limit bucket, every
|
|
480
|
+
// line carries the thread's last-known snapshot, and threads (Desktop tabs,
|
|
481
|
+
// sub-agents) interleave on disk — so "the machine's second-newest line" can be
|
|
482
|
+
// another thread's minutes-old reading, or another bucket entirely.
|
|
483
|
+
export function extractCodexUsage(line, { rollout = null } = {}) {
|
|
479
484
|
if (!line || !line.trim()) return null;
|
|
480
485
|
let entry;
|
|
481
486
|
try { entry = JSON.parse(line); } catch { return null; }
|
|
482
487
|
if (entry?.type !== 'event_msg' || entry.payload?.type !== 'token_count') return null;
|
|
483
488
|
const rl = entry.payload.rate_limits;
|
|
484
489
|
if (!rl || typeof rl !== 'object') return null;
|
|
490
|
+
// Only the account bucket feeds the 5h/weekly lines. Codex defaults a missing
|
|
491
|
+
// limit_id to "codex"; `premium`, `codex_other` and friends are separate
|
|
492
|
+
// meters with their own windows and must not be read as this one moving.
|
|
493
|
+
if (rl.limit_id != null && rl.limit_id !== 'codex') return null;
|
|
485
494
|
|
|
486
495
|
function win(w) {
|
|
487
496
|
if (!w || typeof w !== 'object') return null;
|
|
@@ -501,12 +510,29 @@ export function extractCodexUsage(line) {
|
|
|
501
510
|
return {
|
|
502
511
|
agent: 'codex',
|
|
503
512
|
at: ts,
|
|
513
|
+
rollout: rollout || null,
|
|
504
514
|
primary,
|
|
505
515
|
secondary: win(rl.secondary),
|
|
506
516
|
planType: rl.plan_type || null,
|
|
507
517
|
};
|
|
508
518
|
}
|
|
509
519
|
|
|
520
|
+
// The reading to measure `latest` against: the newest earlier sample from the
|
|
521
|
+
// same rollout. Samples recorded before the rollout was carried (an older
|
|
522
|
+
// daemon's store) fall back to the newest earlier sample of any origin.
|
|
523
|
+
// Strictly earlier: the dashboard hands over the daemon's store and a cold
|
|
524
|
+
// scan of the same files, so `latest`'s own copy sits right before it — and a
|
|
525
|
+
// zero-length interval read as "idle" for as long as the daemon ran.
|
|
526
|
+
export function previousCodexSample(sorted, latest) {
|
|
527
|
+
const idx = sorted.lastIndexOf(latest);
|
|
528
|
+
for (let i = (idx === -1 ? sorted.length : idx) - 1; i >= 0; i--) {
|
|
529
|
+
const s = sorted[i];
|
|
530
|
+
if (s.at >= latest.at) continue;
|
|
531
|
+
if (latest.rollout == null || s.rollout == null || s.rollout === latest.rollout) return s;
|
|
532
|
+
}
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
|
|
510
536
|
// ---------------------------------------------------------------------------
|
|
511
537
|
// Storage
|
|
512
538
|
// ---------------------------------------------------------------------------
|
|
@@ -821,17 +847,19 @@ export function buildUsageReport({
|
|
|
821
847
|
|
|
822
848
|
// --- Codex ---
|
|
823
849
|
{
|
|
824
|
-
const
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
: null;
|
|
850
|
+
const sorted = [...codexSamples].sort((a, b) => a.at - b.at);
|
|
851
|
+
const latest = sorted.at(-1);
|
|
852
|
+
const prev = latest ? previousCodexSample(sorted, latest) : null;
|
|
828
853
|
if (latest?.primary) {
|
|
829
854
|
const windows = [];
|
|
830
855
|
for (const key of ['primary', 'secondary']) {
|
|
831
856
|
const w = latest[key];
|
|
832
857
|
if (!w) continue;
|
|
833
|
-
|
|
834
|
-
|
|
858
|
+
// A window that changed size between the two readings is not the same
|
|
859
|
+
// window; its old reading says nothing about this one's movement.
|
|
860
|
+
const candidate = prev?.[key];
|
|
861
|
+
const prevW = candidate && candidate.windowMinutes === w.windowMinutes ? candidate : null;
|
|
862
|
+
const pct = clampUsedPercent(w.usedPercent, prevW?.usedPercent);
|
|
835
863
|
const label = w.label || labelWindow(w.windowMinutes);
|
|
836
864
|
// %-space burn for ETA on the primary/short window only
|
|
837
865
|
let burn = { idle: true, burnPerMin: 0, activeMin: 0, warmingUp: false };
|
|
@@ -1107,8 +1135,9 @@ export function collectCodexSamples({
|
|
|
1107
1135
|
match,
|
|
1108
1136
|
onCandidate(path) {
|
|
1109
1137
|
onFile?.(path);
|
|
1138
|
+
const rollout = rolloutId(path);
|
|
1110
1139
|
for (const line of tailLines(path)) {
|
|
1111
|
-
const s = extractCodexUsage(line);
|
|
1140
|
+
const s = extractCodexUsage(line, { rollout });
|
|
1112
1141
|
if (s && s.at >= cut) samples.push(s);
|
|
1113
1142
|
}
|
|
1114
1143
|
},
|
package/src/watcher.js
CHANGED
|
@@ -23,8 +23,10 @@ import {
|
|
|
23
23
|
import { getMultiplexer } from './multiplexer.js';
|
|
24
24
|
import { parseTranscriptLine } from './watchers/claude.js';
|
|
25
25
|
import { parseRolloutLines, rolloutMeta } from './watchers/codex.js';
|
|
26
|
-
import { ROLLOUT_RE } from './agents/codex.js';
|
|
27
|
-
import { parseResetTime, resetAtMs } from './time-parser.js';
|
|
26
|
+
import { ROLLOUT_RE, rolloutId } from './agents/codex.js';
|
|
27
|
+
import { parseResetTime, resetAtMs, sourceRank } from './time-parser.js';
|
|
28
|
+
import { getAgent } from './agents/index.js';
|
|
29
|
+
import { modelRemedy } from './patterns.js';
|
|
28
30
|
import { upsertSession, readState, updateState } from './state.js';
|
|
29
31
|
import { getConfig } from './settings.js';
|
|
30
32
|
import { notify } from './notify.js';
|
|
@@ -88,21 +90,25 @@ export function codexSource({ roots }) {
|
|
|
88
90
|
parse(lines, path, { offset } = {}) {
|
|
89
91
|
const hits = parseRolloutLines(lines, { path, offset });
|
|
90
92
|
if (hits.length === 0) return [];
|
|
91
|
-
const last = hits[hits.length - 1]; // the latest snapshot governs
|
|
93
|
+
const last = hits[hits.length - 1]; // the latest snapshot or limit error governs
|
|
92
94
|
const meta = rolloutMeta(path);
|
|
93
95
|
return [{
|
|
94
96
|
agent: 'codex',
|
|
95
97
|
sessionId: meta.sessionId,
|
|
96
98
|
cwd: meta.cwd,
|
|
97
99
|
limitType: last.limitType,
|
|
98
|
-
|
|
100
|
+
// Banner text from a persisted task_complete error; parsed by
|
|
101
|
+
// dispatchCandidate exactly like a scraped pane when no epoch is known.
|
|
102
|
+
resetLine: last.resetLine || null,
|
|
99
103
|
resetAt: last.resetAt,
|
|
104
|
+
reason: last.reachedType || null,
|
|
100
105
|
origin: meta.originator,
|
|
101
106
|
timestampMs: last.timestampMs,
|
|
102
107
|
}];
|
|
103
108
|
},
|
|
104
|
-
usage(lines) {
|
|
105
|
-
|
|
109
|
+
usage(lines, path) {
|
|
110
|
+
const rollout = rolloutId(path);
|
|
111
|
+
return lines.map(line => extractCodexUsage(line, { rollout })).filter(Boolean);
|
|
106
112
|
},
|
|
107
113
|
};
|
|
108
114
|
}
|
|
@@ -184,16 +190,31 @@ export function dispatchCandidate(c) {
|
|
|
184
190
|
: null;
|
|
185
191
|
if (existing && existing.status === 'cancelled') return;
|
|
186
192
|
if (existing && (existing.status === 'stopped' || existing.status === 'resuming')) {
|
|
193
|
+
let kept = false;
|
|
187
194
|
updateState(state => {
|
|
188
195
|
const s = state.sessions[existing.key];
|
|
189
196
|
if (s && (s.status === 'stopped' || s.status === 'resuming')) {
|
|
190
|
-
|
|
191
|
-
s.
|
|
197
|
+
// A weaker estimate never replaces a stronger one that still stands —
|
|
198
|
+
// the rule monitor.js §7 and upsertSession's merge already apply. One
|
|
199
|
+
// Codex stop's token_count line and its task_complete error can land
|
|
200
|
+
// in different ticks, and a "Try again later." banner must not turn
|
|
201
|
+
// the exact epoch the snapshot gave into a 15-minute probe.
|
|
202
|
+
kept = sourceRank(source) < sourceRank(s.resetSource) && s.resetAt > Date.now();
|
|
203
|
+
if (!kept) {
|
|
204
|
+
s.resetAt = at;
|
|
205
|
+
s.resetSource = source;
|
|
206
|
+
if (c.limitType && c.limitType !== 'unknown') s.limitType = c.limitType;
|
|
207
|
+
// The reason belongs to the stop it came with: a later plain stop
|
|
208
|
+
// must not keep reporting an earlier workspace wall.
|
|
209
|
+
if (c.reason) s.limitReason = c.reason;
|
|
210
|
+
else delete s.limitReason;
|
|
211
|
+
}
|
|
192
212
|
if (bannerAt != null) s.bannerAt = bannerAt;
|
|
193
|
-
if (c.limitType && c.limitType !== 'unknown') s.limitType = c.limitType;
|
|
194
213
|
}
|
|
195
214
|
});
|
|
196
|
-
log(
|
|
215
|
+
log(kept
|
|
216
|
+
? `kept the stronger reset for tracked stop: session=${c.sessionId} (${source} would have replaced it)`
|
|
217
|
+
: `refreshed reset for tracked stop: session=${c.sessionId} resetAt=${new Date(at).toISOString()}`);
|
|
197
218
|
return;
|
|
198
219
|
}
|
|
199
220
|
|
|
@@ -217,6 +238,10 @@ export function dispatchCandidate(c) {
|
|
|
217
238
|
lastError: null,
|
|
218
239
|
};
|
|
219
240
|
if (c.env) record.env = c.env; // e.g. CLAUDE_CONFIG_DIR for sandboxed desktop sessions
|
|
241
|
+
// e.g. Codex's rate_limit_reached_type. Cleared explicitly when absent:
|
|
242
|
+
// upsertSession merges into an older record of the same session.
|
|
243
|
+
if (c.reason) record.limitReason = c.reason;
|
|
244
|
+
else if (existing?.limitReason) record.limitReason = null;
|
|
220
245
|
// Raw reset without margin for calibration window math.
|
|
221
246
|
const rawResetMs = c.resetAt != null
|
|
222
247
|
? c.resetAt
|
|
@@ -235,8 +260,16 @@ export function dispatchCandidate(c) {
|
|
|
235
260
|
upsertSession(record, {
|
|
236
261
|
after: calSample ? (state) => applyCalibrationToState(state, calSample) : null,
|
|
237
262
|
});
|
|
238
|
-
log(`limit stop via transcript: agent=${c.agent} session=${c.sessionId || '?'} origin=${c.origin || '?'} resetAt=${new Date(at).toISOString()} (${source})`);
|
|
239
|
-
|
|
263
|
+
log(`limit stop via transcript: agent=${c.agent} session=${c.sessionId || '?'} origin=${c.origin || '?'} resetAt=${new Date(at).toISOString()} (${source})${c.reason ? ` reason=${c.reason}` : ''}`);
|
|
264
|
+
// A model limit (a Codex workspace wall, say) is not lifted by waiting, so
|
|
265
|
+
// "resumes when the limit resets" would be a promise nothing keeps — say
|
|
266
|
+
// what it needs instead, as the pane monitor does.
|
|
267
|
+
if (record.limitType === 'model') {
|
|
268
|
+
const agent = getAgent(c.agent);
|
|
269
|
+
notify(`${agent.name} hit a model limit — needs you`, `${c.cwd || c.agent}: ${modelRemedy(agent)}`);
|
|
270
|
+
} else {
|
|
271
|
+
notify('limit hit 😴', `${c.cwd || c.agent}: tracked — resumes when the limit resets`);
|
|
272
|
+
}
|
|
240
273
|
}
|
|
241
274
|
|
|
242
275
|
function loadOffsets(path) {
|