unsnooze 1.19.2 → 1.19.4
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 +41 -0
- package/README.md +70 -855
- package/package.json +1 -1
- package/src/agents/agy.js +55 -7
- package/src/agents/codex.js +31 -1
- package/src/monitor.js +16 -7
- package/src/patterns.js +23 -1
- package/src/resumer.js +6 -0
- package/src/state.js +15 -0
- package/src/watchers/codex.js +75 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.4",
|
|
4
4
|
"description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, Antigravity, and Cursor CLI sessions when 5-hour or weekly usage limits reset—on macOS, Linux and Windows, with or without a terminal multiplexer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/agents/agy.js
CHANGED
|
@@ -18,23 +18,33 @@ import { homedir } from 'node:os';
|
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
|
|
20
20
|
const AGY_DIR = () => process.env.UNSNOOZE_AGY_DIR || join(homedir(), '.gemini', 'antigravity-cli');
|
|
21
|
+
const SESSION_RECENCY_MS = 5 * 60 * 60 * 1000;
|
|
21
22
|
|
|
22
23
|
const LIMIT_ANCHORS = [
|
|
24
|
+
/Individual quota reached/i,
|
|
23
25
|
/Model quota limit exceeded/i,
|
|
24
26
|
/RESOURCE_EXHAUSTED/,
|
|
25
27
|
/quota (?:limit )?exceeded/i,
|
|
26
28
|
];
|
|
27
29
|
|
|
28
30
|
export const patterns = {
|
|
31
|
+
bannerWraps: true,
|
|
29
32
|
limitPatterns: LIMIT_ANCHORS,
|
|
30
33
|
// "Refreshes in 6 days and 18 hours" renders near the quota banner; anchors
|
|
31
34
|
// double as reset lines for the API-key single-line renders.
|
|
32
35
|
resetPatterns: [
|
|
33
|
-
/Refreshes
|
|
36
|
+
/Refreshes\s+in/i,
|
|
37
|
+
/Resets\s+in/i,
|
|
38
|
+
/\b(?:Resets|Refreshes)(?:\s+in)?\s*$/i,
|
|
34
39
|
...LIMIT_ANCHORS,
|
|
35
40
|
],
|
|
36
|
-
weeklyPatterns: [
|
|
37
|
-
|
|
41
|
+
weeklyPatterns: [
|
|
42
|
+
/Refreshes\s+in\s+\d+\s+days?/i,
|
|
43
|
+
/(?:Refreshes|Resets)\s+in\s+\d+(?:\.\d+)?\s*(?:days?|d)(?![a-z])/i,
|
|
44
|
+
],
|
|
45
|
+
fiveHourPatterns: [
|
|
46
|
+
/(?:Refreshes|Resets)\s+in\s+\d+(?:\.\d+)?\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)(?![a-z])/i,
|
|
47
|
+
],
|
|
38
48
|
busyPatterns: [
|
|
39
49
|
/esc to interrupt/i,
|
|
40
50
|
/attempt \d+\/\d+/i,
|
|
@@ -47,9 +57,10 @@ export const patterns = {
|
|
|
47
57
|
|
|
48
58
|
// ~/.gemini/antigravity-cli/history.jsonl indexes all conversations. Schema is
|
|
49
59
|
// undocumented (and a SQLite migration is rumored) — parse the tail
|
|
50
|
-
// tolerantly
|
|
51
|
-
//
|
|
52
|
-
//
|
|
60
|
+
// tolerantly. A workspace can have multiple live conversations, so when the
|
|
61
|
+
// caller supplies a detection time, use an id only if exactly one conversation
|
|
62
|
+
// appears in recent history. Otherwise the live pane can still be resumed in
|
|
63
|
+
// place; if it has disappeared, the adapter falls back to --continue.
|
|
53
64
|
function tailBytes(path, bytes = 64 * 1024) {
|
|
54
65
|
let fd;
|
|
55
66
|
try {
|
|
@@ -70,13 +81,49 @@ export function latestSessionId(cwd, aroundTs = null, agyDir = AGY_DIR()) {
|
|
|
70
81
|
const text = tailBytes(join(agyDir, 'history.jsonl'));
|
|
71
82
|
if (!text) return null;
|
|
72
83
|
const lines = text.split('\n').filter(l => l.trim());
|
|
84
|
+
const recentIds = new Set();
|
|
85
|
+
let latestId = null;
|
|
86
|
+
let hasTimestamp = false;
|
|
73
87
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
74
88
|
let entry;
|
|
75
89
|
try { entry = JSON.parse(lines[i]); } catch { continue; }
|
|
76
90
|
if (!entry || typeof entry !== 'object') continue;
|
|
77
91
|
if (!Object.values(entry).some(v => v === cwd)) continue;
|
|
78
92
|
const id = entry.conversation_id ?? entry.conversationId ?? entry.id;
|
|
79
|
-
if (typeof id
|
|
93
|
+
if (typeof id !== 'string' || !id) continue;
|
|
94
|
+
latestId ||= id;
|
|
95
|
+
const timestamp = typeof entry.timestamp === 'number'
|
|
96
|
+
? entry.timestamp
|
|
97
|
+
: typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
|
|
98
|
+
if (Number.isFinite(timestamp)) {
|
|
99
|
+
hasTimestamp = true;
|
|
100
|
+
if (Number.isFinite(aroundTs) && Math.abs(aroundTs - timestamp) <= SESSION_RECENCY_MS) {
|
|
101
|
+
recentIds.add(id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!Number.isFinite(aroundTs) || !hasTimestamp) return latestId;
|
|
106
|
+
return recentIds.size === 1 ? recentIds.values().next().value : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function latestBannerAt(cwd, aroundTs = null, agyDir = AGY_DIR()) {
|
|
110
|
+
const text = tailBytes(join(agyDir, 'history.jsonl'));
|
|
111
|
+
if (!text) return null;
|
|
112
|
+
const lines = text.split('\n').filter(l => l.trim());
|
|
113
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
114
|
+
let entry;
|
|
115
|
+
try { entry = JSON.parse(lines[i]); } catch { continue; }
|
|
116
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
117
|
+
if (!Object.values(entry).some(v => v === cwd)) continue;
|
|
118
|
+
const timestamp = typeof entry.timestamp === 'number'
|
|
119
|
+
? entry.timestamp
|
|
120
|
+
: typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
|
|
121
|
+
if (Number.isFinite(timestamp)) {
|
|
122
|
+
if (!Number.isFinite(aroundTs)) return timestamp;
|
|
123
|
+
if (timestamp <= (aroundTs + 5000) && (aroundTs - timestamp) <= SESSION_RECENCY_MS) {
|
|
124
|
+
return timestamp;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
80
127
|
}
|
|
81
128
|
return null;
|
|
82
129
|
}
|
|
@@ -96,6 +143,7 @@ export default {
|
|
|
96
143
|
// v1: every agent launches the bare TUI and gets the prompt typed once idle.
|
|
97
144
|
launchArgs(message) { return { args: [], messageViaPane: true }; },
|
|
98
145
|
latestSessionId,
|
|
146
|
+
latestBannerAt,
|
|
99
147
|
isForegroundCommand(cmd) {
|
|
100
148
|
return cmd === 'agy' || cmd === 'node' || cmd === 'unsnooze';
|
|
101
149
|
},
|
package/src/agents/codex.js
CHANGED
|
@@ -78,13 +78,19 @@ const LIMIT_ANCHORS = [
|
|
|
78
78
|
/You['’]ve hit your usage limit/i, // the desktop app also emits a curly apostrophe
|
|
79
79
|
/Your workspace is out of credits/i,
|
|
80
80
|
/hit your spend cap/i,
|
|
81
|
+
// Plus plan: an out-of-credits picker sits under the banner and can push it
|
|
82
|
+
// past the 12-line scan (#36). This line stays inside it and carries the time.
|
|
83
|
+
/Your rate limit resets on/i,
|
|
81
84
|
];
|
|
82
85
|
|
|
83
86
|
export const patterns = {
|
|
84
87
|
limitPatterns: LIMIT_ANCHORS,
|
|
85
88
|
// The whole banner is ONE line, so the anchors double as reset lines — the
|
|
86
89
|
// proximity engine then hands that line to time-parser (which falls back to
|
|
87
|
-
// the 5h default for "Try again later.").
|
|
90
|
+
// the 5h default for "Try again later."). A narrow pane soft-wraps that
|
|
91
|
+
// line, which can strand the clock on a later row; bannerWraps tells the
|
|
92
|
+
// engine to rejoin the rows before parsing.
|
|
93
|
+
bannerWraps: true,
|
|
88
94
|
resetPatterns: [
|
|
89
95
|
/try again at/i,
|
|
90
96
|
/try again in \d+/i,
|
|
@@ -185,6 +191,30 @@ export function latestSessionId(cwd, aroundTs = null, sessionsRoot = join(CODEX_
|
|
|
185
191
|
return null;
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
// The rollout file a thread is writing to now: the newest of its files. A
|
|
195
|
+
// reverted thread continues in a second `…_<rollout id>` file, which is where
|
|
196
|
+
// a resumed turn lands.
|
|
197
|
+
export function rolloutPathFor(sessionId, sessionsRoot = join(CODEX_DIR, 'sessions')) {
|
|
198
|
+
if (!sessionId) return null;
|
|
199
|
+
const want = String(sessionId).toLowerCase();
|
|
200
|
+
let best = null;
|
|
201
|
+
const walk = (dir, depth) => {
|
|
202
|
+
let entries;
|
|
203
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
204
|
+
for (const e of entries) {
|
|
205
|
+
const p = join(dir, e.name);
|
|
206
|
+
if (e.isDirectory() && depth < 3) { walk(p, depth + 1); continue; }
|
|
207
|
+
const m = e.isFile() && e.name.match(ROLLOUT_RE);
|
|
208
|
+
if (!m || m[1].toLowerCase() !== want) continue;
|
|
209
|
+
let mtime;
|
|
210
|
+
try { mtime = statSync(p).mtimeMs; } catch { continue; }
|
|
211
|
+
if (!best || mtime > best.mtime) best = { path: p, mtime };
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
walk(sessionsRoot, 0);
|
|
215
|
+
return best ? best.path : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
188
218
|
export default {
|
|
189
219
|
id: 'codex',
|
|
190
220
|
name: 'OpenAI Codex CLI',
|
package/src/monitor.js
CHANGED
|
@@ -25,6 +25,7 @@ import { parseResetTime, resetAtMs, sourceRank } from './time-parser.js';
|
|
|
25
25
|
import { upsertSession, setStatus, readState, updateState } from './state.js';
|
|
26
26
|
import { prepareCalibrationSample, applyCalibrationToState } from './usage.js';
|
|
27
27
|
import { latestRateLimitFromTranscript } from './watchers/claude.js';
|
|
28
|
+
import { hasCodexProgressAfter } from './watchers/codex.js';
|
|
28
29
|
import { spawnResumerIfNeeded, spawnDetached, monitorSpawnArgs, UNSNOOZE_BIN } from './spawn.js';
|
|
29
30
|
import { restartOnVersionSkew, hasVersionSkew, PKG_VERSION } from './update-check.js';
|
|
30
31
|
import { makeLogger } from './logger.js';
|
|
@@ -112,10 +113,13 @@ export function createMonitor({
|
|
|
112
113
|
detectedAt,
|
|
113
114
|
};
|
|
114
115
|
}
|
|
116
|
+
const bannerAt = typeof agent.latestBannerAt === 'function'
|
|
117
|
+
? agent.latestBannerAt(cwd, detectedAt)
|
|
118
|
+
: null;
|
|
115
119
|
return {
|
|
116
120
|
resetLine: paneResetLine,
|
|
117
121
|
limitType: paneLimitType,
|
|
118
|
-
bannerAt
|
|
122
|
+
bannerAt,
|
|
119
123
|
sessionId,
|
|
120
124
|
via: null, // filled by caller (scrape/hook)
|
|
121
125
|
detectedAt,
|
|
@@ -131,6 +135,7 @@ export function createMonitor({
|
|
|
131
135
|
// "already past → due now" path, which is margin above wall clock only).
|
|
132
136
|
function isCorroborated(resolved, at, source) {
|
|
133
137
|
if (resolved.via === 'transcript' && resolved.bannerAt != null) return true;
|
|
138
|
+
if (resolved.bannerAt != null && at - RESET_MARGIN_MS > Date.now()) return true;
|
|
134
139
|
if (source === 'absolute' && at - RESET_MARGIN_MS > Date.now()) return true;
|
|
135
140
|
// A model limit carries no time to corroborate — its own remedy hint is
|
|
136
141
|
// the evidence, and the record only ever probes (never blind-wakes), so
|
|
@@ -410,17 +415,21 @@ export function createMonitor({
|
|
|
410
415
|
}
|
|
411
416
|
|
|
412
417
|
// A banner leaving the 12-line scan is not evidence of a resume: ordinary
|
|
413
|
-
// output (including background-agent notices) can scroll it away
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
418
|
+
// output (including background-agent notices) can scroll it away, and in
|
|
419
|
+
// Codex a keypress or tab switch can hide it while the session is still
|
|
420
|
+
// stopped. Claude is terminal only after its parent transcript records
|
|
421
|
+
// newer non-error assistant usage; Codex only after its rollout records
|
|
422
|
+
// newer model output (or, with no rollout to read, the legacy rule).
|
|
423
|
+
// Other adapters retain their legacy behavior until they expose an
|
|
424
|
+
// equally authoritative progress signal.
|
|
417
425
|
if (trackedKey) {
|
|
418
426
|
const state = readState();
|
|
419
427
|
const rec = state.sessions[trackedKey];
|
|
420
428
|
if (rec && rec.status === 'stopped') {
|
|
421
429
|
const cutoff = rec.bannerAt ?? rec.detectedAt;
|
|
422
|
-
const progressed = agent.id
|
|
423
|
-
|
|
430
|
+
const progressed = agent.id === 'claude' ? hasClaudeParentUsageAfter(rec, cutoff)
|
|
431
|
+
: agent.id === 'codex' ? hasCodexProgressAfter(rec, cutoff) !== false
|
|
432
|
+
: true;
|
|
424
433
|
if (progressed) {
|
|
425
434
|
const next = setStatus(trackedKey, 'resumed', {
|
|
426
435
|
lastAttemptAt: Date.now(), bannerCleared: true, lastError: null,
|
package/src/patterns.js
CHANGED
|
@@ -96,6 +96,25 @@ function quotedLineFlags(lines) {
|
|
|
96
96
|
return flags;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// A banner the terminal soft-wraps continues on the next rows until its
|
|
100
|
+
// closing punctuation. Codex writes one long line, so at some widths the
|
|
101
|
+
// reset clock lands on a later row than the phrase that introduces it:
|
|
102
|
+
// "... or try" / "again at 6:02 PM." "... try again at 6:02" / "PM."
|
|
103
|
+
// Rejoin those rows. Stop at a blank row, at the next TUI glyph, or once the
|
|
104
|
+
// sentence has ended, so a footer hint below the banner is never absorbed.
|
|
105
|
+
const WRAP_MAX_ROWS = 3;
|
|
106
|
+
const NEW_BLOCK = /^\s*[›•■⏺⚠>]/;
|
|
107
|
+
function joinWrapped(lines, idx) {
|
|
108
|
+
let joined = lines[idx].trim();
|
|
109
|
+
for (let j = idx + 1; j <= idx + WRAP_MAX_ROWS && j < lines.length; j++) {
|
|
110
|
+
if (/[.!?]$/.test(joined)) break;
|
|
111
|
+
const next = lines[j].trim();
|
|
112
|
+
if (!next || NEW_BLOCK.test(lines[j])) break;
|
|
113
|
+
joined += ` ${next}`;
|
|
114
|
+
}
|
|
115
|
+
return joined;
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
export function detectLimit(text, tailLines = 12, sets = claudePatterns) {
|
|
100
119
|
const lines = contentLines(text, tailLines);
|
|
101
120
|
|
|
@@ -138,7 +157,10 @@ export function detectLimit(text, tailLines = 12, sets = claudePatterns) {
|
|
|
138
157
|
// Most recent reset line wins (the TUI never clears old banners from scrollback).
|
|
139
158
|
let resetLine = null;
|
|
140
159
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
141
|
-
if (sets.resetPatterns.some(p => p.test(lines[i]))) {
|
|
160
|
+
if (sets.resetPatterns.some(p => p.test(lines[i]))) {
|
|
161
|
+
resetLine = sets.bannerWraps ? joinWrapped(lines, i) : lines[i].trim();
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
142
164
|
}
|
|
143
165
|
if (!resetLine) {
|
|
144
166
|
for (let i = lines.length - 1; i >= 0; i--) {
|
package/src/resumer.js
CHANGED
|
@@ -342,6 +342,10 @@ export async function probeFallback(rec, {
|
|
|
342
342
|
return null;
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
if (!bannerAt && typeof agent.latestBannerAt === 'function') {
|
|
346
|
+
bannerAt = agent.latestBannerAt(rec.cwd, now);
|
|
347
|
+
}
|
|
348
|
+
|
|
345
349
|
// Banner still present. If it now parses to a real time, upgrade off fallback.
|
|
346
350
|
if (resetLine) {
|
|
347
351
|
const { at, source } = resetAtMs(parseResetTime(resetLine), {
|
|
@@ -955,6 +959,8 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
|
|
|
955
959
|
if (fromTx) {
|
|
956
960
|
resetLine = fromTx.resetLine ?? resetLine;
|
|
957
961
|
bannerAt = fromTx.timestampMs;
|
|
962
|
+
} else if (typeof agent.latestBannerAt === 'function') {
|
|
963
|
+
bannerAt = agent.latestBannerAt(rec.cwd, Date.now());
|
|
958
964
|
}
|
|
959
965
|
const { at, source } = resetAtMs(parseResetTime(resetLine), {
|
|
960
966
|
marginMs: RESET_MARGIN_MS,
|
package/src/state.js
CHANGED
|
@@ -249,6 +249,10 @@ export function upsertSession(record, { after = null } = {}) {
|
|
|
249
249
|
const fingerprint = needsFingerprint ? workspaceFingerprint(record.cwd) : undefined;
|
|
250
250
|
return updateState(state => {
|
|
251
251
|
prune(state);
|
|
252
|
+
// A transcript/GUI detection has no pane, so its address fields are only
|
|
253
|
+
// defaults (see watcher.js). Taken before the closing branch below, which
|
|
254
|
+
// nulls the address on purpose and must stay distinguishable from this.
|
|
255
|
+
const panelessDetection = !record.pane;
|
|
252
256
|
const closing = state.paneClosures.find(c => c.pane && c.leaseId
|
|
253
257
|
&& c.mux === record.mux && c.paneOwner === record.paneOwner
|
|
254
258
|
&& c.pane === record.pane && c.leaseId === record.leaseId);
|
|
@@ -294,6 +298,17 @@ export function upsertSession(record, { after = null } = {}) {
|
|
|
294
298
|
const merged = {
|
|
295
299
|
...existing,
|
|
296
300
|
...record,
|
|
301
|
+
// A pane-less detection does not know where the session runs. When it
|
|
302
|
+
// lands on a record that holds a live pane, keep that pane's address:
|
|
303
|
+
// the defaults would otherwise point the pane id at another
|
|
304
|
+
// multiplexer, and the resumer would reopen the session beside the
|
|
305
|
+
// running one instead of waking it.
|
|
306
|
+
...(panelessDetection && existing.pane ? {
|
|
307
|
+
mux: existing.mux,
|
|
308
|
+
paneOwner: existing.paneOwner,
|
|
309
|
+
leaseId: existing.leaseId,
|
|
310
|
+
muxSession: existing.muxSession,
|
|
311
|
+
} : {}),
|
|
297
312
|
...(keepReset ? {
|
|
298
313
|
resetAt: existing.resetAt,
|
|
299
314
|
resetSource: existing.resetSource,
|
package/src/watchers/codex.js
CHANGED
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
// it carries no reset time, and the pane path files it under transient
|
|
31
31
|
// overload for the same reason.
|
|
32
32
|
|
|
33
|
-
import { openSync, readSync, closeSync } from 'node:fs';
|
|
33
|
+
import { openSync, readSync, closeSync, statSync } from 'node:fs';
|
|
34
34
|
import { basename } from 'node:path';
|
|
35
|
-
import { ROLLOUT_RE, patterns as codexPatterns } from '../agents/codex.js';
|
|
35
|
+
import { ROLLOUT_RE, rolloutPathFor, patterns as codexPatterns } from '../agents/codex.js';
|
|
36
36
|
import { detectLimit } from '../patterns.js';
|
|
37
37
|
// Usage extractor lives in usage.js (shared cold path + daemon); re-exported
|
|
38
38
|
// here so the plan's watcher surface is the documented import site.
|
|
@@ -351,3 +351,76 @@ export function rolloutMeta(path) {
|
|
|
351
351
|
}
|
|
352
352
|
return { sessionId, cwd, originator };
|
|
353
353
|
}
|
|
354
|
+
|
|
355
|
+
// Model output the rollout records only when a request succeeded. A turn the
|
|
356
|
+
// usage limit refuses writes the user message and a task_complete error, never
|
|
357
|
+
// one of these.
|
|
358
|
+
const MODEL_OUTPUT = new Set(['reasoning', 'function_call', 'custom_tool_call', 'local_shell_call', 'web_search_call']);
|
|
359
|
+
function isModelOutput(entry) {
|
|
360
|
+
if (entry?.type !== 'response_item') return false;
|
|
361
|
+
const p = entry.payload || {};
|
|
362
|
+
return (p.type === 'message' && p.role === 'assistant') || MODEL_OUTPUT.has(p.type);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Durable evidence that a Codex session made progress after a recorded stop:
|
|
366
|
+
// model output in its rollout, newer than the stop and not followed by a newer
|
|
367
|
+
// limit. The Codex counterpart of hasClaudeParentUsageAfter. A banner leaving
|
|
368
|
+
// the pane's scan window is not evidence; a keypress, tab switch or overlay
|
|
369
|
+
// can do that while the session is still stopped. Returns null when there is
|
|
370
|
+
// no rollout to read (no session id, file not found, read error), so the
|
|
371
|
+
// caller can fall back instead of holding the stop forever.
|
|
372
|
+
//
|
|
373
|
+
// The monitor asks every scrape tick while the banner is hidden, so the path
|
|
374
|
+
// lookup (a walk of ~/.codex/sessions) is reused for a minute. A reverted
|
|
375
|
+
// thread's newer continuation file is still picked up within that minute.
|
|
376
|
+
const PATH_TTL_MS = 60_000;
|
|
377
|
+
const pathCache = new Map();
|
|
378
|
+
function cachedRolloutPath(sessionId, sessionsRoot) {
|
|
379
|
+
const key = `${sessionsRoot ?? ''}|${sessionId}`;
|
|
380
|
+
const hit = pathCache.get(key);
|
|
381
|
+
if (hit && Date.now() - hit.at < PATH_TTL_MS) return hit.path;
|
|
382
|
+
const path = sessionsRoot ? rolloutPathFor(sessionId, sessionsRoot) : rolloutPathFor(sessionId);
|
|
383
|
+
if (path) pathCache.set(key, { path, at: Date.now() });
|
|
384
|
+
else pathCache.delete(key);
|
|
385
|
+
return path;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function hasCodexProgressAfter(rec, afterMs, {
|
|
389
|
+
sessionsRoot,
|
|
390
|
+
window = 256 * 1024,
|
|
391
|
+
maxWindow = 4 * 1024 * 1024,
|
|
392
|
+
} = {}) {
|
|
393
|
+
if (!rec?.sessionId || !Number.isFinite(afterMs)) return null;
|
|
394
|
+
const path = cachedRolloutPath(rec.sessionId, sessionsRoot);
|
|
395
|
+
if (!path) return null;
|
|
396
|
+
let fd;
|
|
397
|
+
try {
|
|
398
|
+
const { size } = statSync(path);
|
|
399
|
+
fd = openSync(path, 'r');
|
|
400
|
+
for (let win = window; ; win = Math.min(win * 2, maxWindow)) {
|
|
401
|
+
const len = Math.min(win, size);
|
|
402
|
+
const buf = Buffer.alloc(len);
|
|
403
|
+
readSync(fd, buf, 0, len, size - len);
|
|
404
|
+
const lines = buf.toString('utf-8').split('\n');
|
|
405
|
+
if (len < size) lines.shift(); // the first line may be truncated
|
|
406
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
407
|
+
if (!lines[i].trim()) continue;
|
|
408
|
+
let entry;
|
|
409
|
+
try { entry = JSON.parse(lines[i]); } catch { continue; }
|
|
410
|
+
const at = Date.parse(entry?.timestamp);
|
|
411
|
+
if (!Number.isFinite(at)) continue;
|
|
412
|
+
// Lines are chronological: nothing older can postdate the stop.
|
|
413
|
+
if (at <= afterMs) return false;
|
|
414
|
+
// Newest first, so a limit written after the last output means the
|
|
415
|
+
// session stopped again.
|
|
416
|
+
if (parseRolloutLine(lines[i])) return false;
|
|
417
|
+
if (isModelOutput(entry)) return true;
|
|
418
|
+
}
|
|
419
|
+
if (len >= size || win >= maxWindow) return false;
|
|
420
|
+
}
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
} finally {
|
|
424
|
+
if (fd !== undefined) closeSync(fd);
|
|
425
|
+
}
|
|
426
|
+
}
|