unsnooze 1.12.2 → 1.14.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +288 -22
  3. package/bin/unsnooze.js +52 -3
  4. package/package.json +7 -4
  5. package/src/agents/agy.js +2 -0
  6. package/src/agents/claude.js +2 -0
  7. package/src/agents/codex.js +2 -0
  8. package/src/agents/grok.js +2 -0
  9. package/src/agents/index.js +7 -1
  10. package/src/agents/kimi.js +2 -0
  11. package/src/agents/opencode.js +2 -0
  12. package/src/agents/qwen.js +2 -0
  13. package/src/askpass.js +188 -0
  14. package/src/cli.js +149 -25
  15. package/src/config.js +15 -0
  16. package/src/dashboard/App.js +390 -0
  17. package/src/dashboard/Logo.js +74 -0
  18. package/src/dashboard/components/TextInput.js +63 -0
  19. package/src/dashboard/data.js +143 -0
  20. package/src/dashboard/mark.js +51 -0
  21. package/src/dashboard/mouse-protocol.js +75 -0
  22. package/src/dashboard/mouse.js +125 -0
  23. package/src/dashboard/run.js +108 -0
  24. package/src/dashboard/tabs/DoctorTab.js +35 -0
  25. package/src/dashboard/tabs/FleetTab.js +94 -0
  26. package/src/dashboard/tabs/LogsTab.js +28 -0
  27. package/src/dashboard/tabs/PromptsTab.js +406 -0
  28. package/src/dashboard/tabs/SessionsTab.js +33 -0
  29. package/src/dashboard/tabs/StatusTab.js +58 -0
  30. package/src/dashboard/tabs/UsageTab.js +68 -0
  31. package/src/dashboard/theme.js +32 -0
  32. package/src/doctor.js +22 -16
  33. package/src/fleet.js +927 -0
  34. package/src/hook.js +19 -0
  35. package/src/install.js +9 -0
  36. package/src/monitor.js +14 -0
  37. package/src/prompt-queue.js +561 -0
  38. package/src/prompt.js +411 -0
  39. package/src/remote.js +223 -0
  40. package/src/resumer.js +60 -16
  41. package/src/settings.js +13 -0
  42. package/src/state.js +33 -9
  43. package/src/time-parser.js +15 -1
  44. package/src/tui.js +395 -0
  45. package/src/usage.js +1537 -0
  46. package/src/watcher.js +79 -8
  47. package/src/watchers/claude.js +3 -0
  48. package/src/watchers/codex.js +8 -8
  49. package/src/wizard.js +2 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,96 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.14.1 — 2026-08-04
6
+
7
+ - **Package discovery**: refreshed the npm description and README links, and
8
+ moved the published package homepage from the legacy Combustor Tech hostname
9
+ to [unsnooze.dev](https://unsnooze.dev). No runtime behavior changed.
10
+
11
+ ## 1.14.0 — 2026-07-19
12
+
13
+ - **Queued prompts**: `unsnooze prompt add [--agent id] [--project path]
14
+ [--at time|--now] <text...>` (plus `list`/`remove`/`clear`) queues a
15
+ one-shot prompt that spawns a **brand-new** agent session in a project
16
+ directory once a usage limit clears. `--now`/`--at` (epoch, ISO-8601,
17
+ `+2h30m`, or a bare clock time) skip the reset wait; with no reset signal
18
+ at all, a `next-reset` entry delivers on the very next daemon tick and
19
+ `prompt add` prints a notice to that effect. Delivery is verified against
20
+ a fresh limit banner and backs off on failure — the same backoff floor
21
+ applies to every mode, capped at 5 attempts (same as resume) before an
22
+ entry is marked failed. `autoResume` does not gate delivery. Dashboard:
23
+ a new **Prompts** tab (`7`) — list, `a` to add, `d`/`x` to remove — plus a
24
+ queued-count hint on the Status tab. Fleet: `--host <name>` relays the
25
+ same subcommands to a registered host's own queue (`--project`/`--agent`
26
+ required, everything re-validated server-side); the new `remoteQueue`
27
+ setting (default on) lets a host opt out of all queue traffic, answering
28
+ a typed `disabled` instead of silently dropping it.
29
+ - **Fleet password auth**: hosts can now use a password instead of an ssh key
30
+ — `unsnooze hosts add <name> <dest> --auth password --source
31
+ prompt|env|keychain|command` (`prompt` is interactive no-echo and the
32
+ default; `env`/`keychain`/`command` are daemon-capable). `keychain` is a
33
+ macOS-only built-in; Windows and Linux use `--source command` with a
34
+ per-OS recipe (Linux `pass`/`secret-tool`, Windows `powershell`/`op read`;
35
+ `security` is the macOS recipe — see README's per-OS table). `unsnooze hosts test <name>` pre-flights a host without
36
+ ever printing its secret. Auth-gapped hosts render as `needs-auth` in
37
+ `fleet`/the dashboard, distinct from `unreachable`. Security: the password
38
+ never touches argv, `ps`, or unsnooze's own environment — it flows through
39
+ OpenSSH's `SSH_ASKPASS` hook, helper-stdout → ssh, in-process; unsnooze
40
+ stores no plaintext itself; keys stay the unchanged, BatchMode-hardened
41
+ default.
42
+ - **Fleet — sessions on every machine, over your own SSH**: `unsnooze hosts
43
+ [add|rm|list]` registers ssh destinations; `unsnooze fleet [--json]` and
44
+ the dashboard's new **Fleet** tab fan out to every host in parallel and
45
+ show each one's tracked sessions (state, reset countdown, attach hint),
46
+ with a bounded-concurrency ssh pool, per-host timeouts, and a 24h stale
47
+ cache so one dead box never blocks the rest. `unsnooze _remote` is the
48
+ single remote entrypoint (`status`/`resume`/`cancel`), safe to lock to an
49
+ `authorized_keys` forced command; `unsnooze status --json` and a shared
50
+ resume core back both the local and remote paths. Security posture: no
51
+ listening ports, no custom auth, no tokens — transport is plain OpenSSH
52
+ with host-key checking never weakened; the remote is always the one that
53
+ types, under its own gates; and every field a remote returns is
54
+ control-character-stripped, length-capped, and extracted into fresh
55
+ objects before it touches your terminal or state.
56
+ - **Dashboard mouse support**: click tabs and session rows, wheel-scroll the
57
+ status/sessions lists and a real scrollback window in Logs, clickable
58
+ footer hints (refresh / help / quit). Full keyboard parity kept; `m` (or
59
+ `mouse` config / `UNSNOOZE_MOUSE`) toggles it live so terminal text
60
+ selection is one keypress away. Tracking modes are always cleared on exit,
61
+ crash, and Ctrl-Z — no hijacked mouse after quit.
62
+
63
+ ## 1.13.0 — 2026-07-17
64
+
65
+ - **`unsnooze usage` — know the wall before you hit it**: burn-rate &
66
+ time-to-limit forecast per agent × window. Every figure carries its
67
+ provenance — `(exact)` from Codex rollouts or the opt-in Claude statusline
68
+ shim, `(calibrated from N stops)` learned from unsnooze's own recorded
69
+ limit stops, or `(estimated)` while calibrating. Weighted token burn
70
+ (cache reads ×0.1) over active minutes, account-wide including subagent
71
+ transcripts, per Opus/Sonnet bucket on Max plans. ETA shown as a band,
72
+ cross-checked against the observed reset time — never a false-precision
73
+ minute, never a blind now+5h.
74
+ - **Pre-wall warnings from the daemon**: hybrid thresholds (80/95% and
75
+ ≤30/≤10 min to the wall at current pace), deduped per window instance,
76
+ with a `/compact now` nudge sized from the same context estimator as
77
+ `unsnooze status`. Notify-only — unsnooze still never types anything you
78
+ didn't configure.
79
+ - **`unsnooze usage --install-statusline`**: opt-in shim that persists
80
+ Claude's exact server-side percentages; chains your existing statusline
81
+ command (backed up once, restored on uninstall).
82
+ - **Live dashboard**: `unsnooze dashboard` (also `status`/`usage` on an
83
+ interactive TTY) — full-screen Ink TUI with Status, Usage, Sessions,
84
+ Doctor and Logs tabs, animated ❯ z z z brand mark, help overlay (`?`),
85
+ and a compact layout down to 80×24. Pipes and `--json` stay plain.
86
+ - **Fix**: undated limit banners crossing midnight ("resets 12:04am" seen
87
+ at 9pm) parsed as already-past and were dropped or resumed due-now into a
88
+ still-live limit; they now roll to tomorrow when the announced time is
89
+ within a window's reach, while genuinely stale banners still resolve
90
+ due-now.
91
+ - Codex windows are labeled from `window_minutes` everywhere (300 → 5h,
92
+ 10080 → weekly, 43200 → 30d on the go plan) — stop records no longer
93
+ conflate the monthly window with the weekly bucket.
94
+
5
95
  ## 1.12.2 — 2026-07-16
6
96
 
7
97
  - **Daemon autostart self-heal**: updating via npm never touches the
package/README.md CHANGED
@@ -9,6 +9,10 @@
9
9
  [![node](https://img.shields.io/badge/node-%E2%89%A5%2020-3fb950)](package.json)
10
10
  [![license](https://img.shields.io/badge/license-MIT-8b949e)](LICENSE)
11
11
 
12
+ **Automatically resume every limit-stopped AI coding session when its usage limit resets.**
13
+
14
+ [Website](https://unsnooze.dev) · [Documentation](https://unsnooze.dev/docs/) · [Changelog](https://unsnooze.dev/changelog/) · [Feedback](https://unsnooze.dev/feedback/)
15
+
12
16
  **Claude Code · Codex CLI · Grok · Qwen · Kimi · OpenCode · Antigravity** — when they hit the 5-hour or weekly usage limit
13
17
  ("You've hit your usage limit"), your session just… stops.<br/>
14
18
  unsnooze auto-resumes them: it tracks **every** limit-stopped session across all
@@ -68,6 +72,22 @@ your agent handles permissions.
68
72
  - **Reversible install.** The settings hook and rc-file wrappers are backed up
69
73
  first (`*.unsnooze-orig` pristine + `*.unsnooze-bak` rolling); `unsnooze
70
74
  uninstall` removes every change. Releases are published with npm provenance.
75
+ - **Fleet adds no network surface.** No listening ports, no custom auth, no
76
+ tokens. Transport is your own OpenSSH (`~/.ssh/config`, keys, agent);
77
+ unsnooze never sets `StrictHostKeyChecking=no` and fails fast on unknown
78
+ hosts instead of auto-trusting them.
79
+ - **The remote is the authority.** Fleet resume only marks a session due —
80
+ the remote daemon types under its own gates (foreground, idle, your
81
+ configured message only). A compromised viewer cannot make a remote type
82
+ anything else.
83
+ - **Remote output is untrusted.** Everything a host returns is
84
+ control-character-stripped and length-capped before it touches your
85
+ terminal (ANSI-injection defense), and parsed fields are extracted —
86
+ never merged — into fresh objects.
87
+ - **Lock a key to unsnooze (optional, recommended for automation):**
88
+ `command="unsnooze _remote",restrict ssh-ed25519 AAAA… unsnooze-fleet`
89
+ in the remote `authorized_keys` confines that key to the fleet
90
+ entrypoint — it can never open a shell.
71
91
 
72
92
  **Honest limits:** unsnooze *does* inject keystrokes into your live terminal on
73
93
  your behalf, and it does not sandbox your agent or defend against prompt
@@ -196,34 +216,276 @@ StopFailure hook (claude, grok) ──────────────┤
196
216
  </details>
197
217
 
198
218
  Limit events are never persisted by the CLIs themselves; the reset time is
199
- parsed from the banner text, DST-safe, with a 5-hour fallback when unparseable
200
- — and every resume is verified afterwards (banner came back → reschedule from
201
- the fresh banner, capped at 5 attempts).
219
+ parsed from the banner text, DST-safe. Unparseable banners are never guessed
220
+ at — unsnooze probes the pane on a backoff schedule until a real reset time
221
+ appears. Every resume is verified afterwards (banner came back → reschedule
222
+ from the fresh banner, capped at 5 attempts).
223
+
224
+ ## Know the wall before you hit it
225
+
226
+ Recovery (auto-resume) is only half the job. **Prevention** is knowing when the
227
+ wall is coming — so you can `/compact`, pause, or switch models *before* a stop.
228
+
229
+ ```sh
230
+ unsnooze usage # burn rate, % used, time-to-limit
231
+ unsnooze usage --json # stable machine shape (exit 2 past warn %)
232
+ unsnooze usage --install-statusline # opt-in: exact Claude % via statusline
233
+ unsnooze usage --uninstall-statusline # remove the shim, restore your statusLine
234
+ ```
235
+
236
+ ```text
237
+ $ unsnooze usage
238
+ unsnooze usage — account burn & time-to-limit (daemon: running · warnings at 80,95%)
239
+
240
+ claude 5h [█████████████░░░░░░░] ~64% (calibrated from 4 stops)
241
+ burn ~31k weighted tok/min over last 42 active min
242
+ wall ~1h 10m at this pace · window resets 8:00 pm (absolute)
243
+
244
+ codex 5h [███░░░░░░░░░░░░░░░░░] 5% used (exact)
245
+ monthly [██░░░░░░░░░░░░░░░░░░] 5% used (exact) · resets Aug 11
246
+ burn idle — no active burn
247
+
248
+ Estimates are a lower bound: Claude quotas are account-pooled with claude.ai/Desktop.
249
+ Exact Claude percentages available via: unsnooze usage --install-statusline
250
+ ```
251
+
252
+ | What you get | How |
253
+ |---|---|
254
+ | **Honest labels** | Every figure is tagged `(exact)`, `(calibrated from N stops)`, or `(estimated — calibrating…)`. Never a bare %. |
255
+ | **Account-wide burn** | Sums all active sessions **and** subagents (the limit is per-account, not per-pane). Idle gaps >5 min are excluded so a quiet hour doesn't hide a fast burn. |
256
+ | **Time-to-wall** | ETA as a band (not a false-precision minute), cross-checked against known reset times. Weekly shown info-only — no unstable weekly ETA. |
257
+ | **Pre-wall warnings** | Daemon notifies at `%` bands (`usageWarnAt`, default `80,95`) **and** time tiers (30 / 10 min). Deduped once per window. |
258
+ | **`/compact` nudge only** | Warnings may *suggest* `/compact` so the eventual wake is cheap. unsnooze **never auto-types it** (same security rule as resume: types only your configured message). |
259
+
260
+ **Provenance ladder** (why this isn't another guessed-quota dashboard):
261
+
262
+ 1. **`(exact)`** — Codex always (local `used_percent` + epoch reset). Claude only with the opt-in statusline shim (server-authoritative `rate_limits` from [Claude Code's statusline JSON](https://code.claude.com/docs/en/statusline)).
263
+ 2. **`(calibrated from N stops)`** — Claude token burn vs a ceiling learned from **your** limit-stop ledger (the same stops unsnooze already records for resume). Not plan presets, not circular P90-of-history.
264
+ 3. **`(estimated)`** — used tokens + burn shown; ceiling unknown until the first observed stop.
265
+
266
+ **Honest limits:** Claude transcript sums are a **lower bound** — subscription quotas are account-pooled with claude.ai and the Desktop app. Without the statusline shim, Claude tops out at calibrated/estimated. Exact Claude % needs Pro/Max after the first API response of a session (absent after `/clear` and on API auth).
202
267
 
203
268
  ## Usage
204
269
 
270
+ On an interactive TTY, `status` / `usage` / `sessions` open a **live dashboard**
271
+ (`unsnooze dashboard`) with the brand logo (`❯` + rising `zzz`), tabs, live
272
+ refresh until you press **`q`** — and mouse support: click tabs and rows,
273
+ wheel to scroll (toggle with `m`; hold Shift — Option in iTerm2 — to select text). Pipes, `NO_COLOR`, `CI`, and `--json` stay plain.
274
+
275
+ ```sh
276
+ claude / codex / grok # normal usage — wrapped automatically
277
+ unsnooze status # tracked sessions + reset countdowns
278
+ unsnooze dashboard [tab] # live TUI (status|usage|sessions|doctor|logs|fleet|prompts)
279
+ unsnooze usage [--json] # account burn & time-to-limit forecast
280
+ unsnooze usage --install-statusline # opt-in exact Claude % (chains your statusLine)
281
+ unsnooze resume-now [id|--all] # don't wait for the reset time
282
+ unsnooze cancel [id|--all] # stop tracking a session
283
+ unsnooze message <id> "text" # per-session wake message (--clear to reset)
284
+ unsnooze preview [id] # dry-run: what WOULD happen right now, and why —
285
+ # nothing is typed or opened
286
+ unsnooze sessions # list unsnooze-owned mux sessions + panes
287
+ unsnooze reap [--dry-run|--yes] # close finished panes / empty sessions (default dry-run)
288
+ unsnooze doctor [--fix] # install health check + retire old csg leftovers
289
+ unsnooze prompt add [--agent id] [--project path] [--at time|--now] "text"
290
+ # queue a prompt for a NEW session (see Queued prompts)
291
+ unsnooze prompt list [--json] # list queued prompts
292
+ unsnooze prompt remove <id> # cancel a queued prompt
293
+ unsnooze prompt clear # cancel all pending queued prompts
294
+ unsnooze config list # settings (see below)
295
+ unsnooze config set <k> <v> # e.g. autoResume off
296
+ unsnooze logs [-f] # what unsnooze has been doing
297
+ unsnooze update # update unsnooze itself
298
+ unsnooze daemon # persistent GUI-session watcher (usually run
299
+ # by launchd/systemd via `install --daemon`)
300
+ unsnooze report [agent] # capture a pane to report an undetected banner
301
+ unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
302
+ unsnooze help # full command list (also -h / --help)
303
+ ```
304
+
305
+ ## Queued prompts
306
+
307
+ Queue a prompt now; unsnooze types it into a **brand-new** agent session — a
308
+ fresh window in a project directory — once a usage limit clears (or at a time
309
+ you choose). It's one-shot: each entry is delivered at most once.
310
+
311
+ ```sh
312
+ unsnooze prompt add "run the full test suite and fix any failures"
313
+ # → interactive agent picker on a TTY; queued for next-reset in the cwd
314
+
315
+ unsnooze prompt add --agent codex --project ~/code/api --now "ship the release"
316
+ unsnooze prompt add --at "+2h30m" "rebase onto main" # relative duration
317
+ unsnooze prompt add --at "9pm" "nightly cleanup pass" # next occurrence of a clock time
318
+ unsnooze prompt add --at 1755000000 "..." # epoch (seconds or ms), or an ISO-8601 timestamp
319
+
320
+ unsnooze prompt list [--json] # id, agent, due, status, cwd, prompt preview
321
+ unsnooze prompt remove <id> # cancel one pending/launching entry
322
+ unsnooze prompt clear # cancel every pending/launching entry
323
+ ```
324
+
325
+ Modes:
326
+
327
+ - **next-reset** (default) — delivered once no future reset time is known for
328
+ that agent, i.e. once the current limit clears. If unsnooze holds **no
329
+ reset signal at all** for that agent when you add the entry, there's
330
+ nothing to wait on — it delivers on the very next daemon tick, and
331
+ `prompt add` prints a notice to that effect right away. Use `--at` if you
332
+ want a specific time instead.
333
+ - `--now` — deliver on the next daemon tick, no reset wait at all.
334
+ - `--at <time>` — deliver at a specific time: epoch (seconds or
335
+ milliseconds), an ISO-8601 timestamp, a `+2h30m`-style relative duration,
336
+ or a bare clock time (`9pm`, `2:05pm`, `14:30`) rolled to its next local
337
+ occurrence.
338
+
339
+ If a delivery attempt lands on a pane that's still limited — the reset
340
+ hadn't actually cleared, or a fresh `--now`/`--at` session hits the wall
341
+ immediately — the entry goes back to pending behind a backoff floor before
342
+ it's retried. That floor applies to every mode, so a failing `--now`/`--at`
343
+ entry can't burn through every retry in the first few seconds the way an
344
+ unthrottled retry loop would. Verified delivery (capped at 5 attempts,
345
+ same as resume) marks it `failed` and sends a notification.
346
+
347
+ `autoResume` does **not** gate prompt delivery — the queue runs independently
348
+ of session tracking, even with `autoResume off`.
349
+
350
+ A far-future `--at` entry is expected to keep a transient resumer process
351
+ alive (checking in each tick) until its delivery time arrives, however long
352
+ that is — this is intended, not a leak.
353
+
354
+ **Fleet:** `--host <name>` queues on a registered host instead of locally.
355
+ `--project` (an absolute remote path) and `--agent` are both required — there's
356
+ no local cwd to default to and no interactive picker over an ssh round-trip.
357
+ The remote host re-validates everything server-side. Delivery feedback comes
358
+ from `unsnooze fleet` / the dashboard's Fleet tab (per-host queued count) and
359
+ that host's own notifications (ntfy reaches your phone from any host, not
360
+ just the one you're sitting at). A host can refuse all queue traffic with
361
+ `remoteQueue: false` (`unsnooze config set remoteQueue off`, env
362
+ `UNSNOOZE_REMOTE_QUEUE=0`, **set on the host being controlled**) — the queue
363
+ verbs then answer a typed "disabled" instead of silently dropping; a remote
364
+ that predates this feature reports a clear "too old" error instead of
365
+ failing silently.
366
+
205
367
  ```sh
206
- claude / codex / grok # normal usage — wrapped automatically
207
- unsnooze status # tracked sessions + reset countdowns
208
- unsnooze resume-now [id|--all] # don't wait for the reset time
209
- unsnooze cancel [id|--all] # stop tracking a session
210
- unsnooze message <id> "text" # per-session wake message (--clear to reset)
211
- unsnooze preview [id] # dry-run: what WOULD happen right now, and why —
212
- # nothing is typed or opened
213
- unsnooze sessions # list unsnooze-owned mux sessions + panes
214
- unsnooze reap [--dry-run|--yes] # close finished panes / empty sessions (default dry-run)
215
- unsnooze doctor [--fix] # install health check + retire old csg leftovers
216
- unsnooze config list # settings (see below)
217
- unsnooze config set <k> <v> # e.g. autoResume off
218
- unsnooze logs [-f] # what unsnooze has been doing
219
- unsnooze update # update unsnooze itself
220
- unsnooze daemon # persistent GUI-session watcher (usually run
221
- # by launchd/systemd via `install --daemon`)
222
- unsnooze report [agent] # capture a pane to report an undetected banner
223
- unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
224
- unsnooze help # full command list (also -h / --help)
368
+ unsnooze prompt add --host gpu-box --project /home/me/repo --agent claude --now "..."
369
+ unsnooze prompt list --host gpu-box
225
370
  ```
226
371
 
372
+ **Dashboard:** the **Prompts** tab (`7`) lists queued entries; `a` opens an
373
+ add form (path → agent → when — with a time prompt if you pick "at" — → a
374
+ host step if you have hosts registered → prompt text), `d`/`x` removes the
375
+ selected entry. The Status tab shows a `<n> prompt(s) queued — tab 7` hint
376
+ whenever entries are pending or launching.
377
+
378
+ ## Fleet: sessions on every machine
379
+
380
+ See and act on unsnooze sessions on other machines from one terminal — over
381
+ your own SSH, no new service to run.
382
+
383
+ ```sh
384
+ unsnooze hosts add work you@work-box.local # register an ssh destination
385
+ unsnooze hosts list # registered hosts
386
+ unsnooze hosts rm work # forget a host
387
+ unsnooze fleet [--json] # every host's sessions, fanned out over ssh
388
+ unsnooze dashboard fleet # live Fleet tab — R resumes a selected stopped session (C cancels)
389
+ ```
390
+
391
+ A registered host needs **unsnooze installed** and **ssh key auth** (no
392
+ passwords) — nothing new to open or configure; fleet rides your existing
393
+ `~/.ssh/config`, keys, and agent. **Before adding a host, connect to it the
394
+ normal way once** (`ssh <host>`) so OpenSSH pins the host key itself —
395
+ unsnooze never weakens host-key checking to skip that step, so an unknown or
396
+ changed host fails fast instead of being silently trusted.
397
+
398
+ Fleet is *see and mark*, not *type remotely*: `unsnooze fleet` and the
399
+ dashboard's **Fleet** tab list every reachable host's tracked sessions
400
+ (state, reset countdown, attach hint). Selecting a stopped remote session and
401
+ pressing **`R`** (uppercase — lowercase `r` refreshes) only marks it due — the
402
+ remote's own daemon does the actual keystrokes, under the same
403
+ ownership/liveness/menu gates as a local session.
404
+ Resumed and stopped sessions print an attach hint
405
+ (`ssh -t <host> 'tmux new -A -s <session>'` / `zellij attach <session>`) so
406
+ you can hop over and watch. An unreachable or out-of-date host shows
407
+ `unreachable`/`skew` rather than blocking the rest of the fleet, and falls
408
+ back to its last-known state (marked `stale`) for up to 24h.
409
+
410
+ ### Auth: keys (default) or password
411
+
412
+ Every host picks its own auth, per host — unsnooze imposes no policy:
413
+
414
+ - **`key`** (default, unchanged) — the BatchMode-hardened path above. Nothing
415
+ about a key host changes.
416
+ - **`password`, interactive** (`--source prompt`, the default source when you
417
+ pass `--auth password` with nothing else) — typed at run time, no-echo, on
418
+ whichever terminal is running `unsnooze fleet`/`hosts test`. Nothing is
419
+ stored. There's no terminal under the background daemon, so a `prompt`
420
+ host is naturally interactive-only — it shows `needs-auth` when the daemon
421
+ or a piped/non-TTY caller tries it.
422
+ - **`password`, stored** (`--source env|keychain|command`) — resolved
423
+ automatically, so the daemon and `unsnooze fleet` can reach it unattended.
424
+
425
+ ```sh
426
+ unsnooze hosts add <name> <dest>
427
+ [--auth key|password] # default: key
428
+ [--source prompt|env|keychain|command] # default: prompt, once --auth password is set
429
+ [--env <VARNAME>] # env source: var to read (default UNSNOOZE_PW_<NAME>)
430
+ [--service <s> --account <a>] # keychain source (macOS only)
431
+ [--cmd '<command>'] # command source: program whose stdout is the password
432
+ unsnooze hosts test <name> # pre-flight: resolves the source (never prints the
433
+ # secret) then a real ssh reachability probe
434
+ ```
435
+
436
+ Examples:
437
+
438
+ ```sh
439
+ unsnooze hosts add laptop me@laptop.local --auth password
440
+ # → prompts for a password every time it's used from a real terminal
441
+
442
+ unsnooze hosts add gpu ubuntu@gpu.example.com --auth password --source env --env UNSNOOZE_PW_GPU
443
+ # → export UNSNOOZE_PW_GPU=... in your shell/session first
444
+
445
+ unsnooze hosts add mac me@mac-mini.local --auth password --source keychain --service unsnooze-mac --account me
446
+ # → macOS only; reads via `security find-generic-password`
447
+
448
+ unsnooze hosts add ci ci@build.example.com --auth password --source command --cmd 'op read op://vault/ci/password'
449
+ # → any OS; runs your own secret manager and reads its stdout
450
+
451
+ unsnooze hosts test gpu # resolves the credential + probes reachability; prints "auth ok" or a hint, never the secret
452
+ ```
453
+
454
+ **`command` is the portable, first-class source** — plug in whatever secret
455
+ manager you already run. Per-OS one-liners for `--cmd`:
456
+
457
+ | OS | Example `--cmd` |
458
+ |---|---|
459
+ | macOS | `security find-generic-password -s <service> -a <account> -w` |
460
+ | Linux | `pass show <path>` &nbsp;or&nbsp; `secret-tool lookup <attr> <value> ...` |
461
+ | Windows | `powershell -Command "..."` (e.g. wrapping `Get-StoredCredential`) &nbsp;or&nbsp; `op read op://vault/item/password` |
462
+ | any OS | `op read op://vault/item/password` (1Password CLI), or any other manager's read command |
463
+
464
+ `keychain` is a convenience built-in for **macOS only** (`security
465
+ find-generic-password`) — there's no dependable dependency-free store to
466
+ shell out to on Windows or Linux, so those use `--source command` with the
467
+ recipes above instead.
468
+
469
+ **Security posture:** the password reaches `ssh` through OpenSSH's own
470
+ `SSH_ASKPASS` hook — it never appears on `argv`, in `ps`, or in unsnooze's
471
+ own environment; it flows helper-stdout → ssh, in-process, for that one ssh
472
+ child only. unsnooze itself **stores no plaintext**: `keychain`/`command`
473
+ delegate entirely to the OS store or your own manager, and `env` is
474
+ user-managed (readable by your own shell already — positioned as a
475
+ low-friction/CI convenience, not secret-manager-grade). Keys remain the
476
+ default and their BatchMode-hardened path is untouched by any of this.
477
+
478
+ **Windows note:** native `ssh.exe` (Windows' built-in OpenSSH client) prompts
479
+ fine out of the box for an **interactive** `prompt`-source host — no shim
480
+ needed, it's the zero-friction path. **Stored** sources (`env`/`keychain`/
481
+ `command`) need a non-interactive askpass helper, and native `ssh.exe`
482
+ requires that to be a real launchable `.exe` — it won't reliably run a `.js`
483
+ or npm's `.cmd` shim. Until unsnooze ships that native launcher, use a
484
+ **Git-for-Windows or WSL `ssh`** (both accept the script-based helper the
485
+ same way macOS/Linux do — common on dev machines already) for stored
486
+ password hosts on Windows; a plain `ssh <host>` prompt works with native
487
+ `ssh.exe` regardless.
488
+
227
489
  ## Settings
228
490
 
229
491
  `unsnooze setup` writes `~/.unsnooze/config.json`; change anything later with
@@ -244,6 +506,9 @@ unsnooze help # full command list (also -h / --help)
244
506
  | `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. |
245
507
  | `contextGuard` | `inform` | Big cold context at wake? Waking a session re-reads its **entire context at full uncached price** ([why](#why-did-resuming-a-big-session-eat-so-much-of-my-quota)). `inform` resumes and notifies you of the size; `pause` holds sessions above the threshold for `unsnooze resume-now`; `off` disables. Claude Code only for now. |
246
508
  | `contextGuardTokens` | `100000` | Context-size threshold (tokens) at which `contextGuard` notifies or holds. |
509
+ | `usageWarn` | `notify` | Pre-wall usage warnings from the daemon (`unsnooze usage`): `notify` or `off`. Needs `notifications` on and the daemon running. Env: `UNSNOOZE_USAGE_WARN`. |
510
+ | `usageWarnAt` | `80,95` | Percent thresholds for usage warnings (comma-separated). Non-numeric values fall back to the default — never silently disable. Time tiers (30 / 10 min) are constants, not config keys. Env: `UNSNOOZE_USAGE_WARN_AT`. |
511
+ | `mouse` | `true` | Mouse support in `unsnooze dashboard`: click tabs and session rows, wheel-scroll lists/logs, clickable footer hints. Toggle live with `m`. Hold Shift (Option in iTerm2) to select text while it's on. Env: `UNSNOOZE_MOUSE`. |
247
512
  | `reapResumed` | `false` | Opt-in: auto-close `resumed` panes idle longer than `reapIdleAfter`. Off by default — use `unsnooze reap --yes` for explicit cleanup. |
248
513
  | `reapIdleAfter` | `604800000` (7d) | Idle age (ms) before an opt-in auto-reap closes a `resumed` pane. |
249
514
  | `updateCheck` | `true` | Daily new-version check (a plain GET to the npm registry, nothing identifying is sent). Notices after commands + one desktop toast per version. |
@@ -251,6 +516,7 @@ unsnooze help # full command list (also -h / --help)
251
516
  | `ntfyServer` | `https://ntfy.sh` | ntfy server base URL (self-hosted servers welcome). |
252
517
  | `ntfyToken` | `""` | Optional `tk_…` access token (`Authorization: Bearer`) for reserved topics / authed servers. |
253
518
  | `ntfyPrivacy` | `full` | `terse` keeps directory paths out of pushed bodies (titles only) — recommended on public ntfy.sh topics. |
519
+ | `remoteQueue` | `true` | Set **on the host being controlled**: may other hosts queue prompts on this one (`unsnooze prompt add --host <this>`)? Off = the queue verbs answer a typed `disabled` instead of silently dropping. Env: `UNSNOOZE_REMOTE_QUEUE`. |
254
520
 
255
521
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
256
522
  all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
package/bin/unsnooze.js CHANGED
@@ -48,12 +48,12 @@ function runAgentFallback(agentId, args) {
48
48
 
49
49
  // Only human-facing commands may print update notices — never the wrapper
50
50
  // passthrough, hooks, or daemons (their output lands in agent panes/logs).
51
- const USER_FACING = new Set(['status', 'resume-now', 'cancel', 'message', 'config', 'logs', 'report', 'sessions', 'reap', 'doctor', 'preview', 'help', '-h', '--help', '--help-unsnooze']);
51
+ const USER_FACING = new Set(['status', 'resume-now', 'cancel', 'message', 'config', 'logs', 'report', 'sessions', 'reap', 'doctor', 'preview', 'usage', 'dashboard', 'hosts', 'fleet', 'prompt', 'help', '-h', '--help', '--help-unsnooze']);
52
52
 
53
53
  // Every named subcommand; anything else (or no args) is an agent launch.
54
54
  const NAMED_COMMANDS = new Set([
55
55
  ...USER_FACING, 'setup', 'install', 'uninstall', 'update', 'daemon',
56
- '_hook-stopfailure', '_monitor', '_run', '_resumer', '_update-check',
56
+ '_hook-stopfailure', '_monitor', '_run', '_resumer', '_update-check', '_remote', '_askpass',
57
57
  ]);
58
58
  const isLaunchPath = cmd === '_run' || cmd === undefined || !NAMED_COMMANDS.has(cmd);
59
59
  const launchArgs = cmd === '_run' ? rest.slice(1) : (cmd === undefined ? [] : [cmd, ...rest]);
@@ -100,7 +100,7 @@ async function main() {
100
100
  switch (cmd) {
101
101
  case 'status': {
102
102
  const { cmdStatus } = await import('../src/cli.js');
103
- return cmdStatus();
103
+ return cmdStatus(rest);
104
104
  }
105
105
  case 'resume-now': {
106
106
  const { cmdResumeNow } = await import('../src/cli.js');
@@ -146,6 +146,26 @@ async function main() {
146
146
  const { cmdPreview } = await import('../src/cli.js');
147
147
  return cmdPreview(rest);
148
148
  }
149
+ case 'dashboard': {
150
+ const { cmdDashboard } = await import('../src/dashboard/run.js');
151
+ return cmdDashboard(rest);
152
+ }
153
+ case 'usage': {
154
+ const { cmdUsage } = await import('../src/usage.js');
155
+ return cmdUsage(rest);
156
+ }
157
+ case 'hosts': {
158
+ const { cmdHosts } = await import('../src/fleet.js');
159
+ return cmdHosts(rest);
160
+ }
161
+ case 'fleet': {
162
+ const { cmdFleet } = await import('../src/fleet.js');
163
+ return cmdFleet(rest);
164
+ }
165
+ case 'prompt': {
166
+ const { cmdPrompt } = await import('../src/prompt.js');
167
+ return cmdPrompt(rest);
168
+ }
149
169
  case 'install': {
150
170
  const { cmdInstall } = await import('../src/install.js');
151
171
  return cmdInstall(rest);
@@ -176,6 +196,16 @@ async function main() {
176
196
  if (!mod) return 0;
177
197
  return mod.runResumer();
178
198
  }
199
+ case '_remote': {
200
+ const mod = await safeImport('../src/remote.js');
201
+ if (!mod) return 1;
202
+ return mod.cmdRemote(rest);
203
+ }
204
+ case '_askpass': {
205
+ const mod = await safeImport('../src/askpass.js');
206
+ if (!mod) return 1;
207
+ return mod.cmdAskpass(rest);
208
+ }
179
209
  case 'update': {
180
210
  const { runSelfUpdate } = await import('../src/update-check.js');
181
211
  return runSelfUpdate();
@@ -263,6 +293,25 @@ Usage:
263
293
  unsnooze preview [id] dry-run: what WOULD happen right now, and
264
294
  why — nothing is typed or opened (exit 2
265
295
  when a wake is actionable, else 0)
296
+ unsnooze dashboard [tab] live TUI (status|usage|sessions|doctor|logs|fleet|prompts) — q to quit,
297
+ mouse: click/wheel (m toggles)
298
+ unsnooze hosts [add|rm|list] register ssh hosts for the fleet view
299
+ add <name> <dest> [--auth key|password]
300
+ [--source prompt|env|keychain|command]
301
+ [--env VAR] [--service s --account a]
302
+ [--cmd '<command>']
303
+ unsnooze hosts test <name> pre-flight a host: resolves its credential
304
+ and probes reachability (secret never shown)
305
+ unsnooze fleet [--json] all hosts' sessions (hosts add <name> first)
306
+ unsnooze prompt add [text...] queue a prompt to start a NEW session in a
307
+ project when the limit resets ([--agent id]
308
+ [--project path] [--at time|--now])
309
+ unsnooze prompt list [--json] list queued prompts
310
+ unsnooze prompt remove <id> cancel a queued prompt
311
+ unsnooze prompt clear cancel all pending queued prompts
312
+ unsnooze usage [--json] account burn rate & time-to-limit forecast
313
+ (--install-statusline for exact Claude %,
314
+ --uninstall-statusline to remove it)
266
315
  unsnooze logs [-f] show (or follow) the unsnooze log
267
316
  unsnooze update update unsnooze itself to the latest version
268
317
  unsnooze daemon persistent watcher for GUI sessions (VS Code
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.12.2",
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.",
3
+ "version": "1.14.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.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -49,7 +49,7 @@
49
49
  "bugs": {
50
50
  "url": "https://github.com/saaranshM/unsnooze/issues"
51
51
  },
52
- "homepage": "https://github.com/saaranshM/unsnooze#readme",
52
+ "homepage": "https://unsnooze.dev",
53
53
  "type": "module",
54
54
  "bin": {
55
55
  "unsnooze": "bin/unsnooze.js"
@@ -68,6 +68,9 @@
68
68
  "node": ">=20"
69
69
  },
70
70
  "dependencies": {
71
- "@clack/prompts": "^1.7.0"
71
+ "@clack/prompts": "^1.7.0",
72
+ "ink": "^7.1.1",
73
+ "picocolors": "^1.1.1",
74
+ "react": "^19.2.7"
72
75
  }
73
76
  }
package/src/agents/agy.js CHANGED
@@ -93,6 +93,8 @@ export default {
93
93
  resumeArgs(sessionId) {
94
94
  return { args: sessionId ? [`--conversation=${sessionId}`] : ['--continue'], messageViaPane: true };
95
95
  },
96
+ // v1: every agent launches the bare TUI and gets the prompt typed once idle.
97
+ launchArgs(message) { return { args: [], messageViaPane: true }; },
96
98
  latestSessionId,
97
99
  isForegroundCommand(cmd) {
98
100
  return cmd === 'agy' || cmd === 'node' || cmd === 'unsnooze';
@@ -92,6 +92,8 @@ export default {
92
92
  resumeArgs(sessionId) {
93
93
  return { args: sessionId ? ['--resume', sessionId] : ['-c'], messageViaPane: true };
94
94
  },
95
+ // v1: every agent launches the bare TUI and gets the prompt typed once idle.
96
+ launchArgs(message) { return { args: [], messageViaPane: true }; },
95
97
  latestSessionId,
96
98
  // Estimated tokens the API re-reads on a cold wake (prompt cache long
97
99
  // expired). null → unknown, contextGuard skips. Adapters without this
@@ -123,6 +123,8 @@ export default {
123
123
  messageViaPane: false,
124
124
  };
125
125
  },
126
+ // v1: every agent launches the bare TUI and gets the prompt typed once idle.
127
+ launchArgs(message) { return { args: [], messageViaPane: true }; },
126
128
  latestSessionId,
127
129
  isForegroundCommand(cmd) {
128
130
  return cmd === 'codex' || cmd === 'node' || cmd === 'unsnooze';
@@ -87,6 +87,8 @@ export default {
87
87
  resumeArgs(sessionId) {
88
88
  return { args: sessionId ? ['--resume', sessionId] : ['-c'], messageViaPane: true };
89
89
  },
90
+ // v1: every agent launches the bare TUI and gets the prompt typed once idle.
91
+ launchArgs(message) { return { args: [], messageViaPane: true }; },
90
92
  // Session file format is undocumented; null keeps it conservative — the
91
93
  // reopen path then uses `grok -c`, which Grok itself scopes to the cwd.
92
94
  latestSessionId() {
@@ -11,8 +11,14 @@ import agy from './agy.js';
11
11
 
12
12
  const REGISTRY = { claude, codex, grok, qwen, kimi, opencode, agy };
13
13
 
14
+ // v1: every agent launches the bare TUI and gets the prompt typed once idle.
15
+ function defaultLaunchArgs(message) { return { args: [], messageViaPane: true }; }
16
+
14
17
  export function getAgent(id) {
15
- return REGISTRY[id] || claude;
18
+ const agent = REGISTRY[id] || claude;
19
+ // Third-party/partial adapters may lack launchArgs — synthesize the same
20
+ // default so callers (dispatch, tests) never have to null-check it.
21
+ return typeof agent.launchArgs === 'function' ? agent : { ...agent, launchArgs: defaultLaunchArgs };
16
22
  }
17
23
 
18
24
  export function listAgents() {
@@ -111,6 +111,8 @@ export default {
111
111
  messageViaPane: false,
112
112
  };
113
113
  },
114
+ // v1: every agent launches the bare TUI and gets the prompt typed once idle.
115
+ launchArgs(message) { return { args: [], messageViaPane: true }; },
114
116
  latestSessionId,
115
117
  isForegroundCommand(cmd) {
116
118
  // /i: macOS framework builds report "Python" (verified via tmux
@@ -96,6 +96,8 @@ export default {
96
96
  resumeArgs(sessionId) {
97
97
  return { args: sessionId ? ['-s', sessionId] : ['--continue'], messageViaPane: true };
98
98
  },
99
+ // v1: every agent launches the bare TUI and gets the prompt typed once idle.
100
+ launchArgs(message) { return { args: [], messageViaPane: true }; },
99
101
  latestSessionId,
100
102
  isForegroundCommand(cmd) {
101
103
  // The npm-shipped bun binary reports pane_current_command "opencode.exe"