unsnooze 1.18.0 → 1.19.0
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 +54 -0
- package/README.md +24 -8
- package/bin/unsnooze.js +8 -2
- package/package.json +1 -1
- package/src/doctor.js +4 -2
- package/src/fish.js +22 -0
- package/src/install.js +84 -2
- package/src/launcher.js +13 -4
- package/src/watcher.js +11 -7
- package/src/watchers/codex.js +79 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.19.0 — 2026-09-10
|
|
4
|
+
|
|
5
|
+
Fish shell support, direct help/version commands, and a fix for Codex sessions
|
|
6
|
+
that stop at a reported 99% usage.
|
|
7
|
+
|
|
8
|
+
### Fish shell support
|
|
9
|
+
|
|
10
|
+
`unsnooze setup` and `unsnooze install` now install fish wrappers when fish is
|
|
11
|
+
your login shell or its config file already exists. The default location is
|
|
12
|
+
`~/.config/fish/config.fish`; an absolute `XDG_CONFIG_HOME` is respected.
|
|
13
|
+
`unsnooze doctor` checks this file, and `unsnooze uninstall` removes the wrappers.
|
|
14
|
+
Re-running setup replaces the existing block and preserves your other settings.
|
|
15
|
+
Use `--fishrc <path>` with install or uninstall to choose another config file.
|
|
16
|
+
|
|
17
|
+
After upgrading, run `unsnooze setup` once to add the fish wrappers, then open
|
|
18
|
+
a new shell. Thanks to [@duncanmcqueen](https://github.com/duncanmcqueen) for
|
|
19
|
+
[PR #21](https://github.com/saaranshM/unsnooze/pull/21).
|
|
20
|
+
|
|
21
|
+
### Help and version commands run directly
|
|
22
|
+
|
|
23
|
+
Commands such as `claude --help` and `codex --version` now pass straight to
|
|
24
|
+
the agent. They do not open a multiplexer session, start a pane monitor,
|
|
25
|
+
prepend `launchExtraArgs`, or append an unsnooze update notice. The existing
|
|
26
|
+
print-mode passthrough is unchanged. Thanks to [@chid](https://github.com/chid)
|
|
27
|
+
for [PR #23](https://github.com/saaranshM/unsnooze/pull/23).
|
|
28
|
+
|
|
29
|
+
### Codex can be detected as stopped at a reported 99%
|
|
30
|
+
|
|
31
|
+
The GUI watcher now recognizes the sequence reported in
|
|
32
|
+
[issue #20](https://github.com/saaranshM/unsnooze/issues/20): a five-hour Codex
|
|
33
|
+
window reaches 99%, then the next rate-limit snapshot switches to an empty
|
|
34
|
+
`premium` bucket with no credits. It records the stop and schedules the session
|
|
35
|
+
using the previous reset time, plus the normal safety margin.
|
|
36
|
+
|
|
37
|
+
This fallback requires snapshots from the same rollout no more than a minute
|
|
38
|
+
apart, no available credits, and a future reset time. It works across watcher
|
|
39
|
+
polls and daemon restarts. An already-exhausted weekly window still takes
|
|
40
|
+
precedence if it resets later. A 99% reading alone does not trigger a resume.
|
|
41
|
+
|
|
42
|
+
The separate report of stale 64% usage was not reproduced; this release fixes
|
|
43
|
+
the missed stop detection. Thanks to [@mio-tsuki](https://github.com/mio-tsuki)
|
|
44
|
+
for the sample. Fixed in [PR #24](https://github.com/saaranshM/unsnooze/pull/24).
|
|
45
|
+
|
|
46
|
+
### Test fixes
|
|
47
|
+
|
|
48
|
+
- Permission tests now set their required file mode explicitly, so they pass
|
|
49
|
+
with different system umasks. Thanks to
|
|
50
|
+
[@Chang-Jin-Lee](https://github.com/Chang-Jin-Lee) for
|
|
51
|
+
[PR #22](https://github.com/saaranshM/unsnooze/pull/22).
|
|
52
|
+
- Fish install/uninstall tests use temporary profiles and leave existing user
|
|
53
|
+
settings, wrappers, and daemon state untouched.
|
|
54
|
+
- Help/version tests use a portable test executable instead of relying on
|
|
55
|
+
platform-specific `echo` behavior.
|
|
56
|
+
|
|
3
57
|
## 1.18.0 — 2026-09-02
|
|
4
58
|
|
|
5
59
|
**Cursor CLI support**, a wrapper change that keeps `cursor .` working, and a
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://github.com/saaranshM/unsnooze/actions/workflows/ci.yml)
|
|
8
8
|
[](https://www.npmjs.com/package/unsnooze)
|
|
9
|
-
[](package.json)
|
|
9
|
+
[](package.json)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
|
|
12
12
|
**Automatically resume every limit-stopped AI coding session when its usage limit resets.**
|
|
@@ -223,7 +223,8 @@ Two things worth knowing:
|
|
|
223
223
|
|
|
224
224
|
## GUI surfaces (VS Code extension, desktop apps)
|
|
225
225
|
|
|
226
|
-
Terminal sessions are watched through the shell wrapper
|
|
226
|
+
Terminal sessions are watched through the shell wrapper and tmux, Zellij, herdr,
|
|
227
|
+
or cmux, with headless watching when no multiplexer is available. Sessions in
|
|
227
228
|
**Claude Code's VS Code extension / desktop app** and **Codex's IDE
|
|
228
229
|
extension / desktop app** have no pane to scrape — so `unsnooze daemon` tails
|
|
229
230
|
the session files those surfaces already write:
|
|
@@ -239,6 +240,10 @@ the session files those surfaces already write:
|
|
|
239
240
|
where Codex lives only inside ChatGPT.app (no `codex` on PATH), unsnooze
|
|
240
241
|
automatically resumes through the app-bundled binary
|
|
241
242
|
(`/Applications/ChatGPT.app/Contents/Resources/codex`).
|
|
243
|
+
The watcher also recognizes a reported 99% five-hour window followed within
|
|
244
|
+
one minute by an empty `premium` bucket with no credits, using the previous
|
|
245
|
+
future reset time. The snapshots must come from the same rollout; 99% alone
|
|
246
|
+
is not a stop signal. This works across watcher polls and daemon restarts.
|
|
242
247
|
- **Claude desktop (cowork) sessions** *(experimental, macOS)* run in
|
|
243
248
|
sandboxes under `~/Library/Application Support/Claude`; unsnooze watches
|
|
244
249
|
those too and revives with the session's isolated `CLAUDE_CONFIG_DIR`
|
|
@@ -289,11 +294,11 @@ StopFailure hook (claude, grok) ──────────────┤
|
|
|
289
294
|
|
|
290
295
|
</details>
|
|
291
296
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
+
Detection uses agent hooks, saved session files, and terminal banners. Reset
|
|
298
|
+
times come from the agent's saved timestamp or the banner text, parsed DST-safe.
|
|
299
|
+
When no reset time is available, unsnooze probes on a backoff schedule. Every
|
|
300
|
+
resume is verified afterwards (banner came back → reschedule from the fresh
|
|
301
|
+
banner, capped at 5 attempts).
|
|
297
302
|
|
|
298
303
|
## Know the wall before you hit it
|
|
299
304
|
|
|
@@ -680,9 +685,20 @@ watcher stops (no pane context) always use native.
|
|
|
680
685
|
[Watching without a multiplexer](#watching-without-a-multiplexer))
|
|
681
686
|
- tmux ≥ 3.2, Zellij, **or** herdr ≥ 0.8.0 for pane-level watching. Optional:
|
|
682
687
|
without one, unsnooze runs `headless` and still catches and resumes stops.
|
|
683
|
-
- zsh or bash (wrappers go into `~/.zshrc` / `~/.bashrc`),
|
|
688
|
+
- zsh or bash (wrappers go into `~/.zshrc` / `~/.bashrc`), fish (wrappers go
|
|
689
|
+
into `~/.config/fish/config.fish`), or PowerShell
|
|
684
690
|
(wrappers go into `$PROFILE.CurrentUserAllHosts`)
|
|
685
691
|
|
|
692
|
+
Fish wrappers are installed when fish is the login shell or its config already
|
|
693
|
+
exists. An absolute `XDG_CONFIG_HOME` changes the config location to
|
|
694
|
+
`$XDG_CONFIG_HOME/fish/config.fish`; `unsnooze install --fishrc <path>` and
|
|
695
|
+
`unsnooze uninstall --fishrc <path>` override it explicitly. After upgrading an
|
|
696
|
+
existing install, run `unsnooze setup` and open a new shell to load the wrappers.
|
|
697
|
+
|
|
698
|
+
Agent help/version commands, such as `claude --help` and `codex --version`, run
|
|
699
|
+
directly without a multiplexer session, pane monitor, `launchExtraArgs`, or an
|
|
700
|
+
unsnooze update notice. `unsnooze help` still shows unsnooze's own commands.
|
|
701
|
+
|
|
686
702
|
### Supported terminals
|
|
687
703
|
|
|
688
704
|
unsnooze drives four terminal multiplexers, and works without one.
|
package/bin/unsnooze.js
CHANGED
|
@@ -69,7 +69,7 @@ async function maybeLaunchExitNotice(args) {
|
|
|
69
69
|
try {
|
|
70
70
|
if (process.env.TMUX || process.env.ZELLIJ || process.env.UNSNOOZE_ACTIVE === '1') return;
|
|
71
71
|
if (!process.stderr.isTTY) return; // pipes, CI, scripts
|
|
72
|
-
if (args.
|
|
72
|
+
if (args.some(a => a === '-p' || a === '--print' || a === '-h' || a === '--help' || a === '-v' || a === '-V' || a === '--version')) return; // non-interactive runs
|
|
73
73
|
const mod = await safeImport('../src/update-check.js');
|
|
74
74
|
if (!mod) return;
|
|
75
75
|
const notice = mod.launchExitNotice();
|
|
@@ -340,17 +340,23 @@ Usage:
|
|
|
340
340
|
unsnooze usage [--json] account burn rate & time-to-limit forecast
|
|
341
341
|
(--install-statusline for exact Claude %,
|
|
342
342
|
--uninstall-statusline to remove it)
|
|
343
|
+
unsnooze design [setup] Claude Design from the terminal: check whether
|
|
344
|
+
the claude-design MCP server is registered and
|
|
345
|
+
signed in, or register it with setup
|
|
343
346
|
unsnooze logs [-f] show (or follow) the unsnooze log
|
|
344
347
|
unsnooze update update unsnooze itself to the latest version
|
|
345
348
|
unsnooze daemon persistent watcher for GUI sessions (VS Code
|
|
346
349
|
extension, desktop apps) — no live pane needed
|
|
347
|
-
to detect; revival opens in
|
|
350
|
+
to detect; revival opens in your multiplexer,
|
|
351
|
+
or headless where there is none
|
|
348
352
|
unsnooze config [list|get|set] view or change settings (toggles, global +
|
|
349
353
|
per-agent resume messages, notifyChannel
|
|
350
354
|
auto|native|osc|bell, updateCheck)
|
|
351
355
|
unsnooze setup interactive setup wizard (agents + toggles)
|
|
352
356
|
unsnooze install [--yes] wire up shell wrappers + hooks (non-interactive)
|
|
357
|
+
(--fishrc <path> overrides the fish config)
|
|
353
358
|
unsnooze uninstall [--purge] remove wrappers + hooks (and state with --purge)
|
|
359
|
+
(--fishrc <path> overrides the fish config)
|
|
354
360
|
unsnooze report [agent] [pane] capture a pane to report an undetected banner
|
|
355
361
|
unsnooze help show this help (also -h / --help)`);
|
|
356
362
|
return 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
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/doctor.js
CHANGED
|
@@ -16,6 +16,7 @@ import { makeLogger } from './logger.js';
|
|
|
16
16
|
import { shouldUseTui, formatDoctorTui } from './tui.js';
|
|
17
17
|
import { designRegisteredOffline } from './design.js';
|
|
18
18
|
import { powershellProfilePath } from './powershell.js';
|
|
19
|
+
import { fishConfigPath } from './fish.js';
|
|
19
20
|
|
|
20
21
|
const log = makeLogger('doctor');
|
|
21
22
|
|
|
@@ -116,13 +117,14 @@ function readOrEmpty(path) {
|
|
|
116
117
|
// wrappers installed" is a health question — but it has to be asked of the
|
|
117
118
|
// right file. A PowerShell user has no ~/.zshrc, so checking only the POSIX rc
|
|
118
119
|
// files made every native-Windows `unsnooze doctor` report the wrappers
|
|
119
|
-
// missing and point the user at an install they had already run.
|
|
120
|
+
// missing and point the user at an install they had already run. A fish user
|
|
121
|
+
// has no POSIX rc wrapper either — their block lives in the fish config.
|
|
120
122
|
function defaultWrappersInstalled({
|
|
121
123
|
platform = process.platform,
|
|
122
124
|
rcContent = readOrEmpty,
|
|
123
125
|
profileContent = null,
|
|
124
126
|
} = {}) {
|
|
125
|
-
const rcs = [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')];
|
|
127
|
+
const rcs = [join(homedir(), '.zshrc'), join(homedir(), '.bashrc'), fishConfigPath()];
|
|
126
128
|
if (rcs.some(rc => rcContent(rc).includes(WRAPPER_MARK))) return true;
|
|
127
129
|
if (platform !== 'win32') return false;
|
|
128
130
|
const readProfile = profileContent
|
package/src/fish.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Where the fish shell looks for its config.
|
|
2
|
+
//
|
|
3
|
+
// A leaf module (node builtins only) on purpose — the same reason
|
|
4
|
+
// powershell.js exists: install.js already imports doctor.js, and doctor.js
|
|
5
|
+
// needs this to answer "are the wrappers installed?" for fish users.
|
|
6
|
+
// Declaring it in install.js would close that loop (see
|
|
7
|
+
// test/module-graph.test.js for what that breaks).
|
|
8
|
+
//
|
|
9
|
+
// fish reads its config from <config-home>/fish/config.fish, where
|
|
10
|
+
// <config-home> honors XDG_CONFIG_HOME (absolute paths only — the XDG spec
|
|
11
|
+
// says a relative value must be ignored, and fish follows that).
|
|
12
|
+
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join, isAbsolute } from 'node:path';
|
|
15
|
+
|
|
16
|
+
export function fishConfigPath({ env = process.env, home = homedir() } = {}) {
|
|
17
|
+
// Test/CI override, matching UNSNOOZE_QWEN_DIR and the autostart dir vars.
|
|
18
|
+
if (env.UNSNOOZE_FISH_CONFIG) return env.UNSNOOZE_FISH_CONFIG;
|
|
19
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
20
|
+
if (xdg && isAbsolute(xdg)) return join(xdg, 'fish', 'config.fish');
|
|
21
|
+
return join(home, '.config', 'fish', 'config.fish');
|
|
22
|
+
}
|
package/src/install.js
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
// atomic write)
|
|
6
6
|
// - ~/.zshrc + ~/.bashrc: one fence-marked block with a wrapper function per
|
|
7
7
|
// enabled agent (claude/codex/grok), routed through `unsnooze _run`
|
|
8
|
+
// - ~/.config/fish/config.fish: the same block in fish syntax (fish shares
|
|
9
|
+
// no syntax with POSIX rc files — a bash function there is a parse error)
|
|
8
10
|
// - ~/.grok/hooks/unsnooze.json when the grok agent is enabled
|
|
9
|
-
// --settings <path> / --zshrc <path> override targets (used
|
|
11
|
+
// --settings <path> / --zshrc <path> / --fishrc <path> override targets (used
|
|
12
|
+
// by tests).
|
|
10
13
|
|
|
11
14
|
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
12
15
|
import { execFileSync } from 'node:child_process';
|
|
@@ -21,6 +24,7 @@ import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
|
|
|
21
24
|
import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
|
|
22
25
|
import { uninstallStatuslineShim } from './usage.js';
|
|
23
26
|
import { powershellProfilePath } from './powershell.js';
|
|
27
|
+
import { fishConfigPath } from './fish.js';
|
|
24
28
|
|
|
25
29
|
const FENCE_OPEN = '# >>> unsnooze >>>';
|
|
26
30
|
const FENCE_CLOSE = '# <<< unsnooze <<<';
|
|
@@ -33,13 +37,17 @@ const LEGACY_FENCES = [
|
|
|
33
37
|
const OLD_FENCE_OPEN = LEGACY_FENCES[0].open;
|
|
34
38
|
|
|
35
39
|
function parseArgs(rest) {
|
|
36
|
-
const opts = {
|
|
40
|
+
const opts = {
|
|
41
|
+
yes: false, settings: CLAUDE_SETTINGS, zshrc: join(homedir(), '.zshrc'),
|
|
42
|
+
fishrc: fishConfigPath(), purge: false, daemon: false,
|
|
43
|
+
};
|
|
37
44
|
for (let i = 0; i < rest.length; i++) {
|
|
38
45
|
if (rest[i] === '--yes' || rest[i] === '-y') opts.yes = true;
|
|
39
46
|
else if (rest[i] === '--purge') opts.purge = true;
|
|
40
47
|
else if (rest[i] === '--daemon') opts.daemon = true;
|
|
41
48
|
else if (rest[i] === '--settings') opts.settings = rest[++i];
|
|
42
49
|
else if (rest[i] === '--zshrc') opts.zshrc = rest[++i];
|
|
50
|
+
else if (rest[i] === '--fishrc') opts.fishrc = rest[++i];
|
|
43
51
|
}
|
|
44
52
|
return opts;
|
|
45
53
|
}
|
|
@@ -204,6 +212,38 @@ export function installZshrcBlock(content, agents = ['claude']) {
|
|
|
204
212
|
return { content: result, oldRemoved };
|
|
205
213
|
}
|
|
206
214
|
|
|
215
|
+
// The fish twin of wrapperBlock(). Fish shares no syntax with the POSIX block:
|
|
216
|
+
// `name() { … }` is a parse error there, `$?` is `$status`, and `"${X}"` is
|
|
217
|
+
// `"${X}"` verbatim text — so the wrapper gets its own body. Same semantics as
|
|
218
|
+
// the zsh/bash wrapper: a recursion guard (UNSNOOZE_ACTIVE), the load-bearing
|
|
219
|
+
// missing-file fallback to the real CLI, and `_run <id>` routing.
|
|
220
|
+
//
|
|
221
|
+
// Redefining a function replaces any earlier one, which covers fish "aliases"
|
|
222
|
+
// (they are functions too) — no `unalias` analogue is needed. Fish comments
|
|
223
|
+
// are `#`, so the same fence markers work and stripFencedBlock() removes it
|
|
224
|
+
// unchanged.
|
|
225
|
+
export function fishWrapperBlock(agents = ['claude'], bin = UNSNOOZE_BIN) {
|
|
226
|
+
const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `function ${name} --description 'unsnooze-wrapped ${name}'
|
|
227
|
+
if test "$UNSNOOZE_ACTIVE" = "1"; or not test -f '${bin}'
|
|
228
|
+
command ${name} $argv
|
|
229
|
+
return $status
|
|
230
|
+
end
|
|
231
|
+
node '${bin}' _run ${id} $argv
|
|
232
|
+
end`)).join('\n');
|
|
233
|
+
return `${FENCE_OPEN}
|
|
234
|
+
# unsnooze wrappers: route every interactive launch of the CLIs below through
|
|
235
|
+
# unsnooze so limit stops are recorded and auto-resumed.
|
|
236
|
+
${fns}
|
|
237
|
+
${FENCE_CLOSE}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// installZshrcBlock's fish twin. Same fence, same replace-don't-append
|
|
241
|
+
// behaviour, so re-running setup never stacks a second copy of the wrappers.
|
|
242
|
+
export function installFishBlock(content, agents = ['claude'], bin = UNSNOOZE_BIN) {
|
|
243
|
+
const { content: cleaned } = stripFencedBlock(content, FENCE_OPEN, FENCE_CLOSE);
|
|
244
|
+
return cleaned.replace(/\n+$/, '\n') + '\n' + fishWrapperBlock(agents, bin) + '\n';
|
|
245
|
+
}
|
|
246
|
+
|
|
207
247
|
// --- daemon autostart ---
|
|
208
248
|
// GUI sessions (VS Code extension, desktop apps) never pass through the shell
|
|
209
249
|
// wrappers, so their limit stops are only caught while `unsnooze daemon` is
|
|
@@ -555,6 +595,22 @@ function rcTargets(opts, explicit) {
|
|
|
555
595
|
return existing.length > 0 ? existing : [opts.zshrc];
|
|
556
596
|
}
|
|
557
597
|
|
|
598
|
+
// The fish config is a separate target from the POSIX rc loop: fish syntax
|
|
599
|
+
// shares nothing with bash, so it gets its own block, and fish reads only
|
|
600
|
+
// <config-home>/fish/config.fish. Touch it when the file already exists (the
|
|
601
|
+
// same any-rc rule as zsh/bash), or when fish is the login shell — a fish
|
|
602
|
+
// user may not have created config.fish yet, and a wrapper that would never
|
|
603
|
+
// load is the same silent-miss as a profile written to a guessed path. Empty
|
|
604
|
+
// list = no evidence of fish on this machine; leave it alone.
|
|
605
|
+
function fishTargets(opts, explicit) {
|
|
606
|
+
if (explicit) return [opts.fishrc];
|
|
607
|
+
const cfg = fishConfigPath();
|
|
608
|
+
if (existsSync(cfg)) return [cfg];
|
|
609
|
+
const loginShell = process.env.SHELL || userInfo().shell;
|
|
610
|
+
if (typeof loginShell === 'string' && /(^|\/|\\)fish$/.test(loginShell)) return [cfg];
|
|
611
|
+
return [];
|
|
612
|
+
}
|
|
613
|
+
|
|
558
614
|
export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
|
|
559
615
|
const opts = parseArgs(rest);
|
|
560
616
|
const explicitRc = rest.includes('--zshrc');
|
|
@@ -623,6 +679,21 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
|
|
|
623
679
|
}
|
|
624
680
|
}
|
|
625
681
|
|
|
682
|
+
// 3c. Fish config. The POSIX loop above is a parse error in fish, and the
|
|
683
|
+
// PowerShell profile never loads there — fish needs its own block or a
|
|
684
|
+
// fish user's `claude` never routes through unsnooze at all.
|
|
685
|
+
for (const cfg of fishTargets(opts, rest.includes('--fishrc'))) {
|
|
686
|
+
try {
|
|
687
|
+
mkdirSync(dirname(cfg), { recursive: true });
|
|
688
|
+
const before = existsSync(cfg) ? readFileSync(cfg, 'utf-8') : '';
|
|
689
|
+
if (existsSync(cfg)) backupOnce(cfg);
|
|
690
|
+
atomicWrite(cfg, installFishBlock(before, agents, UNSNOOZE_BIN));
|
|
691
|
+
console.log(`unsnooze: fish wrappers (${agents.join(', ')}) installed in ${cfg}`);
|
|
692
|
+
} catch (err) {
|
|
693
|
+
console.log(`unsnooze: could not write the fish config (${err.message})`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
626
697
|
// 4. Daemon autostart (GUI-session watching), opt-in via --daemon / wizard.
|
|
627
698
|
if (opts.daemon) {
|
|
628
699
|
const target = installDaemonAutostart();
|
|
@@ -698,6 +769,17 @@ export function cmdUninstall(rest) {
|
|
|
698
769
|
console.log(`unsnooze: could not clean the PowerShell profile (${err.message})`);
|
|
699
770
|
}
|
|
700
771
|
|
|
772
|
+
// The fish config is a wrapper site too, same reason as the PowerShell
|
|
773
|
+
// profile above: a stale block would keep shadowing the real CLI.
|
|
774
|
+
for (const cfg of fishTargets(opts, rest.includes('--fishrc'))) {
|
|
775
|
+
if (!existsSync(cfg)) continue;
|
|
776
|
+
const { content, found } = stripFencedBlock(readFileSync(cfg, 'utf-8'), FENCE_OPEN, FENCE_CLOSE);
|
|
777
|
+
if (found) {
|
|
778
|
+
atomicWrite(cfg, content);
|
|
779
|
+
console.log(`unsnooze: fish wrappers removed from ${cfg}`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
701
783
|
const autostart = uninstallDaemonAutostart();
|
|
702
784
|
if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
|
|
703
785
|
|
package/src/launcher.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// - outside a multiplexer: re-exec through its launchWrapped operation
|
|
3
3
|
// - inside one: spawn a detached per-pane monitor, then run the CLI,
|
|
4
4
|
// propagating its exit code
|
|
5
|
-
// - -p/--print: pure pass-through, no monitor (nothing interactive to scrape)
|
|
5
|
+
// - -p/--print, -h/--help, -v/-V/--version: pure pass-through, no monitor (nothing interactive to scrape)
|
|
6
6
|
|
|
7
7
|
import { spawn, spawnSync } from 'node:child_process';
|
|
8
8
|
import { getMultiplexer } from './multiplexer.js';
|
|
@@ -16,8 +16,12 @@ import { AgentDispatchedError, SessionCreateError, attachHint } from './multiple
|
|
|
16
16
|
|
|
17
17
|
const log = makeLogger('launcher');
|
|
18
18
|
|
|
19
|
-
function
|
|
20
|
-
return args.
|
|
19
|
+
export function isPassthrough(args = []) {
|
|
20
|
+
return args.some(a =>
|
|
21
|
+
a === '-p' || a === '--print'
|
|
22
|
+
|| a === '-h' || a === '--help'
|
|
23
|
+
|| a === '-v' || a === '-V' || a === '--version'
|
|
24
|
+
);
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export function resolvePaneOwner(muxName, env = process.env) {
|
|
@@ -44,7 +48,12 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
|
|
|
44
48
|
// Recursion / nested-launch guard: inside an unsnooze-managed session, a
|
|
45
49
|
// plain `claude`/`codex`/`unsnooze` call goes straight through. Same for an
|
|
46
50
|
// agent the user disabled in settings — run it, don't watch it.
|
|
47
|
-
|
|
51
|
+
//
|
|
52
|
+
// Non-interactive / informational invocations (-p/--print, -h/--help, -v/--version)
|
|
53
|
+
// also bypass multiplexer wrapping and monitoring — running a new tmux session
|
|
54
|
+
// or spawning a watcher for `claude --help` flashes the screen and slows down
|
|
55
|
+
// simple queries.
|
|
56
|
+
if (process.env.UNSNOOZE_ACTIVE === '1' || isPassthrough(args) || !getConfig(`agents.${agent.id}`)) {
|
|
48
57
|
const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
|
|
49
58
|
return r.status ?? 1;
|
|
50
59
|
}
|
package/src/watcher.js
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from './config.js';
|
|
23
23
|
import { getMultiplexer } from './multiplexer.js';
|
|
24
24
|
import { parseTranscriptLine } from './watchers/claude.js';
|
|
25
|
-
import {
|
|
25
|
+
import { parseRolloutLines, rolloutMeta } from './watchers/codex.js';
|
|
26
26
|
import { ROLLOUT_RE } from './agents/codex.js';
|
|
27
27
|
import { parseResetTime, resetAtMs } from './time-parser.js';
|
|
28
28
|
import { upsertSession, readState, updateState } from './state.js';
|
|
@@ -85,8 +85,8 @@ export function codexSource({ roots }) {
|
|
|
85
85
|
roots,
|
|
86
86
|
enabled: () => getConfig('agents.codex'),
|
|
87
87
|
match: p => ROLLOUT_RE.test(basename(p)),
|
|
88
|
-
parse(lines, path) {
|
|
89
|
-
const hits = lines
|
|
88
|
+
parse(lines, path, { offset } = {}) {
|
|
89
|
+
const hits = parseRolloutLines(lines, { path, offset });
|
|
90
90
|
if (hits.length === 0) return [];
|
|
91
91
|
const last = hits[hits.length - 1]; // the latest snapshot governs
|
|
92
92
|
const meta = rolloutMeta(path);
|
|
@@ -322,7 +322,10 @@ export function createWatcher({
|
|
|
322
322
|
const lastNl = buf.lastIndexOf(0x0a);
|
|
323
323
|
if (lastNl === -1) { setOffset(path, offset); return null; } // partial line — wait
|
|
324
324
|
setOffset(path, offset + lastNl + 1);
|
|
325
|
-
return
|
|
325
|
+
return {
|
|
326
|
+
offset,
|
|
327
|
+
lines: buf.subarray(0, lastNl + 1).toString('utf-8').split('\n').filter(l => l.trim()),
|
|
328
|
+
};
|
|
326
329
|
}
|
|
327
330
|
|
|
328
331
|
function walk(dir, depth, cb) {
|
|
@@ -353,11 +356,12 @@ export function createWatcher({
|
|
|
353
356
|
const usageMatch = source.usageMatch ? source.usageMatch(path) : stopMatch;
|
|
354
357
|
if (!stopMatch && !usageMatch) return;
|
|
355
358
|
seen.add(path);
|
|
356
|
-
const
|
|
357
|
-
if (!
|
|
359
|
+
const appended = readAppended(path);
|
|
360
|
+
if (!appended || appended.lines.length === 0) return;
|
|
361
|
+
const { lines, offset } = appended;
|
|
358
362
|
if (stopMatch) {
|
|
359
363
|
try {
|
|
360
|
-
candidates.push(...source.parse(lines, path));
|
|
364
|
+
candidates.push(...source.parse(lines, path, { offset }));
|
|
361
365
|
} catch (err) {
|
|
362
366
|
log(`parse error in ${path}: ${err.message}`);
|
|
363
367
|
}
|
package/src/watchers/codex.js
CHANGED
|
@@ -23,17 +23,29 @@ export { extractCodexUsage } from '../usage.js';
|
|
|
23
23
|
// must not conflate it with the weekly bucket.
|
|
24
24
|
import { labelWindow } from '../usage.js';
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
// its used_percent hits 100 (or rate_limit_reached_type says the model was
|
|
28
|
-
// actually blocked); with several exhausted windows the LATEST reset governs —
|
|
29
|
-
// resuming at the earlier one would immediately re-hit the other limit.
|
|
30
|
-
export function parseRolloutLine(line) {
|
|
26
|
+
function rolloutSnapshot(line) {
|
|
31
27
|
if (!line || !line.trim()) return null;
|
|
32
28
|
let entry;
|
|
33
29
|
try { entry = JSON.parse(line); } catch { return null; }
|
|
34
30
|
if (entry?.type !== 'event_msg' || entry.payload?.type !== 'token_count') return null;
|
|
35
31
|
const rl = entry.payload.rate_limits;
|
|
36
32
|
if (!rl || typeof rl !== 'object') return null;
|
|
33
|
+
return entry;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function emptyPremium(rl) {
|
|
37
|
+
return rl.limit_id === 'premium' && rl.primary === null && rl.secondary === null
|
|
38
|
+
&& rl.credits?.has_credits === false && rl.credits?.unlimited === false
|
|
39
|
+
&& rl.credits?.balance != null && rl.credits.balance !== ''
|
|
40
|
+
&& Number(rl.credits.balance) === 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// With several exhausted windows, the latest reset governs: resuming at an
|
|
44
|
+
// earlier one would immediately hit the other limit again.
|
|
45
|
+
function parseSnapshot(entry, previous = null) {
|
|
46
|
+
if (!entry) return null;
|
|
47
|
+
const rl = entry.payload.rate_limits;
|
|
48
|
+
const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
37
49
|
|
|
38
50
|
const windows = ['primary', 'secondary']
|
|
39
51
|
.map(k => rl[k])
|
|
@@ -48,9 +60,28 @@ export function parseRolloutLine(line) {
|
|
|
48
60
|
? named
|
|
49
61
|
: windows.reduce((a, b) => ((b.resets_at || 0) > (a?.resets_at || 0) ? b : a), null);
|
|
50
62
|
}
|
|
63
|
+
// #20: Codex can stop at a reported 99%, then emit an empty premium bucket
|
|
64
|
+
// instead of a 100% snapshot. Infer a stop only for that transition,
|
|
65
|
+
// in the same rollout, within a minute, with no credits and a future reset.
|
|
66
|
+
// A lone 99% snapshot or an unrelated premium bucket is not a stop.
|
|
67
|
+
if (!binding && emptyPremium(rl) && previous) {
|
|
68
|
+
const prior = previous.payload.rate_limits;
|
|
69
|
+
const elapsed = ts - Date.parse(previous.timestamp);
|
|
70
|
+
const primary = prior.primary;
|
|
71
|
+
if (prior.limit_id === 'codex' && elapsed >= 0 && elapsed <= 60_000
|
|
72
|
+
&& primary?.window_minutes === 300
|
|
73
|
+
&& Number.isFinite(primary.used_percent) && primary.used_percent >= 99
|
|
74
|
+
&& Number.isFinite(primary.resets_at) && primary.resets_at * 1000 > ts) {
|
|
75
|
+
binding = primary;
|
|
76
|
+
const secondary = prior.secondary;
|
|
77
|
+
if (Number.isFinite(secondary?.used_percent) && secondary.used_percent >= 100
|
|
78
|
+
&& Number.isFinite(secondary.resets_at) && secondary.resets_at > binding.resets_at) {
|
|
79
|
+
binding = secondary;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
51
83
|
if (!binding) return null;
|
|
52
84
|
|
|
53
|
-
const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
54
85
|
return {
|
|
55
86
|
limitType: labelWindow(binding.window_minutes),
|
|
56
87
|
resetAt: binding.resets_at ? binding.resets_at * 1000 : null,
|
|
@@ -59,6 +90,48 @@ export function parseRolloutLine(line) {
|
|
|
59
90
|
};
|
|
60
91
|
}
|
|
61
92
|
|
|
93
|
+
export function parseRolloutLine(line) {
|
|
94
|
+
return parseSnapshot(rolloutSnapshot(line));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Look immediately before the appended batch, not at the file's current EOF:
|
|
98
|
+
// it may have grown since the watcher read it. This also works after restart
|
|
99
|
+
// without persisting another cache alongside the watcher's byte offsets.
|
|
100
|
+
function previousSnapshot(path, offset) {
|
|
101
|
+
if (!path || !Number.isSafeInteger(offset) || offset <= 0) return null;
|
|
102
|
+
let fd;
|
|
103
|
+
try {
|
|
104
|
+
fd = openSync(path, 'r');
|
|
105
|
+
const length = Math.min(offset, 4 * 1024 * 1024);
|
|
106
|
+
const buf = Buffer.alloc(length);
|
|
107
|
+
const count = readSync(fd, buf, 0, length, offset - length);
|
|
108
|
+
const lines = buf.subarray(0, count).toString('utf-8').split('\n');
|
|
109
|
+
if (offset > length) lines.shift(); // the first line may be truncated
|
|
110
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
111
|
+
const snapshot = rolloutSnapshot(lines[i]);
|
|
112
|
+
if (snapshot) return snapshot;
|
|
113
|
+
}
|
|
114
|
+
} catch { /* missing context means no inferred stop */ }
|
|
115
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseRolloutLines(lines, { path, offset } = {}) {
|
|
120
|
+
let previous;
|
|
121
|
+
const hits = [];
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
const snapshot = rolloutSnapshot(line);
|
|
124
|
+
if (!snapshot) continue;
|
|
125
|
+
if (previous === undefined && emptyPremium(snapshot.payload.rate_limits)) {
|
|
126
|
+
previous = previousSnapshot(path, offset);
|
|
127
|
+
}
|
|
128
|
+
const hit = parseSnapshot(snapshot, previous);
|
|
129
|
+
if (hit) hits.push(hit);
|
|
130
|
+
previous = snapshot;
|
|
131
|
+
}
|
|
132
|
+
return hits;
|
|
133
|
+
}
|
|
134
|
+
|
|
62
135
|
// The session_meta head line can be very long (it embeds the full base
|
|
63
136
|
// instructions) — read in chunks until the first newline.
|
|
64
137
|
function readFirstLine(path, maxBytes = 256 * 1024) {
|