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 +89 -0
- package/README.md +24 -13
- package/bin/unsnooze.js +90 -64
- package/package.json +1 -1
- package/src/agents/codex.js +21 -0
- 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 +11 -0
- package/src/resumer.js +29 -4
- package/src/spawn.js +262 -9
- package/src/watchers/codex.js +13 -5
- package/src/wizard.js +8 -4
- package/src/workspace.js +4 -1
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
|
-
|
|
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.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",
|
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
|
|
@@ -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
|
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 */ }
|
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
|
-
//
|
|
255
|
-
//
|
|
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
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// lesser of the two, and
|
|
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
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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({
|
|
562
|
+
export function uninstallDaemonAutostart({
|
|
563
|
+
platform = process.platform, dir = null, activate = defaultActivate, stop = stopDaemon,
|
|
564
|
+
say = () => {},
|
|
565
|
+
} = {}) {
|
|
532
566
|
if (platform === 'win32') {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
700
|
-
|
|
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
|
-
//
|
|
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', {
|
|
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 {
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/watchers/codex.js
CHANGED
|
@@ -98,10 +98,14 @@ function rolloutLimitError(line) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
function emptyPremium(rl) {
|
|
101
|
-
|
|
102
|
-
&& rl.credits?.has_credits === false && rl.credits?.unlimited === false
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
81
|
-
//
|
|
82
|
-
//
|
|
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
|
-
+
|
|
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
|
|