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 +69 -0
- package/README.md +39 -9
- package/package.json +4 -3
- package/src/agents/claude.js +7 -0
- package/src/cli.js +1 -0
- package/src/config.js +14 -0
- package/src/fleet.js +5 -1
- package/src/hook.js +23 -8
- package/src/install.js +7 -3
- package/src/launcher.js +66 -13
- package/src/lease.js +15 -1
- package/src/monitor.js +62 -12
- package/src/multiplexer.js +14 -4
- package/src/multiplexers/cmux.js +153 -0
- package/src/multiplexers/herdr.js +613 -0
- package/src/multiplexers/session-name.js +30 -0
- package/src/multiplexers/shell-quote.js +96 -0
- package/src/multiplexers/tmux.js +1 -0
- package/src/multiplexers/zellij.js +1 -0
- package/src/mux-sessions.js +68 -0
- package/src/notify.js +2 -1
- package/src/patterns.js +61 -2
- package/src/prompt-queue.js +12 -7
- package/src/reap.js +47 -12
- package/src/report.js +16 -3
- package/src/resumer.js +80 -12
- package/src/settings.js +59 -3
- package/src/state.js +24 -0
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
|
|
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
|
|
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>`
|
|
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 `
|
|
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**
|
|
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
|
|
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
|
|
634
|
-
|
|
635
|
-
|
|
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.
|
|
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",
|
package/src/agents/claude.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
//
|
|
61
|
-
//
|
|
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
|
|
71
|
-
const paneOwner = mux.name
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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:
|
|
148
|
+
pid: child.pid, pidBirth: processBirthFn(child.pid),
|
|
102
149
|
} : null;
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|