unsnooze 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 1.8.1 — 2026-07-13
6
+
7
+ - **Discovery / SEO**: package description and keywords expanded so npm and
8
+ GitHub surface Qwen Code, Kimi CLI, OpenCode, Antigravity, OpenRouter, and
9
+ Zellij alongside Claude/Codex/Grok. README comparison table updated to match.
10
+
11
+ ## 1.8.0 — 2026-07-13
12
+
13
+ - **Notification channels** (`notifyChannel`: `auto` | `native` | `osc` |
14
+ `bell`, env `UNSNOOZE_NOTIFY_CHANNEL`): terminal-branded alerts via OSC 9
15
+ (iTerm2, kitty, WezTerm, Ghostty, Warp) or OSC 777 (rxvt), plus BEL on the
16
+ pane tty. `auto` sends OSC+BEL when tmux can reach client/pane ttys and
17
+ falls back to the OS toast only if OSC delivered nothing; denylisted
18
+ terminals (Apple Terminal, VS Code, Alacritty, Zed) skip OSC in auto.
19
+ OSC/BEL require tmux — Zellij and GUI-watcher stops use native. Existing
20
+ `notifications` remains the master off-switch.
21
+ - **Unified ChatGPT desktop app support** (July 2026: the Codex app became the
22
+ ChatGPT app). Verified against a real install: the app's bundled
23
+ `codex app-server` still writes rollouts to `~/.codex/sessions/` in the same
24
+ format (now with additive `limit_id`/`credits`/`plan_type` fields and
25
+ reason-string `rate_limit_reached_type` values — both handled), and
26
+ `codex resume <uuid>` works for app-originated sessions. New: when `codex`
27
+ is not on PATH but ChatGPT.app is installed, unsnooze resolves the
28
+ app-bundled binary (`ChatGPT.app/Contents/Resources/codex`) for wrappers,
29
+ wizard detection, and revival. Rollouts older than 7 days are now
30
+ zstd-compressed by codex; irrelevant for detection (freshness window is
31
+ minutes) but noted for `unsnooze report` archaeology.
32
+ - Added a dual tmux/Zellij multiplexer backend. The new `multiplexer` setting
33
+ accepts `auto`, `tmux`, or `zellij`; status output identifies the backend,
34
+ qualified pane address, and revival session. Zellij revival uses structured
35
+ pane ownership and a reserved-session smoke test without adding a statusline
36
+ notification path.
37
+
3
38
  ## 1.7.0 — 2026-07-12
4
39
 
5
40
  - **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
@@ -29,7 +29,7 @@ existing tool solves only a slice of it:
29
29
 
30
30
  | | **unsnooze** | claude-auto-retry | autoclaude | hydra |
31
31
  |---|:---:|:---:|:---:|:---:|
32
- | Multi-CLI (Claude Code + Codex + Grok) | ✅ | ❌ Claude only | ❌ Claude only | |
32
+ | Multi-CLI (Claude · Codex · Grok · Qwen · Kimi · OpenCode · Antigravity) | ✅ | ❌ Claude only | ❌ Claude only | partial |
33
33
  | GUI sessions (VS Code ext, desktop apps) | ✅ watcher daemon | ❌ | ❌ | ❌ |
34
34
  | Waits for reset & resumes the **same** session | ✅ | ✅ | ✅ | ❌ switches provider |
35
35
  | All sessions at once (shared ledger + one daemon) | ✅ | ❌ one pane | ✅ | ✅ |
@@ -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 tmux pane scraping for banners and the interactive
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 tmux window** with
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 tmux pane
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 tmux new-window,
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` | Desktop notification on limit detected / session resumed / gave up. |
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
- unsnooze is built on tmux, so on Windows it runs inside
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 nothing to watch; unsnooze will tell you
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 tmux window.
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 id required'); return 2; }
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 tmux pane exists. Run via launchd/systemd or a shell.
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 tmux needed to
126
- detect; revival still opens in tmux
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, updateCheck)
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.7.0",
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.1",
4
+ "description": "Auto-resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode & Antigravity when the 5-hour/weekly usage limit resets tmux, Zellij, VS Code & desktop apps. Wakes every limit-stopped AI coding session automatically.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -10,27 +10,35 @@
10
10
  "openai",
11
11
  "anthropic",
12
12
  "grok",
13
+ "xai",
13
14
  "qwen",
14
15
  "qwen-code",
15
16
  "kimi",
16
17
  "kimi-cli",
18
+ "moonshot",
17
19
  "opencode",
18
20
  "antigravity",
19
21
  "antigravity-cli",
22
+ "gemini",
23
+ "google",
20
24
  "openrouter",
25
+ "chatgpt",
21
26
  "usage-limit",
22
27
  "usage limit",
23
28
  "rate-limit",
24
29
  "rate limit",
25
30
  "5-hour limit",
31
+ "weekly limit",
26
32
  "auto-resume",
27
33
  "auto-retry",
28
34
  "resume",
29
35
  "retry",
30
36
  "tmux",
37
+ "zellij",
31
38
  "ai-agents",
32
39
  "agentic-coding",
33
- "cli"
40
+ "cli",
41
+ "developer-tools"
34
42
  ],
35
43
  "author": "Saaransh Menon <saaransh.dev2811@gmail.com>",
36
44
  "license": "MIT",
@@ -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: process.env.UNSNOOZE_CODEX_BIN || 'codex',
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(` pane ${s.pane ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${hold}`);
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 TMUX_SESSION_NAME = process.env.UNSNOOZE_TMUX_SESSION || 'unsnooze';
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, TMUX_SESSION_NAME } from './config.js';
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 { capturePane } from './tmux.js';
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 pane = process.env.TMUX_PANE || payload.tmux_pane || null;
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, `${pane.replace(/[^%\w]/g, '_')}.json`),
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
- tmuxSession: TMUX_SESSION_NAME,
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 tmux to revive into — daemon unsupported
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 tmux: re-exec inside a new tmux session (the monitor needs a pane
3
- // to scrape) guarded by UNSNOOZE_ACTIVE to prevent recursion
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 { insideTmux, currentPaneId, tmuxAvailable } from './tmux.js';
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
- if (!insideTmux()) {
33
- if (!tmuxAvailable()) {
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('unsnooze: tmux not found — running without limit-watch.\n');
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 (https://learn.microsoft.com/windows/wsl/install) where tmux works.\n');
38
+ process.stderr.write('unsnooze: native Windows is not supported; run inside WSL.\n');
38
39
  } else {
39
- process.stderr.write('unsnooze: install tmux to enable auto-resume (brew install tmux / apt install tmux).\n');
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
- // Re-enter under tmux: `tmux new-session unsnooze _run <agent> <args...>` —
45
- // the inner unsnooze lands in the insideTmux() branch below.
46
- log(`not in tmux — wrapping into a tmux session`);
47
- const inner = ['new-session', process.execPath, process.argv[1], '_run', agent.id, ...args];
48
- const r = spawnSync('tmux', inner, { stdio: 'inherit' });
49
- return r.status ?? 1;
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], { UNSNOOZE_CWD: process.cwd() });
55
- log(`launching ${agent.id} in pane ${pane}, monitor spawned`);
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 tmux but TMUX_PANE unset — launching ${agent.id} without monitor`);
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: { ...process.env, UNSNOOZE_ACTIVE: '1', UNSNOOZE_PANE: pane || '' },
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
+ }