unsnooze 1.7.0 → 1.8.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 +29 -0
- package/README.md +56 -13
- package/bin/unsnooze.js +7 -6
- package/package.json +3 -2
- package/src/agents/codex.js +22 -2
- package/src/cli.js +2 -1
- package/src/config.js +3 -2
- package/src/hook.js +20 -7
- package/src/install.js +1 -1
- package/src/launcher.js +40 -21
- package/src/lease.js +87 -0
- package/src/monitor.js +33 -20
- package/src/multiplexer.js +58 -0
- package/src/multiplexers/tmux.js +200 -0
- package/src/multiplexers/zellij.js +193 -0
- package/src/notify-tty.js +311 -0
- package/src/notify.js +131 -14
- package/src/report.js +11 -4
- package/src/resumer.js +172 -73
- package/src/settings.js +6 -0
- package/src/state.js +29 -6
- package/src/watcher.js +4 -2
- package/src/wizard.js +6 -5
- package/src/tmux.js +0 -95
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 1.8.0 — 2026-07-13
|
|
6
|
+
|
|
7
|
+
- **Notification channels** (`notifyChannel`: `auto` | `native` | `osc` |
|
|
8
|
+
`bell`, env `UNSNOOZE_NOTIFY_CHANNEL`): terminal-branded alerts via OSC 9
|
|
9
|
+
(iTerm2, kitty, WezTerm, Ghostty, Warp) or OSC 777 (rxvt), plus BEL on the
|
|
10
|
+
pane tty. `auto` sends OSC+BEL when tmux can reach client/pane ttys and
|
|
11
|
+
falls back to the OS toast only if OSC delivered nothing; denylisted
|
|
12
|
+
terminals (Apple Terminal, VS Code, Alacritty, Zed) skip OSC in auto.
|
|
13
|
+
OSC/BEL require tmux — Zellij and GUI-watcher stops use native. Existing
|
|
14
|
+
`notifications` remains the master off-switch.
|
|
15
|
+
- **Unified ChatGPT desktop app support** (July 2026: the Codex app became the
|
|
16
|
+
ChatGPT app). Verified against a real install: the app's bundled
|
|
17
|
+
`codex app-server` still writes rollouts to `~/.codex/sessions/` in the same
|
|
18
|
+
format (now with additive `limit_id`/`credits`/`plan_type` fields and
|
|
19
|
+
reason-string `rate_limit_reached_type` values — both handled), and
|
|
20
|
+
`codex resume <uuid>` works for app-originated sessions. New: when `codex`
|
|
21
|
+
is not on PATH but ChatGPT.app is installed, unsnooze resolves the
|
|
22
|
+
app-bundled binary (`ChatGPT.app/Contents/Resources/codex`) for wrappers,
|
|
23
|
+
wizard detection, and revival. Rollouts older than 7 days are now
|
|
24
|
+
zstd-compressed by codex; irrelevant for detection (freshness window is
|
|
25
|
+
minutes) but noted for `unsnooze report` archaeology.
|
|
26
|
+
- Added a dual tmux/Zellij multiplexer backend. The new `multiplexer` setting
|
|
27
|
+
accepts `auto`, `tmux`, or `zellij`; status output identifies the backend,
|
|
28
|
+
qualified pane address, and revival session. Zellij revival uses structured
|
|
29
|
+
pane ownership and a reserved-session smoke test without adding a statusline
|
|
30
|
+
notification path.
|
|
31
|
+
|
|
3
32
|
## 1.7.0 — 2026-07-12
|
|
4
33
|
|
|
5
34
|
- **Four new agent adapters** (all ⚠️ experimental, off by default — enable in
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
**Claude Code · Codex CLI · Grok · Qwen · Kimi · OpenCode · Antigravity** — when they hit the 5-hour or weekly usage limit
|
|
13
13
|
("You've hit your usage limit"), your session just… stops.<br/>
|
|
14
14
|
unsnooze auto-resumes them: it tracks **every** limit-stopped session across all
|
|
15
|
-
your projects and **wakes each one up in tmux the moment the usage limit resets.**
|
|
15
|
+
your projects and **wakes each one up in tmux or Zellij the moment the usage limit resets.**
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
18
|
npm install -g unsnooze && unsnooze setup
|
|
@@ -40,7 +40,7 @@ existing tool solves only a slice of it:
|
|
|
40
40
|
## Supported CLIs
|
|
41
41
|
|
|
42
42
|
- **Claude Code** — dual-channel detection: the `StopFailure` hook (authoritative,
|
|
43
|
-
carries `session_id`) plus
|
|
43
|
+
carries `session_id`) plus multiplexer pane scraping for banners and the interactive
|
|
44
44
|
limit menu (always answered with *"Stop and wait for limit to reset"*, never a
|
|
45
45
|
blind Enter). Dead sessions revive via `claude --resume <id>`.
|
|
46
46
|
- **OpenAI Codex CLI** — scrape-based (Codex fires no event on limits). Detects
|
|
@@ -92,7 +92,7 @@ surfaced as a notification — there's no reset to wait for, only a top-up.
|
|
|
92
92
|
|
|
93
93
|
## GUI surfaces (VS Code extension, desktop apps)
|
|
94
94
|
|
|
95
|
-
Terminal sessions are watched through the shell wrapper + tmux. Sessions in
|
|
95
|
+
Terminal sessions are watched through the shell wrapper + tmux or Zellij. Sessions in
|
|
96
96
|
**Claude Code's VS Code extension / desktop app** and **Codex's IDE
|
|
97
97
|
extension / desktop app** have no pane to scrape — so `unsnooze daemon` tails
|
|
98
98
|
the session files those surfaces already write:
|
|
@@ -102,14 +102,19 @@ the session files those surfaces already write:
|
|
|
102
102
|
shared by the CLI and the VS Code extension.
|
|
103
103
|
- **Codex** writes a `rate_limits` snapshot (usage %, **exact epoch reset
|
|
104
104
|
time**) into every rollout under `~/.codex/sessions/` — shared by the CLI,
|
|
105
|
-
IDE extension, and desktop app
|
|
105
|
+
IDE extension, and the **unified ChatGPT desktop app** (July 2026: the Codex
|
|
106
|
+
app became the ChatGPT app; its bundled `codex app-server` writes the same
|
|
107
|
+
rollouts to the same store — verified against a real install). On machines
|
|
108
|
+
where Codex lives only inside ChatGPT.app (no `codex` on PATH), unsnooze
|
|
109
|
+
automatically resumes through the app-bundled binary
|
|
110
|
+
(`/Applications/ChatGPT.app/Contents/Resources/codex`).
|
|
106
111
|
- **Claude desktop (cowork) sessions** *(experimental, macOS)* run in
|
|
107
112
|
sandboxes under `~/Library/Application Support/Claude`; unsnooze watches
|
|
108
113
|
those too and revives with the session's isolated `CLAUDE_CONFIG_DIR`
|
|
109
114
|
(plus the keychain-scope override that keeps auth working — verified
|
|
110
115
|
against a real desktop session).
|
|
111
116
|
|
|
112
|
-
When the limit resets, the session is revived **in a
|
|
117
|
+
When the limit resets, the session is revived **in a multiplexer pane** with
|
|
113
118
|
`claude --resume <id>` / `codex resume <id>` — it's the same session file, so
|
|
114
119
|
the continued conversation stays visible in the GUI's own history. (Resuming
|
|
115
120
|
*inside* the GUI panel isn't possible today: no extension/app exposes an
|
|
@@ -129,7 +134,7 @@ anytime with `unsnooze config set guiWatch off`.
|
|
|
129
134
|
<summary>text version</summary>
|
|
130
135
|
|
|
131
136
|
```
|
|
132
|
-
claude / codex / grok (shell wrapper) ──► unsnooze _run <agent> ──► CLI in
|
|
137
|
+
claude / codex / grok (shell wrapper) ──► unsnooze _run <agent> ──► CLI in mux pane
|
|
133
138
|
│
|
|
134
139
|
├─ per-pane monitor (scrapes for limit
|
|
135
140
|
│ banners, drives Claude's limit menu,
|
|
@@ -146,7 +151,7 @@ StopFailure hook (claude, grok) ──────────────┤
|
|
|
146
151
|
│
|
|
147
152
|
┌───────────────────┴────────────────────┐
|
|
148
153
|
pane still alive? pane gone?
|
|
149
|
-
send resume message into it
|
|
154
|
+
send resume message into it new multiplexer pane,
|
|
150
155
|
(only if the CLI is foreground `unsnooze _run <agent>
|
|
151
156
|
and not mid-stream) --resume <id>`, verify
|
|
152
157
|
```
|
|
@@ -184,9 +189,11 @@ unsnooze help # full command list (also -h / --help)
|
|
|
184
189
|
|
|
185
190
|
| key | default | meaning |
|
|
186
191
|
|---|---|---|
|
|
192
|
+
| `multiplexer` | `auto` | Backend to use: `auto`, `tmux`, or `zellij`. `auto` prefers the current multiplexer, then the only installed backend, with tmux as the tie-breaker. |
|
|
187
193
|
| `autoResume` | `true` | Master switch. Off = stops are still tracked, but nothing is resumed until you run `unsnooze resume-now` or turn it back on. |
|
|
188
194
|
| `menuAutoAnswer` | `true` | May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only. |
|
|
189
|
-
| `notifications` | `true` |
|
|
195
|
+
| `notifications` | `true` | Master switch for all notifications (limit detected / session resumed / gave up). Off = silence every channel. |
|
|
196
|
+
| `notifyChannel` | `auto` | How to deliver: `auto`, `native`, `osc`, or `bell` (see [Notification channels](#notification-channels)). Env: `UNSNOOZE_NOTIFY_CHANNEL`. |
|
|
190
197
|
| `guiWatch` | `true` | May the daemon watch session files for GUI-surface stops (VS Code extension, desktop apps)? Needs the daemon running (`unsnooze install --daemon`). |
|
|
191
198
|
| `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. Override it for a single session with `unsnooze message <id> "…"` — visible in `unsnooze status`. |
|
|
192
199
|
| `resumeMessages.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
|
|
@@ -198,6 +205,36 @@ unsnooze help # full command list (also -h / --help)
|
|
|
198
205
|
Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
|
|
199
206
|
all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
|
|
200
207
|
|
|
208
|
+
### Notification channels
|
|
209
|
+
|
|
210
|
+
When a limit is hit, a session resumes, or unsnooze gives up, it can alert you
|
|
211
|
+
via an OS toast, a terminal OSC sequence, a BEL, or a mix — controlled by
|
|
212
|
+
`notifyChannel` (default `auto`). `notifications=false` (or
|
|
213
|
+
`UNSNOOZE_NOTIFICATIONS=off`) turns every channel off.
|
|
214
|
+
|
|
215
|
+
| channel | what it does |
|
|
216
|
+
|---|---|
|
|
217
|
+
| `auto` | OSC (when the terminal supports it) **plus** BEL on the pane tty; falls back to native only if OSC delivered nothing (avoids double banners). No pane / non-tmux mux → native. |
|
|
218
|
+
| `native` | OS toast (macOS `osascript`, Linux `notify-send`, WSL/Windows PowerShell toast); tmux `display-message` as a last-resort statusline fallback on other platforms. |
|
|
219
|
+
| `osc` | Force OSC to attached client ttys; native if zero deliveries. |
|
|
220
|
+
| `bell` | BEL to the pane tty; native if undeliverable. |
|
|
221
|
+
|
|
222
|
+
**Terminal support (OSC)** — detection uses `TERM_PROGRAM` / termname / known
|
|
223
|
+
env markers:
|
|
224
|
+
|
|
225
|
+
| terminals | sequence |
|
|
226
|
+
|---|---|
|
|
227
|
+
| iTerm2, kitty, WezTerm, Ghostty, Warp | OSC 9 (`\x1b]9;title: body\x07`) |
|
|
228
|
+
| rxvt / urxvt | OSC 777 (`\x1b]777;notify;title;body\x07`) |
|
|
229
|
+
| Apple Terminal, VS Code, Alacritty, Zed | denylisted — OSC skipped (prefer `native`; force `osc` does not unlock these) |
|
|
230
|
+
| unknown | skipped in `auto`; OSC 9 when `notifyChannel=osc` |
|
|
231
|
+
|
|
232
|
+
**tmux only for OSC/BEL.** Those paths write to the client's tty (OSC) or the
|
|
233
|
+
pane's tty (BEL), which requires tmux's client/pane tty APIs. **Zellij has no
|
|
234
|
+
equivalent**, so OSC/BEL are not attempted under Zellij — notifications fall
|
|
235
|
+
back to native (and Zellij has no statusline-inject equivalent either). GUI
|
|
236
|
+
watcher stops (no pane context) always use native.
|
|
237
|
+
|
|
201
238
|
## Safety properties
|
|
202
239
|
|
|
203
240
|
- **Never injects blind**: keys are only sent when the pane's foreground
|
|
@@ -224,26 +261,31 @@ all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
|
|
|
224
261
|
|
|
225
262
|
## Requirements
|
|
226
263
|
|
|
227
|
-
- Node ≥ 20 and tmux
|
|
264
|
+
- Node ≥ 20 and tmux ≥ 3.2 **or** Zellij
|
|
228
265
|
- macOS, Linux, or **Windows via WSL** (see below)
|
|
229
266
|
- zsh or bash (the wrappers are installed into `~/.zshrc` / `~/.bashrc`)
|
|
230
267
|
|
|
231
268
|
### Windows / WSL
|
|
232
269
|
|
|
233
|
-
|
|
270
|
+
tmux and Zellij are Unix tools, so on Windows unsnooze runs inside
|
|
234
271
|
[WSL](https://learn.microsoft.com/windows/wsl/install) — which is where the
|
|
235
272
|
agent CLIs live on Windows anyway:
|
|
236
273
|
|
|
237
274
|
```sh
|
|
238
275
|
# inside your WSL distro (Ubuntu etc.)
|
|
239
|
-
sudo apt install tmux
|
|
276
|
+
sudo apt install tmux # or install Zellij: https://zellij.dev/documentation/installation
|
|
240
277
|
npm install -g unsnooze && unsnooze setup
|
|
241
278
|
```
|
|
242
279
|
|
|
280
|
+
On macOS, install either backend with `brew install tmux` or
|
|
281
|
+
`brew install zellij`. In `auto` mode unsnooze uses the multiplexer you are
|
|
282
|
+
currently inside; choose one explicitly with
|
|
283
|
+
`unsnooze config set multiplexer tmux` or `zellij`.
|
|
284
|
+
|
|
243
285
|
Everything works as on Linux, including desktop notifications: inside WSL,
|
|
244
286
|
unsnooze raises **native Windows toasts** through `powershell.exe` (no
|
|
245
287
|
`notify-send` or X server needed). Native Windows (PowerShell/cmd, no WSL) is
|
|
246
|
-
not supported — without tmux there is
|
|
288
|
+
not supported — without tmux or Zellij there is no terminal pane to watch; unsnooze will tell you
|
|
247
289
|
so and run your CLI unwatched instead of breaking it.
|
|
248
290
|
|
|
249
291
|
## FAQ
|
|
@@ -289,7 +331,7 @@ disable the check.
|
|
|
289
331
|
|
|
290
332
|
Yes — reset times are stored as absolute timestamps and checked every 30
|
|
291
333
|
seconds, so a laptop that slept through the reset resumes on the next tick,
|
|
292
|
-
and dead panes are reopened by session id in a fresh
|
|
334
|
+
and dead panes are reopened by session id in a fresh multiplexer pane.
|
|
293
335
|
|
|
294
336
|
## Development
|
|
295
337
|
|
|
@@ -297,6 +339,7 @@ and dead panes are reopened by session id in a fresh tmux window.
|
|
|
297
339
|
npm test # unit tests (node:test)
|
|
298
340
|
./scripts/e2e-simulate.sh # full detect → wait → re-open cycle in a
|
|
299
341
|
# scratch tmux session (no real limits needed)
|
|
342
|
+
bash -n scripts/e2e-zellij.sh # syntax-check the reserved-session Zellij smoke test
|
|
300
343
|
```
|
|
301
344
|
|
|
302
345
|
## License
|
package/bin/unsnooze.js
CHANGED
|
@@ -69,9 +69,9 @@ async function main() {
|
|
|
69
69
|
return runHook(rest);
|
|
70
70
|
}
|
|
71
71
|
case '_monitor': {
|
|
72
|
-
if (!rest[0]) { console.error('unsnooze _monitor: pane
|
|
72
|
+
if (!rest[0] || !rest[2]) { console.error('unsnooze _monitor: mux owner pane required'); return 2; }
|
|
73
73
|
const { runMonitor } = await import('../src/monitor.js');
|
|
74
|
-
return runMonitor(rest[0], rest[1]);
|
|
74
|
+
return runMonitor(rest[0], rest[1], rest[2], rest[3], rest[4]);
|
|
75
75
|
}
|
|
76
76
|
case '_run': {
|
|
77
77
|
if (!rest[0]) { console.error('unsnooze _run: agent id required'); return 2; }
|
|
@@ -93,7 +93,7 @@ async function main() {
|
|
|
93
93
|
case 'daemon': {
|
|
94
94
|
// Persistent resumer + transcript watcher: detects and revives limit
|
|
95
95
|
// stops from GUI surfaces (VS Code extension, desktop apps) where no
|
|
96
|
-
// shell wrapper or
|
|
96
|
+
// shell wrapper or multiplexer pane exists. Run via launchd/systemd or a shell.
|
|
97
97
|
const { runResumer } = await import('../src/resumer.js');
|
|
98
98
|
const { createWatcher } = await import('../src/watcher.js');
|
|
99
99
|
const controller = new AbortController();
|
|
@@ -122,10 +122,11 @@ Usage:
|
|
|
122
122
|
unsnooze logs [-f] show (or follow) the unsnooze log
|
|
123
123
|
unsnooze update update unsnooze itself to the latest version
|
|
124
124
|
unsnooze daemon persistent watcher for GUI sessions (VS Code
|
|
125
|
-
extension, desktop apps) — no
|
|
126
|
-
detect; revival
|
|
125
|
+
extension, desktop apps) — no live pane needed
|
|
126
|
+
to detect; revival opens in tmux or Zellij
|
|
127
127
|
unsnooze config [list|get|set] view or change settings (toggles, global +
|
|
128
|
-
per-agent resume messages,
|
|
128
|
+
per-agent resume messages, notifyChannel
|
|
129
|
+
auto|native|osc|bell, updateCheck)
|
|
129
130
|
unsnooze setup interactive setup wizard (agents + toggles)
|
|
130
131
|
unsnooze install [--yes] wire up shell wrappers + hooks (non-interactive)
|
|
131
132
|
unsnooze uninstall [--purge] remove wrappers + hooks (and state with --purge)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Auto-resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode & Antigravity CLI sessions in tmux when the usage limit (5-hour or weekly) resets. Wakes every limit-stopped AI coding session automatically.",
|
|
3
|
+
"version": "1.8.0",
|
|
4
|
+
"description": "Auto-resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode & Antigravity CLI sessions in tmux or Zellij when the usage limit (5-hour or weekly) resets. Wakes every limit-stopped AI coding session automatically.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
7
7
|
"claude-code",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"resume",
|
|
29
29
|
"retry",
|
|
30
30
|
"tmux",
|
|
31
|
+
"zellij",
|
|
31
32
|
"ai-agents",
|
|
32
33
|
"agentic-coding",
|
|
33
34
|
"cli"
|
package/src/agents/codex.js
CHANGED
|
@@ -10,10 +10,30 @@
|
|
|
10
10
|
// Transient errors render "stream error: … retrying 4/5 in 1.4s" and must take
|
|
11
11
|
// the overload path, never the ledger.
|
|
12
12
|
|
|
13
|
-
import { openSync, readSync, closeSync, readdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { openSync, readSync, closeSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { CODEX_DIR } from '../config.js';
|
|
16
16
|
|
|
17
|
+
// Since the 2026 unified ChatGPT desktop app absorbed the Codex app, the codex
|
|
18
|
+
// binary ships INSIDE the app bundle and many machines have no standalone
|
|
19
|
+
// `codex` on PATH at all — yet ~/.codex/sessions rollouts (and the resume
|
|
20
|
+
// command) work identically through the bundled binary (verified live against
|
|
21
|
+
// codex-cli 0.144 from ChatGPT.app).
|
|
22
|
+
export const CHATGPT_CODEX_BIN = '/Applications/ChatGPT.app/Contents/Resources/codex';
|
|
23
|
+
|
|
24
|
+
function codexOnPath(env = process.env) {
|
|
25
|
+
return (env.PATH || '').split(':').some(dir => {
|
|
26
|
+
try { return dir && existsSync(join(dir, 'codex')); } catch { return false; }
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveCodexBin({ env = process.env, onPath = () => codexOnPath(env), exists = existsSync } = {}) {
|
|
31
|
+
if (env.UNSNOOZE_CODEX_BIN) return env.UNSNOOZE_CODEX_BIN;
|
|
32
|
+
if (onPath()) return 'codex';
|
|
33
|
+
if (exists(CHATGPT_CODEX_BIN)) return CHATGPT_CODEX_BIN;
|
|
34
|
+
return 'codex'; // neither — the launcher degrades gracefully on spawn error
|
|
35
|
+
}
|
|
36
|
+
|
|
17
37
|
const LIMIT_ANCHORS = [
|
|
18
38
|
/You've hit your usage limit/i,
|
|
19
39
|
/Your workspace is out of credits/i,
|
|
@@ -91,7 +111,7 @@ export function latestSessionId(cwd, aroundTs = null, sessionsRoot = join(CODEX_
|
|
|
91
111
|
export default {
|
|
92
112
|
id: 'codex',
|
|
93
113
|
name: 'OpenAI Codex CLI',
|
|
94
|
-
bin:
|
|
114
|
+
bin: resolveCodexBin(),
|
|
95
115
|
experimental: false,
|
|
96
116
|
patterns,
|
|
97
117
|
menu: null, // no interactive limit menu
|
package/src/cli.js
CHANGED
|
@@ -30,6 +30,7 @@ export function cmdStatus() {
|
|
|
30
30
|
const id = s.sessionId ? s.sessionId.slice(0, 8) : '(no id)';
|
|
31
31
|
const reset = s.resetAt ? `${new Date(s.resetAt).toLocaleString()} (${fmtCountdown(s.resetAt - now)})` : '?';
|
|
32
32
|
const origin = s.origin ?? (s.pane ? 'cli' : '?');
|
|
33
|
+
const pane = s.paneOwner ? `${s.paneOwner}:${s.pane ?? '-'}` : (s.pane ?? '-');
|
|
33
34
|
const msg = s.resumeMessage
|
|
34
35
|
? ` · msg: "${s.resumeMessage.length > 44 ? s.resumeMessage.slice(0, 44) + '…' : s.resumeMessage}"`
|
|
35
36
|
: '';
|
|
@@ -37,7 +38,7 @@ export function cmdStatus() {
|
|
|
37
38
|
? ` · workspace changed (${s.holdReason ?? '?'}) — resume-now to wake`
|
|
38
39
|
: '';
|
|
39
40
|
console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
|
|
40
|
-
console.log(`
|
|
41
|
+
console.log(` mux ${s.mux ?? '-'} · pane ${pane} · session ${s.muxSession ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${hold}`);
|
|
41
42
|
}
|
|
42
43
|
return 0;
|
|
43
44
|
}
|
package/src/config.js
CHANGED
|
@@ -20,7 +20,9 @@ export const CODEX_DIR = process.env.UNSNOOZE_CODEX_DIR || join(homedir(), '.cod
|
|
|
20
20
|
// Transcript/rollout watcher (GUI detection channel)
|
|
21
21
|
export const WATCH_OFFSETS_FILE = join(STATE_DIR, 'watch-offsets.json');
|
|
22
22
|
|
|
23
|
-
export const
|
|
23
|
+
export const MUX_SESSION_NAME = process.env.UNSNOOZE_SESSION_NAME
|
|
24
|
+
|| process.env.UNSNOOZE_TMUX_SESSION || 'unsnooze';
|
|
25
|
+
export const TMUX_SESSION_NAME = MUX_SESSION_NAME; // read compatibility
|
|
24
26
|
|
|
25
27
|
// Timing (ms unless noted)
|
|
26
28
|
export const RESET_MARGIN_MS = envInt('UNSNOOZE_RESET_MARGIN_MS', 60_000);
|
|
@@ -48,4 +50,3 @@ export const DEDUPE_WINDOW_MS = envInt('UNSNOOZE_DEDUPE_WINDOW_MS', 120_000);
|
|
|
48
50
|
export const PRUNE_AFTER_MS = envInt('UNSNOOZE_PRUNE_AFTER_MS', 7 * 86_400_000);
|
|
49
51
|
export const STALE_LOCK_MS = envInt('UNSNOOZE_STALE_LOCK_MS', 10_000);
|
|
50
52
|
|
|
51
|
-
|
package/src/hook.js
CHANGED
|
@@ -6,15 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
import { EVENTS_DIR, FALLBACK_RESET_MS, RESET_MARGIN_MS, CAPTURE_LINES, PANE_SCAN_LINES,
|
|
9
|
+
import { EVENTS_DIR, FALLBACK_RESET_MS, RESET_MARGIN_MS, CAPTURE_LINES, PANE_SCAN_LINES, MUX_SESSION_NAME } from './config.js';
|
|
10
10
|
import { detectLimit } from './patterns.js';
|
|
11
11
|
import { getAgent } from './agents/index.js';
|
|
12
12
|
import { getConfig } from './settings.js';
|
|
13
13
|
import { parseResetTime, resetAtMs } from './time-parser.js';
|
|
14
14
|
import { upsertSession } from './state.js';
|
|
15
|
-
import {
|
|
15
|
+
import { getMultiplexer } from './multiplexer.js';
|
|
16
16
|
import { spawnResumerIfNeeded } from './spawn.js';
|
|
17
17
|
import { makeLogger } from './logger.js';
|
|
18
|
+
import { addressHash } from './lease.js';
|
|
18
19
|
|
|
19
20
|
const log = makeLogger('hook');
|
|
20
21
|
|
|
@@ -44,7 +45,16 @@ export async function runHook(rest = []) {
|
|
|
44
45
|
let payload = {};
|
|
45
46
|
try { payload = JSON.parse(raw); } catch { /* tolerate non-JSON */ }
|
|
46
47
|
|
|
47
|
-
const
|
|
48
|
+
const managedMux = process.env.UNSNOOZE_MUX;
|
|
49
|
+
const muxName = managedMux || (process.env.ZELLIJ_PANE_ID ? 'zellij' : 'tmux');
|
|
50
|
+
const pane = managedMux
|
|
51
|
+
? (process.env.UNSNOOZE_PANE || null)
|
|
52
|
+
: (muxName === 'zellij' ? process.env.ZELLIJ_PANE_ID : process.env.TMUX_PANE || payload.tmux_pane) || null;
|
|
53
|
+
const paneOwner = muxName === 'zellij'
|
|
54
|
+
? (managedMux ? process.env.UNSNOOZE_PANE_OWNER : process.env.ZELLIJ_SESSION_NAME) || null
|
|
55
|
+
: null;
|
|
56
|
+
const leaseId = process.env.UNSNOOZE_LEASE_ID || null;
|
|
57
|
+
const mux = getMultiplexer(muxName, { owner: paneOwner });
|
|
48
58
|
const kind = classify(payload, raw);
|
|
49
59
|
log(`StopFailure: kind=${kind} pane=${pane} session=${payload.session_id || '?'}`);
|
|
50
60
|
|
|
@@ -53,8 +63,8 @@ export async function runHook(rest = []) {
|
|
|
53
63
|
// record in state.json.
|
|
54
64
|
if (pane) {
|
|
55
65
|
mkdirSync(EVENTS_DIR, { recursive: true });
|
|
56
|
-
writeFileSync(join(EVENTS_DIR, `${
|
|
57
|
-
JSON.stringify({ pane, kind, at: Date.now(), payload: { error: payload.error ?? null } }));
|
|
66
|
+
writeFileSync(join(EVENTS_DIR, `${addressHash({ mux: muxName, paneOwner, pane })}.json`),
|
|
67
|
+
JSON.stringify({ mux: muxName, paneOwner, pane, kind, at: Date.now(), payload: { error: payload.error ?? null } }));
|
|
58
68
|
}
|
|
59
69
|
return 0;
|
|
60
70
|
}
|
|
@@ -66,7 +76,7 @@ export async function runHook(rest = []) {
|
|
|
66
76
|
let limitType = 'unknown';
|
|
67
77
|
if (pane) {
|
|
68
78
|
try {
|
|
69
|
-
const text = await capturePane(pane, CAPTURE_LINES);
|
|
79
|
+
const text = await mux.capturePane(pane, CAPTURE_LINES);
|
|
70
80
|
const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
|
|
71
81
|
if (d.hit) { resetLine = d.resetLine; limitType = d.limitType; }
|
|
72
82
|
} catch { /* pane not capturable (no tmux) — fall back below */ }
|
|
@@ -83,9 +93,12 @@ export async function runHook(rest = []) {
|
|
|
83
93
|
sessionId: sessionId || null,
|
|
84
94
|
cwd,
|
|
85
95
|
pane,
|
|
96
|
+
mux: muxName,
|
|
97
|
+
paneOwner,
|
|
98
|
+
leaseId,
|
|
86
99
|
agent: agent.id,
|
|
87
100
|
origin: 'cli', // the hook only fires for CLI launches we can see
|
|
88
|
-
|
|
101
|
+
muxSession: MUX_SESSION_NAME,
|
|
89
102
|
status: 'stopped',
|
|
90
103
|
limitType,
|
|
91
104
|
detectedVia: 'hook',
|
package/src/install.js
CHANGED
|
@@ -208,7 +208,7 @@ export function installDaemonAutostart({ platform = process.platform, dir = null
|
|
|
208
208
|
activate('systemctl', ['--user', 'enable', '--now', 'unsnooze.service']);
|
|
209
209
|
return target;
|
|
210
210
|
}
|
|
211
|
-
return null; // native Windows: no
|
|
211
|
+
return null; // native Windows: no supported multiplexer to revive into
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
export function uninstallDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
|
package/src/launcher.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// Default `unsnooze _run <agent> [args...]` path: run the agent CLI under watch.
|
|
2
|
-
// - outside
|
|
3
|
-
//
|
|
4
|
-
// - inside tmux: spawn a detached per-pane monitor, then run the CLI,
|
|
2
|
+
// - outside a multiplexer: re-exec through its launchWrapped operation
|
|
3
|
+
// - inside one: spawn a detached per-pane monitor, then run the CLI,
|
|
5
4
|
// propagating its exit code
|
|
6
5
|
// - -p/--print: pure pass-through, no monitor (nothing interactive to scrape)
|
|
7
6
|
|
|
8
7
|
import { spawn, spawnSync } from 'node:child_process';
|
|
9
|
-
import {
|
|
8
|
+
import { getMultiplexer } from './multiplexer.js';
|
|
10
9
|
import { getAgent } from './agents/index.js';
|
|
11
10
|
import { getConfig } from './settings.js';
|
|
12
11
|
import { spawnDetached } from './spawn.js';
|
|
13
12
|
import { makeLogger } from './logger.js';
|
|
13
|
+
import { createLeaseId, processBirth, writeLease, removeLease } from './lease.js';
|
|
14
14
|
|
|
15
15
|
const log = makeLogger('launcher');
|
|
16
16
|
|
|
@@ -29,41 +29,60 @@ export function runLauncher(args, agentId = 'claude') {
|
|
|
29
29
|
return r.status ?? 1;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const mux = getMultiplexer();
|
|
33
|
+
if (!mux.inside()) {
|
|
34
|
+
if (!mux.available()) {
|
|
34
35
|
// Degrade gracefully: run the CLI unwatched rather than dying.
|
|
35
|
-
process.stderr.write(
|
|
36
|
+
process.stderr.write(`unsnooze: ${mux.name} not found — running without limit-watch.\n`);
|
|
36
37
|
if (process.platform === 'win32') {
|
|
37
|
-
process.stderr.write('unsnooze: native Windows is not supported; run inside WSL
|
|
38
|
+
process.stderr.write('unsnooze: native Windows is not supported; run inside WSL.\n');
|
|
38
39
|
} else {
|
|
39
|
-
process.stderr.write(
|
|
40
|
+
process.stderr.write(`unsnooze: install ${mux.name} to enable auto-resume.\n`);
|
|
40
41
|
}
|
|
41
42
|
const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
|
|
42
43
|
return r.status ?? 1;
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
log(`not in ${mux.name} — wrapping into a managed session`);
|
|
46
|
+
return mux.launchWrapped({
|
|
47
|
+
file: process.execPath,
|
|
48
|
+
args: [process.argv[1], '_run', agent.id, ...args],
|
|
49
|
+
env: process.env,
|
|
50
|
+
});
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
const pane = currentPaneId();
|
|
53
|
+
const pane = mux.currentPaneId();
|
|
54
|
+
const paneOwner = mux.name === 'zellij'
|
|
55
|
+
? (process.env.UNSNOOZE_MUX === 'zellij'
|
|
56
|
+
? process.env.UNSNOOZE_PANE_OWNER : process.env.ZELLIJ_SESSION_NAME) || null
|
|
57
|
+
: null;
|
|
58
|
+
const leaseId = process.env.UNSNOOZE_LEASE_ID || createLeaseId();
|
|
53
59
|
if (pane) {
|
|
54
|
-
spawnDetached(['_monitor', pane, agent.id],
|
|
55
|
-
|
|
60
|
+
spawnDetached(['_monitor', mux.name, paneOwner || '', pane, agent.id, leaseId],
|
|
61
|
+
{ UNSNOOZE_CWD: process.cwd() });
|
|
62
|
+
log(`launching ${agent.id} in ${mux.name} ${paneOwner ?? '-'}:${pane}, monitor spawned`);
|
|
56
63
|
} else {
|
|
57
|
-
log(`inside
|
|
64
|
+
log(`inside ${mux.name} but pane id unset — launching ${agent.id} without monitor`);
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
const childEnv = {
|
|
68
|
+
...process.env, UNSNOOZE_ACTIVE: '1', UNSNOOZE_MUX: mux.name,
|
|
69
|
+
UNSNOOZE_PANE: pane || '', UNSNOOZE_PANE_OWNER: paneOwner || '',
|
|
70
|
+
UNSNOOZE_LEASE_ID: leaseId,
|
|
71
|
+
};
|
|
60
72
|
const child = spawn(agent.bin, args, {
|
|
61
73
|
stdio: 'inherit',
|
|
62
|
-
env:
|
|
74
|
+
env: childEnv,
|
|
63
75
|
});
|
|
76
|
+
const lease = pane && child.pid ? {
|
|
77
|
+
leaseId, mux: mux.name, paneOwner, pane, agent: agent.id,
|
|
78
|
+
pid: child.pid, pidBirth: processBirth(child.pid),
|
|
79
|
+
} : null;
|
|
80
|
+
if (lease?.pidBirth) writeLease(lease);
|
|
81
|
+
const cleanup = () => { if (lease) removeLease(lease, leaseId); };
|
|
64
82
|
return new Promise(resolve => {
|
|
65
|
-
child.on('exit', code => resolve(code ?? 1));
|
|
83
|
+
child.on('exit', code => { cleanup(); resolve(code ?? 1); });
|
|
66
84
|
child.on('error', err => {
|
|
85
|
+
cleanup();
|
|
67
86
|
process.stderr.write(`unsnooze: failed to launch ${agent.bin}: ${err.message}\n`);
|
|
68
87
|
resolve(127);
|
|
69
88
|
});
|
package/src/lease.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync,
|
|
3
|
+
} from 'node:fs';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { STATE_DIR } from './config.js';
|
|
8
|
+
|
|
9
|
+
const LEASES_DIR = join(STATE_DIR, 'leases');
|
|
10
|
+
|
|
11
|
+
export function addressHash({ mux, paneOwner, pane }) {
|
|
12
|
+
return createHash('sha256')
|
|
13
|
+
.update(`${mux ?? ''}\0${paneOwner ?? ''}\0${pane ?? ''}`)
|
|
14
|
+
.digest('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createLeaseId() {
|
|
18
|
+
return randomUUID();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function processBirth(pid, {
|
|
22
|
+
platform = process.platform,
|
|
23
|
+
readFile = path => readFileSync(path, 'utf-8'),
|
|
24
|
+
execFile = (file, args) => execFileSync(file, args, { encoding: 'utf-8' }),
|
|
25
|
+
} = {}) {
|
|
26
|
+
try {
|
|
27
|
+
if (platform === 'linux') {
|
|
28
|
+
const stat = readFile(`/proc/${pid}/stat`);
|
|
29
|
+
const close = stat.lastIndexOf(')');
|
|
30
|
+
if (close === -1) return null;
|
|
31
|
+
// The tail begins at field 3 (state); starttime is field 22.
|
|
32
|
+
return stat.slice(close + 1).trim().split(/\s+/)[19] || null;
|
|
33
|
+
}
|
|
34
|
+
if (platform === 'darwin') {
|
|
35
|
+
return execFile('ps', ['-o', 'lstart=', '-p', String(pid)]).trim() || null;
|
|
36
|
+
}
|
|
37
|
+
} catch { /* fail closed */ }
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function leasePath(address, leaseId) {
|
|
42
|
+
return join(LEASES_DIR, `${addressHash(address)}.${leaseId}.json`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function writeLease(lease) {
|
|
46
|
+
mkdirSync(LEASES_DIR, { recursive: true });
|
|
47
|
+
const path = leasePath(lease, lease.leaseId);
|
|
48
|
+
const tmp = `${path}.tmp.${process.pid}.${randomUUID()}`;
|
|
49
|
+
writeFileSync(tmp, JSON.stringify(lease));
|
|
50
|
+
renameSync(tmp, path);
|
|
51
|
+
return lease;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readLease(address, leaseId) {
|
|
55
|
+
try {
|
|
56
|
+
const lease = JSON.parse(readFileSync(leasePath(address, leaseId), 'utf-8'));
|
|
57
|
+
return lease.leaseId === leaseId ? lease : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function removeLease(address, leaseId) {
|
|
64
|
+
const path = leasePath(address, leaseId);
|
|
65
|
+
const lease = readLease(address, leaseId);
|
|
66
|
+
if (!lease || lease.leaseId !== leaseId) return false;
|
|
67
|
+
try { unlinkSync(path); return true; } catch { return false; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function defaultPidAlive(pid) {
|
|
71
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function leaseMatches(rec, {
|
|
75
|
+
mux,
|
|
76
|
+
pidAlive = defaultPidAlive,
|
|
77
|
+
processBirthFn = processBirth,
|
|
78
|
+
} = {}) {
|
|
79
|
+
if (!rec?.leaseId || !rec?.pane || !mux) return false;
|
|
80
|
+
const stored = readLease(rec, rec.leaseId);
|
|
81
|
+
if (!stored || stored.leaseId !== rec.leaseId || stored.agent !== rec.agent) return false;
|
|
82
|
+
if (stored.mux !== rec.mux || stored.paneOwner !== rec.paneOwner || stored.pane !== rec.pane) return false;
|
|
83
|
+
if (!pidAlive(stored.pid)) return false;
|
|
84
|
+
const birth = processBirthFn(stored.pid);
|
|
85
|
+
if (!birth || birth !== stored.pidBirth) return false;
|
|
86
|
+
try { return await mux.paneAlive(rec.pane); } catch { return false; }
|
|
87
|
+
}
|