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.
- package/CHANGELOG.md +64 -0
- package/README.md +35 -0
- package/bin/unsnooze.js +20 -2
- package/package.json +1 -1
- package/src/agents/claude.js +1 -0
- package/src/agents/codex.js +2 -4
- package/src/cli.js +6 -3
- package/src/config.js +5 -0
- package/src/hook.js +1 -0
- package/src/install.js +115 -2
- package/src/notify.js +1 -1
- package/src/resumer.js +44 -9
- package/src/settings.js +27 -4
- package/src/state.js +16 -0
- package/src/time-parser.js +73 -10
- package/src/watcher.js +333 -0
- package/src/watchers/claude.js +39 -0
- package/src/watchers/codex.js +106 -0
- package/src/wizard.js +49 -5
package/src/time-parser.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|