unsnooze 1.14.3 → 1.15.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 CHANGED
@@ -1,5 +1,74 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.15.0 — 2026-08-15
4
+
5
+ - **Two new multiplexer backends: herdr and cmux.** unsnooze now watches and
6
+ revives sessions in [herdr](https://herdr.dev) and [cmux](https://cmux.dev)
7
+ alongside tmux and Zellij, chosen automatically from the multiplexer you are
8
+ already inside or explicitly with `unsnooze config set multiplexer herdr`.
9
+ Both are terminals built for running coding agents, and both start a command
10
+ by *typing* it into a pane rather than exec'ing it, which is a materially
11
+ different contract from tmux and Zellij — a revival's arguments are now
12
+ shell-quoted for them, and an argument that cannot be typed at all (a
13
+ multi-line resume message, say) travels through the pane's environment
14
+ instead of being mangled or refused. herdr needs 0.8.0 or newer; Homebrew
15
+ still ships 0.7.3, so install the release binary. Original herdr backend by
16
+ Wave Consulting (@walt-verweij) with follow-up work by @gaoflow; cmux backend
17
+ by @echarrod.
18
+
19
+ - **Per-model limits are no longer invisible.** Claude Code can stop with
20
+ *"You've reached your Fable 5 limit. Run /usage-credits to continue or switch
21
+ models with /model"* — a banner with no reset time, which the previous
22
+ detection (a limit phrase paired with a time) could never match, and which
23
+ fires no StopFailure hook. The session simply stopped, untracked and
24
+ unannounced. It is now detected and tracked. Because only a human clears it,
25
+ unsnooze never types at such a pane: it probes until the banner goes, and at
26
+ the ceiling records a visible failure naming both remedies rather than
27
+ pretending a wake is coming. Detection is deliberately conservative — an
28
+ agent *quoting* the banner in its own output is not a stop. (@gaoflow)
29
+
30
+ - **Four reset-scheduling bugs found in production.** A banner left on screen
31
+ after its reset passed was re-parsed every few seconds as "now + margin", so
32
+ the wake slid forward forever and the resumer never fired. An undated
33
+ "resets 10:30pm" re-read after midnight resolved to *tomorrow*, 22 hours out.
34
+ Accumulated pane-snapshot records with no session id each revived their own
35
+ copy of the same conversation — 23 records produced roughly 8 clones in one
36
+ repo at a weekly reset; at most one now revives per agent and project. And a
37
+ stop arriving after an old record was abandoned is no longer held back by it.
38
+ (@gaoflow)
39
+
40
+ - **New setting: `resumeExtraArgs.<agent>`.** A revival spawns the agent binary
41
+ directly, so flags that normally come from a shell alias or wrapper do not
42
+ apply — a user who always runs `claude --dangerously-skip-permissions` got a
43
+ permission-prompting revival, which is about as useful as none. Set them per
44
+ agent and they are appended to launches unsnooze performs itself. Quoting is
45
+ respected (`--append-system-prompt "stay in this repo"` is two arguments, not
46
+ five), and `config.json` accepts an array if you would rather not think about
47
+ quoting at all.
48
+
49
+ - **A failed launch no longer leaves a monitor scraping forever.** The monitor
50
+ starts before the agent does, and when the agent failed to start at all there
51
+ was no lease to notice its absence — so the monitor watched an ordinary shell
52
+ prompt indefinitely, one more process per failed launch. It now writes the
53
+ lease unconditionally (it was previously skipped wherever process start times
54
+ are unavailable, including Windows) and gives up if none appears. Reported by
55
+ @echarrod.
56
+
57
+ - **Node 20.12 is now the minimum.** `engines` promised `>=20` while a
58
+ dependency needed `styleText` from `node:util`, which arrived in 20.12 — so
59
+ installs on 20.0–20.11 succeeded and then crashed on startup. The floor is
60
+ honest now, and CI tests that exact version so it cannot drift again.
61
+
62
+ ## 1.14.4 — 2026-08-08
63
+
64
+ - **Windows: the daemon PATH check split on the wrong separator.** The check
65
+ added in 1.14.3 split `PATH` on `:`, which is right on macOS and Linux and
66
+ wrong on Windows — there the separator is `;` and every absolute path
67
+ contains a colon (`C:\...`), so entries were shredded into fragments. The
68
+ predicate now uses the host's own delimiter. No behavior changed on macOS or
69
+ Linux, and Windows has no autostart unit for the check to reach, so this is a
70
+ correctness and test-portability fix rather than a user-visible one.
71
+
3
72
  ## 1.14.3 — 2026-08-08
4
73
 
5
74
  - **Upgrades now reach running watchers.** A per-pane monitor lives as long as
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  **Claude Code · Codex CLI · Grok · Qwen · Kimi · OpenCode · Antigravity** — when they hit the 5-hour or weekly usage limit
17
17
  ("You've hit your usage limit"), your session just… stops.<br/>
18
18
  unsnooze auto-resumes them: it tracks **every** limit-stopped session across all
19
- your projects and **wakes each one up in tmux or Zellij the moment the usage limit resets.**
19
+ your projects and **wakes each one up in tmux, Zellij, or herdr the moment the usage limit resets.**
20
20
 
21
21
  ```sh
22
22
  npm install -g unsnooze && unsnooze setup
@@ -164,7 +164,7 @@ headroom install apply --scope provider --providers manual --target claude --tar
164
164
 
165
165
  ## GUI surfaces (VS Code extension, desktop apps)
166
166
 
167
- Terminal sessions are watched through the shell wrapper + tmux or Zellij. Sessions in
167
+ Terminal sessions are watched through the shell wrapper + tmux, Zellij, or herdr. Sessions in
168
168
  **Claude Code's VS Code extension / desktop app** and **Codex's IDE
169
169
  extension / desktop app** have no pane to scrape — so `unsnooze daemon` tails
170
170
  the session files those surfaces already write:
@@ -417,7 +417,8 @@ pressing **`R`** (uppercase — lowercase `r` refreshes) only marks it due — t
417
417
  remote's own daemon does the actual keystrokes, under the same
418
418
  ownership/liveness/menu gates as a local session.
419
419
  Resumed and stopped sessions print an attach hint
420
- (`ssh -t <host> 'tmux new -A -s <session>'` / `zellij attach <session>`) so
420
+ (`ssh -t <host> 'tmux new -A -s <session>'` / `zellij attach <session>` /
421
+ `ssh -t <host> 'herdr session attach <session>'`) so
421
422
  you can hop over and watch. An unreachable or out-of-date host shows
422
423
  `unreachable`/`skew` rather than blocking the rest of the fleet, and falls
423
424
  back to its last-known state (marked `stale`) for up to 24h.
@@ -508,7 +509,7 @@ password hosts on Windows; a plain `ssh <host>` prompt works with native
508
509
 
509
510
  | key | default | meaning |
510
511
  |---|---|---|
511
- | `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. |
512
+ | `multiplexer` | `auto` | Backend to use: `auto`, `tmux`, `zellij`, `herdr`, or `cmux`. `auto` prefers the current multiplexer (tmux/zellij ambient env, then `CMUX_SOCKET_PATH`); with no ambient env it falls back to whichever of tmux/zellij is installed, tmux as the tie-breaker — cmux is only selected via its ambient env or an explicit setting, never by this installed-backend fallback, since it has no attach-from-outside path to wrap into. cmux support covers resume-in-place (detect, capture, send); it has no joinable named-session model, so idle-session reaping (`unsnooze reap`) does not cover cmux panes, and no attach hint is shown for cmux sessions. |
512
513
  | `autoResume` | `true` | Master switch. Off = stops are still tracked, but nothing is resumed until you run `unsnooze resume-now` or turn it back on. |
513
514
  | `menuAutoAnswer` | `true` | May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only. |
514
515
  | `notifications` | `true` | Master switch for all notifications (limit detected / session resumed / gave up). Off = silence every channel. |
@@ -516,6 +517,7 @@ password hosts on Windows; a plain `ssh <host>` prompt works with native
516
517
  | `guiWatch` | `true` | May the daemon watch session files for GUI-surface stops (VS Code extension, desktop apps)? Needs the daemon running (`unsnooze install --daemon`). |
517
518
  | `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`. |
518
519
  | `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 ""`. |
520
+ | `resumeExtraArgs.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` | `""` | Extra flags for launches unsnooze performs itself. A revival spawns the agent binary directly, so flags you normally get from a shell alias or wrapper (`--dangerously-skip-permissions`, `--model …`) do not apply unless you put them here. Quoting is respected: `--append-system-prompt "stay in this repo"` is two arguments, not five. `config.json` may also hold an array (`["--append-system-prompt", "stay in this repo"]`), which skips parsing entirely. |
519
521
  | `agents.claude` / `agents.codex` | `true` | Which CLIs are guarded. |
520
522
  | `agents.grok` / `agents.qwen` / `agents.kimi` / `agents.opencode` / `agents.agy` | `false` | Experimental adapters — off by default; enable in `unsnooze setup` or `unsnooze config set agents.qwen on`. |
521
523
  | `workspaceGuard` | `inform` | Repo changed while a session slept? `inform` wakes it with a heads-up in the message; `pause` holds it (desktop notification, diff shown on `resume-now`); `off` disables. |
@@ -613,26 +615,53 @@ watcher stops (no pane context) always use native.
613
615
 
614
616
  ## Requirements
615
617
 
616
- - Node ≥ 20 and tmux ≥ 3.2 **or** Zellij
618
+ - Node ≥ 20.12 and tmux ≥ 3.2, Zellij, **or** herdr ≥ 0.8.0
617
619
  - macOS, Linux, or **Windows via WSL** (see below)
618
620
  - zsh or bash (the wrappers are installed into `~/.zshrc` / `~/.bashrc`)
619
621
 
620
622
  ### Windows / WSL
621
623
 
622
- tmux and Zellij are Unix tools, so on Windows unsnooze runs inside
624
+ tmux, Zellij, and herdr are Unix tools, so on Windows unsnooze runs inside
623
625
  [WSL](https://learn.microsoft.com/windows/wsl/install) — which is where the
624
626
  agent CLIs live on Windows anyway:
625
627
 
626
628
  ```sh
627
629
  # inside your WSL distro (Ubuntu etc.)
628
630
  sudo apt install tmux # or install Zellij: https://zellij.dev/documentation/installation
631
+ # install herdr from https://herdr.dev (requires herdr >= 0.8.0)
629
632
  npm install -g unsnooze && unsnooze setup
630
633
  ```
631
634
 
632
635
  On macOS, install either backend with `brew install tmux` or
633
- `brew install zellij`. In `auto` mode unsnooze uses the multiplexer you are
634
- currently inside; choose one explicitly with
635
- `unsnooze config set multiplexer tmux` or `zellij`.
636
+ `brew install zellij`; install herdr from [herdr.dev](https://herdr.dev).
637
+ herdr >= 0.8.0, which is the version the backend is verified against; older 0.x
638
+ releases are refused by available(). Homebrew currently ships 0.7.3, so install
639
+ the release binary from herdr.dev or GitHub rather than `brew install herdr`.
640
+ In `auto` mode unsnooze uses the multiplexer you are currently inside; choose one
641
+ explicitly with `unsnooze config set multiplexer tmux`, `zellij`, or `herdr`.
642
+
643
+ #### herdr specifics
644
+
645
+ herdr differs from tmux and Zellij in three ways that are visible in use:
646
+
647
+ - **unsnooze never restarts a stopped herdr session.** herdr restores saved
648
+ agent panes when a session restarts (`resume_agents_on_restore` is on by
649
+ default), so restarting one can bring your agent back by itself — and a
650
+ revival on top of that would resume the same conversation twice. Reviving
651
+ into a stopped session therefore creates `unsnooze-2` (and so on) rather than
652
+ reusing the name. If you restart a stopped session yourself and herdr brings
653
+ the agent back, unsnooze may still open a fresh pane for it; that case is not
654
+ yet detected.
655
+ - **Multi-line resume messages are carried, not typed.** herdr starts a pane
656
+ command by typing it into the pane's shell, where a newline would submit half
657
+ a command and a tab would be a completion request. Any such argument is passed
658
+ through the workspace environment instead and referenced by the typed line, so
659
+ a multi-paragraph `resumeMessage` reaches the agent as one argument, byte for
660
+ byte. (A NUL byte is still refused — an environment value cannot hold one.)
661
+ - **A custom `HERDR_SOCKET_PATH` that does not match the session being
662
+ addressed disables watching for that pane.** herdr pane ids are per-server,
663
+ so acting on one from the wrong server could type into an unrelated terminal.
664
+ The agent still runs; unsnooze says why it is not watching.
636
665
 
637
666
  Everything works as on Linux, including desktop notifications: inside WSL,
638
667
  unsnooze raises **native Windows toasts** through `powershell.exe` (no
@@ -711,6 +740,7 @@ npm test # unit tests (node:test)
711
740
  ./scripts/e2e-simulate.sh # full detect → wait → re-open cycle in a
712
741
  # scratch tmux session (no real limits needed)
713
742
  bash -n scripts/e2e-zellij.sh # syntax-check the reserved-session Zellij smoke test
743
+ node scripts/e2e-herdr.mjs # drives src/multiplexers/herdr.js against a real herdr (not run by CI)
714
744
  vhs demo/demo.tape # regenerate assets/demo.gif (brew install vhs)
715
745
  ```
716
746
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.14.3",
4
- "description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, and Antigravity sessions when 5-hour or weekly usage limits reset—across tmux, Zellij, VS Code, and desktop apps.",
3
+ "version": "1.15.0",
4
+ "description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, and Antigravity sessions when 5-hour or weekly usage limits reset—across tmux, Zellij, herdr, VS Code, and desktop apps.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -35,6 +35,7 @@
35
35
  "retry",
36
36
  "tmux",
37
37
  "zellij",
38
+ "herdr",
38
39
  "ai-agents",
39
40
  "agentic-coding",
40
41
  "cli",
@@ -65,7 +66,7 @@
65
66
  "test": "node --test"
66
67
  },
67
68
  "engines": {
68
- "node": ">=20"
69
+ "node": ">=20.12"
69
70
  },
70
71
  "dependencies": {
71
72
  "@clack/prompts": "^1.7.0",
@@ -31,6 +31,13 @@ export const patterns = {
31
31
  /limit.*(?:this|per)\s+week/i,
32
32
  ],
33
33
  fiveHourPatterns: [/\d+-hour limit/i, /session limit/i],
34
+ // Per-model limits carry NO reset time and are not cleared by waiting:
35
+ // "You've reached your Fable 5 limit. Run /usage-credits to continue
36
+ // or switch models with /model."
37
+ // The remedy hint is required, not optional — it is what separates a real
38
+ // banner from an agent discussing usage limits in its own output.
39
+ modelLimitPatterns: [/(?:reached|hit)\s+(?:your|the)\s+[\w.\s-]{0,24}?\blimit\b/i],
40
+ modelRemedyPatterns: [/\/usage-credits\b/i, /switch\s+models?\b/i, /\/model\b/i],
34
41
  // While Claude is streaming ("esc to interrupt") or running its own internal
35
42
  // retries ("Retrying in 5s · attempt 3/10"), never inject keys.
36
43
  busyPatterns: [
package/src/cli.js CHANGED
@@ -349,6 +349,7 @@ export async function cmdPreview(rest = [], {
349
349
  'give-up': () => 'would GIVE UP (attempt cap reached)',
350
350
  verifying: () => 'verifying an in-flight resume',
351
351
  retry: () => 'would RETRY later (pane capture failed)',
352
+ superseded: p => `would SKIP — record ${p.target?.key} revives the same conversation`,
352
353
  none: () => 'nothing to do',
353
354
  };
354
355
  let actionable = false;
package/src/config.js CHANGED
@@ -65,6 +65,20 @@ export const READY_TIMEOUT_MS = envInt('UNSNOOZE_READY_TIMEOUT_MS', 60_000);
65
65
  export const EVENT_MARKER_TTL_MS = envInt('UNSNOOZE_EVENT_MARKER_TTL_MS', 120_000);
66
66
  export const WATCH_FRESHNESS_MS = envInt('UNSNOOZE_WATCH_FRESHNESS_MS', 15 * 60_000);
67
67
 
68
+ // How long a monitor waits for its agent's lease to appear before concluding
69
+ // the launch failed and exiting. The launcher writes the lease immediately
70
+ // after spawn(), so this only ever elapses when no agent was started at all.
71
+ export const LEASE_GRACE_MS = envInt('UNSNOOZE_LEASE_GRACE_MS', 60_000);
72
+
73
+ // Multiplexer backends, in detection order. The single source of truth: the
74
+ // factory, the `multiplexer` setting enum and reap's session sweep all read
75
+ // this list. It lives here, in a module that imports nothing but node builtins,
76
+ // because settings.js and multiplexer.js already import each other — declaring
77
+ // it in either one puts the other in a temporal dead zone at import time.
78
+ // cmux is last: an agent can run tmux inside a cmux surface, so tmux/zellij
79
+ // must win detection when both are signalled (see multiplexer.js: detect()).
80
+ export const MUX_NAMES = ['tmux', 'zellij', 'herdr', 'cmux'];
81
+
68
82
  // Pane scanning
69
83
  export const PANE_SCAN_LINES = envInt('UNSNOOZE_PANE_SCAN_LINES', 12);
70
84
  export const CAPTURE_LINES = envInt('UNSNOOZE_CAPTURE_LINES', 200);
package/src/fleet.js CHANGED
@@ -845,7 +845,11 @@ export const MUX_SESSION_RE = /^[A-Za-z0-9_.-]{1,64}$/;
845
845
  // validation — callers must treat null as "omit the attach line".
846
846
  export function attachHintRemote(dest, muxName, muxSession) {
847
847
  if (!validHostToken(dest) || typeof muxSession !== 'string' || !MUX_SESSION_RE.test(muxSession)) return null;
848
- const inner = muxName === 'zellij' ? `zellij attach ${muxSession}` : `tmux new -A -s ${muxSession}`;
848
+ // cmux has no joinable named-session model — there is no remote command
849
+ // that reattaches to a surface, so omit the hint (see attachHint).
850
+ if (muxName === 'cmux') return null;
851
+ const inner = muxName === 'herdr' ? `herdr session attach ${muxSession}`
852
+ : muxName === 'zellij' ? `zellij attach ${muxSession}` : `tmux new -A -s ${muxSession}`;
849
853
  return `ssh -t ${dest} '${inner}'`;
850
854
  }
851
855
 
package/src/hook.js CHANGED
@@ -41,6 +41,21 @@ function classify(payload, raw) {
41
41
  return 'unknown';
42
42
  }
43
43
 
44
+ export function hookContext(env = process.env, payload = {}) {
45
+ const managedMux = env.UNSNOOZE_MUX;
46
+ const muxName = managedMux || (env.HERDR_PANE_ID ? 'herdr' : env.ZELLIJ_PANE_ID ? 'zellij' : 'tmux');
47
+ const pane = managedMux
48
+ ? (env.UNSNOOZE_PANE || null)
49
+ : (muxName === 'herdr' ? env.HERDR_PANE_ID
50
+ : muxName === 'zellij' ? env.ZELLIJ_PANE_ID : env.TMUX_PANE || payload.tmux_pane) || null;
51
+ const paneOwner = muxName === 'herdr'
52
+ ? (managedMux ? env.UNSNOOZE_PANE_OWNER : (env.HERDR_SESSION || 'default'))
53
+ : muxName === 'zellij'
54
+ ? (managedMux ? env.UNSNOOZE_PANE_OWNER : env.ZELLIJ_SESSION_NAME) || null
55
+ : null;
56
+ return { muxName, pane, paneOwner };
57
+ }
58
+
44
59
  export async function runHook(rest = []) {
45
60
  try {
46
61
  const agentIdx = rest.indexOf('--agent');
@@ -57,16 +72,16 @@ export async function runHook(rest = []) {
57
72
  return 0;
58
73
  }
59
74
 
60
- const managedMux = process.env.UNSNOOZE_MUX;
61
- const muxName = managedMux || (process.env.ZELLIJ_PANE_ID ? 'zellij' : 'tmux');
62
- const pane = managedMux
63
- ? (process.env.UNSNOOZE_PANE || null)
64
- : (muxName === 'zellij' ? process.env.ZELLIJ_PANE_ID : process.env.TMUX_PANE || payload.tmux_pane) || null;
65
- const paneOwner = muxName === 'zellij'
66
- ? (managedMux ? process.env.UNSNOOZE_PANE_OWNER : process.env.ZELLIJ_SESSION_NAME) || null
67
- : null;
75
+ const { muxName, pane: rawPane, paneOwner } = hookContext(process.env, payload);
68
76
  const leaseId = process.env.UNSNOOZE_LEASE_ID || null;
69
77
  const mux = getMultiplexer(muxName, { owner: paneOwner });
78
+ // Same guard as the launcher: a pane we cannot prove we are addressing on
79
+ // the right server is worse than no pane, because every later action —
80
+ // capture, inject, close — would be aimed at whatever occupies that id
81
+ // elsewhere. Record the stop without a pane instead.
82
+ const pane = (typeof mux.paneAddressable !== 'function' || mux.paneAddressable())
83
+ ? rawPane : null;
84
+ if (rawPane && !pane) log(`StopFailure: ignoring pane ${rawPane} — not safely addressable`);
70
85
  const kind = classify(payload, raw);
71
86
  log(`StopFailure: kind=${kind} pane=${pane} session=${payload.session_id || '?'}`);
72
87
 
package/src/install.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
12
12
  import { execFileSync } from 'node:child_process';
13
13
  import { homedir, userInfo } from 'node:os';
14
- import { join, dirname } from 'node:path';
14
+ import { join, dirname, delimiter } from 'node:path';
15
15
  import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
16
16
  import { getConfig, configFileExists } from './settings.js';
17
17
  import { xmlEscape } from './notify.js';
@@ -187,9 +187,13 @@ export function autostartUnitPath({ platform = process.platform, dir = null } =
187
187
  // cannot revive anything — every call dies with ENOENT. Pure and synchronous
188
188
  // on purpose: this decides whether to rewrite-and-reload a unit, and that
189
189
  // decision must be deterministic.
190
- export function pathResolves(pathStr, bin) {
190
+ // `sep` defaults to the host's PATH delimiter, not a hardcoded ':'. On Windows
191
+ // that is ';' AND every absolute path contains a colon (C:\...), so splitting
192
+ // on ':' shreds the entries into nonsense. The units we inspect are always
193
+ // POSIX, but this predicate has to give an honest answer wherever it runs.
194
+ export function pathResolves(pathStr, bin, { sep = delimiter } = {}) {
191
195
  if (typeof pathStr !== 'string' || pathStr === '') return false;
192
- return pathStr.split(':').some(d => d && existsSync(join(d, bin)));
196
+ return pathStr.split(sep).some(d => d && existsSync(join(d, bin)));
193
197
  }
194
198
 
195
199
  function xmlUnescape(s) {
package/src/launcher.js CHANGED
@@ -11,7 +11,8 @@ import { getConfig } from './settings.js';
11
11
  import { spawnDetached, monitorSpawnArgs } from './spawn.js';
12
12
  import { makeLogger } from './logger.js';
13
13
  import { createLeaseId, processBirth, writeLease, removeLease } from './lease.js';
14
- import { SessionCreateError } from './multiplexers/session-name.js';
14
+ import { recordOwnedSession } from './mux-sessions.js';
15
+ import { AgentDispatchedError, SessionCreateError, attachHint } from './multiplexers/session-name.js';
15
16
 
16
17
  const log = makeLogger('launcher');
17
18
 
@@ -19,13 +20,25 @@ function isPrintMode(args) {
19
20
  return args.includes('-p') || args.includes('--print');
20
21
  }
21
22
 
23
+ export function resolvePaneOwner(muxName, env = process.env) {
24
+ if (muxName === 'herdr') {
25
+ return (env.UNSNOOZE_MUX === 'herdr'
26
+ ? env.UNSNOOZE_PANE_OWNER : env.HERDR_SESSION || 'default') || null;
27
+ }
28
+ if (muxName === 'zellij') {
29
+ return (env.UNSNOOZE_MUX === 'zellij'
30
+ ? env.UNSNOOZE_PANE_OWNER : env.ZELLIJ_SESSION_NAME) || null;
31
+ }
32
+ return null;
33
+ }
34
+
22
35
  function runUnwatched(agent, args, reason) {
23
36
  if (reason) process.stderr.write(`unsnooze: ${reason}\n`);
24
37
  const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
25
38
  return r.status ?? 1;
26
39
  }
27
40
 
28
- export function runLauncher(args, agentId = 'claude') {
41
+ export function runLauncher(args, agentId = 'claude', { processBirthFn = processBirth } = {}) {
29
42
  const agent = getAgent(agentId);
30
43
 
31
44
  // Recursion / nested-launch guard: inside an unsnooze-managed session, a
@@ -55,10 +68,29 @@ export function runLauncher(args, agentId = 'claude') {
55
68
  file: process.execPath,
56
69
  args: [process.argv[1], '_run', agent.id, ...args],
57
70
  env: process.env,
71
+ // Written before the session is used, not after: a launch that dies
72
+ // halfway still leaves the evidence reap needs to clean up after it.
73
+ // Without this, reap has no proof of ownership and (correctly) refuses
74
+ // to delete anything.
75
+ onSessionCreated: name => recordOwnedSession({ mux: mux.name, name }),
58
76
  });
59
77
  } catch (err) {
60
- // Session creation failed (tmux/zellij binary spawn, unexpected throw).
61
- // Never brick the user's `claude`/`codex` — fall back unwatched.
78
+ // The agent may already be running inside the session: the multiplexer
79
+ // took the command that starts it and something after that failed. The
80
+ // unwatched fallback below would then start a SECOND agent — two live
81
+ // sessions, two sets of edits — from one `unsnooze claude`. Tell the user
82
+ // how to reach the one that exists instead.
83
+ if (err instanceof AgentDispatchedError) {
84
+ process.stderr.write(`unsnooze: ${err.message}\n`);
85
+ const hint = attachHint(err.mux || mux.name, err.session);
86
+ if (hint) process.stderr.write(`unsnooze: reattach with: ${hint}\n`);
87
+ process.stderr.write(`unsnooze: not starting ${agent.id} again — it may already be running.\n`);
88
+ log(`launchWrapped failed after dispatch: ${err.stack || err}`);
89
+ return 1;
90
+ }
91
+ // Session creation failed before anything could start (binary spawn,
92
+ // duplicate name, unexpected throw). Never brick the user's
93
+ // `claude`/`codex` — fall back unwatched.
62
94
  const msg = err instanceof SessionCreateError
63
95
  ? `${err.message} — running without limit-watch.`
64
96
  : `failed to wrap into ${mux.name} (${err.message}) — running without limit-watch.`;
@@ -67,17 +99,32 @@ export function runLauncher(args, agentId = 'claude') {
67
99
  }
68
100
  }
69
101
 
70
- const pane = mux.currentPaneId();
71
- const paneOwner = mux.name === 'zellij'
72
- ? (process.env.UNSNOOZE_MUX === 'zellij'
73
- ? process.env.UNSNOOZE_PANE_OWNER : process.env.ZELLIJ_SESSION_NAME) || null
74
- : null;
102
+ const rawPane = mux.currentPaneId();
103
+ const paneOwner = resolvePaneOwner(mux.name, process.env);
104
+ // A backend may know it cannot safely address the pane it is sitting in —
105
+ // herdr's pane ids are per-server, and an ambient custom socket can point our
106
+ // commands at a different server's identically-numbered pane. Watching
107
+ // nothing beats typing into someone else's terminal, so drop to the same
108
+ // no-pane path a missing pane id already takes: the agent runs normally,
109
+ // just unwatched, and the user is told why.
110
+ const addressable = typeof mux.paneAddressable !== 'function' || mux.paneAddressable();
111
+ if (rawPane && !addressable) {
112
+ const why = typeof mux.addressabilityReason === 'function'
113
+ ? mux.addressabilityReason() : `${mux.name} pane ${rawPane} is not safely addressable`;
114
+ process.stderr.write(`unsnooze: ${why} — running ${agent.id} without limit-watch.\n`);
115
+ log(`pane ${rawPane} not addressable: ${why}`);
116
+ }
117
+ const pane = addressable ? rawPane : null;
75
118
  const leaseId = process.env.UNSNOOZE_LEASE_ID || createLeaseId();
76
119
  if (pane) {
77
120
  // Stamp our own pane (best-effort, tmux only): the identity every later
78
121
  // close/inject decision verifies against — pane ids get recycled.
79
- if (typeof mux.stampPaneOwner === 'function') {
80
- mux.stampPaneOwner(pane, leaseId).catch(() => { /* legacy tmux */ });
122
+ // Bind to the pane's session first: herdr addresses panes through
123
+ // `--session`, so an unbound driver cannot write the stamp at all. tmux
124
+ // ignores the binding.
125
+ const owner = typeof mux.bind === 'function' ? mux.bind(paneOwner) : mux;
126
+ if (typeof owner.stampPaneOwner === 'function') {
127
+ Promise.resolve(owner.stampPaneOwner(pane, leaseId)).catch(() => { /* legacy tmux */ });
81
128
  }
82
129
  spawnDetached(
83
130
  monitorSpawnArgs({ muxName: mux.name, paneOwner, pane, agentId: agent.id, leaseId }),
@@ -98,9 +145,15 @@ export function runLauncher(args, agentId = 'claude') {
98
145
  });
99
146
  const lease = pane && child.pid ? {
100
147
  leaseId, mux: mux.name, paneOwner, pane, agent: agent.id,
101
- pid: child.pid, pidBirth: processBirth(child.pid),
148
+ pid: child.pid, pidBirth: processBirthFn(child.pid),
102
149
  } : null;
103
- if (lease?.pidBirth) writeLease(lease);
150
+ // Write it even when the birth timestamp is unavailable. processBirth only
151
+ // reads /proc on linux and `ps` on darwin, so it is null on Windows and on
152
+ // any ps failure — and gating the write on it meant a perfectly healthy
153
+ // agent there got no lease at all. The monitor reads presence to know its
154
+ // agent is alive; ownership checks stay exactly as strict, because
155
+ // leaseMatches() still refuses to match a lease with a null birth.
156
+ if (lease) writeLease(lease);
104
157
  const cleanup = () => { if (lease) removeLease(lease, leaseId); };
105
158
  return new Promise(resolve => {
106
159
  child.on('exit', code => { cleanup(); resolve(code ?? 1); });
package/src/lease.js CHANGED
@@ -81,8 +81,22 @@ export async function leaseMatches(rec, {
81
81
  if (!stored || stored.leaseId !== rec.leaseId || stored.agent !== rec.agent) return false;
82
82
  if (stored.mux !== rec.mux || stored.paneOwner !== rec.paneOwner || stored.pane !== rec.pane) return false;
83
83
  if (!pidAlive(stored.pid)) return false;
84
+ // The birth time is what makes a recycled pid detectable: same number, later
85
+ // start, different process. Where it is available on both sides it must
86
+ // match exactly.
87
+ //
88
+ // It is not available everywhere. processBirth() reads /proc on linux and
89
+ // `ps` on darwin and returns null otherwise — on Windows, and on any host
90
+ // where that call fails. Treating "this host cannot tell me" the same as
91
+ // "the process is an impostor" meant unsnooze silently refused to resume its
92
+ // own session there, forever. When neither the lease nor the host can supply
93
+ // a birth time, fall back to the identity that is still verifiable: the
94
+ // lease's own fields matched above, the pid is alive, and the pane is alive.
95
+ // That leaves pid reuse as the only false positive, which is the exposure
96
+ // every pre-birth-time version of this code already carried.
84
97
  const birth = processBirthFn(stored.pid);
85
- if (!birth || birth !== stored.pidBirth) return false;
98
+ const unavailable = birth == null && stored.pidBirth == null;
99
+ if (!unavailable && (!birth || birth !== stored.pidBirth)) return false;
86
100
  try { return await mux.paneAlive(rec.pane); } catch { return false; }
87
101
  }
88
102
 
package/src/monitor.js CHANGED
@@ -14,7 +14,7 @@ import { getMultiplexer } from './multiplexer.js';
14
14
  import {
15
15
  SCRAPE_INTERVAL_MS, PANE_SCAN_LINES, CAPTURE_LINES, EVENTS_DIR,
16
16
  EVENT_MARKER_TTL_MS, OVERLOAD_BACKOFF_S, OVERLOAD_JITTER,
17
- PROBE_INTERVAL_MS, RESET_MARGIN_MS,
17
+ PROBE_INTERVAL_MS, RESET_MARGIN_MS, LEASE_GRACE_MS,
18
18
  } from './config.js';
19
19
  import { detectLimit, isBusy, overloadMatch } from './patterns.js';
20
20
  import { getAgent } from './agents/index.js';
@@ -34,11 +34,44 @@ const log = makeLogger('monitor');
34
34
 
35
35
  const sleep = ms => new Promise(r => setTimeout(r, ms));
36
36
 
37
+ // When to wake, from a banner's reset line.
38
+ //
39
+ // Exported because the stale-banner clamp below is a policy decision with a
40
+ // boundary, and a boundary that cannot be tested directly gets tested by
41
+ // accident. Pure: everything it depends on is an argument.
42
+ export function resolveResetAt({
43
+ resetLine, bannerAt, detectedAt, limitType,
44
+ marginMs = RESET_MARGIN_MS, fallbackMs = PROBE_INTERVAL_MS,
45
+ } = {}) {
46
+ let { at, source } = resetAtMs(parseResetTime(resetLine), {
47
+ marginMs,
48
+ fallbackMs,
49
+ now: new Date(detectedAt),
50
+ bannerAt,
51
+ });
52
+ // A 5h rolling window can never reset more than ~5h out. A parse far beyond
53
+ // that means the banner text is stale — e.g. scraped from pane history after
54
+ // midnight, where an undated clock time resolves to tomorrow. The announced
55
+ // reset already passed; wake now instead.
56
+ //
57
+ // Judge the announced reset itself, not the scheduled wake: resetAtMs adds
58
+ // the margin to an absolute parse, and counting that margin as part of the
59
+ // distance condemns legitimate resets near the edge of the window. With a
60
+ // 60-minute margin a real midnight reset seen at 19:00 is 5h away, but its
61
+ // wake is 6h away — stale by that test, and wrongly dragged back to 20:00.
62
+ if (limitType === '5h' && source === 'absolute'
63
+ && (at - marginMs) > detectedAt + 5.5 * 3_600_000) {
64
+ at = detectedAt + marginMs;
65
+ }
66
+ return { at, source };
67
+ }
68
+
37
69
  export function createMonitor({
38
70
  muxName = 'tmux', paneOwner = null, pane, leaseId = null, cwd,
39
71
  agent = getAgent('claude'), mux = getMultiplexer(muxName, { owner: paneOwner }),
40
72
  scrapeInterval = SCRAPE_INTERVAL_MS, notifier = notify,
41
73
  spawner = spawnDetached, versionSkewed = hasVersionSkew, handoffBin = UNSNOOZE_BIN,
74
+ leaseGraceMs = LEASE_GRACE_MS, startedAt = Date.now(),
42
75
  }) {
43
76
  const notifyCtx = { mux: muxName, pane, paneOwner };
44
77
  let trackedKey = null; // state key of the record we created
@@ -88,13 +121,8 @@ export function createMonitor({
88
121
  };
89
122
  }
90
123
 
91
- function computeReset(resetLine, bannerAt, detectedAt) {
92
- return resetAtMs(parseResetTime(resetLine), {
93
- marginMs: RESET_MARGIN_MS,
94
- fallbackMs: PROBE_INTERVAL_MS,
95
- now: new Date(detectedAt),
96
- bannerAt,
97
- });
124
+ function computeReset(resetLine, bannerAt, detectedAt, limitType) {
125
+ return resolveResetAt({ resetLine, bannerAt, detectedAt, limitType });
98
126
  }
99
127
 
100
128
  // §6 first-tick corroboration: only record if a fresh transcript backs it,
@@ -103,6 +131,10 @@ export function createMonitor({
103
131
  function isCorroborated(resolved, at, source) {
104
132
  if (resolved.via === 'transcript' && resolved.bannerAt != null) return true;
105
133
  if (source === 'absolute' && at - RESET_MARGIN_MS > Date.now()) return true;
134
+ // A model limit carries no time to corroborate — its own remedy hint is
135
+ // the evidence, and the record only ever probes (never blind-wakes), so
136
+ // recording it cannot cause a premature resume.
137
+ if (resolved.limitType === 'model') return true;
106
138
  return false;
107
139
  }
108
140
 
@@ -135,7 +167,7 @@ export function createMonitor({
135
167
  async function recordLimit(resetLine, limitType, via) {
136
168
  const resolved = resolveBanner(resetLine, limitType);
137
169
  const detectedVia = resolved.via || via;
138
- const { at, source } = computeReset(resolved.resetLine, resolved.bannerAt, resolved.detectedAt);
170
+ const { at, source } = computeReset(resolved.resetLine, resolved.bannerAt, resolved.detectedAt, resolved.limitType);
139
171
  let muxSession = null;
140
172
  if (typeof mux.sessionForPane === 'function') {
141
173
  try { muxSession = await mux.sessionForPane(pane); } catch { muxSession = null; }
@@ -174,7 +206,13 @@ export function createMonitor({
174
206
  // is ambiguous when historical records exist for the same address.
175
207
  trackedKey = appliedKey;
176
208
  log(`pane ${pane}: limit recorded (${resolved.limitType}, via ${detectedVia}), resets ${new Date(at).toISOString()} (${source})`);
177
- notifier(`${agent.name} hit a usage limit`, `${cwd} — auto-resume at ${new Date(at).toLocaleTimeString()}`, { context: notifyCtx });
209
+ if (resolved.limitType === 'model') {
210
+ notifier(`${agent.name} hit a model limit — needs you`,
211
+ `${cwd} — switch models (/model) or add credits (/usage-credits); unsnooze wakes it once the banner clears`,
212
+ { context: notifyCtx });
213
+ } else {
214
+ notifier(`${agent.name} hit a usage limit`, `${cwd} — auto-resume at ${new Date(at).toLocaleTimeString()}`, { context: notifyCtx });
215
+ }
178
216
  spawnResumerIfNeeded();
179
217
  return { at, source, resolved };
180
218
  }
@@ -238,6 +276,18 @@ export function createMonitor({
238
276
  log(`pane ${pane}: lease ${leaseId} gone — agent exited, monitor exiting`);
239
277
  running = false;
240
278
  return;
279
+ } else if (Date.now() - startedAt > leaseGraceMs) {
280
+ // No lease has EVER appeared. The launcher spawns us before it spawns
281
+ // the agent, so an agent that failed to spawn at all (bad bin, instant
282
+ // crash) leaves us watching a pane that is just the user's shell, with
283
+ // no lease-gone edge to ever fire. Without this the monitor scrapes
284
+ // that pane forever, and they pile up one per failed launch.
285
+ // Deliberately a deadline rather than a tick count: scrapeInterval is
286
+ // injected as 0 in tests and tunable in production, so N ticks is not
287
+ // a duration.
288
+ log(`pane ${pane}: lease ${leaseId} never appeared within ${leaseGraceMs}ms — agent never launched, monitor exiting`);
289
+ running = false;
290
+ return;
241
291
  }
242
292
  }
243
293
 
@@ -305,7 +355,7 @@ export function createMonitor({
305
355
  if (existing && existing.status === 'stopped') {
306
356
  const resolved = resolveBanner(d.resetLine, d.limitType);
307
357
  const via = resolved.via || (marker ? 'hook' : 'scrape');
308
- const { at, source } = computeReset(resolved.resetLine, resolved.bannerAt, resolved.detectedAt);
358
+ const { at, source } = computeReset(resolved.resetLine, resolved.bannerAt, resolved.detectedAt, resolved.limitType);
309
359
  maybeUpgrade(existing, at, source, resolved.bannerAt, resolved.limitType, via);
310
360
  firstTick = false;
311
361
  return;
@@ -317,7 +367,7 @@ export function createMonitor({
317
367
  if (firstTick) {
318
368
  const resolved = resolveBanner(d.resetLine, d.limitType);
319
369
  const detectedVia = resolved.via || via;
320
- const { at, source } = computeReset(resolved.resetLine, resolved.bannerAt, resolved.detectedAt);
370
+ const { at, source } = computeReset(resolved.resetLine, resolved.bannerAt, resolved.detectedAt, resolved.limitType);
321
371
  if (!isCorroborated({ ...resolved, via: detectedVia }, at, source)) {
322
372
  log(`pane ${pane}: first tick banner uncorroborated (${source}) — skipping`);
323
373
  firstTick = false;