unsnooze 1.19.4 → 1.20.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 +6 -0
- package/bin/unsnooze.js +6 -0
- package/package.json +1 -1
- package/src/agents/agy.js +4 -23
- package/src/config.js +15 -1
- package/src/doctor.js +3 -1
- package/src/hook.js +4 -1
- package/src/monitor.js +1 -4
- package/src/multiplexers/tmux.js +65 -27
- package/src/prime-learn.js +172 -0
- package/src/prime.js +384 -0
- package/src/resumer.js +7 -6
- package/src/settings.js +20 -0
- package/src/update-check.js +47 -12
- package/src/watcher.js +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.20.0 — 2026-10-08
|
|
4
|
+
|
|
5
|
+
Window priming starts your 5-hour window before you sit down, and `doctor`
|
|
6
|
+
stops flagging a Claude hook you don't need.
|
|
7
|
+
|
|
8
|
+
### Window priming (experimental)
|
|
9
|
+
|
|
10
|
+
- Claude's 5-hour window starts with your first message, so starting work at
|
|
11
|
+
9am means a reset at 2pm. `unsnooze config set primeAt.claude 06:00` has the
|
|
12
|
+
daemon send one tiny Haiku prompt at 6am, so the window resets at 11am and a
|
|
13
|
+
fresh one lands mid-morning. Codex works the same on plans with a 5-hour
|
|
14
|
+
window (`primeAt.codex`).
|
|
15
|
+
- `primeAt.claude auto` works out the time for you: it learns when you usually
|
|
16
|
+
start from the last 3 weeks of your own prompts (ignoring tool results,
|
|
17
|
+
subagents, revivals and late nights running past midnight) and primes 3 hours
|
|
18
|
+
before. It waits for 5 days of history and re-learns daily.
|
|
19
|
+
- Every prime checks the reset time in the reply and reports whether it
|
|
20
|
+
started the window, found one already running, or found no 5-hour window on
|
|
21
|
+
the plan. `unsnooze prime` shows the schedule and last result;
|
|
22
|
+
`unsnooze prime now` runs one immediately. `primeDays weekdays` skips
|
|
23
|
+
weekends.
|
|
24
|
+
- A prime that fails (say, the network is not back yet after waking) retries
|
|
25
|
+
up to three times, five minutes apart, and notifies once.
|
|
26
|
+
- A prime that hits a limit is never treated as a session to revive.
|
|
27
|
+
|
|
28
|
+
### `unsnooze doctor`
|
|
29
|
+
|
|
30
|
+
- No longer reports a missing Claude StopFailure hook when Claude is disabled.
|
|
31
|
+
`unsnooze install --yes` skips that hook for a disabled Claude, so the
|
|
32
|
+
finding could never be cleared (#44).
|
|
33
|
+
|
|
34
|
+
## 1.19.5 — 2026-10-05
|
|
35
|
+
|
|
36
|
+
Sessions resume again under psmux, Antigravity no longer wakes early, and
|
|
37
|
+
`unsnooze update` updates the copy you are running.
|
|
38
|
+
|
|
39
|
+
### psmux (tmux on native Windows)
|
|
40
|
+
|
|
41
|
+
- Every Claude revival in psmux failed five times with `ready timeout`. psmux
|
|
42
|
+
ran the launch command through a shell, so the space in
|
|
43
|
+
`C:\Program Files\nodejs\node.exe` closed the window before Claude started.
|
|
44
|
+
Each revival now opens its own psmux session with its arguments passed
|
|
45
|
+
through as-is, which also keeps a Codex resume message in one piece (#43).
|
|
46
|
+
- A resume message can no longer be typed into an unrelated window. psmux
|
|
47
|
+
sends keys for a pane that has closed to some other pane, and every psmux
|
|
48
|
+
session numbers its first pane `%1`. unsnooze now addresses panes as
|
|
49
|
+
`session:%N` and checks the pane still exists before reading or typing (#43).
|
|
50
|
+
|
|
51
|
+
### Antigravity (`agy`)
|
|
52
|
+
|
|
53
|
+
- A session could be woken hours before its quota came back. The `Resets in
|
|
54
|
+
3h` countdown was dated from the last prompt sent in that folder, which can
|
|
55
|
+
be much older than the banner. It is now dated from when unsnooze reads the
|
|
56
|
+
banner, so a wake can be slightly late but not early (#40).
|
|
57
|
+
|
|
58
|
+
### `unsnooze update`
|
|
59
|
+
|
|
60
|
+
- With two npm installs on one machine (for example unsnooze under `~/.local`
|
|
61
|
+
and nvm's npm first on PATH), the update went into the other npm's folder
|
|
62
|
+
and the running copy never changed. The update now targets the folder the
|
|
63
|
+
running copy was installed into (#41).
|
|
64
|
+
- It no longer prints "already up to date" when a newer version is known and
|
|
65
|
+
this copy did not change. It says what happened and exits with an error (#41).
|
|
66
|
+
|
|
3
67
|
## 1.19.4 — 2026-09-30
|
|
4
68
|
|
|
5
69
|
Antigravity's new quota banner, and a shorter README.
|
package/README.md
CHANGED
|
@@ -128,6 +128,12 @@ reset, and unsnooze does that for every stopped session automatically.
|
|
|
128
128
|
you would, resumes once, and checks the limit actually lifted. It replaces the
|
|
129
129
|
4am alarm, not the limit.
|
|
130
130
|
|
|
131
|
+
**Can it start my 5-hour window before I sit down?** Yes, experimentally:
|
|
132
|
+
`unsnooze config set primeAt.claude auto` learns when you usually start and has
|
|
133
|
+
the daemon send one tiny Haiku prompt 3 hours earlier, so the window resets
|
|
134
|
+
mid-session instead of 5 hours in. A fixed time like `06:00` works too. Claude
|
|
135
|
+
and Codex only. See [window priming](https://unsnooze.dev/docs/commands/#prime).
|
|
136
|
+
|
|
131
137
|
More questions and troubleshooting: [unsnooze.dev/docs/troubleshooting](https://unsnooze.dev/docs/troubleshooting/).
|
|
132
138
|
|
|
133
139
|
## Documentation
|
package/bin/unsnooze.js
CHANGED
|
@@ -159,6 +159,10 @@ async function main() {
|
|
|
159
159
|
const { cmdUsage } = await import('../src/usage.js');
|
|
160
160
|
return cmdUsage(rest);
|
|
161
161
|
}
|
|
162
|
+
case 'prime': {
|
|
163
|
+
const { cmdPrime } = await import('../src/prime.js');
|
|
164
|
+
return cmdPrime(rest);
|
|
165
|
+
}
|
|
162
166
|
case 'hosts': {
|
|
163
167
|
const { cmdHosts } = await import('../src/fleet.js');
|
|
164
168
|
return cmdHosts(rest);
|
|
@@ -364,6 +368,8 @@ Usage:
|
|
|
364
368
|
unsnooze prompt remove <id> cancel a queued prompt
|
|
365
369
|
unsnooze prompt clear cancel all pending queued prompts
|
|
366
370
|
unsnooze usage [--json] account burn rate & time-to-limit forecast
|
|
371
|
+
unsnooze prime [now [agent]] window priming (experimental): start the 5-hour
|
|
372
|
+
window early — set a time with primeAt.claude
|
|
367
373
|
(--install-statusline for exact Claude %,
|
|
368
374
|
--uninstall-statusline to remove it)
|
|
369
375
|
unsnooze design [setup] Claude Design from the terminal: check whether
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
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
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
// Deferred by design: the hooks.json channel (schema drifted between builds)
|
|
13
13
|
// and the loopback RetrieveUserQuotaSummary endpoint (authoritative per-meter
|
|
14
14
|
// resetTime) — the latter is the natural future resetProbe seam.
|
|
15
|
+
//
|
|
16
|
+
// No latestBannerAt: history.jsonl only records prompts, never banners, so its
|
|
17
|
+
// newest timestamp can predate the banner by hours. Anchoring a relative
|
|
18
|
+
// "Resets in 3h" to it fires the wake early. Countdowns anchor to scrape time.
|
|
15
19
|
|
|
16
20
|
import { openSync, readSync, closeSync, statSync } from 'node:fs';
|
|
17
21
|
import { homedir } from 'node:os';
|
|
@@ -106,28 +110,6 @@ export function latestSessionId(cwd, aroundTs = null, agyDir = AGY_DIR()) {
|
|
|
106
110
|
return recentIds.size === 1 ? recentIds.values().next().value : null;
|
|
107
111
|
}
|
|
108
112
|
|
|
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
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
113
|
export default {
|
|
132
114
|
id: 'agy',
|
|
133
115
|
name: 'Antigravity CLI (Google)',
|
|
@@ -143,7 +125,6 @@ export default {
|
|
|
143
125
|
// v1: every agent launches the bare TUI and gets the prompt typed once idle.
|
|
144
126
|
launchArgs(message) { return { args: [], messageViaPane: true }; },
|
|
145
127
|
latestSessionId,
|
|
146
|
-
latestBannerAt,
|
|
147
128
|
isForegroundCommand(cmd) {
|
|
148
129
|
return cmd === 'agy' || cmd === 'node' || cmd === 'unsnooze';
|
|
149
130
|
},
|
package/src/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
|
-
import { join, resolve } from 'node:path';
|
|
2
|
+
import { join, resolve, win32 } from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
mkdirSync, chmodSync, writeFileSync, renameSync, lstatSync, statSync, readdirSync,
|
|
5
5
|
} from 'node:fs';
|
|
@@ -14,6 +14,20 @@ export const STATE_FILE = join(STATE_DIR, 'state.json');
|
|
|
14
14
|
export const LOCK_DIR = join(STATE_DIR, 'state.lock');
|
|
15
15
|
export const LOG_FILE = join(STATE_DIR, 'unsnooze.log');
|
|
16
16
|
export const EVENTS_DIR = join(STATE_DIR, 'events');
|
|
17
|
+
// Working dir for window primes (prime.js) — the watcher ignores stops in it.
|
|
18
|
+
export const PRIME_DIR = join(STATE_DIR, 'prime');
|
|
19
|
+
|
|
20
|
+
// Is this (agent-recorded) cwd the prime dir? Codex may record it with a
|
|
21
|
+
// \\?\ prefix or another drive-letter case on Windows.
|
|
22
|
+
export function isPrimeDir(dir, primeDir = PRIME_DIR, platform = process.platform) {
|
|
23
|
+
if (!dir) return false;
|
|
24
|
+
const norm = p => {
|
|
25
|
+
const raw = String(p).replace(/^\\\\\?\\/, '');
|
|
26
|
+
return platform === 'win32' ? win32.resolve(raw).toLowerCase() : resolve(raw);
|
|
27
|
+
};
|
|
28
|
+
return norm(dir) === norm(primeDir);
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
export const RESUMER_LOCK = join(STATE_DIR, 'resumer.lock');
|
|
18
32
|
// High-frequency burn accumulator + warn-dedup (daemon single-writer).
|
|
19
33
|
export const USAGE_FILE = join(STATE_DIR, 'usage.json');
|
package/src/doctor.js
CHANGED
|
@@ -238,7 +238,9 @@ export async function runDoctor({
|
|
|
238
238
|
});
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
// install only writes the hook for an enabled claude, so a codex-only
|
|
242
|
+
// setup must not be told to run an install that can never clear this.
|
|
243
|
+
if (enabled('claude') && !hookInstalled()) {
|
|
242
244
|
findings.push({
|
|
243
245
|
id: 'hook-missing', kind: 'health',
|
|
244
246
|
title: 'Claude StopFailure hook is not installed',
|
package/src/hook.js
CHANGED
|
@@ -16,6 +16,7 @@ import { parseResetTime, resetAtMs } from './time-parser.js';
|
|
|
16
16
|
import { upsertSession } from './state.js';
|
|
17
17
|
import { latestRateLimitFromTranscript } from './watchers/claude.js';
|
|
18
18
|
import { getMultiplexer } from './multiplexer.js';
|
|
19
|
+
import { tmuxPaneFromEnv } from './multiplexers/tmux.js';
|
|
19
20
|
import { spawnResumerIfNeeded, ensureDaemon } from './spawn.js';
|
|
20
21
|
import { makeLogger } from './logger.js';
|
|
21
22
|
import { addressHash } from './lease.js';
|
|
@@ -47,7 +48,7 @@ export function hookContext(env = process.env, payload = {}) {
|
|
|
47
48
|
const pane = managedMux
|
|
48
49
|
? (env.UNSNOOZE_PANE || null)
|
|
49
50
|
: (muxName === 'herdr' ? env.HERDR_PANE_ID
|
|
50
|
-
: muxName === 'zellij' ? env.ZELLIJ_PANE_ID : env
|
|
51
|
+
: muxName === 'zellij' ? env.ZELLIJ_PANE_ID : tmuxPaneFromEnv(env) || payload.tmux_pane) || null;
|
|
51
52
|
const paneOwner = muxName === 'herdr'
|
|
52
53
|
? (managedMux ? env.UNSNOOZE_PANE_OWNER : (env.HERDR_SESSION || 'default'))
|
|
53
54
|
: muxName === 'zellij'
|
|
@@ -61,6 +62,8 @@ export async function runHook(rest = [], { ensureDaemonFn = ensureDaemon } = {})
|
|
|
61
62
|
const agentIdx = rest.indexOf('--agent');
|
|
62
63
|
const agent = getAgent(agentIdx !== -1 ? rest[agentIdx + 1] : 'claude');
|
|
63
64
|
if (!getConfig(`agents.${agent.id}`)) return 0; // agent disabled in settings
|
|
65
|
+
// A window prime (prime.js) is a throwaway one-shot — never a session to revive.
|
|
66
|
+
if (process.env.UNSNOOZE_PRIME) return 0;
|
|
64
67
|
// Nothing keeps the daemon running on native Windows (see ensureDaemon),
|
|
65
68
|
// and the hook is the one entry point GUI Claude sessions pass through —
|
|
66
69
|
// so it makes sure the daemon is up. A no-op everywhere else.
|
package/src/monitor.js
CHANGED
|
@@ -113,13 +113,10 @@ export function createMonitor({
|
|
|
113
113
|
detectedAt,
|
|
114
114
|
};
|
|
115
115
|
}
|
|
116
|
-
const bannerAt = typeof agent.latestBannerAt === 'function'
|
|
117
|
-
? agent.latestBannerAt(cwd, detectedAt)
|
|
118
|
-
: null;
|
|
119
116
|
return {
|
|
120
117
|
resetLine: paneResetLine,
|
|
121
118
|
limitType: paneLimitType,
|
|
122
|
-
bannerAt,
|
|
119
|
+
bannerAt: null,
|
|
123
120
|
sessionId,
|
|
124
121
|
via: null, // filled by caller (scrape/hook)
|
|
125
122
|
detectedAt,
|
package/src/multiplexers/tmux.js
CHANGED
|
@@ -6,9 +6,11 @@ import { resolveSessionName, SessionCreateError } from './session-name.js';
|
|
|
6
6
|
|
|
7
7
|
const execFileAsync = promisify(execFileCb);
|
|
8
8
|
|
|
9
|
+
// windowsHide: the console-less monitor polls through the async path every few
|
|
10
|
+
// seconds (see workspace.js). The sync new-session needs the real console.
|
|
9
11
|
function defaultSpawner(file, args, { sync = false, ...options } = {}) {
|
|
10
12
|
if (sync) return spawnSync(file, args, options);
|
|
11
|
-
return execFileAsync(file, args, options).then(({ stdout }) => stdout);
|
|
13
|
+
return execFileAsync(file, args, { windowsHide: true, ...options }).then(({ stdout }) => stdout);
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
function envArgs(env = {}) {
|
|
@@ -51,8 +53,43 @@ const SESSION_START_ERROR_RE = new RegExp([
|
|
|
51
53
|
|
|
52
54
|
export { SessionCreateError };
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
// tmux on native Windows is psmux, and three of its differences break revival
|
|
57
|
+
// (verified against psmux 3.3.6, which reports itself as "tmux 3.3.6"):
|
|
58
|
+
// - Command words are joined with spaces and run through pwsh/cmd unless `--`
|
|
59
|
+
// precedes them, and only new-session honours `--` — new-window always goes
|
|
60
|
+
// through the shell. `C:\Program Files\nodejs\node.exe` then dies at
|
|
61
|
+
// "C:\Program" and the window closes before the agent ever starts.
|
|
62
|
+
// - Pane ids are per session: every session's first pane is %1, and a bare
|
|
63
|
+
// `-t %1` means whichever session is newest. `session:%1` is exact.
|
|
64
|
+
// - A target pane that is gone resolves to some other pane: send-keys exits 0
|
|
65
|
+
// and types into it, capture-pane reads it.
|
|
66
|
+
// Real tmux accepts `--` and `session:%N` too, so an MSYS2 tmux is unharmed.
|
|
67
|
+
|
|
68
|
+
// This pane's id as unsnooze addresses it: `session:%N` inside psmux, which
|
|
69
|
+
// exports PSMUX_SESSION; the bare server-global %N under real tmux.
|
|
70
|
+
export function tmuxPaneFromEnv(env = process.env) {
|
|
71
|
+
if (!env.TMUX_PANE) return null;
|
|
72
|
+
return env.PSMUX_SESSION ? `${env.PSMUX_SESSION}:${env.TMUX_PANE}` : env.TMUX_PANE;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createTmux({ spawner = defaultSpawner, env = process.env, platform = process.platform } = {}) {
|
|
55
76
|
const run = (...args) => spawner('tmux', args);
|
|
77
|
+
const psmux = platform === 'win32';
|
|
78
|
+
const PANE_FMT = psmux ? '#{session_name}:#{pane_id}' : '#{pane_id}';
|
|
79
|
+
const RAW = psmux ? ['--'] : [];
|
|
80
|
+
// A pane-targeted command, refused for a pane that is gone — real tmux does
|
|
81
|
+
// that itself; psmux would run it against another pane.
|
|
82
|
+
const onPane = async (pane, cmd, ...args) => {
|
|
83
|
+
if (psmux && !(await backend.paneAlive(pane))) throw new Error(`can't find pane: ${pane}`);
|
|
84
|
+
return run(cmd, '-t', pane, ...args);
|
|
85
|
+
};
|
|
86
|
+
const sessionTaken = name => {
|
|
87
|
+
try {
|
|
88
|
+
return spawner('tmux', ['has-session', '-t', name], { sync: true, stdio: 'ignore', env }).status === 0;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
56
93
|
|
|
57
94
|
const backend = {
|
|
58
95
|
name: 'tmux',
|
|
@@ -69,33 +106,33 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
69
106
|
inside() { return !!env.TMUX; },
|
|
70
107
|
currentPaneId() {
|
|
71
108
|
if (env.UNSNOOZE_MUX === 'tmux' && env.UNSNOOZE_PANE) return env.UNSNOOZE_PANE;
|
|
72
|
-
return env
|
|
109
|
+
return tmuxPaneFromEnv(env);
|
|
73
110
|
},
|
|
74
111
|
|
|
75
112
|
async capturePane(pane, lines = 200) {
|
|
76
|
-
return
|
|
113
|
+
return onPane(pane, 'capture-pane', '-p', '-S', `-${lines}`);
|
|
77
114
|
},
|
|
78
115
|
|
|
79
116
|
async capturePaneVisible(pane) {
|
|
80
|
-
return
|
|
117
|
+
return onPane(pane, 'capture-pane', '-p');
|
|
81
118
|
},
|
|
82
119
|
|
|
83
120
|
async sendText(pane, text) {
|
|
84
|
-
await
|
|
121
|
+
await onPane(pane, 'send-keys', '-l', text);
|
|
85
122
|
await new Promise(resolve => setTimeout(resolve, SUBMIT_DELAY_MS));
|
|
86
|
-
await
|
|
123
|
+
await onPane(pane, 'send-keys', 'Enter');
|
|
87
124
|
},
|
|
88
125
|
|
|
89
126
|
async sendKey(pane, key) {
|
|
90
|
-
await
|
|
127
|
+
await onPane(pane, 'send-keys', key);
|
|
91
128
|
},
|
|
92
129
|
|
|
93
130
|
async paneAlive(pane) {
|
|
94
131
|
try {
|
|
95
132
|
// tmux 3.7b prints a blank line and exits 0 for a nonexistent target,
|
|
96
|
-
// so the exit code alone is not
|
|
97
|
-
// pane id back.
|
|
98
|
-
const out = await run('display-message', '-t', pane, '-p',
|
|
133
|
+
// and psmux names a different pane, so the exit code alone is not
|
|
134
|
+
// evidence — the output must echo the pane id back.
|
|
135
|
+
const out = await run('display-message', '-t', pane, '-p', PANE_FMT);
|
|
99
136
|
return out.trim() === pane;
|
|
100
137
|
} catch {
|
|
101
138
|
return false;
|
|
@@ -106,7 +143,7 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
106
143
|
// code (same tmux 3.7b blank-success pitfall as paneAlive).
|
|
107
144
|
async sessionForPane(pane) {
|
|
108
145
|
try {
|
|
109
|
-
const out = (await
|
|
146
|
+
const out = (await onPane(pane, 'display-message', '-p', '#{session_name}')).trim();
|
|
110
147
|
return out || null;
|
|
111
148
|
} catch {
|
|
112
149
|
return null;
|
|
@@ -133,7 +170,7 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
133
170
|
|
|
134
171
|
async paneTty(pane) {
|
|
135
172
|
try {
|
|
136
|
-
const out = (await
|
|
173
|
+
const out = (await onPane(pane, 'display-message', '-p', '#{pane_tty}')).trim();
|
|
137
174
|
return out || null;
|
|
138
175
|
} catch {
|
|
139
176
|
return null;
|
|
@@ -166,7 +203,7 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
166
203
|
|
|
167
204
|
async paneCurrentCommand(pane) {
|
|
168
205
|
try {
|
|
169
|
-
return (await
|
|
206
|
+
return (await onPane(pane, 'display-message', '-p', '#{pane_current_command}')).trim();
|
|
170
207
|
} catch {
|
|
171
208
|
return null;
|
|
172
209
|
}
|
|
@@ -178,7 +215,7 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
178
215
|
// older tmux fails quietly and callers fall back to lease checks.
|
|
179
216
|
async stampPaneOwner(pane, leaseId) {
|
|
180
217
|
try {
|
|
181
|
-
await
|
|
218
|
+
await onPane(pane, 'set-option', '-p', '@unsnooze_owner', String(leaseId));
|
|
182
219
|
return true;
|
|
183
220
|
} catch {
|
|
184
221
|
return false;
|
|
@@ -189,7 +226,7 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
189
226
|
// both null — trust stdout, not the exit code (tmux 3.7b blank-success).
|
|
190
227
|
async paneOwnerStamp(pane) {
|
|
191
228
|
try {
|
|
192
|
-
const out = (await
|
|
229
|
+
const out = (await onPane(pane, 'display-message', '-p', '#{@unsnooze_owner}')).trim();
|
|
193
230
|
return out || null;
|
|
194
231
|
} catch {
|
|
195
232
|
return null;
|
|
@@ -217,7 +254,7 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
217
254
|
|
|
218
255
|
async listSessionPanes(sessionName) {
|
|
219
256
|
try {
|
|
220
|
-
const out = await run('list-panes', '-t', sessionName, '-F',
|
|
257
|
+
const out = await run('list-panes', '-t', sessionName, '-F', PANE_FMT);
|
|
221
258
|
return out.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
222
259
|
} catch {
|
|
223
260
|
return [];
|
|
@@ -235,7 +272,15 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
235
272
|
async newWindow(sessionName, cwd, launchSpec) {
|
|
236
273
|
// Environment flags require tmux >= 3.0 for new-window and >= 3.2 for
|
|
237
274
|
// new-session. Older tmux fails revival with an "unknown flag -e" error.
|
|
238
|
-
const launch = [...envArgs(launchSpec.env), launchSpec.file, ...(launchSpec.args || [])];
|
|
275
|
+
const launch = [...envArgs(launchSpec.env), ...RAW, launchSpec.file, ...(launchSpec.args || [])];
|
|
276
|
+
// psmux: new-window would run the command through a shell, so each
|
|
277
|
+
// revival opens its own session — resumer and prompt-queue persist it.
|
|
278
|
+
if (psmux) {
|
|
279
|
+
const session = resolveSessionName(sessionName, sessionTaken);
|
|
280
|
+
const pane = await run('new-session', '-d', '-s', session, '-c', cwd,
|
|
281
|
+
'-P', '-F', PANE_FMT, ...launch);
|
|
282
|
+
return { pane: pane.trim(), paneOwner: null, session };
|
|
283
|
+
}
|
|
239
284
|
let pane;
|
|
240
285
|
if (!(await backend.sessionExists(sessionName))) {
|
|
241
286
|
pane = await run('new-session', '-d', '-s', sessionName, '-c', cwd,
|
|
@@ -254,20 +299,13 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
254
299
|
// tmux in the foreground also gives Ctrl-C to the active pane directly.
|
|
255
300
|
// Its -e environment flags require tmux >= 3.2; older versions fail
|
|
256
301
|
// revival with an "unknown flag -e" error.
|
|
257
|
-
const name = resolveSessionName(wrappedSessionName(env),
|
|
258
|
-
try {
|
|
259
|
-
return spawner('tmux', ['has-session', '-t', candidate],
|
|
260
|
-
{ sync: true, stdio: 'ignore', env }).status === 0;
|
|
261
|
-
} catch {
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
});
|
|
302
|
+
const name = resolveSessionName(wrappedSessionName(env), sessionTaken);
|
|
265
303
|
launchSpec.onSessionCreated?.(name);
|
|
266
304
|
// Session name is discovered live via sessionForPane at record-write
|
|
267
305
|
// time — do NOT inject UNSNOOZE_SESSION_NAME (would leak into daemons
|
|
268
306
|
// spawned from the agent via {...process.env}).
|
|
269
307
|
const args = ['new-session', '-s', name,
|
|
270
|
-
...envArgs(launchSpec.env), launchSpec.file, ...(launchSpec.args || [])];
|
|
308
|
+
...envArgs(launchSpec.env), ...RAW, launchSpec.file, ...(launchSpec.args || [])];
|
|
271
309
|
// stderr is piped, not inherited: the tmux client draws its UI on the
|
|
272
310
|
// stdin/stdout tty; stderr carries only tmux's own error messages, and
|
|
273
311
|
// capturing them is how a session-start failure is told apart from the
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// primeAt.<id> = auto: learn when the user starts work from their own local
|
|
2
|
+
// history, and prime LEAD_MS before it so the reset lands a couple of hours
|
|
3
|
+
// into the session.
|
|
4
|
+
//
|
|
5
|
+
// "Starts work" = the first human prompt of a day that follows IDLE_GAP_MS of
|
|
6
|
+
// no prompts — a window's worth of quiet. That gap rule is what keeps a late
|
|
7
|
+
// night running past midnight from reading as a 00:10 start. Only human
|
|
8
|
+
// prompts count: tool results, subagent turns, unsnooze's own resume messages
|
|
9
|
+
// and primes are activity unsnooze or the agent produced, and a 4am revival
|
|
10
|
+
// must not teach it that the user starts at 4am.
|
|
11
|
+
//
|
|
12
|
+
// Usage outside these agents' local history (claude.ai, the desktop app) is
|
|
13
|
+
// invisible here; a prime that finds the window already running says so.
|
|
14
|
+
//
|
|
15
|
+
// History is large (hundreds of MB of transcripts), so per-file results are
|
|
16
|
+
// cached by size+mtime in ACTIVITY_FILE and only changed files are re-read.
|
|
17
|
+
|
|
18
|
+
import { createReadStream, readdirSync, statSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { createInterface } from 'node:readline';
|
|
20
|
+
import { join, dirname } from 'node:path';
|
|
21
|
+
import { CLAUDE_DIR, CODEX_DIR, STATE_DIR, isPrimeDir, writePrivateFile, ensureStateDir } from './config.js';
|
|
22
|
+
import { resolveResumeMessage } from './settings.js';
|
|
23
|
+
|
|
24
|
+
export const LEARN_DAYS = 21;
|
|
25
|
+
export const MIN_DAYS = 5;
|
|
26
|
+
const IDLE_GAP_MS = 5 * 3_600_000;
|
|
27
|
+
export const LEAD_MS = 3 * 3_600_000;
|
|
28
|
+
const BUCKET_MS = 5 * 60_000;
|
|
29
|
+
const DAY_MS = 86_400_000;
|
|
30
|
+
|
|
31
|
+
export const ACTIVITY_FILE = () => join(STATE_DIR, 'prime-activity.json');
|
|
32
|
+
|
|
33
|
+
function textOf(content) {
|
|
34
|
+
if (typeof content === 'string') return content;
|
|
35
|
+
if (!Array.isArray(content)) return null;
|
|
36
|
+
if (content.some(b => b?.type === 'tool_result')) return null;
|
|
37
|
+
const parts = content.filter(b => b?.type === 'text' || b?.type === 'input_text').map(b => b.text || '');
|
|
38
|
+
return parts.length ? parts.join('\n') : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Epoch ms of a human prompt on this line, else null.
|
|
42
|
+
export function promptTimestamp(agentId, line, { resumePrefix = '' } = {}) {
|
|
43
|
+
if (agentId === 'claude' ? !line.includes('"type":"user"') : !line.includes('"role":"user"')) return null;
|
|
44
|
+
let j;
|
|
45
|
+
try { j = JSON.parse(line); } catch { return null; }
|
|
46
|
+
let text;
|
|
47
|
+
if (agentId === 'claude') {
|
|
48
|
+
if (j.type !== 'user' || j.isSidechain || j.isMeta || j.isCompactSummary) return null;
|
|
49
|
+
text = textOf(j.message?.content);
|
|
50
|
+
} else {
|
|
51
|
+
const p = j.payload;
|
|
52
|
+
if (j.type !== 'response_item' || p?.type !== 'message' || p.role !== 'user') return null;
|
|
53
|
+
text = textOf(p.content);
|
|
54
|
+
// Codex injects <environment_context>, <user_instructions> and AGENTS.md
|
|
55
|
+
// as user messages at every session start — revivals included.
|
|
56
|
+
if (text != null && /^\s*(<[a-z_]+>|# AGENTS\.md)/i.test(text)) return null;
|
|
57
|
+
}
|
|
58
|
+
if (text == null) return null;
|
|
59
|
+
if (resumePrefix && text.trimStart().startsWith(resumePrefix)) return null;
|
|
60
|
+
const ts = Date.parse(j.timestamp);
|
|
61
|
+
return Number.isFinite(ts) ? ts : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Claude: projects/<dashed-cwd>/<session>.jsonl. Codex: sessions/YYYY/MM/DD/.
|
|
65
|
+
export function historyFiles(agentId, { since, claudeDir = CLAUDE_DIR, codexDir = CODEX_DIR } = {}) {
|
|
66
|
+
const out = [];
|
|
67
|
+
const ls = dir => { try { return readdirSync(dir); } catch { return []; } };
|
|
68
|
+
const add = path => {
|
|
69
|
+
try {
|
|
70
|
+
const st = statSync(path);
|
|
71
|
+
if (st.isFile() && st.mtimeMs >= since) out.push({ path, size: st.size, mtimeMs: st.mtimeMs });
|
|
72
|
+
} catch { /* vanished */ }
|
|
73
|
+
};
|
|
74
|
+
if (agentId === 'claude') {
|
|
75
|
+
const root = join(claudeDir, 'projects');
|
|
76
|
+
for (const d of ls(root)) for (const f of ls(join(root, d))) if (f.endsWith('.jsonl')) add(join(root, d, f));
|
|
77
|
+
} else {
|
|
78
|
+
const root = join(codexDir, 'sessions');
|
|
79
|
+
for (const y of ls(root)) for (const m of ls(join(root, y))) for (const d of ls(join(root, y, m))) {
|
|
80
|
+
// Day dirs older than the window cannot hold anything newer than it.
|
|
81
|
+
if (Date.parse(`${y}-${m}-${d}T00:00:00Z`) < since - DAY_MS) continue;
|
|
82
|
+
for (const f of ls(join(root, y, m, d))) if (f.endsWith('.jsonl')) add(join(root, y, m, d, f));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function scanFile(agentId, path, resumePrefix) {
|
|
89
|
+
const buckets = new Set();
|
|
90
|
+
const rl = createInterface({ input: createReadStream(path, { encoding: 'utf-8' }), crlfDelay: Infinity });
|
|
91
|
+
let first = true;
|
|
92
|
+
for await (const line of rl) {
|
|
93
|
+
if (first && agentId === 'codex') {
|
|
94
|
+
first = false;
|
|
95
|
+
// A prime's own rollout is not the user starting work.
|
|
96
|
+
try { if (isPrimeDir(JSON.parse(line)?.payload?.cwd)) { rl.close(); return []; } } catch { /* no meta */ }
|
|
97
|
+
}
|
|
98
|
+
const ts = promptTimestamp(agentId, line, { resumePrefix });
|
|
99
|
+
if (ts != null) buckets.add(Math.floor(ts / BUCKET_MS) * BUCKET_MS);
|
|
100
|
+
}
|
|
101
|
+
return [...buckets].sort((a, b) => a - b);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Prompt timestamps (5-minute buckets) over the learning window.
|
|
105
|
+
export async function collectPrompts(agentId, {
|
|
106
|
+
now = Date.now(), cacheFile = ACTIVITY_FILE(), files = null, scan = scanFile, ...dirs
|
|
107
|
+
} = {}) {
|
|
108
|
+
const since = now - LEARN_DAYS * DAY_MS;
|
|
109
|
+
const resumePrefix = resolveResumeMessage(agentId).trim().slice(0, 40);
|
|
110
|
+
let cache = {};
|
|
111
|
+
try { cache = JSON.parse(readFileSync(cacheFile, 'utf-8'))?.[agentId] || {}; } catch { /* first run */ }
|
|
112
|
+
// Cached timestamps were filtered with the resume message of their day.
|
|
113
|
+
if (cache.resumePrefix !== resumePrefix) cache = {};
|
|
114
|
+
const cachedFiles = cache.files || {};
|
|
115
|
+
const next = {};
|
|
116
|
+
const all = [];
|
|
117
|
+
for (const f of files || historyFiles(agentId, { since, ...dirs })) {
|
|
118
|
+
const hit = cachedFiles[f.path];
|
|
119
|
+
const ts = hit && hit.size === f.size && hit.mtimeMs === f.mtimeMs
|
|
120
|
+
? hit.ts
|
|
121
|
+
: await scan(agentId, f.path, resumePrefix).catch(() => []);
|
|
122
|
+
next[f.path] = { size: f.size, mtimeMs: f.mtimeMs, ts };
|
|
123
|
+
for (const t of ts) if (t >= since) all.push(t);
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
let whole = {};
|
|
127
|
+
try { whole = JSON.parse(readFileSync(cacheFile, 'utf-8')) || {}; } catch { /* fresh */ }
|
|
128
|
+
whole[agentId] = { resumePrefix, files: next };
|
|
129
|
+
ensureStateDir(dirname(cacheFile));
|
|
130
|
+
writePrivateFile(cacheFile, `${cacheFile}.tmp.${process.pid}`, JSON.stringify(whole));
|
|
131
|
+
} catch { /* cache is an optimisation */ }
|
|
132
|
+
return all.sort((a, b) => a - b);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const minuteOfDay = ms => { const d = new Date(ms); return d.getHours() * 60 + d.getMinutes(); };
|
|
136
|
+
|
|
137
|
+
// The usual start time (minutes after local midnight): for each day, the
|
|
138
|
+
// prompt after the longest quiet of at least IDLE_GAP_MS — the one after
|
|
139
|
+
// sleep, not a stray 00:30 prompt after an evening break — then the median.
|
|
140
|
+
// The earliest prompt on record is never a start: what came before it is
|
|
141
|
+
// unknown. null until MIN_DAYS days qualify.
|
|
142
|
+
export function learnStart(timestamps, { days = 'daily' } = {}) {
|
|
143
|
+
const perDay = new Map();
|
|
144
|
+
for (let i = 1; i < timestamps.length; i++) {
|
|
145
|
+
const t = timestamps[i];
|
|
146
|
+
const gap = t - timestamps[i - 1];
|
|
147
|
+
if (gap < IDLE_GAP_MS) continue;
|
|
148
|
+
const d = new Date(t);
|
|
149
|
+
if (days === 'weekdays' && (d.getDay() === 0 || d.getDay() === 6)) continue;
|
|
150
|
+
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
151
|
+
if (!perDay.has(key) || gap > perDay.get(key).gap) perDay.set(key, { gap, minutes: minuteOfDay(t) });
|
|
152
|
+
}
|
|
153
|
+
const mins = [...perDay.values()].map(v => v.minutes).sort((a, b) => a - b);
|
|
154
|
+
if (mins.length < MIN_DAYS) return { minutes: null, days: mins.length };
|
|
155
|
+
const mid = mins.length >> 1;
|
|
156
|
+
const minutes = mins.length % 2 ? mins[mid] : Math.round((mins[mid - 1] + mins[mid]) / 2);
|
|
157
|
+
return { minutes, days: mins.length };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const hhmm = minutes => {
|
|
161
|
+
const m = ((minutes % 1440) + 1440) % 1440;
|
|
162
|
+
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Learn and turn into a prime slot: { start, slot, wrap, days } or
|
|
166
|
+
// { slot: null, days }. wrap: the slot falls the evening before (start < 03:00).
|
|
167
|
+
export async function learnSlot(agentId, { days = 'daily', ...opts } = {}) {
|
|
168
|
+
const learned = learnStart(await collectPrompts(agentId, opts), { days });
|
|
169
|
+
if (learned.minutes == null) return { slot: null, start: null, days: learned.days };
|
|
170
|
+
const slotMinutes = learned.minutes - LEAD_MS / 60_000;
|
|
171
|
+
return { start: hhmm(learned.minutes), slot: hhmm(slotMinutes), wrap: slotMinutes < 0, days: learned.days };
|
|
172
|
+
}
|
package/src/prime.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// Window priming (experimental). Claude's 5-hour window — and Codex's, on
|
|
2
|
+
// plans that have one — starts on the first message after a reset, not on a
|
|
3
|
+
// clock. One tiny prompt to the cheapest model at a chosen time (primeAt.<id>,
|
|
4
|
+
// or `auto` — learned from history, see prime-learn.js) starts it early, so
|
|
5
|
+
// the reset lands mid-workday instead of mid-afternoon.
|
|
6
|
+
//
|
|
7
|
+
// Only claude and codex: Cursor and Grok reset on a fixed billing clock,
|
|
8
|
+
// Qwen's 5h quota slides per request (and its terms forbid scheduled
|
|
9
|
+
// non-interactive use), and Kimi/Antigravity are unverified.
|
|
10
|
+
//
|
|
11
|
+
// Every prime checks itself: the reply carries the window's reset time, and a
|
|
12
|
+
// reset ~5h after the prime means it started the window. Anything else is
|
|
13
|
+
// reported as it is — already running, no 5-hour window on this plan, failed.
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { readdirSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { PRIME_DIR, CODEX_DIR } from './config.js';
|
|
19
|
+
import { getConfig } from './settings.js';
|
|
20
|
+
import { getAgent } from './agents/index.js';
|
|
21
|
+
import { updateState, readState } from './state.js';
|
|
22
|
+
import { extractCodexUsage } from './usage.js';
|
|
23
|
+
import { makeLogger } from './logger.js';
|
|
24
|
+
import { learnSlot, MIN_DAYS } from './prime-learn.js';
|
|
25
|
+
import { standaloneEnv } from './spawn.js';
|
|
26
|
+
|
|
27
|
+
const log = makeLogger('prime');
|
|
28
|
+
|
|
29
|
+
export const PRIME_AGENTS = ['claude', 'codex'];
|
|
30
|
+
const PROMPT = 'Reply with just: ok';
|
|
31
|
+
const FIVE_HOURS_MS = 5 * 3_600_000;
|
|
32
|
+
// A window started by this prime resets 5h after it, give or take the
|
|
33
|
+
// run time and the minute the server rounds to.
|
|
34
|
+
const STARTED_SLACK_MS = 10 * 60_000;
|
|
35
|
+
// A machine asleep at the scheduled time still primes on wake, within this.
|
|
36
|
+
// Later than that the day is skipped — the user is likely working by then.
|
|
37
|
+
export const GRACE_MS = 4 * 3_600_000;
|
|
38
|
+
const RUN_TIMEOUT_MS = 3 * 60_000;
|
|
39
|
+
|
|
40
|
+
// "6:00" / "06:00" → "06:00"; "auto"; "" / "off" → ""; anything else → null.
|
|
41
|
+
export function normalizePrimeAt(raw) {
|
|
42
|
+
const value = String(raw ?? '').trim();
|
|
43
|
+
if (value === '' || /^off$/i.test(value)) return '';
|
|
44
|
+
if (/^auto$/i.test(value)) return 'auto';
|
|
45
|
+
const m = value.match(/^([01]?\d|2[0-3]):([0-5]\d)$/);
|
|
46
|
+
return m ? `${m[1].padStart(2, '0')}:${m[2]}` : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function dayKey(ms) {
|
|
50
|
+
const d = new Date(ms);
|
|
51
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The day a prime is due for right now, as a dayKey, else null. The slot is
|
|
55
|
+
// local time, open for GRACE_MS — yesterday's slot too, so a machine asleep
|
|
56
|
+
// across midnight still primes on wake. `wrap` marks a slot that falls the
|
|
57
|
+
// evening before the day it serves (auto, for a start before 03:00): the
|
|
58
|
+
// weekday check and the once-a-day key belong to that next day.
|
|
59
|
+
export function dueDay({ at, days = 'daily', lastDay = null, now = Date.now(), wrap = false }) {
|
|
60
|
+
const hhmm = normalizePrimeAt(at);
|
|
61
|
+
if (!hhmm || hhmm === 'auto') return null; // auto resolves to a slot first
|
|
62
|
+
const [h, m] = hhmm.split(':').map(Number);
|
|
63
|
+
const d = new Date(now);
|
|
64
|
+
for (const back of [0, 1]) {
|
|
65
|
+
const slot = new Date(d.getFullYear(), d.getMonth(), d.getDate() - back, h, m);
|
|
66
|
+
if (now < slot.getTime() || now - slot.getTime() > GRACE_MS) continue;
|
|
67
|
+
const served = new Date(slot.getFullYear(), slot.getMonth(), slot.getDate() + (wrap ? 1 : 0));
|
|
68
|
+
if (days === 'weekdays' && (served.getDay() === 0 || served.getDay() === 6)) continue;
|
|
69
|
+
const key = dayKey(served.getTime());
|
|
70
|
+
if (key !== lastDay) return key;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const primeDue = opts => dueDay(opts) !== null;
|
|
76
|
+
|
|
77
|
+
// Codex model slugs vary by account, so the default is the first "luna" or
|
|
78
|
+
// "mini" model this install has cached, else codex's own default.
|
|
79
|
+
export function codexPrimeModel({ codexDir = CODEX_DIR } = {}) {
|
|
80
|
+
try {
|
|
81
|
+
const cache = JSON.parse(readFileSync(join(codexDir, 'models_cache.json'), 'utf-8'));
|
|
82
|
+
const list = Array.isArray(cache) ? cache : cache.models || [];
|
|
83
|
+
const slug = list.map(m => m?.slug || m?.id).find(s => /luna|mini/i.test(s || ''));
|
|
84
|
+
return slug || '';
|
|
85
|
+
} catch { return ''; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function primeModel(agentId, opts) {
|
|
89
|
+
const set = getConfig(`primeModel.${agentId}`);
|
|
90
|
+
if (set) return set;
|
|
91
|
+
return agentId === 'claude' ? 'haiku' : codexPrimeModel(opts);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function primeArgs(agentId, { model = '', cwd = PRIME_DIR } = {}) {
|
|
95
|
+
if (agentId === 'claude') {
|
|
96
|
+
// stream-json --verbose is what carries the rate_limit_event readback.
|
|
97
|
+
return ['-p', PROMPT, ...(model ? ['--model', model] : []), '--no-session-persistence',
|
|
98
|
+
'--output-format', 'stream-json', '--verbose'];
|
|
99
|
+
}
|
|
100
|
+
// No --ephemeral: the rollout it suppresses is the readback.
|
|
101
|
+
return ['exec', '--json', ...(model ? ['-m', model] : []), '-c', 'model_reasoning_effort="low"',
|
|
102
|
+
'-s', 'read-only', '--skip-git-repo-check', '-C', cwd, PROMPT];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function jsonLines(text) {
|
|
106
|
+
return String(text || '').split('\n').map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Claude: the rate_limit_events in the stream-json output. Any one of them
|
|
110
|
+
// may describe another bucket (weekly, overage), so "no 5-hour window" is
|
|
111
|
+
// only the answer when none of them carries one.
|
|
112
|
+
export function parseClaudePrime(stdout) {
|
|
113
|
+
let seen = false;
|
|
114
|
+
for (const m of jsonLines(stdout)) {
|
|
115
|
+
if (m.type !== 'rate_limit_event') continue;
|
|
116
|
+
seen = true;
|
|
117
|
+
const info = m.rate_limit_info || {};
|
|
118
|
+
const five = info.unifiedWindows?.five_hour?.resetsAt
|
|
119
|
+
?? (info.rateLimitType === 'five_hour' ? info.resetsAt : null);
|
|
120
|
+
if (Number.isFinite(five)) return { resetsAtMs: five * 1000 };
|
|
121
|
+
}
|
|
122
|
+
return seen ? { noWindow: true } : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Newest-first day dirs under sessions/YYYY/MM/DD — a prime's rollout is in
|
|
126
|
+
// one of the last couple.
|
|
127
|
+
function recentDayDirs(root, limit = 3) {
|
|
128
|
+
const out = [];
|
|
129
|
+
const desc = dir => { try { return readdirSync(dir).filter(n => /^\d+$/.test(n)).sort().reverse(); } catch { return []; } };
|
|
130
|
+
for (const y of desc(root)) for (const mo of desc(join(root, y))) for (const d of desc(join(root, y, mo))) {
|
|
131
|
+
out.push(join(root, y, mo, d));
|
|
132
|
+
if (out.length >= limit) return out;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Codex: thread_id from `exec --json`, then the rollout's last token_count.
|
|
138
|
+
export function parseCodexPrime(stdout, { codexDir = CODEX_DIR } = {}) {
|
|
139
|
+
const started = jsonLines(stdout).find(m => m.type === 'thread.started');
|
|
140
|
+
if (!started?.thread_id) return null;
|
|
141
|
+
for (const dir of recentDayDirs(join(codexDir, 'sessions'))) {
|
|
142
|
+
let name;
|
|
143
|
+
try { name = readdirSync(dir).find(n => n.endsWith(`${started.thread_id}.jsonl`)); } catch { continue; }
|
|
144
|
+
if (!name) continue;
|
|
145
|
+
const lines = readFileSync(join(dir, name), 'utf-8').split('\n');
|
|
146
|
+
let last = null;
|
|
147
|
+
for (const line of lines) last = extractCodexUsage(line) || last;
|
|
148
|
+
if (!last) return null;
|
|
149
|
+
const five = [last.primary, last.secondary].find(w => w?.windowMinutes === 300);
|
|
150
|
+
return five?.resetsAtMs ? { resetsAtMs: five.resetsAtMs } : { noWindow: true, planType: last.planType };
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// stdin closed: `codex exec` with a piped stdin reads it as extra prompt
|
|
156
|
+
// input and exits with no output at all.
|
|
157
|
+
// Resolves on `exit`, not only `close`: a grandchild holding the pipes open
|
|
158
|
+
// must not leave the prime — and with it every later tick — waiting forever.
|
|
159
|
+
function defaultRunner(bin, args, opts) {
|
|
160
|
+
return new Promise(resolve => {
|
|
161
|
+
let stdout = '';
|
|
162
|
+
let stderr = '';
|
|
163
|
+
let child;
|
|
164
|
+
let done = false;
|
|
165
|
+
const timers = [];
|
|
166
|
+
const finish = result => {
|
|
167
|
+
if (done) return;
|
|
168
|
+
done = true;
|
|
169
|
+
timers.forEach(clearTimeout);
|
|
170
|
+
resolve({ stdout, stderr, ...result });
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
child = spawn(bin, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
174
|
+
} catch (err) { finish({ err }); return; }
|
|
175
|
+
timers.push(setTimeout(() => {
|
|
176
|
+
child.kill();
|
|
177
|
+
// An agent that ignores SIGTERM still has to give the tick back.
|
|
178
|
+
timers.push(setTimeout(() => {
|
|
179
|
+
try { child.kill('SIGKILL'); } catch { /* gone */ }
|
|
180
|
+
finish({ err: Object.assign(new Error('timed out'), { killed: true }) });
|
|
181
|
+
}, 5_000));
|
|
182
|
+
}, RUN_TIMEOUT_MS));
|
|
183
|
+
child.stdout.on('data', d => { stdout += d; });
|
|
184
|
+
child.stderr.on('data', d => { stderr += d; });
|
|
185
|
+
child.on('error', err => finish({ err }));
|
|
186
|
+
const settle = (code, signal) => finish({
|
|
187
|
+
err: code === 0 ? null : Object.assign(new Error(`exit ${code ?? signal}`), { killed: !!signal }),
|
|
188
|
+
});
|
|
189
|
+
// close = all output read; exit + a short drain covers held-open pipes.
|
|
190
|
+
child.on('close', settle);
|
|
191
|
+
child.on('exit', (code, signal) => { timers.push(setTimeout(() => settle(code, signal), 2_000)); });
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// One prime, verified. Never throws.
|
|
196
|
+
export async function runPrime(agentId, { runner = defaultRunner, now = () => Date.now(), codexDir = CODEX_DIR } = {}) {
|
|
197
|
+
const at = now();
|
|
198
|
+
const model = primeModel(agentId, { codexDir });
|
|
199
|
+
const base = { agent: agentId, at, model: model || null };
|
|
200
|
+
try {
|
|
201
|
+
mkdirSync(PRIME_DIR, { recursive: true, mode: 0o700 });
|
|
202
|
+
const agent = getAgent(agentId);
|
|
203
|
+
const { err, stdout, stderr } = await runner(agent.bin, primeArgs(agentId, { model, cwd: PRIME_DIR }), {
|
|
204
|
+
cwd: PRIME_DIR,
|
|
205
|
+
// standaloneEnv: `prime now` from inside a Claude Code session must not
|
|
206
|
+
// hand its session markers (CLAUDECODE, UNSNOOZE_MUX…) to the prime.
|
|
207
|
+
// The StopFailure hook ignores runs carrying UNSNOOZE_PRIME, so a prime
|
|
208
|
+
// that hits a limit is never recorded as a session to revive.
|
|
209
|
+
env: { ...standaloneEnv(), UNSNOOZE_PRIME: '1' },
|
|
210
|
+
});
|
|
211
|
+
const read = agentId === 'claude' ? parseClaudePrime(stdout) : parseCodexPrime(stdout, { codexDir });
|
|
212
|
+
if (!read) {
|
|
213
|
+
const why = err ? (err.killed ? 'timed out' : (stderr.trim().split('\n').pop() || err.message)) : 'no rate-limit readback in the reply';
|
|
214
|
+
return { ...base, outcome: 'failed', detail: why.slice(0, 200) };
|
|
215
|
+
}
|
|
216
|
+
if (read.noWindow) return { ...base, outcome: 'no-window', planType: read.planType || null };
|
|
217
|
+
const started = Math.abs(read.resetsAtMs - (at + FIVE_HOURS_MS)) <= STARTED_SLACK_MS;
|
|
218
|
+
return { ...base, outcome: started ? 'started' : 'running', resetsAtMs: read.resetsAtMs };
|
|
219
|
+
} catch (e) {
|
|
220
|
+
return { ...base, outcome: 'failed', detail: String(e.message).slice(0, 200) };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const hm = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
225
|
+
|
|
226
|
+
export function formatPrimeResult(r) {
|
|
227
|
+
switch (r.outcome) {
|
|
228
|
+
case 'started': return `${r.agent} 5-hour window started — resets ${hm(r.resetsAtMs)}`;
|
|
229
|
+
case 'running': return `${r.agent} 5-hour window was already running — resets ${hm(r.resetsAtMs)}`;
|
|
230
|
+
case 'no-window': return `${r.agent} has no 5-hour window${r.planType ? ` on the ${r.planType} plan` : ''} — priming does nothing here (unsnooze config set primeAt.${r.agent} off)`;
|
|
231
|
+
case 'pending': return `${r.agent} prime in progress`;
|
|
232
|
+
default: return `${r.agent} prime failed: ${r.detail || 'unknown error'}`;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const PRIME_TITLES = {
|
|
237
|
+
started: 'window primed',
|
|
238
|
+
running: 'window already running',
|
|
239
|
+
'no-window': 'window priming does nothing',
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// A failed prime (network still down after wake, a 5xx) retries within the
|
|
243
|
+
// slot's grace, a few times and not back to back.
|
|
244
|
+
const MAX_ATTEMPTS = 3;
|
|
245
|
+
const RETRY_GAP_MS = 5 * 60_000;
|
|
246
|
+
|
|
247
|
+
// The day this record has settled, as dueDay's lastDay: a failure with tries
|
|
248
|
+
// left settles nothing once the retry gap has passed.
|
|
249
|
+
function settledDay(rec, now) {
|
|
250
|
+
if (!rec) return null;
|
|
251
|
+
const retry = rec.outcome === 'failed' && !rec.manual
|
|
252
|
+
&& (rec.attempts || 1) < MAX_ATTEMPTS && now - rec.at >= RETRY_GAP_MS;
|
|
253
|
+
return retry ? null : rec.day;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Scheduled results keep the day they served and the attempt count; a manual
|
|
257
|
+
// `prime now` keeps whatever the schedule had, so testing at 05:00 does not
|
|
258
|
+
// cancel the 06:00 prime.
|
|
259
|
+
function record(result, { day, attempts, manual = false } = {}) {
|
|
260
|
+
updateState(state => {
|
|
261
|
+
state.prime = state.prime && typeof state.prime === 'object' ? state.prime : {};
|
|
262
|
+
const prev = state.prime[result.agent] || {};
|
|
263
|
+
state.prime[result.agent] = manual
|
|
264
|
+
? { learned: prev.learned, ...result, day: prev.day ?? null, attempts: prev.attempts, manual: true }
|
|
265
|
+
: { learned: prev.learned, ...result, day, attempts };
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The slot to prime at: the fixed time, or for auto the learned one —
|
|
270
|
+
// re-learned once a day and kept in state, so ticks in between cost nothing.
|
|
271
|
+
async function resolveSlot(id, { now, days, last, learn }) {
|
|
272
|
+
const at = normalizePrimeAt(getConfig(`primeAt.${id}`));
|
|
273
|
+
if (at !== 'auto') return { at, wrap: false };
|
|
274
|
+
const known = last[id]?.learned;
|
|
275
|
+
if (known?.day === dayKey(now) && known.primeDays === days) return { at: known.slot, wrap: !!known.wrap };
|
|
276
|
+
const learned = { ...(await learn(id, { days, now })), day: dayKey(now), primeDays: days };
|
|
277
|
+
updateState(state => {
|
|
278
|
+
state.prime = state.prime && typeof state.prime === 'object' ? state.prime : {};
|
|
279
|
+
state.prime[id] = { ...(state.prime[id] || { agent: id }), learned };
|
|
280
|
+
});
|
|
281
|
+
log(learned.slot
|
|
282
|
+
? `${id}: usually starts ~${learned.start} (${learned.days} days) — priming at ${learned.slot}`
|
|
283
|
+
: `${id}: auto priming still learning (${learned.days} of ${MIN_DAYS} days)`);
|
|
284
|
+
return { at: learned.slot, wrap: !!learned.wrap };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let ticking = false;
|
|
288
|
+
|
|
289
|
+
// Daemon tick. The day is claimed before the run starts, so the next tick
|
|
290
|
+
// (or a second daemon) cannot fire the same prime while this one is out.
|
|
291
|
+
export async function tickPrime({ now = Date.now(), run = runPrime, notifyFn = null, learn = learnSlot } = {}) {
|
|
292
|
+
const on = PRIME_AGENTS.filter(id => getConfig(`agents.${id}`) && normalizePrimeAt(getConfig(`primeAt.${id}`)));
|
|
293
|
+
if (!on.length || ticking) return []; // the common case: priming off, no state read
|
|
294
|
+
ticking = true;
|
|
295
|
+
try { return await tickOn(on, { now, run, notifyFn, learn }); } finally { ticking = false; }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function tickOn(on, { now, run, notifyFn, learn }) {
|
|
299
|
+
const days = getConfig('primeDays');
|
|
300
|
+
const last = readState().prime || {};
|
|
301
|
+
const due = new Map();
|
|
302
|
+
for (const id of on) {
|
|
303
|
+
const { at, wrap } = await resolveSlot(id, { now, days, last, learn });
|
|
304
|
+
const day = dueDay({ at, wrap, days, lastDay: settledDay(last[id], now), now });
|
|
305
|
+
if (day) due.set(id, day);
|
|
306
|
+
}
|
|
307
|
+
if (!due.size) return [];
|
|
308
|
+
// Claimed under the state lock, so a second daemon cannot fire it too.
|
|
309
|
+
const claimed = new Map();
|
|
310
|
+
updateState(state => {
|
|
311
|
+
state.prime = state.prime && typeof state.prime === 'object' ? state.prime : {};
|
|
312
|
+
for (const [id, day] of due) {
|
|
313
|
+
const rec = state.prime[id];
|
|
314
|
+
if (settledDay(rec, now) === day) continue;
|
|
315
|
+
const attempts = rec?.day === day && rec.outcome === 'failed' ? (rec.attempts || 1) + 1 : 1;
|
|
316
|
+
claimed.set(id, { day, attempts });
|
|
317
|
+
state.prime[id] = { learned: rec?.learned, agent: id, at: now, day, attempts, outcome: 'pending' };
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
const notify = notifyFn || (await import('./notify.js')).notify;
|
|
321
|
+
const results = [];
|
|
322
|
+
for (const [id, claim] of claimed) {
|
|
323
|
+
const result = await run(id);
|
|
324
|
+
record(result, claim);
|
|
325
|
+
const line = formatPrimeResult(result);
|
|
326
|
+
log(line);
|
|
327
|
+
// A failure with retries left is only logged — one toast per prime.
|
|
328
|
+
const final = result.outcome !== 'failed' || claim.attempts >= MAX_ATTEMPTS;
|
|
329
|
+
if (final) {
|
|
330
|
+
try { notify(PRIME_TITLES[result.outcome] || 'window prime failed', line, { priority: 2 }); } catch { /* never break the daemon */ }
|
|
331
|
+
}
|
|
332
|
+
results.push(result);
|
|
333
|
+
}
|
|
334
|
+
return results;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// `unsnooze prime` — schedule + last result; `unsnooze prime now [agent…]`.
|
|
338
|
+
export async function cmdPrime(rest = [], { run = runPrime, print = console.log, learn = learnSlot } = {}) {
|
|
339
|
+
if (rest[0] === 'now') {
|
|
340
|
+
const ids = rest.slice(1).length ? rest.slice(1) : PRIME_AGENTS.filter(id => getConfig(`agents.${id}`));
|
|
341
|
+
const bad = ids.filter(id => !PRIME_AGENTS.includes(id));
|
|
342
|
+
if (bad.length) {
|
|
343
|
+
print(`unsnooze: priming supports ${PRIME_AGENTS.join(' and ')} only (not ${bad.join(', ')})`);
|
|
344
|
+
return 1;
|
|
345
|
+
}
|
|
346
|
+
let code = 0;
|
|
347
|
+
for (const id of ids) {
|
|
348
|
+
print(`unsnooze: priming ${id}…`);
|
|
349
|
+
const result = await run(id);
|
|
350
|
+
record(result, { manual: true });
|
|
351
|
+
print(`unsnooze: ${formatPrimeResult(result)}`);
|
|
352
|
+
if (result.outcome === 'failed') code = 1;
|
|
353
|
+
}
|
|
354
|
+
return code;
|
|
355
|
+
}
|
|
356
|
+
if (rest[0] && rest[0] !== 'status') {
|
|
357
|
+
print('usage: unsnooze prime [status] | unsnooze prime now [claude|codex]');
|
|
358
|
+
return 1;
|
|
359
|
+
}
|
|
360
|
+
const days = getConfig('primeDays');
|
|
361
|
+
const last = readState().prime || {};
|
|
362
|
+
print('window priming (experimental) — starts the 5-hour window early with one tiny prompt');
|
|
363
|
+
for (const id of PRIME_AGENTS) {
|
|
364
|
+
const at = normalizePrimeAt(getConfig(`primeAt.${id}`));
|
|
365
|
+
let sched = 'off';
|
|
366
|
+
if (at === 'auto') {
|
|
367
|
+
const l = await learn(id, { days });
|
|
368
|
+
sched = l.slot
|
|
369
|
+
? `auto — you usually start ~${l.start} (${l.days} days), so it primes at ${l.slot} ${days}, model ${primeModel(id) || 'default'}`
|
|
370
|
+
: `auto — still learning when you start (${l.days} of ${MIN_DAYS} days); no primes until then`;
|
|
371
|
+
} else if (at) sched = `${at} ${days}, model ${primeModel(id) || 'default'}`;
|
|
372
|
+
const prev = last[id]?.at ? ` last: ${dayKey(last[id].at)} ${hm(last[id].at)} — ${formatPrimeResult(last[id])}` : '';
|
|
373
|
+
print(` ${id.padEnd(7)} ${sched}${prev ? `\n ${prev}` : ''}`);
|
|
374
|
+
}
|
|
375
|
+
if (PRIME_AGENTS.some(id => normalizePrimeAt(getConfig(`primeAt.${id}`)))) {
|
|
376
|
+
const { autostartUnitPath } = await import('./install.js');
|
|
377
|
+
if (process.platform !== 'win32' && !existsSync(autostartUnitPath())) {
|
|
378
|
+
print('\n ! scheduled primes run from the daemon, which is not installed: unsnooze install --daemon');
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
print('\n turn on: unsnooze config set primeAt.claude auto (or a time like 06:00)');
|
|
382
|
+
}
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
package/src/resumer.js
CHANGED
|
@@ -342,10 +342,6 @@ 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
|
-
|
|
349
345
|
// Banner still present. If it now parses to a real time, upgrade off fallback.
|
|
350
346
|
if (resetLine) {
|
|
351
347
|
const { at, source } = resetAtMs(parseResetTime(resetLine), {
|
|
@@ -959,8 +955,6 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
|
|
|
959
955
|
if (fromTx) {
|
|
960
956
|
resetLine = fromTx.resetLine ?? resetLine;
|
|
961
957
|
bannerAt = fromTx.timestampMs;
|
|
962
|
-
} else if (typeof agent.latestBannerAt === 'function') {
|
|
963
|
-
bannerAt = agent.latestBannerAt(rec.cwd, Date.now());
|
|
964
958
|
}
|
|
965
959
|
const { at, source } = resetAtMs(parseResetTime(resetLine), {
|
|
966
960
|
marginMs: RESET_MARGIN_MS,
|
|
@@ -1159,6 +1153,13 @@ export async function runResumer({
|
|
|
1159
1153
|
log(`usage warn tick failed: ${err.message}`);
|
|
1160
1154
|
}
|
|
1161
1155
|
|
|
1156
|
+
// Window priming (experimental) — not awaited: a prime is a model call
|
|
1157
|
+
// of up to minutes, and resumes must not queue behind it. tickPrime
|
|
1158
|
+
// claims the day before running, so later ticks cannot double-fire.
|
|
1159
|
+
import('./prime.js')
|
|
1160
|
+
.then(({ tickPrime }) => tickPrime())
|
|
1161
|
+
.catch(err => log(`prime tick failed: ${err.message}`));
|
|
1162
|
+
|
|
1162
1163
|
// Scheduled cleanup: age prune + pane-aware sweep + abandon stale stops.
|
|
1163
1164
|
try {
|
|
1164
1165
|
updateState(state => { prune(state); return state; });
|
package/src/settings.js
CHANGED
|
@@ -57,6 +57,12 @@ export const DEFAULTS = {
|
|
|
57
57
|
// compacted) is useless if it only applies to revives, because the session
|
|
58
58
|
// that runs out of context is the one the user started.
|
|
59
59
|
launchExtraArgs: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '', cursor: '' },
|
|
60
|
+
// Window priming (experimental, see prime.js): one tiny prompt at primeAt
|
|
61
|
+
// (local HH:MM, or auto = learned from history) starts the 5-hour window
|
|
62
|
+
// early. '' = off.
|
|
63
|
+
primeAt: { claude: '', codex: '' },
|
|
64
|
+
primeModel: { claude: '', codex: '' }, // '' = claude haiku / codex's cheapest cached model
|
|
65
|
+
primeDays: 'daily', // daily | weekdays
|
|
60
66
|
agents: { claude: true, codex: true, grok: false, qwen: false, kimi: false, opencode: false, agy: false, cursor: false }, // experimental agents default off
|
|
61
67
|
};
|
|
62
68
|
|
|
@@ -107,6 +113,11 @@ const ENV_NAMES = {
|
|
|
107
113
|
'launchExtraArgs.opencode': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_OPENCODE',
|
|
108
114
|
'launchExtraArgs.agy': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_AGY',
|
|
109
115
|
'launchExtraArgs.cursor': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_CURSOR',
|
|
116
|
+
'primeAt.claude': 'UNSNOOZE_PRIME_AT_CLAUDE',
|
|
117
|
+
'primeAt.codex': 'UNSNOOZE_PRIME_AT_CODEX',
|
|
118
|
+
'primeModel.claude': 'UNSNOOZE_PRIME_MODEL_CLAUDE',
|
|
119
|
+
'primeModel.codex': 'UNSNOOZE_PRIME_MODEL_CODEX',
|
|
120
|
+
primeDays: 'UNSNOOZE_PRIME_DAYS',
|
|
110
121
|
'agents.claude': 'UNSNOOZE_AGENT_CLAUDE',
|
|
111
122
|
'agents.codex': 'UNSNOOZE_AGENT_CODEX',
|
|
112
123
|
'agents.grok': 'UNSNOOZE_AGENT_GROK',
|
|
@@ -127,6 +138,7 @@ const ENUMS = {
|
|
|
127
138
|
notifyChannel: ['auto', 'native', 'osc', 'bell'],
|
|
128
139
|
ntfyPrivacy: ['full', 'terse'],
|
|
129
140
|
usageWarn: ['off', 'notify'],
|
|
141
|
+
primeDays: ['daily', 'weekdays'],
|
|
130
142
|
};
|
|
131
143
|
|
|
132
144
|
function parseBool(raw) {
|
|
@@ -253,6 +265,14 @@ export function setConfigValue(key, rawValue) {
|
|
|
253
265
|
value = n;
|
|
254
266
|
} else {
|
|
255
267
|
value = String(rawValue);
|
|
268
|
+
if (key.startsWith('primeAt.')) {
|
|
269
|
+
// Inline, not imported from prime.js: that module pulls in usage.js,
|
|
270
|
+
// and settings.js must stay a cheap leaf for the hook path.
|
|
271
|
+
const m = value.trim().match(/^([01]?\d|2[0-3]):([0-5]\d)$/);
|
|
272
|
+
const auto = /^auto$/i.test(value.trim());
|
|
273
|
+
if (!m && !auto && !/^(off)?$/i.test(value.trim())) throw new Error(`unsnooze: "${key}" needs auto, a local time like 06:00, or off`);
|
|
274
|
+
value = auto ? 'auto' : m ? `${m[1].padStart(2, '0')}:${m[2]}` : '';
|
|
275
|
+
}
|
|
256
276
|
if (ENUMS[key] && !ENUMS[key].includes(value)) {
|
|
257
277
|
throw new Error(`unsnooze: "${key}" must be one of: ${ENUMS[key].join(', ')}`);
|
|
258
278
|
}
|
package/src/update-check.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { readFileSync, existsSync } from 'node:fs';
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
|
-
import { join, dirname } from 'node:path';
|
|
12
|
+
import { join, dirname, posix, win32 } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { getConfig } from './settings.js';
|
|
15
15
|
import { notify } from './notify.js';
|
|
@@ -225,24 +225,59 @@ export async function runUpdateCheck({ fetcher, notifier = notify, now = Date.no
|
|
|
225
225
|
return 0;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
228
|
+
// The npm prefix this copy was installed into, so `unsnooze update` replaces
|
|
229
|
+
// the copy that is running. With two npm prefixes on one machine (nvm's npm
|
|
230
|
+
// first on PATH while unsnooze lives under ~/.local, or Homebrew's npm next
|
|
231
|
+
// to nvm), a bare `npm install -g` writes into npm's own prefix and leaves
|
|
232
|
+
// the copy the wrappers, the StopFailure hook and the daemon unit exec as it
|
|
233
|
+
// was. npm's global layout is <prefix>/lib/node_modules/unsnooze plus a
|
|
234
|
+
// <prefix>/bin/unsnooze link on POSIX, <prefix>\node_modules\unsnooze plus
|
|
235
|
+
// <prefix>\unsnooze.cmd on Windows. Anything else (a checkout or `npm link`,
|
|
236
|
+
// npx, a pnpm/yarn/bun global, a project dependency) is null: npm's default
|
|
237
|
+
// prefix, as before.
|
|
238
|
+
export function installPrefix({ root = ROOT, platform = process.platform, exists = existsSync } = {}) {
|
|
239
|
+
const win = platform === 'win32';
|
|
240
|
+
const path = win ? win32 : posix;
|
|
241
|
+
const modules = path.dirname(root);
|
|
242
|
+
if (path.basename(root) !== 'unsnooze' || path.basename(modules) !== 'node_modules') return null;
|
|
243
|
+
const parent = path.dirname(modules);
|
|
244
|
+
if (win) return exists(path.join(parent, 'unsnooze.cmd')) ? parent : null;
|
|
245
|
+
if (path.basename(parent) !== 'lib') return null;
|
|
246
|
+
const prefix = path.dirname(parent);
|
|
247
|
+
return exists(path.join(prefix, 'bin', 'unsnooze')) ? prefix : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// `unsnooze update` — self-update via npm, then show what changed. With
|
|
251
|
+
// --prefix pointed at this copy's own prefix, npm -g overwrites this install
|
|
252
|
+
// in place, so re-reading package.json/CHANGELOG.md after a successful
|
|
253
|
+
// install yields the NEW version's info.
|
|
254
|
+
export function runSelfUpdate({ runner = spawnSync, print = console.log, root = ROOT, current = PKG_VERSION } = {}) {
|
|
255
|
+
const prefix = installPrefix({ root });
|
|
256
|
+
const args = ['install', '-g', ...(prefix ? ['--prefix', prefix] : []), 'unsnooze@latest'];
|
|
257
|
+
const command = ['npm', ...args].map(a => (/\s/.test(a) ? `"${a}"` : a)).join(' ');
|
|
258
|
+
print(`unsnooze ${current} — updating via ${command} …`);
|
|
259
|
+
const r = runner('npm', args, { stdio: 'inherit' });
|
|
234
260
|
if (r.error || r.status !== 0) {
|
|
235
261
|
print(`unsnooze: update failed (${r.error ? r.error.message : `npm exited ${r.status}`}).`);
|
|
236
|
-
print(
|
|
262
|
+
print(`unsnooze: try it manually: ${command}`);
|
|
237
263
|
print('unsnooze: (permission errors usually mean your npm prefix needs sudo or a user-writable prefix)');
|
|
238
264
|
return r.status || 1;
|
|
239
265
|
}
|
|
240
|
-
let newVersion
|
|
266
|
+
let newVersion;
|
|
241
267
|
try {
|
|
242
|
-
newVersion = JSON.parse(readFileSync(join(
|
|
268
|
+
newVersion = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version;
|
|
243
269
|
} catch { /* moved install dir — the generic message below still holds */ }
|
|
244
|
-
|
|
245
|
-
|
|
270
|
+
// npm exited 0 but this copy is unchanged, while the registry check already
|
|
271
|
+
// knows a newer release: most likely npm installed it into another prefix.
|
|
272
|
+
const { latest } = readCache();
|
|
273
|
+
if (newVersion === current && isNewer(latest, current)) {
|
|
274
|
+
print(`unsnooze: npm finished, but this copy is still ${current} and ${latest} is out.`);
|
|
275
|
+
print(`unsnooze: npm may have installed it into its own prefix (\`npm prefix -g\`) instead of ${root}.`);
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
newVersion ??= current;
|
|
279
|
+
if (newVersion === current) {
|
|
280
|
+
print(`unsnooze: already up to date (${current}).`);
|
|
246
281
|
} else {
|
|
247
282
|
const section = changelogSection(newVersion);
|
|
248
283
|
print(`unsnooze: updated to ${newVersion}.${section ? ` What's new:\n${section}` : ''}`);
|
package/src/watcher.js
CHANGED
|
@@ -18,7 +18,7 @@ import { join, sep, basename, dirname } from 'node:path';
|
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
19
|
import {
|
|
20
20
|
CLAUDE_DIR, CODEX_DIR, WATCH_OFFSETS_FILE, WATCH_FRESHNESS_MS,
|
|
21
|
-
RESET_MARGIN_MS, PROBE_INTERVAL_MS,
|
|
21
|
+
RESET_MARGIN_MS, PROBE_INTERVAL_MS, isPrimeDir,
|
|
22
22
|
} from './config.js';
|
|
23
23
|
import { getMultiplexer } from './multiplexer.js';
|
|
24
24
|
import { parseTranscriptLine } from './watchers/claude.js';
|
|
@@ -170,6 +170,9 @@ export function defaultSources() {
|
|
|
170
170
|
// MAX_RESUME_ATTEMPTS cap could never bind — and a cancelled record stays
|
|
171
171
|
// cancelled.
|
|
172
172
|
export function dispatchCandidate(c) {
|
|
173
|
+
// A window prime's own rollout (prime.js runs codex in PRIME_DIR) hitting a
|
|
174
|
+
// limit is not a session — reviving it would spend quota on nothing.
|
|
175
|
+
if (isPrimeDir(c.cwd)) return;
|
|
173
176
|
const detectedAt = c.timestampMs || Date.now();
|
|
174
177
|
const bannerAt = c.timestampMs || null;
|
|
175
178
|
let at, source;
|