unsnooze 1.19.1 → 1.19.2

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,94 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.2 — 2026-09-27
4
+
5
+ Setup on Windows no longer creates a Scheduled Task. That task is what
6
+ Microsoft Defender flagged as a Trojan.
7
+
8
+ ### Website redesign
9
+
10
+ - The new night-shift design now covers the homepage, documentation, changelog,
11
+ and feedback page, with responsive layouts and reduced-motion support.
12
+ - Release metadata refreshes every five minutes so the site follows published
13
+ npm versions. Failed feedback-board reads now show an error instead of
14
+ loading indefinitely.
15
+ - Windows setup and troubleshooting docs describe the on-demand daemon.
16
+
17
+ ### Codex wake recovery
18
+
19
+ - Team/business stops with a null credits balance now retain the exhausted
20
+ five-hour window's reset instead of being held as a workspace credit wall.
21
+ - If Codex consumes Enter as a pasted newline, verification retries Enter
22
+ up to three times while the exact wake prompt remains in the composer.
23
+ Each retry rechecks pane ownership and agent liveness. The old banner is
24
+ not re-recorded as a new stop, and the wake text is never pasted twice.
25
+ An unconfirmed submission is left visible for manual inspection.
26
+
27
+ ### No Scheduled Task on Windows
28
+
29
+ A user reported that Microsoft Defender flagged `unsnooze setup` as
30
+ `Trojan:Win32/Commando.A!ml` as it created the `\unsnooze` Scheduled Task. It
31
+ was a false positive, but an understandable one. Setup ran
32
+ `schtasks /create /f /sc onlogon /tr "node.exe …\AppData\Roaming\npm\…\unsnooze.js daemon"`:
33
+ an interpreter told to run a script out of `%APPDATA%` at every logon, which is
34
+ exactly how malware persists, and `Commando.A!ml` is Defender's
35
+ machine-learning verdict on command lines like that one. A Run key or a
36
+ Startup-folder entry reads the same way, so the daemon uses none of them now.
37
+ The task rarely worked anyway: from a shell that is not elevated, `schtasks`
38
+ refuses an `onlogon` task with "Access is denied", and setup said it was
39
+ installed regardless.
40
+
41
+ On Windows the daemon is now an ordinary background process:
42
+
43
+ - **Setup starts it.** Saying yes to guarding GUI sessions (or running
44
+ `unsnooze install --daemon`) starts the daemon at once and records that
45
+ choice in `~/.unsnooze/daemon-on-demand`. Nothing is registered with Windows.
46
+ - **It is started again on demand.** Every agent launched through the
47
+ PowerShell wrappers, and every Claude Code StopFailure hook, starts the
48
+ daemon if it is not running. After a restart it is back as soon as you use
49
+ an agent.
50
+ - **One runs at a time.** The daemon keeps a heartbeat pidfile
51
+ (`~/.unsnooze/daemon.pid`), and a second daemon stands down at once. Once the
52
+ heartbeat is five minutes old the file no longer counts, whoever its pid
53
+ belongs to by then, so a pid Windows recycles after a sign-out cannot keep
54
+ the daemon from starting. A resumer lock left behind by a daemon that was
55
+ killed is cleared too, so it cannot stall every resume.
56
+ - **It carries no session with it.** Started from inside a Claude Code
57
+ session (by the hook), it used to inherit that session's environment,
58
+ including `CLAUDECODE=1`, Claude Code's nested-session guard. Every Claude
59
+ session it revived would then have refused to start. Background processes
60
+ now drop the variables that mark one session; your own settings, such as
61
+ `CLAUDE_CONFIG_DIR` and `CLAUDE_CODE_USE_BEDROCK`, are kept. They also run in
62
+ your home directory, not the project that happened to start them, which
63
+ Windows would otherwise refuse to delete or rename until sign-out. The
64
+ transient resumer the hook starts gets the same treatment, on every
65
+ platform.
66
+ - **No console windows.** A background daemon has no console. The `git` calls
67
+ it makes when a session stops and wakes, and the agent a revival starts,
68
+ now run hidden instead of each popping up a window of its own.
69
+ - **Its output goes to `~/.unsnooze/daemon.log`**, as the launchd daemon's
70
+ does, so a crash leaves a trace.
71
+ - **Re-running setup restarts it.** `unsnooze install --daemon` stops the
72
+ running daemon and starts a fresh one. That is how it picks up a new
73
+ version, or a `PATH` that now finds an agent, and `unsnooze doctor` now
74
+ says so instead of pointing at `schtasks`.
75
+
76
+ If you use only the Codex app or IDE on Windows, nothing you do passes through
77
+ a wrapper or the hook. After signing in, run `unsnooze install --daemon` (or
78
+ launch any wrapped agent) to start the daemon.
79
+
80
+ `unsnooze uninstall` removes the marker first, then stops the daemon, so
81
+ nothing can start it again halfway through; a daemon that was only starting at
82
+ that moment sees the marker gone and exits. It also deletes a `\unsnooze`
83
+ Scheduled Task left by an earlier version, after checking that one exists, so
84
+ a machine that never had one only ever sees a read-only query. If the delete
85
+ is refused (a task created from an administrator shell can need one to
86
+ remove), it prints the command to run. Setup does not go looking for that
87
+ task: that would mean running `schtasks` again. A daemon such a task already
88
+ started keeps running until you sign out; from the next sign-in the task only
89
+ starts the same on-demand daemon, which the pidfile keeps to one. macOS and
90
+ Linux are unchanged (launchd / systemd).
91
+
3
92
  ## 1.19.1 — 2026-09-21
4
93
 
5
94
  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.2",
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
@@ -101,6 +102,26 @@ export const patterns = {
101
102
  transientPatterns: [/stream error/i, /exceeded retry limit/i],
102
103
  };
103
104
 
105
+ // A submitted user turn uses the same › glyph as the composer. Only accept
106
+ // the LAST prompt, followed directly by a blank line and Codex's status footer.
107
+ // ponytail: truncated/collapsed pastes cannot be proved; leave those for the user.
108
+ export function pendingCodexPrompt(text, message) {
109
+ if (!message) return false;
110
+ const lines = stripVTControlCharacters(text).split('\n');
111
+ const start = lines.findLastIndex(line => /^\s*›/.test(line));
112
+ if (start < 0) return false;
113
+ const tail = lines.slice(start + 1);
114
+ const footer = tail.findIndex((line, i) => i > 0 && !tail[i - 1].trim()
115
+ && (/\s·\s/.test(line) || /(?:\d+% context left|\? for shortcuts)/.test(line)));
116
+ if (footer < 0) return false;
117
+ if (tail.slice(footer + 1).some(line => line.trim()
118
+ && !/(?:\d+% context left|\? for shortcuts)/.test(line))) return false;
119
+ // Ignore wrapping and the extra newline swallowed by paste-burst handling.
120
+ const compact = value => value.replace(/\s/g, '');
121
+ const draft = [lines[start].replace(/^\s*›\s?/, ''), ...tail.slice(0, footer)].join('\n');
122
+ return compact(draft) === compact(message);
123
+ }
124
+
104
125
  // Sessions live in ~/.codex/sessions/YYYY/MM/DD/rollout-{ts}-{THREAD}.jsonl;
105
126
  // the first JSONL line carries the session cwd. Conservative: no cwd match →
106
127
  // null (the resumer then uses `codex resume --last`, which codex itself scopes
@@ -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 */ }
package/src/install.js CHANGED
@@ -14,14 +14,14 @@
14
14
  import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { homedir, userInfo } from 'node:os';
17
- import { join, dirname, delimiter } from 'node:path';
18
- import { CLAUDE_SETTINGS, STATE_DIR, WINDOWS_TASK_NAME } from './config.js';
17
+ import { join, dirname, basename, delimiter } from 'node:path';
18
+ import { CLAUDE_SETTINGS, STATE_DIR, WINDOWS_TASK_NAME, DAEMON_ON_DEMAND } from './config.js';
19
19
  import { getConfig, configFileExists } from './settings.js';
20
20
  import { xmlEscape } from './notify.js';
21
21
  import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
22
22
  import { getAgent } from './agents/index.js';
23
23
  import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
24
- import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
24
+ import { UNSNOOZE_BIN, stopResumer, restartDaemon, stopDaemon } from './spawn.js';
25
25
  import { uninstallStatuslineShim } from './usage.js';
26
26
  import { powershellProfilePath } from './powershell.js';
27
27
  import { fishConfigPath } from './fish.js';
@@ -248,11 +248,12 @@ export function installFishBlock(content, agents = ['claude'], bin = UNSNOOZE_BI
248
248
  // GUI sessions (VS Code extension, desktop apps) never pass through the shell
249
249
  // wrappers, so their limit stops are only caught while `unsnooze daemon` is
250
250
  // alive. Autostart keeps it alive: a launchd user agent on macOS, a systemd
251
- // user unit on Linux/WSL.
251
+ // user unit on Linux/WSL. Native Windows deliberately gets neither a unit nor
252
+ // a Scheduled Task — the daemon is started on demand there (DAEMON_ON_DEMAND).
252
253
 
253
254
  export const DAEMON_LABEL = 'com.unsnooze.daemon';
254
- // Task Scheduler has no reverse-DNS convention and shows this name to the user
255
- // in taskschd.msc, so it is a plain word rather than the launchd label.
255
+ // The Scheduled Task name unsnooze <= 1.19.1 used; kept for the uninstall that
256
+ // removes one left behind.
256
257
  export { WINDOWS_TASK_NAME };
257
258
 
258
259
  // Is this process running under the supervisor we install, rather than from
@@ -269,14 +270,15 @@ export { WINDOWS_TASK_NAME };
269
270
  export function isSupervised({ platform = process.platform, env = process.env } = {}) {
270
271
  if (platform === 'darwin') return env.XPC_SERVICE_NAME === DAEMON_LABEL;
271
272
  if (platform === 'linux') return typeof env.INVOCATION_ID === 'string' && env.INVOCATION_ID !== '';
272
- // win32 stays false on purpose, even though we now install a Scheduled Task.
273
- // launchd KeepAlive and systemd Restart=always bring the daemon straight
274
- // back; a logon-triggered task does not restart anything until the next
275
- // logon. Answering true here would let the version-skew guard exit 0 into
276
- // nothing and stop Windows watching silently — the exact failure mode of
277
- // issue #8, just with a different trigger. The cost is that a Windows daemon
278
- // keeps running pre-upgrade code until the user logs back in; that is the
279
- // lesser of the two, and `unsnooze doctor` reports the skew.
273
+ // win32 stays false on purpose. launchd KeepAlive and systemd Restart=always
274
+ // bring the daemon straight back; on Windows it is only started again by the
275
+ // next wrapped launch or StopFailure hook, which may be hours away. Answering
276
+ // true here would let the version-skew guard exit 0 into nothing and stop
277
+ // Windows watching silently — the exact failure mode of issue #8, just with a
278
+ // different trigger. The cost is that a Windows daemon keeps running
279
+ // pre-upgrade code until it is restarted (a sign-out, or re-running
280
+ // `unsnooze install --daemon`); that is the lesser of the two, and
281
+ // `unsnooze doctor` reports the skew.
280
282
  return false;
281
283
  }
282
284
 
@@ -434,20 +436,49 @@ function defaultActivate(cmd, args) {
434
436
  }
435
437
  }
436
438
 
439
+ // Native Windows keeps its "unit" in the state dir: a marker that asks for the
440
+ // daemon to be started on demand. `dir` relocates it, as it does the unit dir
441
+ // elsewhere.
442
+ function onDemandMarker(dir) {
443
+ return dir ? join(dir, basename(DAEMON_ON_DEMAND)) : DAEMON_ON_DEMAND;
444
+ }
445
+
446
+ const ON_DEMAND_NOTE = 'While this file exists, the unsnooze daemon is started on demand — by the\n'
447
+ + 'agent wrappers and the StopFailure hook — whenever it is not running. Nothing\n'
448
+ + 'is registered to run at sign-in. `unsnooze uninstall` removes this file.\n';
449
+
450
+ // unsnooze <= 1.19.1 registered the daemon as a logon Scheduled Task. Nothing
451
+ // creates one now, but an install from then may still have it; uninstall
452
+ // removes it. Ask before deleting, so a machine that never had one — most of
453
+ // them: creating it needed an elevated shell — only ever sees a read-only
454
+ // query. /f so the delete is not an interactive prompt. 'removed', 'stuck'
455
+ // (it exists but the delete failed — a task created from an elevated shell
456
+ // can need one to delete), or null when there was none.
457
+ export function removeLegacyScheduledTask({ activate = defaultActivate } = {}) {
458
+ if (!activate('schtasks', ['/query', '/tn', WINDOWS_TASK_NAME])) return null;
459
+ return activate('schtasks', ['/delete', '/f', '/tn', WINDOWS_TASK_NAME]) ? 'removed' : 'stuck';
460
+ }
461
+
437
462
  export function installDaemonAutostart({
438
463
  platform = process.platform, dir = null, activate = defaultActivate, path = undefined,
464
+ start = restartDaemon,
439
465
  } = {}) {
440
- // Native Windows has no unit *file* — the Task Scheduler holds the record —
441
- // so it is handled before the file-based platforms below. It matters more
442
- // here than anywhere else: the transcript watcher lives in the daemon, and
443
- // headless has no pane monitor to fall back on, so without this a Windows
444
- // machine only ever catches limit stops through the StopFailure hook.
466
+ // Native Windows matters more than anywhere: the transcript watcher lives in
467
+ // the daemon, and headless has no pane monitor to fall back on. But nothing
468
+ // is registered with Windows to start it. Up to 1.19.1 this created a logon
469
+ // Scheduled Task (`schtasks /create /sc onlogon /tr "node … daemon"`), and
470
+ // Microsoft Defender flagged setup as Trojan:Win32/Commando.A!ml for it —
471
+ // an interpreter running a script from %APPDATA% at every logon is exactly
472
+ // how malware persists, and any logon entry (Run key, Startup folder) reads
473
+ // the same way. Without elevation schtasks refused the task anyway. So the
474
+ // daemon is started now, and started again on demand (spawn.js ensureDaemon).
445
475
  if (platform === 'win32') {
446
- // /f overwrites an existing task rather than failing with "already exists",
447
- // which is what re-running setup does every time.
448
- activate('schtasks', ['/create', '/f', '/tn', WINDOWS_TASK_NAME, '/sc', 'onlogon',
449
- '/tr', `"${process.execPath}" "${UNSNOOZE_BIN}" daemon`]);
450
- return `Scheduled Task \\${WINDOWS_TASK_NAME}`;
476
+ const marker = onDemandMarker(dir);
477
+ atomicWrite(marker, ON_DEMAND_NOTE);
478
+ // Same interlock as the unit platforms below: the real starter only runs
479
+ // for the marker it would actually read.
480
+ if (start !== restartDaemon || marker === DAEMON_ON_DEMAND) start();
481
+ return marker;
451
482
  }
452
483
  const target = autostartUnitPath({ platform, dir });
453
484
  if (!target) return null;
@@ -528,11 +559,30 @@ export function healDaemonAutostart({
528
559
  return installDaemonAutostart({ platform, dir, activate, path: better });
529
560
  }
530
561
 
531
- export function uninstallDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
562
+ export function uninstallDaemonAutostart({
563
+ platform = process.platform, dir = null, activate = defaultActivate, stop = stopDaemon,
564
+ say = () => {},
565
+ } = {}) {
532
566
  if (platform === 'win32') {
533
- // /f so a missing task is not an interactive prompt on an uninstall path.
534
- activate('schtasks', ['/delete', '/f', '/tn', WINDOWS_TASK_NAME]);
535
- return `Scheduled Task \\${WINDOWS_TASK_NAME}`;
567
+ const removed = [];
568
+ // The marker first, so nothing starts the daemon again once it is stopped
569
+ // (and an on-demand daemon that is only now starting sees it gone).
570
+ const marker = onDemandMarker(dir);
571
+ if (existsSync(marker)) {
572
+ try { unlinkSync(marker); removed.push(marker); } catch { /* still there — not reported as removed */ }
573
+ }
574
+ const live = marker === DAEMON_ON_DEMAND;
575
+ if (stop !== stopDaemon || live) stop();
576
+ // The interlock again: a relocated marker is a fixture, and the machine's
577
+ // real Task Scheduler is none of its business.
578
+ const act = (activate === defaultActivate && !live) ? () => false : activate;
579
+ const legacy = removeLegacyScheduledTask({ activate: act });
580
+ if (legacy === 'removed') removed.push(`Scheduled Task \\${WINDOWS_TASK_NAME}`);
581
+ else if (legacy === 'stuck') {
582
+ say(`could not delete the Scheduled Task \\${WINDOWS_TASK_NAME} an earlier version created; `
583
+ + `from an administrator shell run: schtasks /delete /tn ${WINDOWS_TASK_NAME} /f`);
584
+ }
585
+ return removed.length ? removed.join(' and ') : null;
536
586
  }
537
587
  if (platform === 'darwin') {
538
588
  const target = join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`);
@@ -696,8 +746,21 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
696
746
 
697
747
  // 4. Daemon autostart (GUI-session watching), opt-in via --daemon / wizard.
698
748
  if (opts.daemon) {
699
- const target = installDaemonAutostart();
700
- if (target) console.log(`unsnooze: daemon autostart installed (${target}) — GUI sessions are watched`);
749
+ // On Windows the start's outcome is kept, so the message can be honest.
750
+ let started = null;
751
+ const target = installDaemonAutostart(process.platform === 'win32'
752
+ ? { start: () => { started = restartDaemon(); } } : {});
753
+ if (target && process.platform === 'win32') {
754
+ if (started) console.log(`unsnooze: daemon started in the background (pid ${started}) — GUI sessions are watched`);
755
+ else console.log('unsnooze: the daemon did not start — run `unsnooze daemon` to see why');
756
+ console.log('unsnooze: nothing is registered to run at sign-in (no Scheduled Task); your wrapped');
757
+ console.log('unsnooze: agents and the Claude hook start the daemon again whenever it is not running');
758
+ // A task left by 1.19.1 or earlier is deliberately not looked for here:
759
+ // node.exe spawning schtasks.exe is the pairing Defender reacted to, and
760
+ // setup should spawn nothing of the kind. Such a task only starts this
761
+ // same daemon at sign-in, which the pidfile tolerates; uninstall removes
762
+ // it.
763
+ } else if (target) console.log(`unsnooze: daemon autostart installed (${target}) — GUI sessions are watched`);
701
764
  else console.log('unsnooze: daemon autostart is not supported on this platform');
702
765
  }
703
766
 
@@ -724,7 +787,16 @@ export function cmdUninstall(rest) {
724
787
  const opts = parseArgs(rest);
725
788
  const explicitRc = rest.includes('--zshrc');
726
789
 
727
- // Stop the resumer/daemon first so it cannot keep writing state after hooks
790
+ // Whatever starts the daemon goes first — the launchd/systemd unit, or on
791
+ // Windows the marker the wrappers and the hook consult — so nothing can
792
+ // start it again mid-uninstall. On Windows this order also stops the daemon
793
+ // while its pidfile still proves which process it is, not after
794
+ // stopResumer has killed it and left a pid that may already be someone
795
+ // else's.
796
+ const autostart = uninstallDaemonAutostart({ say: m => console.log(`unsnooze: ${m}`) });
797
+ if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
798
+
799
+ // Stop the resumer/daemon so it cannot keep writing state after hooks
728
800
  // are gone (zombie-daemon-running-deleted-code failure mode).
729
801
  try {
730
802
  const result = stopResumer();
@@ -780,9 +852,6 @@ export function cmdUninstall(rest) {
780
852
  }
781
853
  }
782
854
 
783
- const autostart = uninstallDaemonAutostart();
784
- if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
785
-
786
855
  // Restore Claude statusLine if our usage shim was chained in.
787
856
  try {
788
857
  const r = uninstallStatuslineShim({ settingsPath: opts.settings });
package/src/launcher.js CHANGED
@@ -8,7 +8,7 @@ import { spawn, spawnSync } from 'node:child_process';
8
8
  import { getMultiplexer } from './multiplexer.js';
9
9
  import { getAgent } from './agents/index.js';
10
10
  import { getConfig, resolveLaunchExtraArgs } from './settings.js';
11
- import { spawnDetached, monitorSpawnArgs } from './spawn.js';
11
+ import { spawnDetached, monitorSpawnArgs, ensureDaemon } from './spawn.js';
12
12
  import { makeLogger } from './logger.js';
13
13
  import { createLeaseId, processBirth, writeLease, removeLease } from './lease.js';
14
14
  import { recordOwnedSession } from './mux-sessions.js';
@@ -36,6 +36,16 @@ export function resolvePaneOwner(muxName, env = process.env) {
36
36
  return null;
37
37
  }
38
38
 
39
+ // A headless revival runs this launcher detached, with no console and no
40
+ // terminal on any stdio. On Windows the agent would then be given a console
41
+ // window of its own — an empty window popping up on the desktop at every
42
+ // reset, which closing kills the revival. Hide it there. Whenever a terminal
43
+ // is attached nothing changes: the agent, a console program, shares the
44
+ // launcher's console, and windowsHide only affects a console Windows creates.
45
+ export function hideAgentWindow(streams = process) {
46
+ return !(streams.stdin?.isTTY || streams.stdout?.isTTY || streams.stderr?.isTTY);
47
+ }
48
+
39
49
  function runUnwatched(agent, args, reason) {
40
50
  if (reason) process.stderr.write(`unsnooze: ${reason}\n`);
41
51
  return runPassthrough(agent, args);
@@ -49,7 +59,9 @@ function runPassthrough(agent, args) {
49
59
  return r.status ?? 1;
50
60
  }
51
61
 
52
- export function runLauncher(args, agentId = 'claude', { processBirthFn = processBirth } = {}) {
62
+ export function runLauncher(args, agentId = 'claude', {
63
+ processBirthFn = processBirth, ensureDaemonFn = ensureDaemon,
64
+ } = {}) {
53
65
  const agent = getAgent(agentId);
54
66
 
55
67
  // Recursion / nested-launch guard: inside an unsnooze-managed session, a
@@ -122,6 +134,12 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
122
134
  }
123
135
  }
124
136
 
137
+ // Nothing keeps the daemon running on native Windows (see ensureDaemon), so
138
+ // every watched launch makes sure it is. Here, past the re-exec above, so a
139
+ // launch that wraps itself asks once, not twice. A no-op everywhere else,
140
+ // and until `install --daemon` has asked for the daemon.
141
+ ensureDaemonFn();
142
+
125
143
  const rawPane = mux.currentPaneId();
126
144
  const paneOwner = resolvePaneOwner(mux.name, process.env);
127
145
  // A backend may know it cannot safely address the pane it is sitting in —
@@ -179,7 +197,7 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
179
197
  // the reason, never as a crash with no exit code for the resumer to read.
180
198
  let child;
181
199
  try {
182
- child = spawn(agent.bin, args, { stdio: 'inherit', env: childEnv });
200
+ child = spawn(agent.bin, args, { stdio: 'inherit', env: childEnv, windowsHide: hideAgentWindow() });
183
201
  } catch (err) {
184
202
  return launchFailed(agent, err);
185
203
  }
package/src/monitor.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  } from './config.js';
19
19
  import { detectLimit, isBusy, overloadMatch, modelRemedy } from './patterns.js';
20
20
  import { getAgent } from './agents/index.js';
21
+ import { pendingCodexPrompt } from './agents/codex.js';
21
22
  import { getConfig } from './settings.js';
22
23
  import { notify } from './notify.js';
23
24
  import { parseResetTime, resetAtMs, sourceRank } from './time-parser.js';
@@ -327,6 +328,16 @@ export function createMonitor({
327
328
  const visible = mux.capturePaneVisible
328
329
  ? await mux.capturePaneVisible(pane).catch(() => '')
329
330
  : text;
331
+ if (agent.id === 'codex' && Object.values(readState().sessions).some(rec =>
332
+ rec.agent === 'codex' && rec.mux === muxName && rec.pane === pane
333
+ && rec.paneOwner === paneOwner && (rec.leaseId ?? null) === leaseId
334
+ && ['resuming', 'failed'].includes(rec.status)
335
+ && pendingCodexPrompt(visible, rec.pendingPrompt))) {
336
+ // The resumer owns this unsubmitted draft. Its old banner is not a new
337
+ // stop; refreshing detectedAt here would invalidate the submit retry.
338
+ firstTick = false;
339
+ return;
340
+ }
330
341
  if (agent.menu && agent.menu.isPrompt(visible, PANE_SCAN_LINES)) {
331
342
  if (!getConfig('menuAutoAnswer')) {
332
343
  // Watch-only mode: record the stop (reset time may not be visible
package/src/resumer.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from './config.js';
17
17
  import { detectLimit, isBusy, modelRemedy } from './patterns.js';
18
18
  import { getAgent } from './agents/index.js';
19
+ import { pendingCodexPrompt } from './agents/codex.js';
19
20
  import { parseResetTime, resetAtMs, nextProbeDelayMs } from './time-parser.js';
20
21
  import {
21
22
  readState, updateState, setStatus, dueSessions, activeStopped,
@@ -224,7 +225,7 @@ function paneStopOwner(rec, state = readState()) {
224
225
  // One live pane can accept one wake. Legacy/subagent detections outside the
225
226
  // short ingest-dedupe window are therefore collapsed here: the newest active
226
227
  // stop owns the target and older active records become explicit history.
227
- function claimStopForResume(rec) {
228
+ function claimStopForResume(rec, pendingPrompt = null) {
228
229
  const cutoff = stopEpisodeAt(rec);
229
230
  let claimed = false;
230
231
  updateState(state => {
@@ -254,6 +255,7 @@ function claimStopForResume(rec) {
254
255
  // single point where unsnooze takes ownership of a stop, whether the
255
256
  // wake is typed, driven through the menu, or reopened.
256
257
  resumedBy: 'unsnooze',
258
+ pendingPrompt, submitRetries: 0,
257
259
  });
258
260
  claimed = true;
259
261
  }
@@ -713,7 +715,7 @@ export async function dispatchOne(rec, {
713
715
  // assessPane is asynchronous. Close the final human/provider-resume race
714
716
  // after assessment and immediately before the keystroke.
715
717
  if (finishIfClaudeProgressed(rec, agent)) return 'already-resumed';
716
- if (!claimStopForResume(rec)) return 'stale';
718
+ if (!claimStopForResume(rec, agent.id === 'codex' ? resumeMessage : null)) return 'stale';
717
719
  await mux.sendText(rec.pane, resumeMessage);
718
720
  log(`${key}: sent continue via ${rec.mux} ${rec.paneOwner ?? '-'}:${rec.pane}`);
719
721
  notifyContext();
@@ -874,11 +876,11 @@ function recordVerifyRetry(rec, lastError) {
874
876
  let applied;
875
877
  if (verifyRetries >= MAX_VERIFY_RETRIES) {
876
878
  const attempts = (rec.attempts || 0) + 1;
877
- applied = transitionStopEpisode(rec, 'stopped', {
879
+ applied = transitionStopEpisode(rec, rec.pendingPrompt ? 'failed' : 'stopped', {
878
880
  attempts,
879
881
  // Same backoff (and same manual exemption) as routed retries.
880
882
  resetAt: rec.manual ? Date.now() : Date.now() + retryBackoffMs(attempts),
881
- lastError,
883
+ lastError: rec.pendingPrompt ? `${lastError} — wake submission unconfirmed; inspect the Codex pane` : lastError,
882
884
  verifyRetries: 0, resumeEpisodeAt: null,
883
885
  }, { expect: ['resuming'] });
884
886
  } else {
@@ -917,6 +919,29 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
917
919
  catch (err) {
918
920
  return recordVerifyRetry(rec, `verify capture: ${err.message}`);
919
921
  }
922
+ // A delayed TUI can process text and Enter as a single paste burst (#30).
923
+ // Recover only our exact draft, never type it again or treat the banner
924
+ // above it as a fresh refusal. Re-check the normal ownership/busy gates
925
+ // and the stop episode immediately before every additional Enter.
926
+ if (agent.id === 'codex' && pendingCodexPrompt(text, rec.pendingPrompt)) {
927
+ const a = await assessPane(rec, agent, { mux });
928
+ const current = readState().sessions[key];
929
+ if (!current || current.status !== 'resuming' || !samePaneTarget(current, rec)
930
+ || stopEpisodeAt(current) !== stopEpisodeAt(rec)) return 'stale';
931
+ if (a.busy) return 'pending';
932
+ if ((rec.submitRetries || 0) >= 3 || !a.authorized || typeof mux.sendKey !== 'function') {
933
+ transitionStopEpisode(rec, 'failed', {
934
+ lastError: 'wake prompt not submitted — inspect the Codex pane and press Enter to continue',
935
+ }, { expect: ['resuming'] });
936
+ return 'held';
937
+ }
938
+ if (!pendingCodexPrompt(a.text, rec.pendingPrompt)) return 'pending';
939
+ if (!transitionStopEpisode(rec, 'resuming', { submitRetries: (rec.submitRetries || 0) + 1 },
940
+ { expect: ['resuming'] })) return 'stale';
941
+ try { await mux.sendKey(rec.pane, 'Enter'); }
942
+ catch (err) { log(`${key}: submit retry failed: ${err.message}`); }
943
+ return 'pending';
944
+ }
920
945
  const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
921
946
  if (d.hit || (agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES))) {
922
947
  // Limit not actually reset — reschedule from the fresh banner.
package/src/spawn.js CHANGED
@@ -1,22 +1,78 @@
1
1
  // Detached-process helpers shared by launcher, hook, and monitor.
2
2
 
3
3
  import { spawn } from 'node:child_process';
4
- import { existsSync, readFileSync, unlinkSync } from 'node:fs';
4
+ import {
5
+ existsSync, readFileSync, unlinkSync, writeFileSync, statSync, utimesSync, openSync, closeSync,
6
+ } from 'node:fs';
7
+ import { homedir } from 'node:os';
5
8
  import { fileURLToPath } from 'node:url';
6
9
  import { join, dirname } from 'node:path';
7
- import { RESUMER_LOCK } from './config.js';
10
+ import { RESUMER_LOCK, DAEMON_ON_DEMAND, DAEMON_PIDFILE, STATE_DIR, ensureStateDir } from './config.js';
8
11
  import { makeLogger } from './logger.js';
9
12
 
10
13
  const log = makeLogger('spawn');
11
14
 
12
15
  export const UNSNOOZE_BIN = join(dirname(dirname(fileURLToPath(import.meta.url))), 'bin', 'unsnooze.js');
13
16
 
14
- export function spawnDetached(args, env = {}) {
15
- const child = spawn(process.execPath, [UNSNOOZE_BIN, ...args], {
17
+ // Variables that describe one agent session rather than the user. Claude Code
18
+ // sets the first group for every command and hook it runs, and CLAUDECODE is
19
+ // its nested-session guard: a claude started with it set refuses to run
20
+ // ("cannot be launched inside another Claude Code session"). The launcher sets
21
+ // the second group for the agent it wraps. A process that outlives the session
22
+ // that started it must carry neither, because every revival it launches
23
+ // inherits its environment. User configuration — CLAUDE_CONFIG_DIR,
24
+ // CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_GIT_BASH_PATH, TMUX — is kept.
25
+ const SESSION_ENV = new Set([
26
+ 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SSE_PORT',
27
+ 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_SESSION_ID', 'CLAUDE_PROJECT_DIR', 'CLAUDE_ENV_FILE',
28
+ 'UNSNOOZE_ACTIVE', 'UNSNOOZE_MUX', 'UNSNOOZE_PANE', 'UNSNOOZE_PANE_OWNER', 'UNSNOOZE_LEASE_ID',
29
+ 'UNSNOOZE_CWD',
30
+ ]);
31
+
32
+ // Windows environment names are case-insensitive, and a copied process.env
33
+ // keeps whatever case the variable was set with.
34
+ export function standaloneEnv(env = process.env) {
35
+ const out = {};
36
+ for (const [key, value] of Object.entries(env)) {
37
+ if (!SESSION_ENV.has(key.toUpperCase())) out[key] = value;
38
+ }
39
+ return out;
40
+ }
41
+
42
+ // spawnDetached's options, apart so they can be tested without a child.
43
+ // `standalone` is for processes that outlive the session that started them —
44
+ // the daemon and a transient resumer: no session markers, and the home
45
+ // directory rather than the project the session happened to be in (on Windows
46
+ // a live process's working directory cannot be deleted or renamed, so a
47
+ // daemon started from a project would pin that folder until sign-out).
48
+ export function detachedSpawnOptions({ env = {}, standalone = false, logFd = null } = {}) {
49
+ return {
16
50
  detached: true,
17
- stdio: 'ignore',
18
- env: { ...process.env, ...env },
19
- });
51
+ stdio: logFd == null ? 'ignore' : ['ignore', logFd, logFd],
52
+ env: { ...(standalone ? standaloneEnv() : process.env), ...env },
53
+ ...(standalone ? { cwd: homedir() } : {}),
54
+ };
55
+ }
56
+
57
+ // `logFile` appends the child's stdout/stderr to a file instead of discarding
58
+ // them: a daemon with no supervisor to capture its output would otherwise leave
59
+ // no trace of a crash.
60
+ export function spawnDetached(args, env = {}, { standalone = false, logFile = null, execPath = process.execPath } = {}) {
61
+ let logFd = null;
62
+ if (logFile) {
63
+ try { logFd = openSync(logFile, 'a', 0o600); } catch { /* no log, not no child */ }
64
+ }
65
+ let child;
66
+ try {
67
+ child = spawn(execPath, [UNSNOOZE_BIN, ...args], detachedSpawnOptions({ env, standalone, logFd }));
68
+ } finally {
69
+ // The child has its own copy of the descriptor.
70
+ if (logFd != null) { try { closeSync(logFd); } catch { /* already closed */ } }
71
+ }
72
+ // spawn() reports ENOENT, EACCES, EAGAIN and EMFILE as an 'error' event after
73
+ // it has returned. Unheard, that is an uncaught exception in the caller —
74
+ // the launcher, with the user's agent already running.
75
+ child.on('error', err => log(`could not start ${args[0]}: ${err.message}`));
20
76
  child.unref();
21
77
  return child.pid;
22
78
  }
@@ -51,7 +107,9 @@ export function spawnResumerIfNeeded() {
51
107
  if (Number.isFinite(pid) && pidAlive(pid)) return null;
52
108
  }
53
109
  } catch { /* unreadable lock — let the daemon sort it out */ }
54
- const pid = spawnDetached(['_resumer']);
110
+ // Standalone: it is spawned from inside a session (the hook, a pane's
111
+ // monitor) and outlives it, and a headless revival inherits its environment.
112
+ const pid = spawnDetached(['_resumer'], {}, { standalone: true });
55
113
  log(`spawned resumer pid ${pid}`);
56
114
  return pid;
57
115
  }
@@ -63,7 +121,8 @@ export function stopResumer() {
63
121
  try {
64
122
  if (!existsSync(RESUMER_LOCK)) return { stopped: false, pid: null, reason: 'no-lock' };
65
123
  pid = parseInt(readFileSync(RESUMER_LOCK, 'utf-8'), 10);
66
- if (Number.isFinite(pid) && pidAlive(pid)) {
124
+ // A stale lock can name a recycled pid — this very process included.
125
+ if (Number.isFinite(pid) && pid !== process.pid && pidAlive(pid)) {
67
126
  try { process.kill(pid, 'SIGTERM'); } catch { /* raced with exit */ }
68
127
  log(`stopped resumer pid ${pid}`);
69
128
  try { unlinkSync(RESUMER_LOCK); } catch { /* gone */ }
@@ -77,3 +136,197 @@ export function stopResumer() {
77
136
  return { stopped: false, pid, reason: err.message };
78
137
  }
79
138
  }
139
+
140
+ // --- the on-demand daemon (native Windows) ----------------------------------
141
+ // launchd and systemd keep the daemon alive on macOS and Linux. Windows gets no
142
+ // equivalent, on purpose — see DAEMON_ON_DEMAND in config.js — so there the
143
+ // daemon is an ordinary background process that the wrappers and the
144
+ // StopFailure hook start whenever it is not running.
145
+
146
+ // How often a running daemon refreshes its pidfile, and how old the refresh may
147
+ // be before the file stops counting as proof of life. Five missed beats, so a
148
+ // busy moment is never mistaken for death. A machine waking from a long sleep
149
+ // can briefly look daemon-less; the worst that does is start a second daemon,
150
+ // which the first yields to on its next beat (claimDaemon).
151
+ export const DAEMON_HEARTBEAT_MS = 60_000;
152
+ export const DAEMON_STALE_MS = 5 * DAEMON_HEARTBEAT_MS;
153
+
154
+ function readPidfile(path) {
155
+ try { return parseInt(readFileSync(path, 'utf-8'), 10); } catch { return NaN; }
156
+ }
157
+
158
+ // The pid of the daemon that is demonstrably running, else null. A live pid
159
+ // alone proves nothing on Windows: pids are recycled quickly, and a sign-out or
160
+ // a crash leaves the file behind to name whichever process gets that pid next.
161
+ // The daemon touches the file every minute, so a stale one is ignored whoever
162
+ // its pid belongs to now.
163
+ export function runningDaemonPid({ path = DAEMON_PIDFILE, now = Date.now(), alive = pidAlive } = {}) {
164
+ const pid = readPidfile(path);
165
+ if (!alive(pid)) return null;
166
+ try {
167
+ return now - statSync(path).mtimeMs <= DAEMON_STALE_MS ? pid : null;
168
+ } catch {
169
+ return null; // vanished between the read and the stat
170
+ }
171
+ }
172
+
173
+ // `wx` creates the file a moment before the claimant's pid is written into it.
174
+ // A file that young with no pid yet is somebody mid-claim, not a leftover.
175
+ const CLAIM_GRACE_MS = 2_000;
176
+
177
+ function midClaim(path, now = Date.now()) {
178
+ try {
179
+ return readFileSync(path, 'utf-8').trim() === '' && now - statSync(path).mtimeMs < CLAIM_GRACE_MS;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ // The daemon's half of the pidfile. Starters race — a wrapper and the hook can
186
+ // both find no daemon and both start one — so each new daemon claims the file,
187
+ // and a daemon that finds a live one already there stands down before doing
188
+ // anything. `wx` like the resumer lock; a stale file is replaced. Returns a
189
+ // release() for shutdown, or null when this daemon must not run.
190
+ //
191
+ // While held, the heartbeat keeps the claim fresh, and calls onLost() — the
192
+ // daemon should exit — once the file names another pid (this daemon looked
193
+ // dead through a long sleep and a newer one took over, or two claims raced:
194
+ // one daemon, whichever claimed last), once the file has been missing on two
195
+ // beats in a row (one unreadable moment, say an antivirus scan, is not proof),
196
+ // or once wanted() says the daemon is no longer asked for.
197
+ export function claimDaemon({
198
+ path = DAEMON_PIDFILE, pid = process.pid, running = runningDaemonPid,
199
+ heartbeatMs = DAEMON_HEARTBEAT_MS, onLost = () => {}, wanted = () => true,
200
+ lock = RESUMER_LOCK,
201
+ } = {}) {
202
+ ensureStateDir(dirname(path));
203
+ let claimed = false;
204
+ for (let attempt = 0; attempt < 2 && !claimed; attempt++) {
205
+ try {
206
+ writeFileSync(path, String(pid), { flag: 'wx', mode: 0o600 });
207
+ claimed = true;
208
+ } catch {
209
+ const previous = readPidfile(path);
210
+ const holder = running({ path });
211
+ if (holder === pid) claimed = true;
212
+ else if (holder) {
213
+ log(`daemon pid ${holder} is already running — pid ${pid} not starting another`);
214
+ return null;
215
+ } else if (midClaim(path)) {
216
+ log(`another daemon is claiming ${path} — pid ${pid} not starting another`);
217
+ return null;
218
+ } else {
219
+ // The previous daemon is gone: dead, or its pid now someone else's.
220
+ // TerminateProcess and sign-out skip its cleanup, so the resumer lock
221
+ // it held still names that pid — and on Windows, where the holder
222
+ // cannot be checked with ps, a recycled pid there would be honored
223
+ // forever and no resume would ever run again.
224
+ if (Number.isFinite(previous) && readPidfile(path) === previous && readPidfile(lock) === previous) {
225
+ try { unlinkSync(lock); } catch { /* released meanwhile */ }
226
+ log(`cleared the resumer lock left by daemon pid ${previous}`);
227
+ }
228
+ try { unlinkSync(path); } catch { /* raced with another claimant */ }
229
+ }
230
+ }
231
+ }
232
+ if (!claimed) {
233
+ log(`could not claim ${path} — pid ${pid} not starting`);
234
+ return null;
235
+ }
236
+ let misses = 0;
237
+ const beat = setInterval(() => {
238
+ const named = readPidfile(path);
239
+ let why = null;
240
+ if (!wanted()) why = 'it is no longer wanted';
241
+ else if (Number.isFinite(named) && named !== pid) why = `${path} now names pid ${named}`;
242
+ else if (!Number.isFinite(named) && ++misses >= 2) why = `${path} is gone`;
243
+ if (why) {
244
+ clearInterval(beat);
245
+ log(`daemon pid ${pid} standing down: ${why}`);
246
+ onLost();
247
+ return;
248
+ }
249
+ if (Number.isFinite(named)) {
250
+ misses = 0;
251
+ try { const t = new Date(); utimesSync(path, t, t); } catch { /* the next beat retries */ }
252
+ }
253
+ }, heartbeatMs);
254
+ beat.unref();
255
+ return () => {
256
+ clearInterval(beat);
257
+ if (readPidfile(path) === pid) {
258
+ try { unlinkSync(path); } catch { /* already gone */ }
259
+ }
260
+ };
261
+ }
262
+
263
+ // Is the on-demand daemon still asked for? `install --daemon` writes the
264
+ // marker and `uninstall` removes it.
265
+ export function onDemandWanted(marker = DAEMON_ON_DEMAND) {
266
+ return existsSync(marker);
267
+ }
268
+
269
+ // Where the on-demand daemon's own stdout/stderr go — the file launchd writes
270
+ // them to elsewhere, which bin/unsnooze.js rotates when the daemon starts.
271
+ const DAEMON_LOG = join(STATE_DIR, 'daemon.log');
272
+
273
+ function spawnOnDemandDaemon(args) {
274
+ return spawnDetached(args, {}, { standalone: true, logFile: DAEMON_LOG });
275
+ }
276
+
277
+ // Start the daemon unless it is already running — native Windows only, and
278
+ // only once `unsnooze install --daemon` has asked for it (the marker). The
279
+ // wrappers and the StopFailure hook call this, and between them they run
280
+ // whenever an agent does, so the daemon is back soon after a sign-in without
281
+ // anything registered to start at logon. Never throws: it sits on the launch
282
+ // path, where a failure must cost the daemon and nothing else.
283
+ //
284
+ // --on-demand ties the daemon to the marker: it exits when the marker goes
285
+ // (uninstall), where a daemon somebody runs by hand keeps running.
286
+ export function ensureDaemon({
287
+ platform = process.platform, marker = DAEMON_ON_DEMAND,
288
+ running = runningDaemonPid, spawner = spawnOnDemandDaemon,
289
+ } = {}) {
290
+ try {
291
+ if (platform !== 'win32' || !existsSync(marker) || running()) return null;
292
+ const pid = spawner(['daemon', '--on-demand']);
293
+ log(`daemon not running — started it on demand (pid ${pid})`);
294
+ return pid;
295
+ } catch (err) {
296
+ log(`could not start the daemon on demand: ${err.message}`);
297
+ return null;
298
+ }
299
+ }
300
+
301
+ // Stop the on-demand daemon (uninstall, and the restart `install --daemon`
302
+ // does). Only a daemon proven live by its heartbeat is signalled — a stale
303
+ // file's pid may be anybody's by now — and never this process, which a pid
304
+ // recycled inside the heartbeat window could be. The file goes either way, so
305
+ // a live daemon too stale to prove itself stands down at its next beats.
306
+ // Returns the pid that was signalled, or null.
307
+ export function stopDaemon({
308
+ path = DAEMON_PIDFILE, running = runningDaemonPid, lock = RESUMER_LOCK,
309
+ kill = (pid, signal) => process.kill(pid, signal),
310
+ } = {}) {
311
+ const found = running({ path });
312
+ const pid = found === process.pid ? null : found;
313
+ if (pid) {
314
+ try { kill(pid, 'SIGTERM'); } catch { /* raced with its exit */ }
315
+ log(`stopped daemon pid ${pid}`);
316
+ // On Windows that was TerminateProcess: nothing of the daemon's own ran,
317
+ // so a resumer lock it held still names it (see claimDaemon).
318
+ if (readPidfile(lock) === pid) {
319
+ try { unlinkSync(lock); } catch { /* already gone */ }
320
+ }
321
+ }
322
+ try { unlinkSync(path); } catch { /* none to remove */ }
323
+ return pid;
324
+ }
325
+
326
+ // What `install --daemon` does on Windows, as launchctl unload+load does on
327
+ // macOS: a daemon already running keeps the code and PATH it started with, so
328
+ // re-running setup is how a user hands it new ones.
329
+ export function restartDaemon() {
330
+ stopDaemon();
331
+ return ensureDaemon();
332
+ }
@@ -98,10 +98,14 @@ function rolloutLimitError(line) {
98
98
  }
99
99
 
100
100
  function emptyPremium(rl) {
101
- return rl.limit_id === 'premium' && rl.primary === null && rl.secondary === null
102
- && rl.credits?.has_credits === false && rl.credits?.unlimited === false
103
- && rl.credits?.balance != null && rl.credits.balance !== ''
104
- && Number(rl.credits.balance) === 0;
101
+ if (!(rl.limit_id === 'premium' && rl.primary === null && rl.secondary === null
102
+ && rl.credits?.has_credits === false && rl.credits?.unlimited === false)) return false;
103
+ if (rl.credits?.balance != null && rl.credits.balance !== '') {
104
+ return Number(rl.credits.balance) === 0;
105
+ }
106
+ // Workspace plans (Business/Team) send the same empty bucket with no balance
107
+ // at all, and say why in rate_limit_reached_type instead.
108
+ return workspaceWall(rl.rate_limit_reached_type);
105
109
  }
106
110
 
107
111
  // rate_limit_reached_type names WHY the server refused, never a window. The
@@ -289,7 +293,11 @@ export function parseRolloutLines(lines, { path, offset } = {}) {
289
293
  // human, exactly as parseSnapshot filed it.
290
294
  error.limitType = 'model';
291
295
  error.resetLine = null;
292
- } else if (error.limitType === 'unknown') {
296
+ } else if (error.limitType === 'unknown'
297
+ // A workspace-wall banner over a spent window: the snapshot already
298
+ // weighed the reason against the window, and the window's reset
299
+ // brings the allowance back. Other model limits keep their label.
300
+ || (error.limitType === 'model' && workspaceWall(stop.reachedType))) {
293
301
  error.limitType = stop.limitType;
294
302
  }
295
303
  }
package/src/wizard.js CHANGED
@@ -77,14 +77,18 @@ export async function runWizard() {
77
77
  });
78
78
  if (p.isCancel(notifications)) return cancelled();
79
79
 
80
- // GUI watching: only meaningful where an autostart daemon can run. Windows
81
- // joined that list with the Task Scheduler unit — and needs it most, since
82
- // headless has no pane monitor and the transcript watcher lives in the daemon.
80
+ // GUI watching: only meaningful where the daemon can run. Windows needs it
81
+ // most, since headless has no pane monitor and the transcript watcher lives
82
+ // in the daemon — and there it is started on demand, with nothing registered
83
+ // to run at sign-in (see DAEMON_ON_DEMAND in config.js).
83
84
  let guiWatch = false;
84
85
  if (['darwin', 'linux', 'win32'].includes(process.platform)) {
86
+ const how = process.platform === 'win32'
87
+ ? 'started on demand by your agents — nothing runs at sign-in'
88
+ : (process.platform === 'darwin' ? 'launchd' : 'systemd');
85
89
  const answer = await p.confirm({
86
90
  message: 'Also guard GUI sessions (Claude Code in VS Code/desktop, Codex app/IDE)?\n'
87
- + ' Installs a small background daemon (launchd/systemd/Task Scheduler) that watches session\n'
91
+ + ` Runs a small background daemon (${how}) that watches session\n`
88
92
  + ' files for limit stops; revived sessions open in the configured multiplexer and stay visible in\n'
89
93
  + ' the GUI\'s own history.',
90
94
  initialValue: DEFAULTS.guiWatch,
package/src/workspace.js CHANGED
@@ -7,9 +7,12 @@
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { createHash } from 'node:crypto';
9
9
 
10
+ // windowsHide: this runs in the daemon and the resumer, which have no console
11
+ // on Windows, so git would otherwise flash a console window of its own at
12
+ // every stop and every wake.
10
13
  function git(cwd, args) {
11
14
  return execFileSync('git', ['-C', cwd, ...args], {
12
- stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500,
15
+ stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500, windowsHide: true,
13
16
  }).toString().trim();
14
17
  }
15
18