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/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 { existsSync, readFileSync, unlinkSync } from 'node:fs';
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
- export function spawnDetached(args, env = {}) {
15
- const child = spawn(process.execPath, [UNSNOOZE_BIN, ...args], {
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
- const pid = spawnDetached(['_resumer']);
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
- if (Number.isFinite(pid) && pidAlive(pid)) {
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
+ }
@@ -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
- if (t <= anchor) {
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 spikes (>100) and one-tick stale regressions that jump >15pp upward
347
- // without intermediate samples (Codex can briefly report stale/overshoot %).
348
- export function smoothUsedPercent(current, previous) {
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
- let v = current;
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: smoothUsedPercent(pct, last?.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
- export function extractCodexUsage(line) {
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 latest = [...codexSamples].sort((a, b) => a.at - b.at).at(-1);
825
- const prev = codexSamples.length >= 2
826
- ? [...codexSamples].sort((a, b) => a.at - b.at).at(-2)
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
- const prevW = prev?.[key];
834
- const pct = smoothUsedPercent(w.usedPercent, prevW?.usedPercent);
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
- resetLine: null,
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
- return lines.map(extractCodexUsage).filter(Boolean);
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
- s.resetAt = at;
191
- s.resetSource = source;
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(`refreshed reset for tracked stop: session=${c.sessionId} resetAt=${new Date(at).toISOString()}`);
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
- notify('limit hit 😴', `${c.cwd || c.agent}: tracked — resumes when the limit resets`);
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) {