unsnooze 1.12.2 → 1.14.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +286 -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,90 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.14.0 — 2026-07-19
6
+
7
+ - **Queued prompts**: `unsnooze prompt add [--agent id] [--project path]
8
+ [--at time|--now] <text...>` (plus `list`/`remove`/`clear`) queues a
9
+ one-shot prompt that spawns a **brand-new** agent session in a project
10
+ directory once a usage limit clears. `--now`/`--at` (epoch, ISO-8601,
11
+ `+2h30m`, or a bare clock time) skip the reset wait; with no reset signal
12
+ at all, a `next-reset` entry delivers on the very next daemon tick and
13
+ `prompt add` prints a notice to that effect. Delivery is verified against
14
+ a fresh limit banner and backs off on failure — the same backoff floor
15
+ applies to every mode, capped at 5 attempts (same as resume) before an
16
+ entry is marked failed. `autoResume` does not gate delivery. Dashboard:
17
+ a new **Prompts** tab (`7`) — list, `a` to add, `d`/`x` to remove — plus a
18
+ queued-count hint on the Status tab. Fleet: `--host <name>` relays the
19
+ same subcommands to a registered host's own queue (`--project`/`--agent`
20
+ required, everything re-validated server-side); the new `remoteQueue`
21
+ setting (default on) lets a host opt out of all queue traffic, answering
22
+ a typed `disabled` instead of silently dropping it.
23
+ - **Fleet password auth**: hosts can now use a password instead of an ssh key
24
+ — `unsnooze hosts add <name> <dest> --auth password --source
25
+ prompt|env|keychain|command` (`prompt` is interactive no-echo and the
26
+ default; `env`/`keychain`/`command` are daemon-capable). `keychain` is a
27
+ macOS-only built-in; Windows and Linux use `--source command` with a
28
+ per-OS recipe (Linux `pass`/`secret-tool`, Windows `powershell`/`op read`;
29
+ `security` is the macOS recipe — see README's per-OS table). `unsnooze hosts test <name>` pre-flights a host without
30
+ ever printing its secret. Auth-gapped hosts render as `needs-auth` in
31
+ `fleet`/the dashboard, distinct from `unreachable`. Security: the password
32
+ never touches argv, `ps`, or unsnooze's own environment — it flows through
33
+ OpenSSH's `SSH_ASKPASS` hook, helper-stdout → ssh, in-process; unsnooze
34
+ stores no plaintext itself; keys stay the unchanged, BatchMode-hardened
35
+ default.
36
+ - **Fleet — sessions on every machine, over your own SSH**: `unsnooze hosts
37
+ [add|rm|list]` registers ssh destinations; `unsnooze fleet [--json]` and
38
+ the dashboard's new **Fleet** tab fan out to every host in parallel and
39
+ show each one's tracked sessions (state, reset countdown, attach hint),
40
+ with a bounded-concurrency ssh pool, per-host timeouts, and a 24h stale
41
+ cache so one dead box never blocks the rest. `unsnooze _remote` is the
42
+ single remote entrypoint (`status`/`resume`/`cancel`), safe to lock to an
43
+ `authorized_keys` forced command; `unsnooze status --json` and a shared
44
+ resume core back both the local and remote paths. Security posture: no
45
+ listening ports, no custom auth, no tokens — transport is plain OpenSSH
46
+ with host-key checking never weakened; the remote is always the one that
47
+ types, under its own gates; and every field a remote returns is
48
+ control-character-stripped, length-capped, and extracted into fresh
49
+ objects before it touches your terminal or state.
50
+ - **Dashboard mouse support**: click tabs and session rows, wheel-scroll the
51
+ status/sessions lists and a real scrollback window in Logs, clickable
52
+ footer hints (refresh / help / quit). Full keyboard parity kept; `m` (or
53
+ `mouse` config / `UNSNOOZE_MOUSE`) toggles it live so terminal text
54
+ selection is one keypress away. Tracking modes are always cleared on exit,
55
+ crash, and Ctrl-Z — no hijacked mouse after quit.
56
+
57
+ ## 1.13.0 — 2026-07-17
58
+
59
+ - **`unsnooze usage` — know the wall before you hit it**: burn-rate &
60
+ time-to-limit forecast per agent × window. Every figure carries its
61
+ provenance — `(exact)` from Codex rollouts or the opt-in Claude statusline
62
+ shim, `(calibrated from N stops)` learned from unsnooze's own recorded
63
+ limit stops, or `(estimated)` while calibrating. Weighted token burn
64
+ (cache reads ×0.1) over active minutes, account-wide including subagent
65
+ transcripts, per Opus/Sonnet bucket on Max plans. ETA shown as a band,
66
+ cross-checked against the observed reset time — never a false-precision
67
+ minute, never a blind now+5h.
68
+ - **Pre-wall warnings from the daemon**: hybrid thresholds (80/95% and
69
+ ≤30/≤10 min to the wall at current pace), deduped per window instance,
70
+ with a `/compact now` nudge sized from the same context estimator as
71
+ `unsnooze status`. Notify-only — unsnooze still never types anything you
72
+ didn't configure.
73
+ - **`unsnooze usage --install-statusline`**: opt-in shim that persists
74
+ Claude's exact server-side percentages; chains your existing statusline
75
+ command (backed up once, restored on uninstall).
76
+ - **Live dashboard**: `unsnooze dashboard` (also `status`/`usage` on an
77
+ interactive TTY) — full-screen Ink TUI with Status, Usage, Sessions,
78
+ Doctor and Logs tabs, animated ❯ z z z brand mark, help overlay (`?`),
79
+ and a compact layout down to 80×24. Pipes and `--json` stay plain.
80
+ - **Fix**: undated limit banners crossing midnight ("resets 12:04am" seen
81
+ at 9pm) parsed as already-past and were dropped or resumed due-now into a
82
+ still-live limit; they now roll to tomorrow when the announced time is
83
+ within a window's reach, while genuinely stale banners still resolve
84
+ due-now.
85
+ - Codex windows are labeled from `window_minutes` everywhere (300 → 5h,
86
+ 10080 → weekly, 43200 → 30d on the go plan) — stop records no longer
87
+ conflate the monthly window with the weekly bucket.
88
+
5
89
  ## 1.12.2 — 2026-07-16
6
90
 
7
91
  - **Daemon autostart self-heal**: updating via npm never touches the
package/README.md CHANGED
@@ -9,6 +9,8 @@
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
+ **[unsnooze.combustortech.in](https://unsnooze.combustortech.in)** — docs · changelog · feedback
13
+
12
14
  **Claude Code · Codex CLI · Grok · Qwen · Kimi · OpenCode · Antigravity** — when they hit the 5-hour or weekly usage limit
13
15
  ("You've hit your usage limit"), your session just… stops.<br/>
14
16
  unsnooze auto-resumes them: it tracks **every** limit-stopped session across all
@@ -68,6 +70,22 @@ your agent handles permissions.
68
70
  - **Reversible install.** The settings hook and rc-file wrappers are backed up
69
71
  first (`*.unsnooze-orig` pristine + `*.unsnooze-bak` rolling); `unsnooze
70
72
  uninstall` removes every change. Releases are published with npm provenance.
73
+ - **Fleet adds no network surface.** No listening ports, no custom auth, no
74
+ tokens. Transport is your own OpenSSH (`~/.ssh/config`, keys, agent);
75
+ unsnooze never sets `StrictHostKeyChecking=no` and fails fast on unknown
76
+ hosts instead of auto-trusting them.
77
+ - **The remote is the authority.** Fleet resume only marks a session due —
78
+ the remote daemon types under its own gates (foreground, idle, your
79
+ configured message only). A compromised viewer cannot make a remote type
80
+ anything else.
81
+ - **Remote output is untrusted.** Everything a host returns is
82
+ control-character-stripped and length-capped before it touches your
83
+ terminal (ANSI-injection defense), and parsed fields are extracted —
84
+ never merged — into fresh objects.
85
+ - **Lock a key to unsnooze (optional, recommended for automation):**
86
+ `command="unsnooze _remote",restrict ssh-ed25519 AAAA… unsnooze-fleet`
87
+ in the remote `authorized_keys` confines that key to the fleet
88
+ entrypoint — it can never open a shell.
71
89
 
72
90
  **Honest limits:** unsnooze *does* inject keystrokes into your live terminal on
73
91
  your behalf, and it does not sandbox your agent or defend against prompt
@@ -196,34 +214,276 @@ StopFailure hook (claude, grok) ──────────────┤
196
214
  </details>
197
215
 
198
216
  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).
217
+ parsed from the banner text, DST-safe. Unparseable banners are never guessed
218
+ at — unsnooze probes the pane on a backoff schedule until a real reset time
219
+ appears. Every resume is verified afterwards (banner came back → reschedule
220
+ from the fresh banner, capped at 5 attempts).
221
+
222
+ ## Know the wall before you hit it
223
+
224
+ Recovery (auto-resume) is only half the job. **Prevention** is knowing when the
225
+ wall is coming — so you can `/compact`, pause, or switch models *before* a stop.
226
+
227
+ ```sh
228
+ unsnooze usage # burn rate, % used, time-to-limit
229
+ unsnooze usage --json # stable machine shape (exit 2 past warn %)
230
+ unsnooze usage --install-statusline # opt-in: exact Claude % via statusline
231
+ unsnooze usage --uninstall-statusline # remove the shim, restore your statusLine
232
+ ```
233
+
234
+ ```text
235
+ $ unsnooze usage
236
+ unsnooze usage — account burn & time-to-limit (daemon: running · warnings at 80,95%)
237
+
238
+ claude 5h [█████████████░░░░░░░] ~64% (calibrated from 4 stops)
239
+ burn ~31k weighted tok/min over last 42 active min
240
+ wall ~1h 10m at this pace · window resets 8:00 pm (absolute)
241
+
242
+ codex 5h [███░░░░░░░░░░░░░░░░░] 5% used (exact)
243
+ monthly [██░░░░░░░░░░░░░░░░░░] 5% used (exact) · resets Aug 11
244
+ burn idle — no active burn
245
+
246
+ Estimates are a lower bound: Claude quotas are account-pooled with claude.ai/Desktop.
247
+ Exact Claude percentages available via: unsnooze usage --install-statusline
248
+ ```
249
+
250
+ | What you get | How |
251
+ |---|---|
252
+ | **Honest labels** | Every figure is tagged `(exact)`, `(calibrated from N stops)`, or `(estimated — calibrating…)`. Never a bare %. |
253
+ | **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. |
254
+ | **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. |
255
+ | **Pre-wall warnings** | Daemon notifies at `%` bands (`usageWarnAt`, default `80,95`) **and** time tiers (30 / 10 min). Deduped once per window. |
256
+ | **`/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). |
257
+
258
+ **Provenance ladder** (why this isn't another guessed-quota dashboard):
259
+
260
+ 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)).
261
+ 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.
262
+ 3. **`(estimated)`** — used tokens + burn shown; ceiling unknown until the first observed stop.
263
+
264
+ **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
265
 
203
266
  ## Usage
204
267
 
268
+ On an interactive TTY, `status` / `usage` / `sessions` open a **live dashboard**
269
+ (`unsnooze dashboard`) with the brand logo (`❯` + rising `zzz`), tabs, live
270
+ refresh until you press **`q`** — and mouse support: click tabs and rows,
271
+ wheel to scroll (toggle with `m`; hold Shift — Option in iTerm2 — to select text). Pipes, `NO_COLOR`, `CI`, and `--json` stay plain.
272
+
273
+ ```sh
274
+ claude / codex / grok # normal usage — wrapped automatically
275
+ unsnooze status # tracked sessions + reset countdowns
276
+ unsnooze dashboard [tab] # live TUI (status|usage|sessions|doctor|logs|fleet|prompts)
277
+ unsnooze usage [--json] # account burn & time-to-limit forecast
278
+ unsnooze usage --install-statusline # opt-in exact Claude % (chains your statusLine)
279
+ unsnooze resume-now [id|--all] # don't wait for the reset time
280
+ unsnooze cancel [id|--all] # stop tracking a session
281
+ unsnooze message <id> "text" # per-session wake message (--clear to reset)
282
+ unsnooze preview [id] # dry-run: what WOULD happen right now, and why —
283
+ # nothing is typed or opened
284
+ unsnooze sessions # list unsnooze-owned mux sessions + panes
285
+ unsnooze reap [--dry-run|--yes] # close finished panes / empty sessions (default dry-run)
286
+ unsnooze doctor [--fix] # install health check + retire old csg leftovers
287
+ unsnooze prompt add [--agent id] [--project path] [--at time|--now] "text"
288
+ # queue a prompt for a NEW session (see Queued prompts)
289
+ unsnooze prompt list [--json] # list queued prompts
290
+ unsnooze prompt remove <id> # cancel a queued prompt
291
+ unsnooze prompt clear # cancel all pending queued prompts
292
+ unsnooze config list # settings (see below)
293
+ unsnooze config set <k> <v> # e.g. autoResume off
294
+ unsnooze logs [-f] # what unsnooze has been doing
295
+ unsnooze update # update unsnooze itself
296
+ unsnooze daemon # persistent GUI-session watcher (usually run
297
+ # by launchd/systemd via `install --daemon`)
298
+ unsnooze report [agent] # capture a pane to report an undetected banner
299
+ unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
300
+ unsnooze help # full command list (also -h / --help)
301
+ ```
302
+
303
+ ## Queued prompts
304
+
305
+ Queue a prompt now; unsnooze types it into a **brand-new** agent session — a
306
+ fresh window in a project directory — once a usage limit clears (or at a time
307
+ you choose). It's one-shot: each entry is delivered at most once.
308
+
205
309
  ```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)
310
+ unsnooze prompt add "run the full test suite and fix any failures"
311
+ # → interactive agent picker on a TTY; queued for next-reset in the cwd
312
+
313
+ unsnooze prompt add --agent codex --project ~/code/api --now "ship the release"
314
+ unsnooze prompt add --at "+2h30m" "rebase onto main" # relative duration
315
+ unsnooze prompt add --at "9pm" "nightly cleanup pass" # next occurrence of a clock time
316
+ unsnooze prompt add --at 1755000000 "..." # epoch (seconds or ms), or an ISO-8601 timestamp
317
+
318
+ unsnooze prompt list [--json] # id, agent, due, status, cwd, prompt preview
319
+ unsnooze prompt remove <id> # cancel one pending/launching entry
320
+ unsnooze prompt clear # cancel every pending/launching entry
225
321
  ```
226
322
 
323
+ Modes:
324
+
325
+ - **next-reset** (default) — delivered once no future reset time is known for
326
+ that agent, i.e. once the current limit clears. If unsnooze holds **no
327
+ reset signal at all** for that agent when you add the entry, there's
328
+ nothing to wait on — it delivers on the very next daemon tick, and
329
+ `prompt add` prints a notice to that effect right away. Use `--at` if you
330
+ want a specific time instead.
331
+ - `--now` — deliver on the next daemon tick, no reset wait at all.
332
+ - `--at <time>` — deliver at a specific time: epoch (seconds or
333
+ milliseconds), an ISO-8601 timestamp, a `+2h30m`-style relative duration,
334
+ or a bare clock time (`9pm`, `2:05pm`, `14:30`) rolled to its next local
335
+ occurrence.
336
+
337
+ If a delivery attempt lands on a pane that's still limited — the reset
338
+ hadn't actually cleared, or a fresh `--now`/`--at` session hits the wall
339
+ immediately — the entry goes back to pending behind a backoff floor before
340
+ it's retried. That floor applies to every mode, so a failing `--now`/`--at`
341
+ entry can't burn through every retry in the first few seconds the way an
342
+ unthrottled retry loop would. Verified delivery (capped at 5 attempts,
343
+ same as resume) marks it `failed` and sends a notification.
344
+
345
+ `autoResume` does **not** gate prompt delivery — the queue runs independently
346
+ of session tracking, even with `autoResume off`.
347
+
348
+ A far-future `--at` entry is expected to keep a transient resumer process
349
+ alive (checking in each tick) until its delivery time arrives, however long
350
+ that is — this is intended, not a leak.
351
+
352
+ **Fleet:** `--host <name>` queues on a registered host instead of locally.
353
+ `--project` (an absolute remote path) and `--agent` are both required — there's
354
+ no local cwd to default to and no interactive picker over an ssh round-trip.
355
+ The remote host re-validates everything server-side. Delivery feedback comes
356
+ from `unsnooze fleet` / the dashboard's Fleet tab (per-host queued count) and
357
+ that host's own notifications (ntfy reaches your phone from any host, not
358
+ just the one you're sitting at). A host can refuse all queue traffic with
359
+ `remoteQueue: false` (`unsnooze config set remoteQueue off`, env
360
+ `UNSNOOZE_REMOTE_QUEUE=0`, **set on the host being controlled**) — the queue
361
+ verbs then answer a typed "disabled" instead of silently dropping; a remote
362
+ that predates this feature reports a clear "too old" error instead of
363
+ failing silently.
364
+
365
+ ```sh
366
+ unsnooze prompt add --host gpu-box --project /home/me/repo --agent claude --now "..."
367
+ unsnooze prompt list --host gpu-box
368
+ ```
369
+
370
+ **Dashboard:** the **Prompts** tab (`7`) lists queued entries; `a` opens an
371
+ add form (path → agent → when — with a time prompt if you pick "at" — → a
372
+ host step if you have hosts registered → prompt text), `d`/`x` removes the
373
+ selected entry. The Status tab shows a `<n> prompt(s) queued — tab 7` hint
374
+ whenever entries are pending or launching.
375
+
376
+ ## Fleet: sessions on every machine
377
+
378
+ See and act on unsnooze sessions on other machines from one terminal — over
379
+ your own SSH, no new service to run.
380
+
381
+ ```sh
382
+ unsnooze hosts add work you@work-box.local # register an ssh destination
383
+ unsnooze hosts list # registered hosts
384
+ unsnooze hosts rm work # forget a host
385
+ unsnooze fleet [--json] # every host's sessions, fanned out over ssh
386
+ unsnooze dashboard fleet # live Fleet tab — R resumes a selected stopped session (C cancels)
387
+ ```
388
+
389
+ A registered host needs **unsnooze installed** and **ssh key auth** (no
390
+ passwords) — nothing new to open or configure; fleet rides your existing
391
+ `~/.ssh/config`, keys, and agent. **Before adding a host, connect to it the
392
+ normal way once** (`ssh <host>`) so OpenSSH pins the host key itself —
393
+ unsnooze never weakens host-key checking to skip that step, so an unknown or
394
+ changed host fails fast instead of being silently trusted.
395
+
396
+ Fleet is *see and mark*, not *type remotely*: `unsnooze fleet` and the
397
+ dashboard's **Fleet** tab list every reachable host's tracked sessions
398
+ (state, reset countdown, attach hint). Selecting a stopped remote session and
399
+ pressing **`R`** (uppercase — lowercase `r` refreshes) only marks it due — the
400
+ remote's own daemon does the actual keystrokes, under the same
401
+ ownership/liveness/menu gates as a local session.
402
+ Resumed and stopped sessions print an attach hint
403
+ (`ssh -t <host> 'tmux new -A -s <session>'` / `zellij attach <session>`) so
404
+ you can hop over and watch. An unreachable or out-of-date host shows
405
+ `unreachable`/`skew` rather than blocking the rest of the fleet, and falls
406
+ back to its last-known state (marked `stale`) for up to 24h.
407
+
408
+ ### Auth: keys (default) or password
409
+
410
+ Every host picks its own auth, per host — unsnooze imposes no policy:
411
+
412
+ - **`key`** (default, unchanged) — the BatchMode-hardened path above. Nothing
413
+ about a key host changes.
414
+ - **`password`, interactive** (`--source prompt`, the default source when you
415
+ pass `--auth password` with nothing else) — typed at run time, no-echo, on
416
+ whichever terminal is running `unsnooze fleet`/`hosts test`. Nothing is
417
+ stored. There's no terminal under the background daemon, so a `prompt`
418
+ host is naturally interactive-only — it shows `needs-auth` when the daemon
419
+ or a piped/non-TTY caller tries it.
420
+ - **`password`, stored** (`--source env|keychain|command`) — resolved
421
+ automatically, so the daemon and `unsnooze fleet` can reach it unattended.
422
+
423
+ ```sh
424
+ unsnooze hosts add <name> <dest>
425
+ [--auth key|password] # default: key
426
+ [--source prompt|env|keychain|command] # default: prompt, once --auth password is set
427
+ [--env <VARNAME>] # env source: var to read (default UNSNOOZE_PW_<NAME>)
428
+ [--service <s> --account <a>] # keychain source (macOS only)
429
+ [--cmd '<command>'] # command source: program whose stdout is the password
430
+ unsnooze hosts test <name> # pre-flight: resolves the source (never prints the
431
+ # secret) then a real ssh reachability probe
432
+ ```
433
+
434
+ Examples:
435
+
436
+ ```sh
437
+ unsnooze hosts add laptop me@laptop.local --auth password
438
+ # → prompts for a password every time it's used from a real terminal
439
+
440
+ unsnooze hosts add gpu ubuntu@gpu.example.com --auth password --source env --env UNSNOOZE_PW_GPU
441
+ # → export UNSNOOZE_PW_GPU=... in your shell/session first
442
+
443
+ unsnooze hosts add mac me@mac-mini.local --auth password --source keychain --service unsnooze-mac --account me
444
+ # → macOS only; reads via `security find-generic-password`
445
+
446
+ unsnooze hosts add ci ci@build.example.com --auth password --source command --cmd 'op read op://vault/ci/password'
447
+ # → any OS; runs your own secret manager and reads its stdout
448
+
449
+ unsnooze hosts test gpu # resolves the credential + probes reachability; prints "auth ok" or a hint, never the secret
450
+ ```
451
+
452
+ **`command` is the portable, first-class source** — plug in whatever secret
453
+ manager you already run. Per-OS one-liners for `--cmd`:
454
+
455
+ | OS | Example `--cmd` |
456
+ |---|---|
457
+ | macOS | `security find-generic-password -s <service> -a <account> -w` |
458
+ | Linux | `pass show <path>` &nbsp;or&nbsp; `secret-tool lookup <attr> <value> ...` |
459
+ | Windows | `powershell -Command "..."` (e.g. wrapping `Get-StoredCredential`) &nbsp;or&nbsp; `op read op://vault/item/password` |
460
+ | any OS | `op read op://vault/item/password` (1Password CLI), or any other manager's read command |
461
+
462
+ `keychain` is a convenience built-in for **macOS only** (`security
463
+ find-generic-password`) — there's no dependable dependency-free store to
464
+ shell out to on Windows or Linux, so those use `--source command` with the
465
+ recipes above instead.
466
+
467
+ **Security posture:** the password reaches `ssh` through OpenSSH's own
468
+ `SSH_ASKPASS` hook — it never appears on `argv`, in `ps`, or in unsnooze's
469
+ own environment; it flows helper-stdout → ssh, in-process, for that one ssh
470
+ child only. unsnooze itself **stores no plaintext**: `keychain`/`command`
471
+ delegate entirely to the OS store or your own manager, and `env` is
472
+ user-managed (readable by your own shell already — positioned as a
473
+ low-friction/CI convenience, not secret-manager-grade). Keys remain the
474
+ default and their BatchMode-hardened path is untouched by any of this.
475
+
476
+ **Windows note:** native `ssh.exe` (Windows' built-in OpenSSH client) prompts
477
+ fine out of the box for an **interactive** `prompt`-source host — no shim
478
+ needed, it's the zero-friction path. **Stored** sources (`env`/`keychain`/
479
+ `command`) need a non-interactive askpass helper, and native `ssh.exe`
480
+ requires that to be a real launchable `.exe` — it won't reliably run a `.js`
481
+ or npm's `.cmd` shim. Until unsnooze ships that native launcher, use a
482
+ **Git-for-Windows or WSL `ssh`** (both accept the script-based helper the
483
+ same way macOS/Linux do — common on dev machines already) for stored
484
+ password hosts on Windows; a plain `ssh <host>` prompt works with native
485
+ `ssh.exe` regardless.
486
+
227
487
  ## Settings
228
488
 
229
489
  `unsnooze setup` writes `~/.unsnooze/config.json`; change anything later with
@@ -244,6 +504,9 @@ unsnooze help # full command list (also -h / --help)
244
504
  | `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
505
  | `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
506
  | `contextGuardTokens` | `100000` | Context-size threshold (tokens) at which `contextGuard` notifies or holds. |
507
+ | `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`. |
508
+ | `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`. |
509
+ | `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
510
  | `reapResumed` | `false` | Opt-in: auto-close `resumed` panes idle longer than `reapIdleAfter`. Off by default — use `unsnooze reap --yes` for explicit cleanup. |
248
511
  | `reapIdleAfter` | `604800000` (7d) | Idle age (ms) before an opt-in auto-reap closes a `resumed` pane. |
249
512
  | `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 +514,7 @@ unsnooze help # full command list (also -h / --help)
251
514
  | `ntfyServer` | `https://ntfy.sh` | ntfy server base URL (self-hosted servers welcome). |
252
515
  | `ntfyToken` | `""` | Optional `tk_…` access token (`Authorization: Bearer`) for reserved topics / authed servers. |
253
516
  | `ntfyPrivacy` | `full` | `terse` keeps directory paths out of pushed bodies (titles only) — recommended on public ntfy.sh topics. |
517
+ | `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
518
 
255
519
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
256
520
  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.0",
4
+ "description": "Auto-resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode & Antigravity when the 5-hour/weekly usage limit resets \u2014 tmux, Zellij, VS Code & desktop apps. Wakes every limit-stopped AI coding session automatically.",
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.combustortech.in",
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"