unsnooze 1.0.0 → 1.2.0

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.
@@ -9,6 +9,9 @@ const RELATIVE_TIME_REGEX = /(?:try again|wait|resets?\s+in)[:\s]\s*(?:for\s+)?(
9
9
  // "resets Tuesday 3pm" / "resets on Mon" — weekly limits carry a day name.
10
10
  const DAY_REGEX = /resets?\s+(?:on\s+)?(mon|tue|wed|thu|fri|sat|sun)[a-z]*/i;
11
11
  const DAY_INDEX = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
12
+ // Month-date weekly form (transcript/API error text):
13
+ // "resets Jul 4 at 12:30am (Asia/Calcutta)"
14
+ const RESET_DATE_REGEX = /resets?\s+(?:on\s+)?(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:st|nd|rd|th)?(?:,?\s+\d{4})?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
12
15
 
13
16
  // Codex CLI forms ("try again at …", local time):
14
17
  // same day: "or try again at 3:51 PM."
@@ -50,6 +53,24 @@ export function parseResetTime(text) {
50
53
  };
51
54
  }
52
55
 
56
+ const resetDateMatch = text.match(RESET_DATE_REGEX);
57
+ if (resetDateMatch) {
58
+ const [, mon, dayOfMonth, hourRaw, minuteRaw, ampmRaw, timezone] = resetDateMatch;
59
+ const ampm = ampmRaw?.toLowerCase() || null;
60
+ let hour = parseInt(hourRaw, 10);
61
+ if (ampm === 'pm' && hour !== 12) hour += 12;
62
+ if (ampm === 'am' && hour === 12) hour = 0;
63
+ return {
64
+ month: MONTH_INDEX[mon.toLowerCase()],
65
+ dayOfMonth: parseInt(dayOfMonth, 10),
66
+ hour,
67
+ minute: minuteRaw ? parseInt(minuteRaw, 10) : 0,
68
+ timezone: timezone || null,
69
+ ambiguous: !ampm && hour >= 1 && hour <= 12,
70
+ day: null,
71
+ };
72
+ }
73
+
53
74
  const dayMatch = text.match(DAY_REGEX);
54
75
  const absMatch = text.match(RESET_TIME_REGEX);
55
76
  if (absMatch) {
@@ -120,16 +141,7 @@ export function resetAtMs(parsed, { marginMs = 60_000, fallbackMs = 5 * 3_600_00
120
141
  // UTC guess until it formats as the desired local h:m. Correction normalized
121
142
  // to [-720, +720] minutes to take the minimum-magnitude step (avoids the
122
143
  // off-by-a-day bug in high-offset timezones).
123
- function targetTimestamp(h, m) {
124
- const parts = new Intl.DateTimeFormat('en-US', {
125
- timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour12: false,
126
- }).formatToParts(now);
127
- const y = parseInt(parts.find(p => p.type === 'year').value);
128
- const mo = parseInt(parts.find(p => p.type === 'month').value);
129
- const d = parseInt(parts.find(p => p.type === 'day').value);
130
-
131
- const targetStr = `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
132
- let candidate = new Date(targetStr + 'Z').getTime();
144
+ function correctWallClock(candidate, h, m) {
133
145
  for (let i = 0; i < 3; i++) {
134
146
  const fp = new Intl.DateTimeFormat('en-US', {
135
147
  timeZone: tz, hour: 'numeric', minute: 'numeric', hour12: false,
@@ -145,6 +157,18 @@ export function resetAtMs(parsed, { marginMs = 60_000, fallbackMs = 5 * 3_600_00
145
157
  return candidate;
146
158
  }
147
159
 
160
+ function targetTimestamp(h, m) {
161
+ const parts = new Intl.DateTimeFormat('en-US', {
162
+ timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour12: false,
163
+ }).formatToParts(now);
164
+ const y = parseInt(parts.find(p => p.type === 'year').value);
165
+ const mo = parseInt(parts.find(p => p.type === 'month').value);
166
+ const d = parseInt(parts.find(p => p.type === 'day').value);
167
+
168
+ const targetStr = `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
169
+ return correctWallClock(new Date(targetStr + 'Z').getTime(), h, m);
170
+ }
171
+
148
172
  function nextOccurrence(h, m) {
149
173
  let t = targetTimestamp(h, m);
150
174
  if (t <= now.getTime()) t += 86_400_000;
@@ -160,6 +184,45 @@ export function resetAtMs(parsed, { marginMs = 60_000, fallbackMs = 5 * 3_600_00
160
184
  return t;
161
185
  }
162
186
 
187
+ // Month-date form ("resets Jul 4 at 12:30am (tz)"): roll forward from today
188
+ // in the target tz to the named month/day, re-correct the wall-clock (the
189
+ // walk may cross DST). An already-past date means we misread stale text —
190
+ // fall back rather than waiting toward next year.
191
+ if (parsed.month != null) {
192
+ const monthDayAt = t => {
193
+ const fp = new Intl.DateTimeFormat('en-US', { timeZone: tz, month: 'numeric', day: 'numeric' })
194
+ .formatToParts(new Date(t));
195
+ return [
196
+ parseInt(fp.find(p => p.type === 'month').value, 10) - 1,
197
+ parseInt(fp.find(p => p.type === 'day').value, 10),
198
+ ];
199
+ };
200
+ const resolve = (h, m) => {
201
+ // Re-correct the wall clock on EVERY day step: a raw 24h jump across a
202
+ // DST transition drifts the local time, and a drifted probe can match
203
+ // the named date at the wrong instant (landing a day late after the
204
+ // final correction). The walk is capped just past the 10-day acceptance
205
+ // window below — longer walks are discarded anyway.
206
+ let t = targetTimestamp(h, m);
207
+ for (let i = 0; i <= 12; i++) {
208
+ const [mo, d] = monthDayAt(t);
209
+ if (mo === parsed.month && d === parsed.dayOfMonth) return t;
210
+ t = correctWallClock(t + 86_400_000, h, m);
211
+ }
212
+ return null; // beyond the weekly window (or an impossible date)
213
+ };
214
+ const candidates = parsed.ambiguous
215
+ ? [resolve(parsed.hour, parsed.minute), resolve((parsed.hour + 12) % 24, parsed.minute)]
216
+ : [resolve(parsed.hour, parsed.minute)];
217
+ // The yearless form only ever names a date within the weekly window; a
218
+ // resolution further out means the date already passed and the walk landed
219
+ // on NEXT year's occurrence — that's a misread, not an 11-month wait.
220
+ const maxAhead = now.getTime() + 10 * 86_400_000;
221
+ const future = candidates.filter(t => t != null && t > now.getTime() && t <= maxAhead);
222
+ if (future.length === 0) return { at: now.getTime() + fallbackMs + marginMs, source: 'fallback' };
223
+ return { at: Math.min(...future) + marginMs, source };
224
+ }
225
+
163
226
  let at;
164
227
  if (parsed.ambiguous) {
165
228
  const t1 = nextOccurrence(parsed.hour, parsed.minute);
package/src/watcher.js ADDED
@@ -0,0 +1,333 @@
1
+ // Session-file watcher — the tmux-free detection channel. Tails the files the
2
+ // agent CLIs already write (Claude Code transcripts, Codex rollouts) for
3
+ // limit-stop evidence, so sessions running in GUI surfaces (VS Code extension,
4
+ // desktop apps) are tracked even though there is no pane to scrape and no
5
+ // shell wrapper in the launch path. Records land in the same ledger with
6
+ // pane-less records the resumer already reopens by session id.
7
+ //
8
+ // Offset semantics: on a cold start (no offsets file) every existing file
9
+ // starts at EOF — history is never replayed as fresh stops. Files that appear
10
+ // later are read from the start (a stop that happened while the daemon was
11
+ // down is still a real stop); the freshness window drops anything old.
12
+
13
+ import {
14
+ readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync,
15
+ openSync, readSync, closeSync, existsSync,
16
+ } from 'node:fs';
17
+ import { join, sep, basename, dirname } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+ import {
20
+ CLAUDE_DIR, CODEX_DIR, WATCH_OFFSETS_FILE, WATCH_FRESHNESS_MS,
21
+ RESET_MARGIN_MS, FALLBACK_RESET_MS, TMUX_SESSION_NAME,
22
+ } from './config.js';
23
+ import { parseTranscriptLine } from './watchers/claude.js';
24
+ import { parseRolloutLine, rolloutMeta } from './watchers/codex.js';
25
+ import { ROLLOUT_RE } from './agents/codex.js';
26
+ import { parseResetTime, resetAtMs } from './time-parser.js';
27
+ import { upsertSession, readState, updateState } from './state.js';
28
+ import { getConfig } from './settings.js';
29
+ import { notify } from './notify.js';
30
+ import { makeLogger } from './logger.js';
31
+
32
+ const log = makeLogger('watcher');
33
+
34
+ const MAX_WALK_DEPTH = 8;
35
+ // A file first seen mid-run is read from the start, but never more than this —
36
+ // a giant transcript is history, and the freshness window drops it anyway.
37
+ const MAX_READ_BYTES = 10 * 1024 * 1024;
38
+
39
+ export function claudeSource({ roots }) {
40
+ return {
41
+ agent: 'claude',
42
+ roots,
43
+ enabled: () => getConfig('agents.claude'),
44
+ // Subagent transcripts live under <session>/subagents/ — their limit
45
+ // entries duplicate the parent session's own entry.
46
+ match: p => p.endsWith('.jsonl') && !p.split(sep).includes('subagents'),
47
+ parse(lines) {
48
+ return lines
49
+ .map(parseTranscriptLine)
50
+ .filter(Boolean)
51
+ .map(rec => ({
52
+ agent: 'claude',
53
+ sessionId: rec.sessionId,
54
+ cwd: rec.cwd,
55
+ limitType: rec.limitType,
56
+ resetLine: rec.resetLine,
57
+ resetAt: null,
58
+ origin: rec.entrypoint,
59
+ timestampMs: rec.timestampMs,
60
+ }));
61
+ },
62
+ };
63
+ }
64
+
65
+ export function codexSource({ roots }) {
66
+ return {
67
+ agent: 'codex',
68
+ roots,
69
+ enabled: () => getConfig('agents.codex'),
70
+ match: p => ROLLOUT_RE.test(basename(p)),
71
+ parse(lines, path) {
72
+ const hits = lines.map(parseRolloutLine).filter(Boolean);
73
+ if (hits.length === 0) return [];
74
+ const last = hits[hits.length - 1]; // the latest snapshot governs
75
+ const meta = rolloutMeta(path);
76
+ return [{
77
+ agent: 'codex',
78
+ sessionId: meta.sessionId,
79
+ cwd: meta.cwd,
80
+ limitType: last.limitType,
81
+ resetLine: null,
82
+ resetAt: last.resetAt,
83
+ origin: meta.originator,
84
+ timestampMs: last.timestampMs,
85
+ }];
86
+ },
87
+ };
88
+ }
89
+
90
+ // Claude desktop (cowork) sessions are sandboxed: each runs against an
91
+ // isolated CLAUDE_CONFIG_DIR at <session>/local_<id>/.claude, so the global
92
+ // hook never fires and the transcripts live outside ~/.claude. Reviving one
93
+ // needs that same CLAUDE_CONFIG_DIR exported — carried on the record as env —
94
+ // plus CLAUDE_SECURESTORAGE_CONFIG_DIR='' so auth stays on the DEFAULT
95
+ // keychain entry: the service name is otherwise derived from the config dir,
96
+ // and the sandbox holds no credentials (verified against a real cowork
97
+ // session: with the pair set, `claude -p --resume <id>` answers; without the
98
+ // override it dies with "Not logged in").
99
+ // Experimental: desktop worktree/VM sessions may still differ.
100
+ export function claudeDesktopSource({ roots }) {
101
+ const base = claudeSource({ roots });
102
+ return {
103
+ ...base,
104
+ parse(lines, path) {
105
+ const configDir = configDirFromPath(path);
106
+ return base.parse(lines, path).map(rec => ({
107
+ ...rec,
108
+ origin: 'desktop',
109
+ env: configDir
110
+ ? { CLAUDE_CONFIG_DIR: configDir, CLAUDE_SECURESTORAGE_CONFIG_DIR: '' }
111
+ : undefined,
112
+ }));
113
+ },
114
+ };
115
+ }
116
+
117
+ function configDirFromPath(path) {
118
+ const marker = `${sep}.claude${sep}`;
119
+ const idx = path.indexOf(marker);
120
+ return idx === -1 ? null : path.slice(0, idx + marker.length - 1);
121
+ }
122
+
123
+ export function defaultSources() {
124
+ const sources = [
125
+ claudeSource({ roots: [join(CLAUDE_DIR, 'projects')] }),
126
+ codexSource({ roots: [join(CODEX_DIR, 'sessions')] }),
127
+ ];
128
+ if (process.platform === 'darwin') {
129
+ sources.push(claudeDesktopSource({
130
+ roots: [process.env.UNSNOOZE_CLAUDE_DESKTOP_DIR
131
+ || join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')],
132
+ }));
133
+ }
134
+ return sources;
135
+ }
136
+
137
+ // Turn a watcher candidate into a ledger record. No pane key at all — a merge
138
+ // into an existing scrape/hook record must never clobber a live pane id.
139
+ //
140
+ // The same stop is re-emitted whenever the CLI appends another limit line (a
141
+ // GUI auto-retry, or the revived session hitting the still-active limit), so
142
+ // an existing record's lifecycle must be respected: an active record only
143
+ // gets its reset time refreshed — never its status/attempts reset, or the
144
+ // MAX_RESUME_ATTEMPTS cap could never bind — and a cancelled record stays
145
+ // cancelled.
146
+ export function dispatchCandidate(c) {
147
+ const detectedAt = c.timestampMs || Date.now();
148
+ let at, source;
149
+ if (c.resetAt) {
150
+ at = c.resetAt + RESET_MARGIN_MS;
151
+ source = 'absolute';
152
+ } else {
153
+ ({ at, source } = resetAtMs(parseResetTime(c.resetLine), {
154
+ marginMs: RESET_MARGIN_MS, fallbackMs: FALLBACK_RESET_MS,
155
+ }));
156
+ }
157
+
158
+ const existing = c.sessionId
159
+ ? Object.values(readState().sessions).find(s => s.sessionId === c.sessionId)
160
+ : null;
161
+ if (existing && existing.status === 'cancelled') return;
162
+ if (existing && (existing.status === 'stopped' || existing.status === 'resuming')) {
163
+ updateState(state => {
164
+ const s = state.sessions[existing.key];
165
+ if (s && (s.status === 'stopped' || s.status === 'resuming')) {
166
+ s.resetAt = at;
167
+ s.resetSource = source;
168
+ if (c.limitType && c.limitType !== 'unknown') s.limitType = c.limitType;
169
+ }
170
+ });
171
+ log(`refreshed reset for tracked stop: session=${c.sessionId} resetAt=${new Date(at).toISOString()}`);
172
+ return;
173
+ }
174
+
175
+ const record = {
176
+ sessionId: c.sessionId || null,
177
+ cwd: c.cwd || null,
178
+ agent: c.agent,
179
+ origin: c.origin || null,
180
+ tmuxSession: TMUX_SESSION_NAME,
181
+ status: 'stopped',
182
+ limitType: c.limitType || 'unknown',
183
+ detectedVia: 'transcript',
184
+ detectedAt,
185
+ resetAt: at,
186
+ resetSource: source,
187
+ attempts: 0,
188
+ lastAttemptAt: null,
189
+ lastError: null,
190
+ };
191
+ if (c.env) record.env = c.env; // e.g. CLAUDE_CONFIG_DIR for sandboxed desktop sessions
192
+ upsertSession(record);
193
+ log(`limit stop via transcript: agent=${c.agent} session=${c.sessionId || '?'} origin=${c.origin || '?'} resetAt=${new Date(at).toISOString()} (${source})`);
194
+ notify('limit hit 😴', `${c.cwd || c.agent}: tracked — resumes when the limit resets`);
195
+ }
196
+
197
+ function loadOffsets(path) {
198
+ try {
199
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
200
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ function readRange(path, from, to) {
207
+ let fd;
208
+ try {
209
+ fd = openSync(path, 'r');
210
+ const len = Math.min(to - from, MAX_READ_BYTES);
211
+ const buf = Buffer.alloc(len);
212
+ let read = 0;
213
+ while (read < len) {
214
+ const n = readSync(fd, buf, read, len - read, from + read);
215
+ if (n <= 0) break;
216
+ read += n;
217
+ }
218
+ return buf.subarray(0, read);
219
+ } catch {
220
+ return Buffer.alloc(0);
221
+ } finally {
222
+ if (fd !== undefined) closeSync(fd);
223
+ }
224
+ }
225
+
226
+ export function createWatcher({
227
+ sources = defaultSources(),
228
+ offsetsPath = WATCH_OFFSETS_FILE,
229
+ freshnessMs = WATCH_FRESHNESS_MS,
230
+ onStop = dispatchCandidate,
231
+ now = Date.now,
232
+ } = {}) {
233
+ let offsets = loadOffsets(offsetsPath);
234
+ let coldStart = offsets === null;
235
+ if (coldStart) offsets = {};
236
+ let dirty = coldStart;
237
+
238
+ function setOffset(path, value) {
239
+ if (offsets[path] !== value) { offsets[path] = value; dirty = true; }
240
+ }
241
+
242
+ function saveOffsets(seen) {
243
+ // Unseen ≠ gone: a source may be disabled this tick or a root transiently
244
+ // unreadable — wiping those offsets would replay history on re-enable.
245
+ // Only forget files that no longer exist.
246
+ for (const key of Object.keys(offsets)) {
247
+ if (!seen.has(key) && !existsSync(key)) { delete offsets[key]; dirty = true; }
248
+ }
249
+ if (!dirty) return;
250
+ try {
251
+ mkdirSync(dirname(offsetsPath), { recursive: true });
252
+ const tmp = join(dirname(offsetsPath), `.offsets.tmp.${process.pid}`);
253
+ writeFileSync(tmp, JSON.stringify(offsets));
254
+ renameSync(tmp, offsetsPath);
255
+ dirty = false;
256
+ } catch (err) {
257
+ log(`offsets save failed: ${err.message}`);
258
+ }
259
+ }
260
+
261
+ // Appended complete lines since the last tick, advancing the stored offset.
262
+ // All offset math in bytes (a \n byte can't occur inside a multibyte char).
263
+ function readAppended(path) {
264
+ let size;
265
+ try { size = statSync(path).size; } catch { return null; }
266
+ const known = Object.prototype.hasOwnProperty.call(offsets, path);
267
+ let offset;
268
+ if (!known) {
269
+ if (coldStart) { setOffset(path, size); return null; }
270
+ offset = Math.max(0, size - MAX_READ_BYTES);
271
+ } else {
272
+ offset = offsets[path];
273
+ }
274
+ // Nothing new, or rewritten shorter (rotate/truncate) — pin to EOF.
275
+ if (size <= offset) { setOffset(path, size); return null; }
276
+ const buf = readRange(path, offset, size);
277
+ const lastNl = buf.lastIndexOf(0x0a);
278
+ if (lastNl === -1) { setOffset(path, offset); return null; } // partial line — wait
279
+ setOffset(path, offset + lastNl + 1);
280
+ return buf.subarray(0, lastNl + 1).toString('utf-8').split('\n').filter(l => l.trim());
281
+ }
282
+
283
+ function walk(dir, depth, cb) {
284
+ let entries;
285
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
286
+ for (const e of entries) {
287
+ const p = join(dir, e.name);
288
+ if (e.isDirectory()) {
289
+ if (depth < MAX_WALK_DEPTH) walk(p, depth + 1, cb);
290
+ continue;
291
+ }
292
+ if (e.isFile()) cb(p);
293
+ }
294
+ }
295
+
296
+ return {
297
+ async tick() {
298
+ const seen = new Set();
299
+ const candidates = [];
300
+ for (const source of sources) {
301
+ if (source.enabled && !source.enabled()) continue;
302
+ for (const root of source.roots) {
303
+ walk(root, 0, path => {
304
+ if (!source.match(path)) return;
305
+ seen.add(path);
306
+ const lines = readAppended(path);
307
+ if (!lines || lines.length === 0) return;
308
+ try {
309
+ candidates.push(...source.parse(lines, path));
310
+ } catch (err) {
311
+ log(`parse error in ${path}: ${err.message}`);
312
+ }
313
+ });
314
+ }
315
+ }
316
+ coldStart = false;
317
+ saveOffsets(seen);
318
+ // Strict freshness: a candidate without a parseable timestamp cannot be
319
+ // dated, and dispatching it risks reviving a long-finished session (the
320
+ // offsets can legitimately replay old lines after a file reappears).
321
+ // Every real transcript/rollout line carries an ISO timestamp.
322
+ const fresh = [];
323
+ for (const c of candidates) {
324
+ if (Number.isFinite(c.timestampMs) && now() - c.timestampMs <= freshnessMs) fresh.push(c);
325
+ else log(`dropped stale/undatable candidate: agent=${c.agent} session=${c.sessionId || '?'} ts=${c.timestampMs}`);
326
+ }
327
+ for (const c of fresh) {
328
+ try { onStop(c); } catch (err) { log(`onStop failed: ${err.message}`); }
329
+ }
330
+ return fresh.length;
331
+ },
332
+ };
333
+ }
@@ -0,0 +1,39 @@
1
+ // Claude Code transcript watcher: parses lines appended to session transcript
2
+ // files (~/.claude/projects/<dashed-cwd>/<session-id>.jsonl) into limit-stop
3
+ // candidates. This is the detection channel for GUI surfaces (VS Code
4
+ // extension, desktop app) where no tmux pane exists and hooks may not fire —
5
+ // a rate-limit stop lands in the transcript as a structured API-error entry:
6
+ // { "error":"rate_limit", "isApiErrorMessage":true, "apiErrorStatus":429,
7
+ // "entrypoint":"cli", "cwd":..., "sessionId":...,
8
+ // "message":{"content":[{"type":"text","text":"You've hit your session
9
+ // limit · resets 6:40pm (Asia/Calcutta)"}]} }
10
+
11
+ import { detectLimit } from '../patterns.js';
12
+ import { patterns } from '../agents/claude.js';
13
+ import { PANE_SCAN_LINES } from '../config.js';
14
+
15
+ // One transcript JSONL line → limit-stop candidate or null. Sidechain
16
+ // (subagent) entries are skipped — the resume target is the parent session,
17
+ // whose own entry carries the same error.
18
+ export function parseTranscriptLine(line) {
19
+ if (!line || !line.trim()) return null;
20
+ let entry;
21
+ try { entry = JSON.parse(line); } catch { return null; }
22
+ if (!entry || entry.isApiErrorMessage !== true) return null;
23
+ if (entry.error !== 'rate_limit') return null;
24
+ if (entry.isSidechain) return null;
25
+
26
+ const content = entry.message?.content;
27
+ const text = (Array.isArray(content) && content.find(c => c?.type === 'text')?.text) || '';
28
+ const d = detectLimit(text, PANE_SCAN_LINES, patterns);
29
+ const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
30
+
31
+ return {
32
+ sessionId: entry.sessionId || null,
33
+ cwd: entry.cwd || null,
34
+ entrypoint: entry.entrypoint || null,
35
+ limitType: d.hit ? d.limitType : 'unknown',
36
+ resetLine: d.hit ? d.resetLine : null,
37
+ timestampMs: Number.isFinite(ts) ? ts : null,
38
+ };
39
+ }
@@ -0,0 +1,106 @@
1
+ // Codex rollout watcher: parses lines appended to session rollout files
2
+ // (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl) into limit-stop candidates.
3
+ //
4
+ // Codex never persists Error/StreamError events to rollouts (confirmed in
5
+ // codex-rs/rollout policy), so the limit banner text is NOT in the file. What
6
+ // IS persisted is a token_count event per turn carrying a rate_limits
7
+ // snapshot — used_percent per window plus an exact resets_at epoch:
8
+ // {"type":"event_msg","payload":{"type":"token_count","rate_limits":{
9
+ // "primary":{"used_percent":100,"window_minutes":300,"resets_at":1778672230},
10
+ // "secondary":{"used_percent":1,"window_minutes":10080,"resets_at":...},
11
+ // "rate_limit_reached_type":null}}}
12
+ // That epoch is more precise than any scraped banner, and rollouts are shared
13
+ // by every Codex surface (CLI, IDE extension, desktop app).
14
+
15
+ import { openSync, readSync, closeSync } from 'node:fs';
16
+ import { basename } from 'node:path';
17
+ import { ROLLOUT_RE } from '../agents/codex.js';
18
+
19
+ const WEEK_MINUTES = 7 * 1440;
20
+
21
+ function windowLimitType(windowMinutes) {
22
+ if (!Number.isFinite(windowMinutes)) return 'unknown';
23
+ return windowMinutes >= WEEK_MINUTES ? 'weekly' : '5h';
24
+ }
25
+
26
+ // One rollout JSONL line → limit-stop candidate or null. A window binds when
27
+ // its used_percent hits 100 (or rate_limit_reached_type says the model was
28
+ // actually blocked); with several exhausted windows the LATEST reset governs —
29
+ // resuming at the earlier one would immediately re-hit the other limit.
30
+ export function parseRolloutLine(line) {
31
+ if (!line || !line.trim()) return null;
32
+ let entry;
33
+ try { entry = JSON.parse(line); } catch { return null; }
34
+ if (entry?.type !== 'event_msg' || entry.payload?.type !== 'token_count') return null;
35
+ const rl = entry.payload.rate_limits;
36
+ if (!rl || typeof rl !== 'object') return null;
37
+
38
+ const windows = ['primary', 'secondary']
39
+ .map(k => rl[k])
40
+ .filter(w => w && typeof w === 'object');
41
+ let binding = null;
42
+ const exhausted = windows.filter(w => (w.used_percent ?? 0) >= 100);
43
+ if (exhausted.length > 0) {
44
+ binding = exhausted.reduce((a, b) => ((b.resets_at || 0) > (a.resets_at || 0) ? b : a));
45
+ } else if (rl.rate_limit_reached_type) {
46
+ const named = rl[rl.rate_limit_reached_type];
47
+ binding = (named && typeof named === 'object')
48
+ ? named
49
+ : windows.reduce((a, b) => ((b.resets_at || 0) > (a?.resets_at || 0) ? b : a), null);
50
+ }
51
+ if (!binding) return null;
52
+
53
+ const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
54
+ return {
55
+ limitType: windowLimitType(binding.window_minutes),
56
+ resetAt: binding.resets_at ? binding.resets_at * 1000 : null,
57
+ reachedType: rl.rate_limit_reached_type || null,
58
+ timestampMs: Number.isFinite(ts) ? ts : null,
59
+ };
60
+ }
61
+
62
+ // The session_meta head line can be very long (it embeds the full base
63
+ // instructions) — read in chunks until the first newline.
64
+ function readFirstLine(path, maxBytes = 256 * 1024) {
65
+ let fd;
66
+ try {
67
+ fd = openSync(path, 'r');
68
+ const chunk = Buffer.alloc(16 * 1024);
69
+ let head = '';
70
+ let pos = 0;
71
+ while (pos < maxBytes) {
72
+ const n = readSync(fd, chunk, 0, chunk.length, pos);
73
+ if (n <= 0) break;
74
+ head += chunk.toString('utf-8', 0, n);
75
+ pos += n;
76
+ const nl = head.indexOf('\n');
77
+ if (nl !== -1) return head.slice(0, nl);
78
+ }
79
+ return head;
80
+ } catch {
81
+ return '';
82
+ } finally {
83
+ if (fd !== undefined) closeSync(fd);
84
+ }
85
+ }
86
+
87
+ // Session identity for a rollout file: the session_meta head line when
88
+ // parseable, else the uuid embedded in the filename.
89
+ export function rolloutMeta(path) {
90
+ let sessionId = null;
91
+ let cwd = null;
92
+ let originator = null;
93
+ try {
94
+ const meta = JSON.parse(readFirstLine(path));
95
+ if (meta?.type === 'session_meta') {
96
+ sessionId = meta.payload?.id || null;
97
+ cwd = meta.payload?.cwd || null;
98
+ originator = meta.payload?.originator || null;
99
+ }
100
+ } catch { /* unreadable head — fall back to the filename */ }
101
+ if (!sessionId) {
102
+ const m = basename(path).match(ROLLOUT_RE);
103
+ if (m) sessionId = m[1];
104
+ }
105
+ return { sessionId, cwd, originator };
106
+ }
package/src/wizard.js CHANGED
@@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process';
6
6
  import * as p from '@clack/prompts';
7
7
  import { listAgents } from './agents/index.js';
8
8
  import { isCommunityGrokCli } from './agents/grok.js';
9
- import { DEFAULTS, writeConfig } from './settings.js';
9
+ import { DEFAULTS, writeConfig, readFileConfig } from './settings.js';
10
10
 
11
11
  export function detectInstalledAgents({ which = defaultWhich } = {}) {
12
12
  return listAgents().map(agent => ({ agent, installed: which(agent.bin) }));
@@ -69,32 +69,76 @@ export async function runWizard() {
69
69
  });
70
70
  if (p.isCancel(notifications)) return cancelled();
71
71
 
72
+ // GUI watching: only meaningful where an autostart daemon can run.
73
+ let guiWatch = false;
74
+ if (process.platform === 'darwin' || process.platform === 'linux') {
75
+ const answer = await p.confirm({
76
+ message: 'Also guard GUI sessions (Claude Code in VS Code/desktop, Codex app/IDE)?\n'
77
+ + ' Installs a small background daemon (launchd/systemd) that watches session\n'
78
+ + ' files for limit stops; revived sessions open in tmux and stay visible in\n'
79
+ + ' the GUI\'s own history.',
80
+ initialValue: DEFAULTS.guiWatch,
81
+ });
82
+ if (p.isCancel(answer)) return cancelled();
83
+ guiWatch = answer;
84
+ }
85
+
86
+ // Seed message prompts from the existing config so a setup re-run shows —
87
+ // and keeps — values previously set here or via `unsnooze config`.
88
+ const existing = readFileConfig();
89
+ const existingMsgs = existing.resumeMessages
90
+ && typeof existing.resumeMessages === 'object' && !Array.isArray(existing.resumeMessages)
91
+ ? existing.resumeMessages : {};
92
+
72
93
  const customizeMsg = await p.confirm({
73
94
  message: 'Customize the message sent to resume a session?',
74
95
  initialValue: false,
75
96
  });
76
97
  if (p.isCancel(customizeMsg)) return cancelled();
77
98
 
78
- let resumeMessage = DEFAULTS.resumeMessage;
99
+ let resumeMessage = typeof existing.resumeMessage === 'string' && existing.resumeMessage.trim()
100
+ ? existing.resumeMessage : DEFAULTS.resumeMessage;
79
101
  if (customizeMsg) {
80
102
  const msg = await p.text({
81
103
  message: 'Resume message:',
82
- initialValue: DEFAULTS.resumeMessage,
104
+ initialValue: resumeMessage,
83
105
  validate: v => (v.trim() ? undefined : 'message cannot be empty'),
84
106
  });
85
107
  if (p.isCancel(msg)) return cancelled();
86
108
  resumeMessage = msg;
87
109
  }
88
110
 
111
+ const customizePerAgent = await p.confirm({
112
+ message: 'Set a different resume message for specific agents?',
113
+ initialValue: false,
114
+ });
115
+ if (p.isCancel(customizePerAgent)) return cancelled();
116
+
117
+ const resumeMessages = {};
118
+ if (customizePerAgent) {
119
+ for (const agent of listAgents().filter(a => agents.includes(a.id))) {
120
+ const msg = await p.text({
121
+ message: `Message for ${agent.name} (leave empty to use the global message):`,
122
+ initialValue: typeof existingMsgs[agent.id] === 'string' ? existingMsgs[agent.id] : '',
123
+ });
124
+ if (p.isCancel(msg)) return cancelled();
125
+ resumeMessages[agent.id] = typeof msg === 'string' && msg.trim() ? msg : '';
126
+ }
127
+ }
128
+
129
+ // Merge over the existing file so a setup re-run keeps settings the wizard
130
+ // doesn't ask about (e.g. per-agent messages set via `unsnooze config`).
89
131
  writeConfig({
90
- autoResume, menuAutoAnswer, notifications, resumeMessage,
132
+ ...existing,
133
+ autoResume, menuAutoAnswer, notifications, guiWatch, resumeMessage,
134
+ resumeMessages: { ...existingMsgs, ...resumeMessages },
91
135
  agents: Object.fromEntries(listAgents().map(a => [a.id, agents.includes(a.id)])),
92
136
  });
93
137
 
94
138
  const s = p.spinner();
95
139
  s.start('Installing shell wrappers and hooks');
96
140
  const { cmdInstall } = await import('./install.js');
97
- const code = cmdInstall(['--yes']);
141
+ const code = cmdInstall(guiWatch ? ['--yes', '--daemon'] : ['--yes']);
98
142
  s.stop(code === 0 ? 'Wrappers and hooks installed' : 'Install hit a problem — see output above');
99
143
 
100
144
  p.outro(code === 0