unsnooze 1.19.1 → 1.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/README.md +24 -13
- package/bin/unsnooze.js +90 -64
- package/package.json +1 -1
- package/src/agents/codex.js +52 -1
- package/src/agents/opencode.js +2 -1
- package/src/config.js +16 -1
- package/src/doctor.js +4 -5
- package/src/hook.js +6 -2
- package/src/install.js +103 -34
- package/src/launcher.js +21 -3
- package/src/monitor.js +22 -6
- package/src/patterns.js +23 -1
- package/src/resumer.js +29 -4
- package/src/spawn.js +262 -9
- package/src/state.js +15 -0
- package/src/watchers/codex.js +88 -7
- package/src/wizard.js +8 -4
- package/src/workspace.js +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,113 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.19.3 — 2026-09-30
|
|
4
|
+
|
|
5
|
+
Codex fixes from a single herdr report: stops that were missed, woken late,
|
|
6
|
+
or woken in the wrong place.
|
|
7
|
+
|
|
8
|
+
- The out-of-credits screen on Plus plans no longer hides a usage limit. Its
|
|
9
|
+
Upgrade / Add Credits picker pushed the banner off the part of the pane
|
|
10
|
+
unsnooze reads; the "Your rate limit resets on" line is now recognised
|
|
11
|
+
instead (#36).
|
|
12
|
+
- A reset time that a narrow pane wraps onto the next row is read correctly,
|
|
13
|
+
instead of falling back to guessing and probing (#33).
|
|
14
|
+
- A Codex stop is only marked resumed once its session log shows new output.
|
|
15
|
+
A keypress or tab switch that briefly hides the banner no longer drops the
|
|
16
|
+
stop and its exact reset time. Stops with no session log keep the old
|
|
17
|
+
behaviour (#34).
|
|
18
|
+
- A session running in herdr (or any non-default multiplexer) keeps its pane
|
|
19
|
+
address when a second detection of the same stop comes in, so it is woken
|
|
20
|
+
in its own pane instead of reopened beside it (#35).
|
|
21
|
+
|
|
22
|
+
## 1.19.2 — 2026-09-27
|
|
23
|
+
|
|
24
|
+
Setup on Windows no longer creates a Scheduled Task. That task is what
|
|
25
|
+
Microsoft Defender flagged as a Trojan.
|
|
26
|
+
|
|
27
|
+
### Website redesign
|
|
28
|
+
|
|
29
|
+
- The new night-shift design now covers the homepage, documentation, changelog,
|
|
30
|
+
and feedback page, with responsive layouts and reduced-motion support.
|
|
31
|
+
- Release metadata refreshes every five minutes so the site follows published
|
|
32
|
+
npm versions. Failed feedback-board reads now show an error instead of
|
|
33
|
+
loading indefinitely.
|
|
34
|
+
- Windows setup and troubleshooting docs describe the on-demand daemon.
|
|
35
|
+
|
|
36
|
+
### Codex wake recovery
|
|
37
|
+
|
|
38
|
+
- Team/business stops with a null credits balance now retain the exhausted
|
|
39
|
+
five-hour window's reset instead of being held as a workspace credit wall.
|
|
40
|
+
- If Codex consumes Enter as a pasted newline, verification retries Enter
|
|
41
|
+
up to three times while the exact wake prompt remains in the composer.
|
|
42
|
+
Each retry rechecks pane ownership and agent liveness. The old banner is
|
|
43
|
+
not re-recorded as a new stop, and the wake text is never pasted twice.
|
|
44
|
+
An unconfirmed submission is left visible for manual inspection.
|
|
45
|
+
|
|
46
|
+
### No Scheduled Task on Windows
|
|
47
|
+
|
|
48
|
+
A user reported that Microsoft Defender flagged `unsnooze setup` as
|
|
49
|
+
`Trojan:Win32/Commando.A!ml` as it created the `\unsnooze` Scheduled Task. It
|
|
50
|
+
was a false positive, but an understandable one. Setup ran
|
|
51
|
+
`schtasks /create /f /sc onlogon /tr "node.exe …\AppData\Roaming\npm\…\unsnooze.js daemon"`:
|
|
52
|
+
an interpreter told to run a script out of `%APPDATA%` at every logon, which is
|
|
53
|
+
exactly how malware persists, and `Commando.A!ml` is Defender's
|
|
54
|
+
machine-learning verdict on command lines like that one. A Run key or a
|
|
55
|
+
Startup-folder entry reads the same way, so the daemon uses none of them now.
|
|
56
|
+
The task rarely worked anyway: from a shell that is not elevated, `schtasks`
|
|
57
|
+
refuses an `onlogon` task with "Access is denied", and setup said it was
|
|
58
|
+
installed regardless.
|
|
59
|
+
|
|
60
|
+
On Windows the daemon is now an ordinary background process:
|
|
61
|
+
|
|
62
|
+
- **Setup starts it.** Saying yes to guarding GUI sessions (or running
|
|
63
|
+
`unsnooze install --daemon`) starts the daemon at once and records that
|
|
64
|
+
choice in `~/.unsnooze/daemon-on-demand`. Nothing is registered with Windows.
|
|
65
|
+
- **It is started again on demand.** Every agent launched through the
|
|
66
|
+
PowerShell wrappers, and every Claude Code StopFailure hook, starts the
|
|
67
|
+
daemon if it is not running. After a restart it is back as soon as you use
|
|
68
|
+
an agent.
|
|
69
|
+
- **One runs at a time.** The daemon keeps a heartbeat pidfile
|
|
70
|
+
(`~/.unsnooze/daemon.pid`), and a second daemon stands down at once. Once the
|
|
71
|
+
heartbeat is five minutes old the file no longer counts, whoever its pid
|
|
72
|
+
belongs to by then, so a pid Windows recycles after a sign-out cannot keep
|
|
73
|
+
the daemon from starting. A resumer lock left behind by a daemon that was
|
|
74
|
+
killed is cleared too, so it cannot stall every resume.
|
|
75
|
+
- **It carries no session with it.** Started from inside a Claude Code
|
|
76
|
+
session (by the hook), it used to inherit that session's environment,
|
|
77
|
+
including `CLAUDECODE=1`, Claude Code's nested-session guard. Every Claude
|
|
78
|
+
session it revived would then have refused to start. Background processes
|
|
79
|
+
now drop the variables that mark one session; your own settings, such as
|
|
80
|
+
`CLAUDE_CONFIG_DIR` and `CLAUDE_CODE_USE_BEDROCK`, are kept. They also run in
|
|
81
|
+
your home directory, not the project that happened to start them, which
|
|
82
|
+
Windows would otherwise refuse to delete or rename until sign-out. The
|
|
83
|
+
transient resumer the hook starts gets the same treatment, on every
|
|
84
|
+
platform.
|
|
85
|
+
- **No console windows.** A background daemon has no console. The `git` calls
|
|
86
|
+
it makes when a session stops and wakes, and the agent a revival starts,
|
|
87
|
+
now run hidden instead of each popping up a window of its own.
|
|
88
|
+
- **Its output goes to `~/.unsnooze/daemon.log`**, as the launchd daemon's
|
|
89
|
+
does, so a crash leaves a trace.
|
|
90
|
+
- **Re-running setup restarts it.** `unsnooze install --daemon` stops the
|
|
91
|
+
running daemon and starts a fresh one. That is how it picks up a new
|
|
92
|
+
version, or a `PATH` that now finds an agent, and `unsnooze doctor` now
|
|
93
|
+
says so instead of pointing at `schtasks`.
|
|
94
|
+
|
|
95
|
+
If you use only the Codex app or IDE on Windows, nothing you do passes through
|
|
96
|
+
a wrapper or the hook. After signing in, run `unsnooze install --daemon` (or
|
|
97
|
+
launch any wrapped agent) to start the daemon.
|
|
98
|
+
|
|
99
|
+
`unsnooze uninstall` removes the marker first, then stops the daemon, so
|
|
100
|
+
nothing can start it again halfway through; a daemon that was only starting at
|
|
101
|
+
that moment sees the marker gone and exits. It also deletes a `\unsnooze`
|
|
102
|
+
Scheduled Task left by an earlier version, after checking that one exists, so
|
|
103
|
+
a machine that never had one only ever sees a read-only query. If the delete
|
|
104
|
+
is refused (a task created from an administrator shell can need one to
|
|
105
|
+
remove), it prints the command to run. Setup does not go looking for that
|
|
106
|
+
task: that would mean running `schtasks` again. A daemon such a task already
|
|
107
|
+
started keeps running until you sign out; from the next sign-in the task only
|
|
108
|
+
starts the same on-demand daemon, which the pidfile keeps to one. macOS and
|
|
109
|
+
Linux are unchanged (launchd / systemd).
|
|
110
|
+
|
|
3
111
|
## 1.19.1 — 2026-09-21
|
|
4
112
|
|
|
5
113
|
A Codex usage reading that was averaged down, a headless Codex revival
|
package/README.md
CHANGED
|
@@ -265,7 +265,8 @@ the continued conversation stays visible in the GUI's own history. (Resuming
|
|
|
265
265
|
*inside* the GUI panel isn't possible today: no extension/app exposes an
|
|
266
266
|
IPC/URI that can send a prompt.)
|
|
267
267
|
|
|
268
|
-
Enable it in `unsnooze setup` (installs a launchd agent / systemd user unit
|
|
268
|
+
Enable it in `unsnooze setup` (installs a launchd agent / systemd user unit;
|
|
269
|
+
on Windows the daemon is started on demand instead — see [Windows](#windows)),
|
|
269
270
|
or run `unsnooze install --daemon` / `unsnooze daemon` yourself. Turn it off
|
|
270
271
|
anytime with `unsnooze config set guiWatch off`.
|
|
271
272
|
|
|
@@ -384,7 +385,8 @@ unsnooze config set <k> <v> # e.g. autoResume off
|
|
|
384
385
|
unsnooze logs [-f] # what unsnooze has been doing
|
|
385
386
|
unsnooze update # update unsnooze itself
|
|
386
387
|
unsnooze daemon # persistent GUI-session watcher (usually run
|
|
387
|
-
# by launchd/systemd via `install --daemon
|
|
388
|
+
# by launchd/systemd via `install --daemon`;
|
|
389
|
+
# on Windows, started on demand)
|
|
388
390
|
unsnooze report [agent] # capture a pane to report an undetected banner
|
|
389
391
|
unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
|
|
390
392
|
unsnooze help # full command list (also -h / --help)
|
|
@@ -767,17 +769,26 @@ Windows, WSL is still the better home.
|
|
|
767
769
|
### Windows
|
|
768
770
|
|
|
769
771
|
Native Windows works: PowerShell wrappers, a cmd-safe StopFailure hook, and a
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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.
|
|
3
|
+
"version": "1.19.3",
|
|
4
4
|
"description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, Antigravity, and Cursor CLI sessions when 5-hour or weekly usage limits reset—on macOS, Linux and Windows, with or without a terminal multiplexer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/agents/codex.js
CHANGED
|
@@ -14,6 +14,7 @@ import { openSync, readSync, closeSync, readdirSync, statSync, existsSync } from
|
|
|
14
14
|
import { join, basename } from 'node:path';
|
|
15
15
|
import { CODEX_DIR } from '../config.js';
|
|
16
16
|
import { findOnPath } from '../which.js';
|
|
17
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
17
18
|
|
|
18
19
|
// Since the 2026 unified ChatGPT desktop app absorbed the Codex app, the codex
|
|
19
20
|
// binary ships INSIDE the app bundle and many machines have no standalone
|
|
@@ -77,13 +78,19 @@ const LIMIT_ANCHORS = [
|
|
|
77
78
|
/You['’]ve hit your usage limit/i, // the desktop app also emits a curly apostrophe
|
|
78
79
|
/Your workspace is out of credits/i,
|
|
79
80
|
/hit your spend cap/i,
|
|
81
|
+
// Plus plan: an out-of-credits picker sits under the banner and can push it
|
|
82
|
+
// past the 12-line scan (#36). This line stays inside it and carries the time.
|
|
83
|
+
/Your rate limit resets on/i,
|
|
80
84
|
];
|
|
81
85
|
|
|
82
86
|
export const patterns = {
|
|
83
87
|
limitPatterns: LIMIT_ANCHORS,
|
|
84
88
|
// The whole banner is ONE line, so the anchors double as reset lines — the
|
|
85
89
|
// proximity engine then hands that line to time-parser (which falls back to
|
|
86
|
-
// the 5h default for "Try again later.").
|
|
90
|
+
// the 5h default for "Try again later."). A narrow pane soft-wraps that
|
|
91
|
+
// line, which can strand the clock on a later row; bannerWraps tells the
|
|
92
|
+
// engine to rejoin the rows before parsing.
|
|
93
|
+
bannerWraps: true,
|
|
87
94
|
resetPatterns: [
|
|
88
95
|
/try again at/i,
|
|
89
96
|
/try again in \d+/i,
|
|
@@ -101,6 +108,26 @@ export const patterns = {
|
|
|
101
108
|
transientPatterns: [/stream error/i, /exceeded retry limit/i],
|
|
102
109
|
};
|
|
103
110
|
|
|
111
|
+
// A submitted user turn uses the same › glyph as the composer. Only accept
|
|
112
|
+
// the LAST prompt, followed directly by a blank line and Codex's status footer.
|
|
113
|
+
// ponytail: truncated/collapsed pastes cannot be proved; leave those for the user.
|
|
114
|
+
export function pendingCodexPrompt(text, message) {
|
|
115
|
+
if (!message) return false;
|
|
116
|
+
const lines = stripVTControlCharacters(text).split('\n');
|
|
117
|
+
const start = lines.findLastIndex(line => /^\s*›/.test(line));
|
|
118
|
+
if (start < 0) return false;
|
|
119
|
+
const tail = lines.slice(start + 1);
|
|
120
|
+
const footer = tail.findIndex((line, i) => i > 0 && !tail[i - 1].trim()
|
|
121
|
+
&& (/\s·\s/.test(line) || /(?:\d+% context left|\? for shortcuts)/.test(line)));
|
|
122
|
+
if (footer < 0) return false;
|
|
123
|
+
if (tail.slice(footer + 1).some(line => line.trim()
|
|
124
|
+
&& !/(?:\d+% context left|\? for shortcuts)/.test(line))) return false;
|
|
125
|
+
// Ignore wrapping and the extra newline swallowed by paste-burst handling.
|
|
126
|
+
const compact = value => value.replace(/\s/g, '');
|
|
127
|
+
const draft = [lines[start].replace(/^\s*›\s?/, ''), ...tail.slice(0, footer)].join('\n');
|
|
128
|
+
return compact(draft) === compact(message);
|
|
129
|
+
}
|
|
130
|
+
|
|
104
131
|
// Sessions live in ~/.codex/sessions/YYYY/MM/DD/rollout-{ts}-{THREAD}.jsonl;
|
|
105
132
|
// the first JSONL line carries the session cwd. Conservative: no cwd match →
|
|
106
133
|
// null (the resumer then uses `codex resume --last`, which codex itself scopes
|
|
@@ -164,6 +191,30 @@ export function latestSessionId(cwd, aroundTs = null, sessionsRoot = join(CODEX_
|
|
|
164
191
|
return null;
|
|
165
192
|
}
|
|
166
193
|
|
|
194
|
+
// The rollout file a thread is writing to now: the newest of its files. A
|
|
195
|
+
// reverted thread continues in a second `…_<rollout id>` file, which is where
|
|
196
|
+
// a resumed turn lands.
|
|
197
|
+
export function rolloutPathFor(sessionId, sessionsRoot = join(CODEX_DIR, 'sessions')) {
|
|
198
|
+
if (!sessionId) return null;
|
|
199
|
+
const want = String(sessionId).toLowerCase();
|
|
200
|
+
let best = null;
|
|
201
|
+
const walk = (dir, depth) => {
|
|
202
|
+
let entries;
|
|
203
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
204
|
+
for (const e of entries) {
|
|
205
|
+
const p = join(dir, e.name);
|
|
206
|
+
if (e.isDirectory() && depth < 3) { walk(p, depth + 1); continue; }
|
|
207
|
+
const m = e.isFile() && e.name.match(ROLLOUT_RE);
|
|
208
|
+
if (!m || m[1].toLowerCase() !== want) continue;
|
|
209
|
+
let mtime;
|
|
210
|
+
try { mtime = statSync(p).mtimeMs; } catch { continue; }
|
|
211
|
+
if (!best || mtime > best.mtime) best = { path: p, mtime };
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
walk(sessionsRoot, 0);
|
|
215
|
+
return best ? best.path : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
167
218
|
export default {
|
|
168
219
|
id: 'codex',
|
|
169
220
|
name: 'OpenAI Codex CLI',
|
package/src/agents/opencode.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
//
|
|
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
|
|
352
|
-
+ `
|
|
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 */ }
|