unsnooze 1.19.0 → 1.19.1

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,134 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.1 — 2026-09-21
4
+
5
+ A Codex usage reading that was averaged down, a headless Codex revival
6
+ that could never have worked — reported as a success — and Codex stops that
7
+ were never seen at all behind a proxy or in a reverted thread.
8
+
9
+ ### `unsnooze usage` reports Codex's exact percentage as read
10
+
11
+ The 64% in [issue #20](https://github.com/saaranshM/unsnooze/issues/20) was
12
+ `(29 + 99) / 2`: a jump of more than 15 points was averaged with the reading
13
+ before it and still labelled `exact`. The averaging was meant to soften a
14
+ one-tick spike, but at the moment a session is blocked no further reading
15
+ arrives, so the low number stayed for the rest of the window — and 64 is
16
+ below the 80/95 warning thresholds, so the usage-wall notification never
17
+ fired. Codex's `used_percent` is the server's own number and is now shown
18
+ unchanged (still clamped to 0–100).
19
+
20
+ Two things could put a much lower reading in front of the latest one: Codex
21
+ writes one snapshot per rate-limit bucket per response, and `unsnooze usage`
22
+ read every bucket (`premium`, `codex_other`, …) as the account window; and
23
+ Codex Desktop tabs and sub-agents each write their own rollout, so "the
24
+ previous reading" could be another thread's minutes-old snapshot. Only the
25
+ account bucket feeds the 5h/weekly lines now, and burn is measured between
26
+ readings of the same rollout. Thanks to
27
+ [@mio-tsuki](https://github.com/mio-tsuki) for working out the arithmetic.
28
+
29
+ ### Headless Codex revivals run, and a revival that dies says so
30
+
31
+ [Issue #25](https://github.com/saaranshM/unsnooze/issues/25): on Windows the
32
+ daemon's Scheduled Task could not find `codex`, every revival died with
33
+ `spawn codex ENOENT`, and unsnooze logged `verified resumed` anyway.
34
+
35
+ - **`codex exec resume`.** Under the headless backend a revival ran the Codex
36
+ TUI, which exits before touching the session when stdin is not a terminal —
37
+ on every OS, since the backend shipped in 1.16.0. Headless Codex revivals
38
+ now run `codex exec resume <id> "<prompt>"`, which continues the same
39
+ conversation non-interactively, the way Claude's headless revival already
40
+ passed its prompt in argv. It is given `--skip-git-repo-check`: `exec`
41
+ refuses any directory that is not a git repository, which the TUI never
42
+ did, and the session being revived already ran there. `resumeExtraArgs.codex`
43
+ goes right after `exec`, where `-s`, `-p` and `--add-dir` parse; after
44
+ `resume` they were rejected outright.
45
+ - **A dead revival is a failed attempt.** With no pane to capture, an empty
46
+ capture used to count as a cleared banner. The headless backend now records
47
+ each revival's exit, and a non-zero one puts the stop back on the ledger
48
+ with backoff and a `last error` that carries the child's own words — visible
49
+ in `unsnooze status`, and kept when unsnooze finally gives up on the session.
50
+ A revival still running at the 20-second check is watched for up to two
51
+ minutes before it counts as resumed, long enough to catch the failures that
52
+ take a while (an auth refresh giving up, a retry loop); exit 0 counts as
53
+ resumed. A revival started by a resumer the Claude hook spawned no longer
54
+ inherits Claude's nested-launch marker, which had made it drop
55
+ `launchExtraArgs` and fail with a bare "exit 1".
56
+ - **A revival into a deleted directory no longer takes the daemon down.** When
57
+ a session's directory was gone (a removed worktree, say), the headless
58
+ launch failed with an error nothing was listening for, and the daemon
59
+ crashed. It is now an ordinary failed attempt that names the reason.
60
+ - **`unsnooze preview` shows the headless command.** It described a typed
61
+ resume and the Codex TUI for sessions that dispatch revives through argv
62
+ and `codex exec resume`.
63
+ - **Windows finds the Codex runtime.** The Desktop/Store install keeps
64
+ `codex.exe` under a versioned directory that changes on update, and a daemon
65
+ started at logon keeps the PATH it was born with. Codex is now resolved at
66
+ launch: `UNSNOOZE_CODEX_BIN`, then `codex.exe` on PATH, then the newest
67
+ runtime under `%LOCALAPPDATA%\OpenAI\Codex\bin`. A `.cmd`/`.bat` shim
68
+ cannot be launched directly by Node; the launcher now says so and names the
69
+ variable to point at the `.exe` instead of failing with `EINVAL`.
70
+ - **`unsnooze doctor` names each agent's binary** as the launcher would resolve
71
+ it, and reports an agent that cannot be launched as a health problem — on
72
+ Windows with the note that the daemon's environment can lag the shell's and
73
+ how to refresh it. Only for agents that have run on the machine: Claude and
74
+ Codex are both on by default, and a Claude-only machine is not unhealthy for
75
+ having no `codex`. The lookup follows spawn's own Windows rules: an `.exe`
76
+ anywhere on PATH wins over an npm shim earlier on it, quoted PATH entries
77
+ count, and a path given without its extension finds the `.exe`.
78
+ - **Codex stops bind to the right reset.** A `rate_limit_reached` at a reported
79
+ 99.x% was scheduled for the *weekly* reset (the latest one), days out; it now
80
+ binds the window nearest exhaustion. A workspace wall — credits depleted or
81
+ a spend cap, with no window spent — has no reset to wait for: it is announced
82
+ as needing you rather than "resumes when the limit resets", probed, and then
83
+ held with the remedy (add credits or raise the cap, then continue the
84
+ session in Codex) instead of a blind wake. A spent window (99% or more)
85
+ still governs when a workspace reason rides along, since its reset is what
86
+ brings the plan allowance back. `unsnooze status --json` shows the reason as
87
+ `limitReason`.
88
+ - **A weaker reading no longer undoes an exact reset.** A re-detected stop
89
+ whose only evidence is a banner ("Try again later.") used to replace the
90
+ exact reset the rollout snapshot had given, turning a known wake into a
91
+ 15-minute probe; the stronger schedule now stands until it passes, the rule
92
+ pane detection already followed. `unsnooze usage` in the dashboard also
93
+ stops reading Codex burn as idle whenever the daemon is running.
94
+
95
+ The Windows resolution follows the layout in the report and wants a Windows
96
+ run before it is trusted; continuing an open Codex Desktop thread in place
97
+ (`codex queue`) is a separate follow-up. Thanks to
98
+ [@nwn900](https://github.com/nwn900) for the precise report.
99
+
100
+ ### Codex stops behind a proxy, and in reverted threads
101
+
102
+ [Issue #27](https://github.com/saaranshM/unsnooze/issues/27) found two ways
103
+ a Codex desktop-app or IDE session went untracked:
104
+
105
+ - **Reverted threads are watched.** An edited, regenerated or `/undo`-ed
106
+ thread continues in `rollout-<ts>-<thread id>_<rollout id>.jsonl`, a name
107
+ the rollout watcher never opened. Those files are now watched, counted by
108
+ `unsnooze usage`, and resumed by the stable thread id.
109
+ - **A stop recorded only as an error is a stop.** Behind an OpenAI-compatible
110
+ proxy the rate-limit headers never reach Codex, every snapshot has empty
111
+ windows, and no stop was ever detected. Since codex-cli 0.145 the failed
112
+ turn's `task_complete` carries the limit error itself; unsnooze now reads
113
+ it and dates it from the banner the way a scraped pane is dated ("Try again
114
+ later." is probed). When the same turn also wrote an exhausted snapshot,
115
+ its exact reset still wins, and a workspace wall — in the snapshot or in
116
+ the banner's own words — stays a wall. Only the banner counts: Codex marks
117
+ billing and plan errors ("Quota exceeded…", "upgrade to Plus…") with the
118
+ same `usage_limit_exceeded` code, and those are not stops, nor is a bare 429,
119
+ which carries no reset time.
120
+ - **Codex's current banner is recognized.** Codex now writes "You’ve hit your
121
+ usage limit" with a typographic apostrophe; the anchor accepts both, in
122
+ rollouts and in panes.
123
+ - **A reset in the banner's own minute is not tomorrow.** Codex prints the
124
+ reset time without seconds, so a limit hit at 7:36:15 that resets at
125
+ 7:36:40 says "try again at 7:36 AM" — which was read as 7:36 the next
126
+ morning, a day late. It now wakes at 7:37.
127
+
128
+ Thanks to [@d-jiao](https://github.com/d-jiao) for the report, the fix in
129
+ [PR #28](https://github.com/saaranshM/unsnooze/pull/28), and checking it
130
+ against 523 real rollouts.
131
+
3
132
  ## 1.19.0 — 2026-09-10
4
133
 
5
134
  Fish shell support, direct help/version commands, and a fix for Codex sessions
package/README.md CHANGED
@@ -244,6 +244,15 @@ the session files those surfaces already write:
244
244
  one minute by an empty `premium` bucket with no credits, using the previous
245
245
  future reset time. The snapshots must come from the same rollout; 99% alone
246
246
  is not a stop signal. This works across watcher polls and daemon restarts.
247
+ Since codex-cli 0.145 the failed turn's `task_complete` also carries the
248
+ limit error itself (`codex_error_info: "usage_limit_exceeded"` plus the
249
+ banner text), and unsnooze reads that too — it is the only signal when
250
+ Codex runs behind an OpenAI-compatible proxy (`model_providers.<x>.base_url`),
251
+ where the rate-limit headers never reach Codex and every snapshot has empty
252
+ windows. The banner's time is parsed like a scraped pane; "Try again later."
253
+ takes the probe path. Reverted threads (the desktop app's edit/regenerate,
254
+ `/undo`) continue in `rollout-<ts>-<thread id>_<rollout id>.jsonl`; those
255
+ files are watched and resumed by the stable thread id.
247
256
  - **Claude desktop (cowork) sessions** *(experimental, macOS)* run in
248
257
  sandboxes under `~/Library/Application Support/Claude`; unsnooze watches
249
258
  those too and revives with the session's isolated `CLAUDE_CONFIG_DIR`
@@ -719,7 +728,10 @@ herdr restores saved agent panes itself, so a revival on top would resume the
719
728
  same conversation twice.
720
729
  ² Into a fresh workspace; cmux has no joinable named-session model.
721
730
  ³ As a detached process. Its output goes to `~/.unsnooze/headless/<session>.log`,
722
- since there is no scrollback to read it out of later.
731
+ since there is no scrollback to read it out of later; the prompt rides in argv
732
+ (`claude --resume <id> "…"`, `codex exec resume <id> "…"`), and a revival that
733
+ exits non-zero is retried, with its last output as the `last error` in
734
+ `unsnooze status`.
723
735
  ⁴ Nothing to wrap into — your own terminal is the session.
724
736
 
725
737
  Pick one explicitly with `unsnooze config set multiplexer tmux|zellij|herdr|cmux|headless`.
@@ -758,12 +770,18 @@ Native Windows works: PowerShell wrappers, a cmd-safe StopFailure hook, and a
758
770
  logon-triggered Scheduled Task for the daemon. Install as usual and run
759
771
  `unsnooze doctor` to confirm.
760
772
 
761
- Two honest caveats. The daemon is started at logon but not restarted if it
773
+ Three honest caveats. The daemon is started at logon but not restarted if it
762
774
  dies — Task Scheduler is not a supervisor the way launchd and systemd are — so
763
775
  after upgrading unsnooze, log out and back in (or run `unsnooze daemon`
764
- yourself). And stored-password fleet hosts still need Git-for-Windows or WSL
765
- `ssh`; native `ssh.exe` requires an `.exe` askpass helper unsnooze does not yet
766
- ship.
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
781
+ `UNSNOOZE_CODEX_BIN` (or `UNSNOOZE_CLAUDE_BIN`, …) pins a path; point it at an
782
+ `.exe`, since Node cannot launch a `.cmd` shim directly. And stored-password
783
+ fleet hosts still need Git-for-Windows or WSL `ssh`; native `ssh.exe` requires
784
+ an `.exe` askpass helper unsnooze does not yet ship.
767
785
 
768
786
  WSL remains the richer option, because that is where the Unix multiplexers live
769
787
  — and where the agent CLIs often already are:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.19.0",
3
+ "version": "1.19.1",
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",
@@ -11,8 +11,9 @@
11
11
  // the overload path, never the ledger.
12
12
 
13
13
  import { openSync, readSync, closeSync, readdirSync, statSync, existsSync } from 'node:fs';
14
- import { join } from 'node:path';
14
+ import { join, basename } from 'node:path';
15
15
  import { CODEX_DIR } from '../config.js';
16
+ import { findOnPath } from '../which.js';
16
17
 
17
18
  // Since the 2026 unified ChatGPT desktop app absorbed the Codex app, the codex
18
19
  // binary ships INSIDE the app bundle and many machines have no standalone
@@ -21,21 +22,59 @@ import { CODEX_DIR } from '../config.js';
21
22
  // codex-cli 0.144 from ChatGPT.app).
22
23
  export const CHATGPT_CODEX_BIN = '/Applications/ChatGPT.app/Contents/Resources/codex';
23
24
 
24
- function codexOnPath(env = process.env) {
25
- return (env.PATH || '').split(':').some(dir => {
26
- try { return dir && existsSync(join(dir, 'codex')); } catch { return false; }
27
- });
25
+ // Windows keeps the Desktop/Store install's CLI under a versioned runtime
26
+ // directory — %LOCALAPPDATA%\OpenAI\Codex\bin\<hash>\codex.exe — whose name
27
+ // changes on every update. The interactive shell finds it through a PATH the
28
+ // app rewrote; a daemon started at logon keeps the PATH it was born with,
29
+ // which after an update names a directory that no longer exists (#25). So
30
+ // look the directory up at launch time and take the newest runtime, the way
31
+ // CHATGPT_CODEX_BIN covers the macOS bundle. Layout as reported by the
32
+ // issue's Microsoft Store install; a machine without it just gets null.
33
+ export function windowsBundledCodex({ env = process.env, readdir = readdirSync, stat = statSync } = {}) {
34
+ if (!env.LOCALAPPDATA) return null;
35
+ const base = join(env.LOCALAPPDATA, 'OpenAI', 'Codex', 'bin');
36
+ let entries;
37
+ try { entries = readdir(base, { withFileTypes: true }); } catch { return null; }
38
+ let best = null;
39
+ for (const e of entries) {
40
+ if (!e.isDirectory()) continue;
41
+ const exe = join(base, e.name, 'codex.exe');
42
+ let mtime;
43
+ try { mtime = stat(exe).mtimeMs; } catch { continue; }
44
+ if (!best || mtime > best.mtime) best = { exe, mtime };
45
+ }
46
+ return best ? best.exe : null;
28
47
  }
29
48
 
30
- export function resolveCodexBin({ env = process.env, onPath = () => codexOnPath(env), exists = existsSync } = {}) {
49
+ // What to spawn for codex. Order, on Windows: an explicit UNSNOOZE_CODEX_BIN,
50
+ // a codex.exe on PATH (spawn resolves the bare name itself), the newest
51
+ // Desktop/Store runtime, then a .cmd/.bat shim on PATH by full path — Node
52
+ // refuses to spawn those without a shell, and a full path makes the launcher's
53
+ // error say which file it was. Elsewhere: PATH, then the macOS app bundle.
54
+ export function resolveCodexBin({
55
+ env = process.env,
56
+ platform = process.platform,
57
+ exists = existsSync,
58
+ onPath = null,
59
+ bundled = () => windowsBundledCodex({ env }),
60
+ } = {}) {
31
61
  if (env.UNSNOOZE_CODEX_BIN) return env.UNSNOOZE_CODEX_BIN;
32
- if (onPath()) return 'codex';
62
+ if (platform === 'win32') {
63
+ if (findOnPath(['codex.exe'], { env, exists, platform })) return 'codex';
64
+ const runtime = bundled();
65
+ if (runtime) return runtime;
66
+ const shim = findOnPath(['codex.cmd', 'codex.bat'], { env, exists, platform });
67
+ if (shim) return shim.path;
68
+ return 'codex';
69
+ }
70
+ const found = onPath ? onPath() : !!findOnPath(['codex'], { env, exists, platform });
71
+ if (found) return 'codex';
33
72
  if (exists(CHATGPT_CODEX_BIN)) return CHATGPT_CODEX_BIN;
34
73
  return 'codex'; // neither — the launcher degrades gracefully on spawn error
35
74
  }
36
75
 
37
76
  const LIMIT_ANCHORS = [
38
- /You've hit your usage limit/i,
77
+ /You['’]ve hit your usage limit/i, // the desktop app also emits a curly apostrophe
39
78
  /Your workspace is out of credits/i,
40
79
  /hit your spend cap/i,
41
80
  ];
@@ -62,11 +101,28 @@ export const patterns = {
62
101
  transientPatterns: [/stream error/i, /exceeded retry limit/i],
63
102
  };
64
103
 
65
- // Sessions live in ~/.codex/sessions/YYYY/MM/DD/rollout-{ts}-{UUID}.jsonl;
104
+ // Sessions live in ~/.codex/sessions/YYYY/MM/DD/rollout-{ts}-{THREAD}.jsonl;
66
105
  // the first JSONL line carries the session cwd. Conservative: no cwd match →
67
106
  // null (the resumer then uses `codex resume --last`, which codex itself scopes
68
107
  // to the launch cwd).
69
- export const ROLLOUT_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
108
+ //
109
+ // A reverted thread (the desktop app's edit/regenerate, `/undo`) continues in
110
+ // rollout-{ts}-{THREAD}_{ROLLOUT}.jsonl — codex-rs/rollout/src/rollout_file_name.rs:
111
+ // "filenames for reverted threads append an underscore and a distinct rollout
112
+ // ID after the stable thread ID". Group 1 is always the thread id, which is
113
+ // the id `codex resume` takes; the rollout id after the underscore is not.
114
+ const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
115
+ export const ROLLOUT_RE = new RegExp(`^rollout-.*-(${UUID})(?:_${UUID})?\\.jsonl$`, 'i');
116
+
117
+ // The thread uuid a rollout path names, or null for anything else. Cheaper than
118
+ // rolloutMeta() (no read) and the same id in every rollout seen so far. A
119
+ // reverted thread's `…_<rollout id>` file names the same thread, so usage
120
+ // readings compare across the two files of one conversation.
121
+ export function rolloutId(path) {
122
+ if (typeof path !== 'string') return null;
123
+ const m = basename(path).match(ROLLOUT_RE);
124
+ return m ? m[1].toLowerCase() : null;
125
+ }
70
126
 
71
127
  function fileHead(path, bytes = 4096) {
72
128
  let fd;
@@ -115,12 +171,40 @@ export default {
115
171
  experimental: false,
116
172
  patterns,
117
173
  menu: null, // no interactive limit menu
174
+ // What to do about a workspace wall (credits depleted, a spend cap): no
175
+ // window reset clears it, so the resumer's ceiling notification names this.
176
+ // Not `unsnooze resume-now`: the hold leaves the record failed, and
177
+ // resume-now only wakes stopped ones — it would answer "no matching stopped
178
+ // sessions" to the one command the notification told the user to run.
179
+ modelRemedy: 'add credits or raise the spend cap in the ChatGPT workspace (or ask its owner to), then continue the session in Codex',
118
180
  // Resume takes the prompt in argv — `codex resume <id> "msg"` starts the turn
119
181
  // immediately, nothing to type into the TUI.
120
- resumeArgs(sessionId, message) {
182
+ //
183
+ // canType: false (headless — no pane): the TUI cannot run there at all. It
184
+ // refuses a non-TTY stdin before it looks at the session ("Error: stdin is
185
+ // not a terminal", exit 1 — reproduced against codex-cli 0.150 with the
186
+ // headless backend's exact stdio), so `codex exec resume <id> "msg"` carries
187
+ // the same conversation forward non-interactively instead (#25). With
188
+ // --last, codex reads a lone positional as the prompt, not a session id.
189
+ //
190
+ // `exec` also refuses any directory that is not a git repository unless
191
+ // told --skip-git-repo-check ("Not inside a trusted directory…", exit 1),
192
+ // a guard the TUI never had: without the flag, a session that ran in a
193
+ // plain folder could never be revived headless. It is the user's own
194
+ // session, resumed where it already ran, so the flag goes on.
195
+ //
196
+ // extraArgsAt: resumeExtraArgs go right after `exec`, not at the end. The
197
+ // `resume` subcommand rejects -s/--sandbox, -p/--profile and --add-dir
198
+ // ("unexpected argument", exit 2) — exactly what a user sets to match
199
+ // their normal launch — while `exec` takes them. (TUI-only flags such as
200
+ // -a or --search have no exec equivalent and still fail, visibly.)
201
+ resumeArgs(sessionId, message, { canType = true } = {}) {
202
+ const tail = sessionId ? [sessionId, message] : ['--last', message];
203
+ if (canType) return { args: ['resume', ...tail], messageViaPane: false };
121
204
  return {
122
- args: sessionId ? ['resume', sessionId, message] : ['resume', '--last', message],
205
+ args: ['exec', '--skip-git-repo-check', 'resume', ...tail],
123
206
  messageViaPane: false,
207
+ extraArgsAt: 1,
124
208
  };
125
209
  },
126
210
  // v1: every agent launches the bare TUI and gets the prompt typed once idle.
package/src/cli.js CHANGED
@@ -90,6 +90,10 @@ export async function cmdStatus(args = []) {
90
90
  sessions: Object.values(state.sessions).map(s => ({
91
91
  key: s.key, sessionId: s.sessionId ?? null, agent: s.agent ?? 'claude',
92
92
  cwd: s.cwd ?? null, status: s.status, limitType: s.limitType ?? null,
93
+ // Why the provider refused, when it said (Codex's rate_limit_reached_type,
94
+ // e.g. workspace_member_credits_depleted) — the reason a stop is probed
95
+ // and held rather than scheduled.
96
+ limitReason: s.limitReason ?? null,
93
97
  resetAt: s.resetAt ?? null, resetSource: s.resetSource ?? null,
94
98
  mux: s.mux ?? null, pane: s.pane ?? null, muxSession: s.muxSession ?? null,
95
99
  attempts: s.attempts ?? 0, lastError: s.lastError ?? null,
package/src/config.js CHANGED
@@ -229,6 +229,10 @@ export const PROBE_INTERVAL_MS = envInt('UNSNOOZE_PROBE_INTERVAL_MS', 15 * 60_00
229
229
  export const PROBE_MAX_MS = envInt('UNSNOOZE_PROBE_MAX_MS', 60 * 60_000);
230
230
  export const STAGGER_MS = envInt('UNSNOOZE_STAGGER_MS', 8_000);
231
231
  export const VERIFY_DELAY_MS = envInt('UNSNOOZE_VERIFY_DELAY_MS', 20_000);
232
+ // A headless revival still running at verify time is watched this long before
233
+ // it counts as resumed: the failures worth catching (an auth refresh that gives
234
+ // up, a retry loop on a bad network) take longer than VERIFY_DELAY_MS to exit.
235
+ export const HEADLESS_SETTLE_MS = envInt('UNSNOOZE_HEADLESS_SETTLE_MS', 2 * 60_000);
232
236
  export const BUSY_DEFER_MS = envInt('UNSNOOZE_BUSY_DEFER_MS', 60_000);
233
237
  export const READY_TIMEOUT_MS = envInt('UNSNOOZE_READY_TIMEOUT_MS', 60_000);
234
238
  export const EVENT_MARKER_TTL_MS = envInt('UNSNOOZE_EVENT_MARKER_TTL_MS', 120_000);
@@ -254,6 +258,8 @@ export const MUX_NAMES = ['tmux', 'zellij', 'herdr', 'cmux', 'headless'];
254
258
  // Where a headless revive tees the agent's output. There is no pane to scroll
255
259
  // back through, so the log is the only record of what an unattended run did.
256
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).
262
+ export const WINDOWS_TASK_NAME = 'unsnooze';
257
263
 
258
264
  // Pane scanning
259
265
  export const PANE_SCAN_LINES = envInt('UNSNOOZE_PANE_SCAN_LINES', 12);
package/src/doctor.js CHANGED
@@ -17,6 +17,10 @@ import { shouldUseTui, formatDoctorTui } from './tui.js';
17
17
  import { designRegisteredOffline } from './design.js';
18
18
  import { powershellProfilePath } from './powershell.js';
19
19
  import { fishConfigPath } from './fish.js';
20
+ import { listAgents } from './agents/index.js';
21
+ import { getConfig } from './settings.js';
22
+ import { resolveBin } from './which.js';
23
+ import { WINDOWS_TASK_NAME, CLAUDE_DIR, CODEX_DIR } from './config.js';
20
24
 
21
25
  const log = makeLogger('doctor');
22
26
 
@@ -187,6 +191,11 @@ export async function runDoctor({
187
191
  profileContent = undefined,
188
192
  autostartDir = null,
189
193
  stateDir = STATE_DIR,
194
+ env = process.env,
195
+ exists = existsSync,
196
+ agents = listAgents(),
197
+ enabled = id => !!getConfig(`agents.${id}`),
198
+ inUse = agentInUse,
190
199
  } = {}) {
191
200
  const findings = [];
192
201
 
@@ -288,6 +297,14 @@ export async function runDoctor({
288
297
  }
289
298
  } catch { /* best-effort: a PATH check must never break doctor */ }
290
299
 
300
+ // What each enabled agent would actually be launched as — resolved the way
301
+ // the launcher resolves it, from this environment. #25 was a daemon whose
302
+ // logon-time PATH no longer named the Codex runtime directory; every revival
303
+ // died with ENOENT and nothing said so until the headless log was read.
304
+ // Doctor cannot see the daemon's environment, only its own, so on Windows
305
+ // it also says how the two can differ.
306
+ for (const f of agentBinFindings({ platform, env, exists, agents, enabled, inUse })) findings.push(f);
307
+
291
308
  findings.push({
292
309
  id: 'daemon', kind: 'info',
293
310
  title: daemonRunning() ? 'resumer/daemon: running' : 'resumer/daemon: not running (starts on the next limit stop)',
@@ -310,6 +327,66 @@ export async function runDoctor({
310
327
  return { findings, healthy };
311
328
  }
312
329
 
330
+ // claude and codex are enabled by default, so "enabled" does not mean "used
331
+ // here": a machine that has only ever run one of them has nothing of the
332
+ // other's to revive, and must not be reported unhealthy for lacking its
333
+ // binary. Their own data directories say whether they have run here. The
334
+ // other agents are opt-in; the user switching one on is evidence enough.
335
+ const AGENT_HOMES = { claude: CLAUDE_DIR, codex: CODEX_DIR };
336
+ export function agentInUse(id, { exists = existsSync } = {}) {
337
+ const home = AGENT_HOMES[id];
338
+ return home ? exists(home) : true;
339
+ }
340
+
341
+ export function agentBinFindings({
342
+ platform = process.platform, env = process.env, exists = existsSync,
343
+ agents = listAgents(), enabled = id => !!getConfig(`agents.${id}`),
344
+ inUse = agentInUse,
345
+ } = {}) {
346
+ const findings = [];
347
+ const envVar = id => `UNSNOOZE_${String(id).toUpperCase()}_BIN`;
348
+ // Two commands, not `a && b`: && only exists from PowerShell 7, and the
349
+ // Windows PowerShell 5.1 that ships with the OS is a supported shell.
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)`
354
+ : '';
355
+ for (const agent of agents) {
356
+ if (!agent?.id || !enabled(agent.id)) continue;
357
+ const bin = agent.bin;
358
+ let resolved = null;
359
+ try { resolved = resolveBin(bin, { env, exists, platform }); } catch { resolved = null; }
360
+ if (resolved?.launchable) {
361
+ findings.push({
362
+ id: `agent-bin-${agent.id}`, kind: 'info',
363
+ title: `${agent.id}: ${resolved.path}`,
364
+ detail: '',
365
+ });
366
+ } else if (resolved) {
367
+ findings.push({
368
+ id: `agent-bin-${agent.id}`, kind: 'health',
369
+ title: `${agent.id} resolves to ${resolved.path}, a .cmd/.bat shim that cannot be launched directly`,
370
+ detail: ` set ${envVar(agent.id)} to the agent's .exe`,
371
+ });
372
+ } else if (!inUse(agent.id)) {
373
+ findings.push({
374
+ id: `agent-bin-${agent.id}`, kind: 'info',
375
+ title: `${agent.id}: not installed (no ${agent.id} sessions here to revive)`,
376
+ detail: '',
377
+ });
378
+ } else {
379
+ findings.push({
380
+ id: `agent-bin-${agent.id}`, kind: 'health',
381
+ title: `${agent.id} is not launchable from this environment (${bin} not found)`,
382
+ detail: ` every revival of a ${agent.id} session will fail with ENOENT\n`
383
+ + ` install it, put it on PATH, or set ${envVar(agent.id)}${windowsNote}`,
384
+ });
385
+ }
386
+ }
387
+ return findings;
388
+ }
389
+
313
390
  // --- fixes ------------------------------------------------------------------------
314
391
 
315
392
  function defaultKill(pid) {
package/src/install.js CHANGED
@@ -15,7 +15,7 @@ import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSy
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { homedir, userInfo } from 'node:os';
17
17
  import { join, dirname, delimiter } from 'node:path';
18
- import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
18
+ import { CLAUDE_SETTINGS, STATE_DIR, WINDOWS_TASK_NAME } 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';
@@ -253,7 +253,7 @@ export function installFishBlock(content, agents = ['claude'], bin = UNSNOOZE_BI
253
253
  export const DAEMON_LABEL = 'com.unsnooze.daemon';
254
254
  // Task Scheduler has no reverse-DNS convention and shows this name to the user
255
255
  // in taskschd.msc, so it is a plain word rather than the launchd label.
256
- export const WINDOWS_TASK_NAME = 'unsnooze';
256
+ export { WINDOWS_TASK_NAME };
257
257
 
258
258
  // Is this process running under the supervisor we install, rather than from
259
259
  // somebody's shell? It decides whether exiting is safe: a supervised daemon
package/src/launcher.js CHANGED
@@ -38,7 +38,14 @@ export function resolvePaneOwner(muxName, env = process.env) {
38
38
 
39
39
  function runUnwatched(agent, args, reason) {
40
40
  if (reason) process.stderr.write(`unsnooze: ${reason}\n`);
41
+ return runPassthrough(agent, args);
42
+ }
43
+
44
+ // spawnSync reports a binary it could not start as r.error with no status;
45
+ // `r.status ?? 1` alone turned `spawn codex ENOENT` into a silent exit 1.
46
+ function runPassthrough(agent, args) {
41
47
  const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
48
+ if (r.error) return launchFailed(agent, r.error);
42
49
  return r.status ?? 1;
43
50
  }
44
51
 
@@ -54,8 +61,7 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
54
61
  // or spawning a watcher for `claude --help` flashes the screen and slows down
55
62
  // simple queries.
56
63
  if (process.env.UNSNOOZE_ACTIVE === '1' || isPassthrough(args) || !getConfig(`agents.${agent.id}`)) {
57
- const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
58
- return r.status ?? 1;
64
+ return runPassthrough(agent, args);
59
65
  }
60
66
 
61
67
  // Flags the user wants on every session they start (claude's --autocompact
@@ -166,10 +172,17 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
166
172
  UNSNOOZE_PANE: pane || '', UNSNOOZE_PANE_OWNER: paneOwner || '',
167
173
  UNSNOOZE_LEASE_ID: leaseId,
168
174
  };
169
- const child = spawn(agent.bin, args, {
170
- stdio: 'inherit',
171
- env: childEnv,
172
- });
175
+ // The bin can be a path we resolved ourselves (a Windows .cmd shim, say).
176
+ // Node ≥ 20.12 refuses to spawn .bat/.cmd without a shell, and depending on
177
+ // the version that is a synchronous throw rather than an 'error' event —
178
+ // either way it has to come out as the same "failed to launch" line, with
179
+ // the reason, never as a crash with no exit code for the resumer to read.
180
+ let child;
181
+ try {
182
+ child = spawn(agent.bin, args, { stdio: 'inherit', env: childEnv });
183
+ } catch (err) {
184
+ return launchFailed(agent, err);
185
+ }
173
186
  const lease = pane && child.pid ? {
174
187
  leaseId, mux: mux.name, paneOwner, pane, agent: agent.id,
175
188
  pid: child.pid, pidBirth: processBirthFn(child.pid),
@@ -186,8 +199,19 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
186
199
  child.on('exit', code => { cleanup(); resolve(code ?? 1); });
187
200
  child.on('error', err => {
188
201
  cleanup();
189
- process.stderr.write(`unsnooze: failed to launch ${agent.bin}: ${err.message}\n`);
190
- resolve(127);
202
+ resolve(launchFailed(agent, err));
191
203
  });
192
204
  });
193
205
  }
206
+
207
+ // One place for the message, because the resumer reads it back out of the
208
+ // headless log when a revival dies (#25). 127 like a shell's "command not
209
+ // found"; the hint names the one misconfiguration that produces EINVAL here.
210
+ function launchFailed(agent, err) {
211
+ process.stderr.write(`unsnooze: failed to launch ${agent.bin}: ${err.message}\n`);
212
+ if (/\.(cmd|bat)$/i.test(String(agent.bin))) {
213
+ process.stderr.write(`unsnooze: ${agent.bin} is a .cmd/.bat shim, which cannot be launched directly — `
214
+ + `point UNSNOOZE_${agent.id.toUpperCase()}_BIN at the .exe\n`);
215
+ }
216
+ return 127;
217
+ }