unsnooze 1.19.1 → 1.19.3

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,113 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.3 — 2026-09-30
4
+
5
+ Codex fixes from a single herdr report: stops that were missed, woken late,
6
+ or woken in the wrong place.
7
+
8
+ - The out-of-credits screen on Plus plans no longer hides a usage limit. Its
9
+ Upgrade / Add Credits picker pushed the banner off the part of the pane
10
+ unsnooze reads; the "Your rate limit resets on" line is now recognised
11
+ instead (#36).
12
+ - A reset time that a narrow pane wraps onto the next row is read correctly,
13
+ instead of falling back to guessing and probing (#33).
14
+ - A Codex stop is only marked resumed once its session log shows new output.
15
+ A keypress or tab switch that briefly hides the banner no longer drops the
16
+ stop and its exact reset time. Stops with no session log keep the old
17
+ behaviour (#34).
18
+ - A session running in herdr (or any non-default multiplexer) keeps its pane
19
+ address when a second detection of the same stop comes in, so it is woken
20
+ in its own pane instead of reopened beside it (#35).
21
+
22
+ ## 1.19.2 — 2026-09-27
23
+
24
+ Setup on Windows no longer creates a Scheduled Task. That task is what
25
+ Microsoft Defender flagged as a Trojan.
26
+
27
+ ### Website redesign
28
+
29
+ - The new night-shift design now covers the homepage, documentation, changelog,
30
+ and feedback page, with responsive layouts and reduced-motion support.
31
+ - Release metadata refreshes every five minutes so the site follows published
32
+ npm versions. Failed feedback-board reads now show an error instead of
33
+ loading indefinitely.
34
+ - Windows setup and troubleshooting docs describe the on-demand daemon.
35
+
36
+ ### Codex wake recovery
37
+
38
+ - Team/business stops with a null credits balance now retain the exhausted
39
+ five-hour window's reset instead of being held as a workspace credit wall.
40
+ - If Codex consumes Enter as a pasted newline, verification retries Enter
41
+ up to three times while the exact wake prompt remains in the composer.
42
+ Each retry rechecks pane ownership and agent liveness. The old banner is
43
+ not re-recorded as a new stop, and the wake text is never pasted twice.
44
+ An unconfirmed submission is left visible for manual inspection.
45
+
46
+ ### No Scheduled Task on Windows
47
+
48
+ A user reported that Microsoft Defender flagged `unsnooze setup` as
49
+ `Trojan:Win32/Commando.A!ml` as it created the `\unsnooze` Scheduled Task. It
50
+ was a false positive, but an understandable one. Setup ran
51
+ `schtasks /create /f /sc onlogon /tr "node.exe …\AppData\Roaming\npm\…\unsnooze.js daemon"`:
52
+ an interpreter told to run a script out of `%APPDATA%` at every logon, which is
53
+ exactly how malware persists, and `Commando.A!ml` is Defender's
54
+ machine-learning verdict on command lines like that one. A Run key or a
55
+ Startup-folder entry reads the same way, so the daemon uses none of them now.
56
+ The task rarely worked anyway: from a shell that is not elevated, `schtasks`
57
+ refuses an `onlogon` task with "Access is denied", and setup said it was
58
+ installed regardless.
59
+
60
+ On Windows the daemon is now an ordinary background process:
61
+
62
+ - **Setup starts it.** Saying yes to guarding GUI sessions (or running
63
+ `unsnooze install --daemon`) starts the daemon at once and records that
64
+ choice in `~/.unsnooze/daemon-on-demand`. Nothing is registered with Windows.
65
+ - **It is started again on demand.** Every agent launched through the
66
+ PowerShell wrappers, and every Claude Code StopFailure hook, starts the
67
+ daemon if it is not running. After a restart it is back as soon as you use
68
+ an agent.
69
+ - **One runs at a time.** The daemon keeps a heartbeat pidfile
70
+ (`~/.unsnooze/daemon.pid`), and a second daemon stands down at once. Once the
71
+ heartbeat is five minutes old the file no longer counts, whoever its pid
72
+ belongs to by then, so a pid Windows recycles after a sign-out cannot keep
73
+ the daemon from starting. A resumer lock left behind by a daemon that was
74
+ killed is cleared too, so it cannot stall every resume.
75
+ - **It carries no session with it.** Started from inside a Claude Code
76
+ session (by the hook), it used to inherit that session's environment,
77
+ including `CLAUDECODE=1`, Claude Code's nested-session guard. Every Claude
78
+ session it revived would then have refused to start. Background processes
79
+ now drop the variables that mark one session; your own settings, such as
80
+ `CLAUDE_CONFIG_DIR` and `CLAUDE_CODE_USE_BEDROCK`, are kept. They also run in
81
+ your home directory, not the project that happened to start them, which
82
+ Windows would otherwise refuse to delete or rename until sign-out. The
83
+ transient resumer the hook starts gets the same treatment, on every
84
+ platform.
85
+ - **No console windows.** A background daemon has no console. The `git` calls
86
+ it makes when a session stops and wakes, and the agent a revival starts,
87
+ now run hidden instead of each popping up a window of its own.
88
+ - **Its output goes to `~/.unsnooze/daemon.log`**, as the launchd daemon's
89
+ does, so a crash leaves a trace.
90
+ - **Re-running setup restarts it.** `unsnooze install --daemon` stops the
91
+ running daemon and starts a fresh one. That is how it picks up a new
92
+ version, or a `PATH` that now finds an agent, and `unsnooze doctor` now
93
+ says so instead of pointing at `schtasks`.
94
+
95
+ If you use only the Codex app or IDE on Windows, nothing you do passes through
96
+ a wrapper or the hook. After signing in, run `unsnooze install --daemon` (or
97
+ launch any wrapped agent) to start the daemon.
98
+
99
+ `unsnooze uninstall` removes the marker first, then stops the daemon, so
100
+ nothing can start it again halfway through; a daemon that was only starting at
101
+ that moment sees the marker gone and exits. It also deletes a `\unsnooze`
102
+ Scheduled Task left by an earlier version, after checking that one exists, so
103
+ a machine that never had one only ever sees a read-only query. If the delete
104
+ is refused (a task created from an administrator shell can need one to
105
+ remove), it prints the command to run. Setup does not go looking for that
106
+ task: that would mean running `schtasks` again. A daemon such a task already
107
+ started keeps running until you sign out; from the next sign-in the task only
108
+ starts the same on-demand daemon, which the pidfile keeps to one. macOS and
109
+ Linux are unchanged (launchd / systemd).
110
+
3
111
  ## 1.19.1 — 2026-09-21
4
112
 
5
113
  A Codex usage reading that was averaged down, a headless Codex revival
package/README.md CHANGED
@@ -265,7 +265,8 @@ the continued conversation stays visible in the GUI's own history. (Resuming
265
265
  *inside* the GUI panel isn't possible today: no extension/app exposes an
266
266
  IPC/URI that can send a prompt.)
267
267
 
268
- Enable it in `unsnooze setup` (installs a launchd agent / systemd user unit),
268
+ Enable it in `unsnooze setup` (installs a launchd agent / systemd user unit;
269
+ on Windows the daemon is started on demand instead — see [Windows](#windows)),
269
270
  or run `unsnooze install --daemon` / `unsnooze daemon` yourself. Turn it off
270
271
  anytime with `unsnooze config set guiWatch off`.
271
272
 
@@ -384,7 +385,8 @@ unsnooze config set <k> <v> # e.g. autoResume off
384
385
  unsnooze logs [-f] # what unsnooze has been doing
385
386
  unsnooze update # update unsnooze itself
386
387
  unsnooze daemon # persistent GUI-session watcher (usually run
387
- # by launchd/systemd via `install --daemon`)
388
+ # by launchd/systemd via `install --daemon`;
389
+ # on Windows, started on demand)
388
390
  unsnooze report [agent] # capture a pane to report an undetected banner
389
391
  unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
390
392
  unsnooze help # full command list (also -h / --help)
@@ -767,17 +769,26 @@ Windows, WSL is still the better home.
767
769
  ### Windows
768
770
 
769
771
  Native Windows works: PowerShell wrappers, a cmd-safe StopFailure hook, and a
770
- logon-triggered Scheduled Task for the daemon. Install as usual and run
771
- `unsnooze doctor` to confirm.
772
-
773
- Three honest caveats. The daemon is started at logon but not restarted if it
774
- dies — Task Scheduler is not a supervisor the way launchd and systemd are — so
775
- after upgrading unsnooze, log out and back in (or run `unsnooze daemon`
776
- yourself). The daemon also keeps the `PATH` it was born with: an agent whose
777
- install directory changes on update (the Codex Desktop/Store runtime lives
778
- under `%LOCALAPPDATA%\OpenAI\Codex\bin\<hash>`) can vanish from the daemon's
779
- view while your shell still finds it — unsnooze looks that directory up at
780
- launch time, `unsnooze doctor` prints what each agent resolves to, and
772
+ daemon started on demand. Install as usual and run `unsnooze doctor` to confirm.
773
+
774
+ unsnooze registers nothing to run at sign-in on Windows — no Scheduled Task, Run
775
+ key or Startup-folder entry. (Up to 1.19.1 it created a logon Scheduled Task,
776
+ and Microsoft Defender flagged setup as `Trojan:Win32/Commando.A!ml` for it;
777
+ `unsnooze uninstall` removes one left behind.)
778
+ Setup starts the daemon instead, and every agent you launch from PowerShell and
779
+ every Claude Code StopFailure hook starts it again whenever it is not running,
780
+ so after a restart it is back as soon as you use an agent. If you only use the
781
+ Codex app or IDE, nothing you do passes through a wrapper or the hook: run
782
+ `unsnooze install --daemon` after signing in to start it.
783
+
784
+ Three honest caveats. Nothing supervises the daemon the way launchd and systemd
785
+ do, and a running daemon keeps the code it started with, so after upgrading
786
+ unsnooze re-run `unsnooze install --daemon` (or sign out and back in). The
787
+ daemon also keeps the `PATH` it was born with: an agent whose install directory
788
+ changes on update (the Codex Desktop/Store runtime lives under
789
+ `%LOCALAPPDATA%\OpenAI\Codex\bin\<hash>`) can vanish from the daemon's view
790
+ while your shell still finds it — unsnooze looks that directory up at launch
791
+ time, `unsnooze doctor` prints what each agent resolves to, and
781
792
  `UNSNOOZE_CODEX_BIN` (or `UNSNOOZE_CLAUDE_BIN`, …) pins a path; point it at an
782
793
  `.exe`, since Node cannot launch a `.cmd` shim directly. And stored-password
783
794
  fleet hosts still need Git-for-Windows or WSL `ssh`; native `ssh.exe` requires
package/bin/unsnooze.js CHANGED
@@ -223,81 +223,107 @@ async function main() {
223
223
  case 'daemon': {
224
224
  // Persistent resumer + transcript watcher: detects and revives limit
225
225
  // stops from GUI surfaces (VS Code extension, desktop apps) where no
226
- // shell wrapper or multiplexer pane exists. Run via launchd/systemd or a shell.
226
+ // shell wrapper or multiplexer pane exists. Run via launchd/systemd, on
227
+ // demand on native Windows (spawn.js ensureDaemon), or from a shell.
227
228
  // Exit 0 on load failure: with launchd KeepAlive a crash here means an
228
229
  // instant-respawn crash-loop for the whole upgrade window.
229
230
  const resumerMod = await safeImport('../src/resumer.js');
230
231
  const watcherMod = await safeImport('../src/watcher.js');
231
- if (!resumerMod || !watcherMod) return 0;
232
+ const spawnMod = await safeImport('../src/spawn.js');
233
+ if (!resumerMod || !watcherMod || !spawnMod) return 0;
232
234
  const { runResumer } = resumerMod;
233
235
  const { createWatcher } = watcherMod;
234
236
  const controller = new AbortController();
235
237
  process.on('SIGTERM', () => controller.abort());
236
238
  process.on('SIGINT', () => controller.abort());
237
- // Self-heal pre-1.12 autostart units: they lack PATH, so this daemon
238
- // cannot find tmux and every revival dies. Healing rewrites the unit
239
- // and reloads it — which intentionally kills THIS process; the
240
- // supervisor restarts us under the fixed unit. One-time: healed units
241
- // pass the check forever after.
242
- const installMod = await safeImport('../src/install.js');
243
- if (installMod?.healDaemonAutostart) {
244
- try {
245
- const healed = installMod.healDaemonAutostart();
246
- if (healed) {
247
- const lm = await safeImport('../src/logger.js');
248
- lm?.log('daemon', `autostart unit lacked PATH — regenerated ${healed}, reloading (self-heal)`);
249
- }
250
- } catch { /* heal is best-effort — a broken unit must not block the daemon */ }
251
- }
252
- // daemon.log is launchd/systemd-captured stdout+stderr. launchd holds
253
- // an open fd on it for our whole lifetime, so rotation must be
254
- // copy-truncate: renaming would leave launchd appending to the renamed
255
- // inode forever, with no fresh daemon.log until the next respawn.
256
- const loggerMod = await safeImport('../src/logger.js');
257
- const configMod = await safeImport('../src/config.js');
258
- if (loggerMod && configMod) {
259
- const { join } = await import('node:path');
260
- loggerMod.copyTruncateIfLarge(join(configMod.STATE_DIR, 'daemon.log'));
239
+ // Native Windows has no launchd/systemd to keep this a single instance,
240
+ // and the wrappers and the hook can both start one at once. Claim the
241
+ // pidfile before doing anything else: a daemon that finds a live one
242
+ // already running exits here, and one that later finds itself replaced
243
+ // stands down. --on-demand (how ensureDaemon starts it) also ties the
244
+ // daemon to its marker, so uninstall ends it rather than sign-out.
245
+ const onDemand = rest.includes('--on-demand');
246
+ let releaseDaemon = null;
247
+ if (process.platform === 'win32') {
248
+ const wanted = onDemand ? () => spawnMod.onDemandWanted() : () => true;
249
+ if (!wanted()) return 0; // uninstalled between the start and now
250
+ releaseDaemon = spawnMod.claimDaemon({ onLost: () => controller.abort(), wanted });
251
+ if (!releaseDaemon) {
252
+ // Reaches a terminal only when a user ran `unsnooze daemon` by hand;
253
+ // an on-demand start has nowhere to print.
254
+ const pid = spawnMod.runningDaemonPid();
255
+ console.error(`unsnooze: the daemon is already running${pid ? ` (pid ${pid})` : ''} — not starting another`);
256
+ return 0;
257
+ }
261
258
  }
262
- // Version-skew guard: when npm swaps the package underneath us, exit
263
- // cleanly so launchd/systemd restart the daemon on the fresh code.
264
- // Interval stays at 15 minutes on purpose — a tighter loop raises the
265
- // odds of restarting INTO a half-installed package, which is the
266
- // crash-loop the unit throttles exist to survive.
267
- // Exiting is only safe under a supervisor. `unsnooze daemon` run from a
268
- // shell has nothing to restart it, so there it warns and keeps watching
269
- // rather than ending GUI watching silently.
270
- const updMod = await safeImport('../src/update-check.js');
271
- const notifyMod = await safeImport('../src/notify.js');
272
- if (updMod?.hasVersionSkew) {
273
- let warnedSkew = false;
274
- setInterval(() => {
275
- const action = updMod.daemonSkewAction({
276
- skewed: updMod.hasVersionSkew(),
277
- // Unknown means assume supervised: that is how the daemon is
278
- // installed for all but a handful of users, and it preserves the
279
- // long-standing behavior when install.js could not be loaded.
280
- supervised: installMod?.isSupervised ? installMod.isSupervised() : true,
281
- alreadyWarned: warnedSkew,
282
- });
283
- if (action === 'restart') controller.abort();
284
- else if (action === 'warn') {
285
- warnedSkew = true;
286
- const msg = 'package was upgraded on disk but this daemon is not supervised — '
287
- + 'still running the old code; restart it to pick up the new version';
288
- loggerMod?.log('daemon', msg);
289
- notifyMod?.notify('unsnooze needs a restart', msg, { priority: 4 });
290
- }
291
- }, 15 * 60_000).unref();
259
+ // From the claim on, every way out gives it back.
260
+ try {
261
+ // Self-heal pre-1.12 autostart units: they lack PATH, so this daemon
262
+ // cannot find tmux and every revival dies. Healing rewrites the unit
263
+ // and reloads it — which intentionally kills THIS process; the
264
+ // supervisor restarts us under the fixed unit. One-time: healed units
265
+ // pass the check forever after.
266
+ const installMod = await safeImport('../src/install.js');
267
+ if (installMod?.healDaemonAutostart) {
268
+ try {
269
+ const healed = installMod.healDaemonAutostart();
270
+ if (healed) {
271
+ const lm = await safeImport('../src/logger.js');
272
+ lm?.log('daemon', `autostart unit lacked PATH — regenerated ${healed}, reloading (self-heal)`);
273
+ }
274
+ } catch { /* heal is best-effort — a broken unit must not block the daemon */ }
275
+ }
276
+ // daemon.log is launchd/systemd-captured stdout+stderr. launchd holds
277
+ // an open fd on it for our whole lifetime, so rotation must be
278
+ // copy-truncate: renaming would leave launchd appending to the renamed
279
+ // inode forever, with no fresh daemon.log until the next respawn.
280
+ const loggerMod = await safeImport('../src/logger.js');
281
+ const configMod = await safeImport('../src/config.js');
282
+ if (loggerMod && configMod) {
283
+ const { join } = await import('node:path');
284
+ loggerMod.copyTruncateIfLarge(join(configMod.STATE_DIR, 'daemon.log'));
285
+ }
286
+ // Version-skew guard: when npm swaps the package underneath us, exit
287
+ // cleanly so launchd/systemd restart the daemon on the fresh code.
288
+ // Interval stays at 15 minutes on purpose — a tighter loop raises the
289
+ // odds of restarting INTO a half-installed package, which is the
290
+ // crash-loop the unit throttles exist to survive.
291
+ // Exiting is only safe under a supervisor. `unsnooze daemon` run from a
292
+ // shell has nothing to restart it, so there it warns and keeps watching
293
+ // rather than ending GUI watching silently.
294
+ const updMod = await safeImport('../src/update-check.js');
295
+ const notifyMod = await safeImport('../src/notify.js');
296
+ if (updMod?.hasVersionSkew) {
297
+ let warnedSkew = false;
298
+ setInterval(() => {
299
+ const action = updMod.daemonSkewAction({
300
+ skewed: updMod.hasVersionSkew(),
301
+ // Unknown means assume supervised: that is how the daemon is
302
+ // installed for all but a handful of users, and it preserves the
303
+ // long-standing behavior when install.js could not be loaded.
304
+ supervised: installMod?.isSupervised ? installMod.isSupervised() : true,
305
+ alreadyWarned: warnedSkew,
306
+ });
307
+ if (action === 'restart') controller.abort();
308
+ else if (action === 'warn') {
309
+ warnedSkew = true;
310
+ const msg = 'package was upgraded on disk but this daemon is not supervised — '
311
+ + 'still running the old code; '
312
+ + (onDemand ? 'run `unsnooze install --daemon` to restart it' : 'restart it to pick up the new version');
313
+ loggerMod?.log('daemon', msg);
314
+ notifyMod?.notify('unsnooze needs a restart', msg, { priority: 4 });
315
+ }
316
+ }, 15 * 60_000).unref();
317
+ }
318
+ // Daily update check from the daemon: GUI-only users never run CLI
319
+ // commands, so this is what gets them the "new version" desktop toast.
320
+ const { spawnDetached } = spawnMod;
321
+ spawnDetached(['_update-check']);
322
+ setInterval(() => spawnDetached(['_update-check']), 24 * 3_600_000).unref();
323
+ return await runResumer({ persistent: true, watcher: createWatcher(), signal: controller.signal });
324
+ } finally {
325
+ releaseDaemon?.();
292
326
  }
293
- // Daily update check from the daemon: GUI-only users never run CLI
294
- // commands, so this is what gets them the "new version" desktop toast.
295
- const spawnMod = await safeImport('../src/spawn.js');
296
- if (!spawnMod) return 0;
297
- const { spawnDetached } = spawnMod;
298
- spawnDetached(['_update-check']);
299
- setInterval(() => spawnDetached(['_update-check']), 24 * 3_600_000).unref();
300
- return runResumer({ persistent: true, watcher: createWatcher(), signal: controller.signal });
301
327
  }
302
328
  case 'help':
303
329
  case '-h':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.19.1",
3
+ "version": "1.19.3",
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",
@@ -14,6 +14,7 @@ import { openSync, readSync, closeSync, readdirSync, statSync, existsSync } from
14
14
  import { join, basename } from 'node:path';
15
15
  import { CODEX_DIR } from '../config.js';
16
16
  import { findOnPath } from '../which.js';
17
+ import { stripVTControlCharacters } from 'node:util';
17
18
 
18
19
  // Since the 2026 unified ChatGPT desktop app absorbed the Codex app, the codex
19
20
  // binary ships INSIDE the app bundle and many machines have no standalone
@@ -77,13 +78,19 @@ const LIMIT_ANCHORS = [
77
78
  /You['’]ve hit your usage limit/i, // the desktop app also emits a curly apostrophe
78
79
  /Your workspace is out of credits/i,
79
80
  /hit your spend cap/i,
81
+ // Plus plan: an out-of-credits picker sits under the banner and can push it
82
+ // past the 12-line scan (#36). This line stays inside it and carries the time.
83
+ /Your rate limit resets on/i,
80
84
  ];
81
85
 
82
86
  export const patterns = {
83
87
  limitPatterns: LIMIT_ANCHORS,
84
88
  // The whole banner is ONE line, so the anchors double as reset lines — the
85
89
  // proximity engine then hands that line to time-parser (which falls back to
86
- // the 5h default for "Try again later.").
90
+ // the 5h default for "Try again later."). A narrow pane soft-wraps that
91
+ // line, which can strand the clock on a later row; bannerWraps tells the
92
+ // engine to rejoin the rows before parsing.
93
+ bannerWraps: true,
87
94
  resetPatterns: [
88
95
  /try again at/i,
89
96
  /try again in \d+/i,
@@ -101,6 +108,26 @@ export const patterns = {
101
108
  transientPatterns: [/stream error/i, /exceeded retry limit/i],
102
109
  };
103
110
 
111
+ // A submitted user turn uses the same › glyph as the composer. Only accept
112
+ // the LAST prompt, followed directly by a blank line and Codex's status footer.
113
+ // ponytail: truncated/collapsed pastes cannot be proved; leave those for the user.
114
+ export function pendingCodexPrompt(text, message) {
115
+ if (!message) return false;
116
+ const lines = stripVTControlCharacters(text).split('\n');
117
+ const start = lines.findLastIndex(line => /^\s*›/.test(line));
118
+ if (start < 0) return false;
119
+ const tail = lines.slice(start + 1);
120
+ const footer = tail.findIndex((line, i) => i > 0 && !tail[i - 1].trim()
121
+ && (/\s·\s/.test(line) || /(?:\d+% context left|\? for shortcuts)/.test(line)));
122
+ if (footer < 0) return false;
123
+ if (tail.slice(footer + 1).some(line => line.trim()
124
+ && !/(?:\d+% context left|\? for shortcuts)/.test(line))) return false;
125
+ // Ignore wrapping and the extra newline swallowed by paste-burst handling.
126
+ const compact = value => value.replace(/\s/g, '');
127
+ const draft = [lines[start].replace(/^\s*›\s?/, ''), ...tail.slice(0, footer)].join('\n');
128
+ return compact(draft) === compact(message);
129
+ }
130
+
104
131
  // Sessions live in ~/.codex/sessions/YYYY/MM/DD/rollout-{ts}-{THREAD}.jsonl;
105
132
  // the first JSONL line carries the session cwd. Conservative: no cwd match →
106
133
  // null (the resumer then uses `codex resume --last`, which codex itself scopes
@@ -164,6 +191,30 @@ export function latestSessionId(cwd, aroundTs = null, sessionsRoot = join(CODEX_
164
191
  return null;
165
192
  }
166
193
 
194
+ // The rollout file a thread is writing to now: the newest of its files. A
195
+ // reverted thread continues in a second `…_<rollout id>` file, which is where
196
+ // a resumed turn lands.
197
+ export function rolloutPathFor(sessionId, sessionsRoot = join(CODEX_DIR, 'sessions')) {
198
+ if (!sessionId) return null;
199
+ const want = String(sessionId).toLowerCase();
200
+ let best = null;
201
+ const walk = (dir, depth) => {
202
+ let entries;
203
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
204
+ for (const e of entries) {
205
+ const p = join(dir, e.name);
206
+ if (e.isDirectory() && depth < 3) { walk(p, depth + 1); continue; }
207
+ const m = e.isFile() && e.name.match(ROLLOUT_RE);
208
+ if (!m || m[1].toLowerCase() !== want) continue;
209
+ let mtime;
210
+ try { mtime = statSync(p).mtimeMs; } catch { continue; }
211
+ if (!best || mtime > best.mtime) best = { path: p, mtime };
212
+ }
213
+ };
214
+ walk(sessionsRoot, 0);
215
+ return best ? best.path : null;
216
+ }
217
+
167
218
  export default {
168
219
  id: 'codex',
169
220
  name: 'OpenAI Codex CLI',
@@ -51,10 +51,11 @@ export const patterns = {
51
51
  terminalPatterns: [/insufficient credits/i, /out of credits/i],
52
52
  };
53
53
 
54
+ // windowsHide: the console-less daemon calls this too (see workspace.js).
54
55
  function runSessionList() {
55
56
  return execFileSync(process.env.UNSNOOZE_OPENCODE_BIN || 'opencode',
56
57
  ['session', 'list', '--format', 'json'],
57
- { timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
58
+ { timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
58
59
  }
59
60
 
60
61
  // `opencode session list --format json` — sessions live in a SQLite db, so ask
package/src/config.js CHANGED
@@ -258,9 +258,24 @@ export const MUX_NAMES = ['tmux', 'zellij', 'herdr', 'cmux', 'headless'];
258
258
  // Where a headless revive tees the agent's output. There is no pane to scroll
259
259
  // back through, so the log is the only record of what an unattended run did.
260
260
  export const HEADLESS_LOG_DIR = join(STATE_DIR, 'headless');
261
- // The daemon's Windows Task Scheduler entry (install.js creates it; doctor names it).
261
+ // The logon Scheduled Task that unsnooze <= 1.19.1 registered for the daemon.
262
+ // Nothing creates it any more (see DAEMON_ON_DEMAND); uninstall still removes
263
+ // one an older install left behind.
262
264
  export const WINDOWS_TASK_NAME = 'unsnooze';
263
265
 
266
+ // The daemon on native Windows. There is no launchd/systemd to hand it to, and
267
+ // every way of starting a program at sign-in — a Scheduled Task, a Run key, the
268
+ // Startup folder — is what antivirus heuristics are trained to call malware
269
+ // persistence: Microsoft Defender flagged `schtasks /create /sc onlogon` as
270
+ // Trojan:Win32/Commando.A!ml. So nothing is registered with Windows. This
271
+ // marker is the whole record (install --daemon writes it), and while it exists
272
+ // the wrappers and the StopFailure hook start the daemon whenever it is not
273
+ // running.
274
+ export const DAEMON_ON_DEMAND = join(STATE_DIR, 'daemon-on-demand');
275
+ // Which daemon is running (Windows only). Its mtime is a heartbeat, so a pid
276
+ // that Windows recycled after a crash or a sign-out is not mistaken for it.
277
+ export const DAEMON_PIDFILE = join(STATE_DIR, 'daemon.pid');
278
+
264
279
  // Pane scanning
265
280
  export const PANE_SCAN_LINES = envInt('UNSNOOZE_PANE_SCAN_LINES', 12);
266
281
  export const CAPTURE_LINES = envInt('UNSNOOZE_CAPTURE_LINES', 200);
package/src/doctor.js CHANGED
@@ -20,7 +20,7 @@ import { fishConfigPath } from './fish.js';
20
20
  import { listAgents } from './agents/index.js';
21
21
  import { getConfig } from './settings.js';
22
22
  import { resolveBin } from './which.js';
23
- import { WINDOWS_TASK_NAME, CLAUDE_DIR, CODEX_DIR } from './config.js';
23
+ import { CLAUDE_DIR, CODEX_DIR } from './config.js';
24
24
 
25
25
  const log = makeLogger('doctor');
26
26
 
@@ -345,12 +345,11 @@ export function agentBinFindings({
345
345
  } = {}) {
346
346
  const findings = [];
347
347
  const envVar = id => `UNSNOOZE_${String(id).toUpperCase()}_BIN`;
348
- // Two commands, not `a && b`: && only exists from PowerShell 7, and the
348
+ // One command, never `a && b`: && only exists from PowerShell 7, and the
349
349
  // Windows PowerShell 5.1 that ships with the OS is a supported shell.
350
350
  const windowsNote = platform === 'win32'
351
- ? `\n the daemon's Scheduled Task keeps the PATH it had at logon; after an agent update, run\n`
352
- + ` schtasks /end /tn ${WINDOWS_TASK_NAME}\n`
353
- + ` schtasks /run /tn ${WINDOWS_TASK_NAME} (or sign out and in)`
351
+ ? `\n the daemon keeps the PATH of whatever started it; once this shell finds the agent,\n`
352
+ + ` restart it from here with: unsnooze install --daemon (or sign out and back in)`
354
353
  : '';
355
354
  for (const agent of agents) {
356
355
  if (!agent?.id || !enabled(agent.id)) continue;
package/src/hook.js CHANGED
@@ -16,7 +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 { spawnResumerIfNeeded } from './spawn.js';
19
+ import { spawnResumerIfNeeded, ensureDaemon } from './spawn.js';
20
20
  import { makeLogger } from './logger.js';
21
21
  import { addressHash } from './lease.js';
22
22
  import { prepareCalibrationSample, applyCalibrationToState } from './usage.js';
@@ -56,11 +56,15 @@ export function hookContext(env = process.env, payload = {}) {
56
56
  return { muxName, pane, paneOwner };
57
57
  }
58
58
 
59
- export async function runHook(rest = []) {
59
+ export async function runHook(rest = [], { ensureDaemonFn = ensureDaemon } = {}) {
60
60
  try {
61
61
  const agentIdx = rest.indexOf('--agent');
62
62
  const agent = getAgent(agentIdx !== -1 ? rest[agentIdx + 1] : 'claude');
63
63
  if (!getConfig(`agents.${agent.id}`)) return 0; // agent disabled in settings
64
+ // Nothing keeps the daemon running on native Windows (see ensureDaemon),
65
+ // and the hook is the one entry point GUI Claude sessions pass through —
66
+ // so it makes sure the daemon is up. A no-op everywhere else.
67
+ ensureDaemonFn();
64
68
  const raw = await readStdin();
65
69
  let payload = {};
66
70
  try { payload = JSON.parse(raw); } catch { /* tolerate non-JSON */ }