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 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.19.4",
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
- if (!hookInstalled()) {
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.TMUX_PANE || payload.tmux_pane) || null;
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,
@@ -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
- export function createTmux({ spawner = defaultSpawner, env = process.env } = {}) {
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.TMUX_PANE || null;
109
+ return tmuxPaneFromEnv(env);
73
110
  },
74
111
 
75
112
  async capturePane(pane, lines = 200) {
76
- return run('capture-pane', '-t', pane, '-p', '-S', `-${lines}`);
113
+ return onPane(pane, 'capture-pane', '-p', '-S', `-${lines}`);
77
114
  },
78
115
 
79
116
  async capturePaneVisible(pane) {
80
- return run('capture-pane', '-t', pane, '-p');
117
+ return onPane(pane, 'capture-pane', '-p');
81
118
  },
82
119
 
83
120
  async sendText(pane, text) {
84
- await run('send-keys', '-t', pane, '-l', text);
121
+ await onPane(pane, 'send-keys', '-l', text);
85
122
  await new Promise(resolve => setTimeout(resolve, SUBMIT_DELAY_MS));
86
- await run('send-keys', '-t', pane, 'Enter');
123
+ await onPane(pane, 'send-keys', 'Enter');
87
124
  },
88
125
 
89
126
  async sendKey(pane, key) {
90
- await run('send-keys', '-t', pane, key);
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 evidence — the output must echo the
97
- // pane id back.
98
- const out = await run('display-message', '-t', pane, '-p', '#{pane_id}');
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 run('display-message', '-t', pane, '-p', '#{session_name}')).trim();
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 run('display-message', '-t', pane, '-p', '#{pane_tty}')).trim();
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 run('display-message', '-t', pane, '-p', '#{pane_current_command}')).trim();
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 run('set-option', '-p', '-t', pane, '@unsnooze_owner', String(leaseId));
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 run('display-message', '-t', pane, '-p', '#{@unsnooze_owner}')).trim();
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', '#{pane_id}');
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), candidate => {
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
  }
@@ -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
- // `unsnooze update` — self-update via npm, then show what changed. npm -g
229
- // overwrites this install in place, so re-reading package.json/CHANGELOG.md
230
- // after a successful install yields the NEW version's info.
231
- export function runSelfUpdate({ runner = spawnSync, print = console.log } = {}) {
232
- print(`unsnooze ${PKG_VERSION} — updating via npm install -g unsnooze@latest …`);
233
- const r = runner('npm', ['install', '-g', 'unsnooze@latest'], { stdio: 'inherit' });
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('unsnooze: try it manually: npm install -g unsnooze@latest');
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 = PKG_VERSION;
266
+ let newVersion;
241
267
  try {
242
- newVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
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
- if (newVersion === PKG_VERSION) {
245
- print(`unsnooze: already up to date (${PKG_VERSION}).`);
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;