unsnooze 1.15.0 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.1 — 2026-08-16
4
+
5
+ - **`unsnooze status` no longer offers an attach command for headless sessions.**
6
+ A headless "pane" is a detached pid, but the attach hint fell through to the
7
+ default `tmux attach -t <session>` — a command that at best does nothing and
8
+ at worst attaches to an unrelated tmux session that happens to share the name.
9
+ Headless output lives in `~/.unsnooze/headless/<session>.log`, and status now
10
+ says nothing rather than something wrong. Found while building the capability
11
+ table for the new **[Supported terminals](https://unsnooze.dev/docs/#terminals)**
12
+ docs section, which sets out exactly what each of tmux, Zellij, herdr, cmux
13
+ and headless does and does not do.
14
+
15
+ ## 1.16.0 — 2026-08-16
16
+
17
+ - **Claude Design, and native Windows along the way.** ([#13](https://github.com/saaranshM/unsnooze/issues/13))
18
+ Claude Design shares your 5-hour and weekly limits with everything else, so a
19
+ long design run stops exactly like any other session — the obstacle was never
20
+ the stop, it was that Design's canvas is a web app with no pane to watch, and
21
+ that the reporter was on Windows, where unsnooze watched nothing at all.
22
+ `unsnooze design setup` wires up the official `claude-design` MCP server so
23
+ the work runs inside Claude Code, where it is watched like anything else;
24
+ `unsnooze design` reports whether it is registered and signed in, keeping
25
+ those two apart because an expired `/design-login` is *not* a usage limit and
26
+ waiting will never clear it. unsnooze does not automate the web canvas and
27
+ will not: Anthropic's Consumer Terms bar automated access to claude.ai, and
28
+ accounts have been terminated over it.
29
+
30
+ - **Watching without a multiplexer (`headless`).** A pane was only one of three
31
+ detection channels — the StopFailure hook and the transcript watcher never
32
+ needed one. What was missing was somewhere to put a revived agent, so there
33
+ is now a backend where a "pane" is a pid and a revive is a detached process
34
+ logging to `~/.unsnooze/headless/`. That makes native Windows, bare servers
35
+ and CI work. It is picked only when no multiplexer is installed, never ahead
36
+ of one, and it is honestly weaker: no limit-menu answering, no busy
37
+ detection, no live pane to attach to.
38
+
39
+ - **Native Windows is supported.** Previously unsnooze printed *"native Windows
40
+ is not supported; run inside WSL"* and ran your CLI unwatched. Two things
41
+ were broken beneath that: the StopFailure hook command was POSIX
42
+ (`test -f …`), which cmd.exe has no `test` for, so the hook failed on every
43
+ turn; and the shell wrapper only ever reached `~/.zshrc` and `~/.bashrc`,
44
+ files a PowerShell user does not have — so nothing routed through unsnooze in
45
+ the first place. Both are fixed, the daemon autostarts from a logon-triggered
46
+ Scheduled Task, and `unsnooze doctor` stops reporting wrappers as missing
47
+ when they are installed. WSL remains the richer option.
48
+
49
+ - **A server throttle is no longer mistaken for your usage limit.** Claude Code
50
+ says *"API Error: Server is temporarily limiting requests (not your usage
51
+ limit)"* — and `usage limit` matched the parenthetical. Since the TUI never
52
+ clears old banners from scrollback, any stale *"resets 3pm"* nearby was enough
53
+ to turn a throttle that clears in seconds into an hours-long scheduled wait.
54
+ It is now read as what it is and handled on the transient overload ladder.
55
+
56
+ - **Non-resetting Claude stops are notified, not scheduled.** The claude adapter
57
+ gained the `terminalPatterns` every other adapter already had. An expired
58
+ Claude Design credential and an exhausted credit balance are stops no amount
59
+ of waiting clears, so unsnooze says so once instead of booking a wake and
60
+ burning the attempt cap. This matters most for unattended runs: a revived
61
+ headless session has no interactive terminal, so `/design-login` cannot even
62
+ be offered there. All patterns are anchored to the CLI's real error phrasings
63
+ rather than to the bare command name, so an agent explaining `/design-login`
64
+ is not a stop.
65
+
66
+ - **Claude's own auto-continue is recognised, not raced.** Claude Code shipped
67
+ in-process auto-continue on 2026-08-14 (desktop; the CLI carries it behind a
68
+ gate that currently defaults off). unsnooze was already guarded — every resume
69
+ path checks whether the session moved on its own, and the 60s reset margin
70
+ means Claude gets there first — but a session that woke itself was recorded
71
+ identically to one unsnooze woke, so the deliberate stand-aside was invisible.
72
+ Records now carry who resumed them and `unsnooze status` says *"resumed itself
73
+ (unsnooze stood aside)"*. Note the native feature covers only the 5-hour
74
+ limit: weekly stops, closed apps, headless boxes and every non-Claude CLI
75
+ remain unsnooze's job.
76
+
77
+ - **`launchExtraArgs.<agent>`.** The launch-side twin of `resumeExtraArgs`, for
78
+ flags that must hold for a whole session rather than just a revival —
79
+ `unsnooze config set launchExtraArgs.claude "--autocompact 400000"` keeps a
80
+ context-heavy run compacting instead of stalling. Revivals inherit them, so
81
+ the flag survives the wake.
82
+
3
83
  ## 1.15.0 — 2026-08-15
4
84
 
5
85
  - **Two new multiplexer backends: herdr and cmux.** unsnooze now watches and
package/README.md CHANGED
@@ -16,7 +16,9 @@
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, Zellij, or herdr the moment the usage limit resets.**
19
+ your projects and **wakes each one up the moment the usage limit resets** — in
20
+ tmux, Zellij, herdr or cmux, and on **macOS, Linux and Windows**, with or
21
+ without a terminal multiplexer.
20
22
 
21
23
  ```sh
22
24
  npm install -g unsnooze && unsnooze setup
@@ -162,6 +164,45 @@ explicit setup is:
162
164
  headroom install apply --scope provider --providers manual --target claude --target codex
163
165
  ```
164
166
 
167
+ ## Claude Design
168
+
169
+ Claude Design shares your usual 5-hour and weekly limits with chat, Cowork and
170
+ Claude Code — so a long design run stops the same way everything else does, and
171
+ unsnooze resumes it the same way. What it needs first is a terminal to run in.
172
+
173
+ Claude Design has three surfaces. The canvas at `claude.ai/design` and the
174
+ Claude Desktop sidebar are web apps. The third is an official MCP server that
175
+ Claude Code drives from your terminal, and that is the one unsnooze supports:
176
+
177
+ ```sh
178
+ unsnooze design setup # registers the claude-design MCP server
179
+ # then, inside Claude Code:
180
+ /design-login
181
+ unsnooze design # confirms it is registered and signed in
182
+ ```
183
+
184
+ For long design runs, give the session room to compact rather than stall:
185
+
186
+ ```sh
187
+ unsnooze config set launchExtraArgs.claude "--autocompact 400000"
188
+ ```
189
+
190
+ **unsnooze does not automate the web canvas, and will not.** Anthropic's
191
+ Consumer Terms bar accessing Claude "through automated or non-human means,
192
+ whether through a bot, script, or otherwise", and accounts have been terminated
193
+ for it. Claude Code is the documented exemption — which is exactly what
194
+ unsnooze drives. A browser-driving "Design adapter" is not a missing feature
195
+ here; it is one this project declines.
196
+
197
+ Two things worth knowing:
198
+
199
+ - A signed-out `/design-login` is **not** a usage limit. Waiting will never
200
+ clear it, so unsnooze reports it separately instead of letting it look like a
201
+ stop that will resolve itself.
202
+ - Design used to have its own weekly allowance. It does not now — everything
203
+ draws from one shared pool, which is why `unsnooze usage` already accounts
204
+ for design work without knowing it is design work.
205
+
165
206
  ## GUI surfaces (VS Code extension, desktop apps)
166
207
 
167
208
  Terminal sessions are watched through the shell wrapper + tmux, Zellij, or herdr. Sessions in
@@ -509,7 +550,7 @@ password hosts on Windows; a plain `ssh <host>` prompt works with native
509
550
 
510
551
  | key | default | meaning |
511
552
  |---|---|---|
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. |
553
+ | `multiplexer` | `auto` | Backend to use: `auto`, `tmux`, `zellij`, `herdr`, `cmux`, or `headless`. `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. `headless` is the no-multiplexer mode (see [Watching without a multiplexer](#watching-without-a-multiplexer)) — `auto` falls back to it only when no multiplexer is installed at all, never ahead of one. |
513
554
  | `autoResume` | `true` | Master switch. Off = stops are still tracked, but nothing is resumed until you run `unsnooze resume-now` or turn it back on. |
514
555
  | `menuAutoAnswer` | `true` | May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only. |
515
556
  | `notifications` | `true` | Master switch for all notifications (limit detected / session resumed / gave up). Off = silence every channel. |
@@ -518,6 +559,7 @@ password hosts on Windows; a plain `ssh <host>` prompt works with native
518
559
  | `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`. |
519
560
  | `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
561
  | `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. |
562
+ | `launchExtraArgs.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` | `""` | Extra flags for the sessions **you** start through the shell wrapper — the launch-side twin of `resumeExtraArgs`. Use it for flags that have to hold for the whole session: `unsnooze config set launchExtraArgs.claude "--autocompact 400000"` keeps a long, context-heavy run compacting instead of stalling. Flags are placed before your own arguments, since `claude "do the thing"` puts a positional prompt there. Revivals inherit them too, so a flag survives the wake. |
521
563
  | `agents.claude` / `agents.codex` | `true` | Which CLIs are guarded. |
522
564
  | `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`. |
523
565
  | `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. |
@@ -615,15 +657,82 @@ watcher stops (no pane context) always use native.
615
657
 
616
658
  ## Requirements
617
659
 
618
- - Node ≥ 20.12 and tmux ≥ 3.2, Zellij, **or** herdr ≥ 0.8.0
619
- - macOS, Linux, or **Windows via WSL** (see below)
620
- - zsh or bash (the wrappers are installed into `~/.zshrc` / `~/.bashrc`)
660
+ - Node ≥ 20.12
661
+ - macOS, Linux, or Windows — natively on all three (see
662
+ [Watching without a multiplexer](#watching-without-a-multiplexer))
663
+ - tmux ≥ 3.2, Zellij, **or** herdr ≥ 0.8.0 for pane-level watching. Optional:
664
+ without one, unsnooze runs `headless` and still catches and resumes stops.
665
+ - zsh or bash (wrappers go into `~/.zshrc` / `~/.bashrc`), or PowerShell
666
+ (wrappers go into `$PROFILE.CurrentUserAllHosts`)
667
+
668
+ ### Supported terminals
669
+
670
+ unsnooze drives four terminal multiplexers, and works without one.
671
+
672
+ | | [tmux](https://github.com/tmux/tmux) ≥ 3.2 | [Zellij](https://zellij.dev) | [herdr](https://herdr.dev) ≥ 0.8.0 | [cmux](https://cmux.dev) | `headless` |
673
+ |---|:---:|:---:|:---:|:---:|:---:|
674
+ | Detect a limit stop | pane + hook + transcript | pane + hook + transcript | pane + hook + transcript | pane + hook + transcript | hook + transcript |
675
+ | Resume a live session in place | ✅ | ✅ | ✅ | ✅ | — |
676
+ | Answer Claude's limit menu | ✅ | ✅ | ✅ | ✅ | — |
677
+ | Revive a session whose pane died | ✅ | ✅ | ✅¹ | ✅² | ✅³ |
678
+ | Wrap a bare `claude` into a managed session | ✅ | ✅ | ✅ | — | n/a⁴ |
679
+ | `unsnooze reap` idle sessions | ✅ | ✅ | ✅ | — | — |
680
+ | `attach:` hint in `unsnooze status` | ✅ | ✅ | ✅ | — | — |
681
+ | OSC notifications to your terminal | ✅ | — | — | — | — |
682
+
683
+ ¹ Into a fresh session name (`unsnooze-2`), never by restarting the stopped one —
684
+ herdr restores saved agent panes itself, so a revival on top would resume the
685
+ same conversation twice.
686
+ ² Into a fresh workspace; cmux has no joinable named-session model.
687
+ ³ As a detached process. Its output goes to `~/.unsnooze/headless/<session>.log`,
688
+ since there is no scrollback to read it out of later.
689
+ ⁴ Nothing to wrap into — your own terminal is the session.
690
+
691
+ Pick one explicitly with `unsnooze config set multiplexer tmux|zellij|herdr|cmux|headless`.
692
+ On `auto`, unsnooze uses the multiplexer you are currently inside; failing that,
693
+ the only one installed (tmux breaks ties); failing that, `headless`.
694
+
695
+ **Install:** `brew install tmux` or `brew install zellij` on macOS,
696
+ `sudo apt install tmux` on Linux/WSL. herdr comes from
697
+ [herdr.dev](https://herdr.dev) — Homebrew still ships 0.7.3, which `available()`
698
+ refuses, so take the release binary.
699
+
700
+ ### Watching without a multiplexer
701
+
702
+ A terminal pane is only one of unsnooze's three detection channels. The Claude
703
+ Code **StopFailure hook** and the **session transcript watcher** need no pane at
704
+ all — which is what makes native Windows, bare servers and CI work.
705
+
706
+ When no multiplexer is installed, `multiplexer: auto` resolves to `headless`:
707
+
708
+ | | with tmux/Zellij/herdr | `headless` |
709
+ |---|---|---|
710
+ | detects limit stops | pane scrape + hook + transcript | hook + transcript |
711
+ | resumes at reset | yes | yes |
712
+ | answers Claude's limit menu | yes | no |
713
+ | waits out a busy pane | yes | no |
714
+ | revived session | live pane you can attach to | detached process, output in `~/.unsnooze/headless/` |
715
+
716
+ `headless` is never chosen ahead of a real multiplexer — only when none is
717
+ installed, or when you ask for it with
718
+ `unsnooze config set multiplexer headless`. If you want the full behaviour on
719
+ Windows, WSL is still the better home.
720
+
721
+ ### Windows
722
+
723
+ Native Windows works: PowerShell wrappers, a cmd-safe StopFailure hook, and a
724
+ logon-triggered Scheduled Task for the daemon. Install as usual and run
725
+ `unsnooze doctor` to confirm.
621
726
 
622
- ### Windows / WSL
727
+ Two honest caveats. The daemon is started at logon but not restarted if it
728
+ dies — Task Scheduler is not a supervisor the way launchd and systemd are — so
729
+ after upgrading unsnooze, log out and back in (or run `unsnooze daemon`
730
+ yourself). And stored-password fleet hosts still need Git-for-Windows or WSL
731
+ `ssh`; native `ssh.exe` requires an `.exe` askpass helper unsnooze does not yet
732
+ ship.
623
733
 
624
- tmux, Zellij, and herdr are Unix tools, so on Windows unsnooze runs inside
625
- [WSL](https://learn.microsoft.com/windows/wsl/install) — which is where the
626
- agent CLIs live on Windows anyway:
734
+ WSL remains the richer option, because that is where the Unix multiplexers live
735
+ — and where the agent CLIs often already are:
627
736
 
628
737
  ```sh
629
738
  # inside your WSL distro (Ubuntu etc.)
@@ -665,9 +774,7 @@ herdr differs from tmux and Zellij in three ways that are visible in use:
665
774
 
666
775
  Everything works as on Linux, including desktop notifications: inside WSL,
667
776
  unsnooze raises **native Windows toasts** through `powershell.exe` (no
668
- `notify-send` or X server needed). Native Windows (PowerShell/cmd, no WSL) is
669
- not supported — without tmux or Zellij there is no terminal pane to watch; unsnooze will tell you
670
- so and run your CLI unwatched instead of breaking it.
777
+ `notify-send` or X server needed).
671
778
 
672
779
  ## FAQ
673
780
 
package/bin/unsnooze.js CHANGED
@@ -48,7 +48,7 @@ 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', 'usage', 'dashboard', 'hosts', 'fleet', 'prompt', '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', 'design', '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([
@@ -142,6 +142,10 @@ async function main() {
142
142
  const { cmdDoctor } = await import('../src/doctor.js');
143
143
  return cmdDoctor(rest);
144
144
  }
145
+ case 'design': {
146
+ const { cmdDesign } = await import('../src/design.js');
147
+ return cmdDesign(rest);
148
+ }
145
149
  case 'preview': {
146
150
  const { cmdPreview } = await import('../src/cli.js');
147
151
  return cmdPreview(rest);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unsnooze",
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.",
3
+ "version": "1.16.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—on macOS, Linux and Windows, with or without a terminal multiplexer.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -39,7 +39,14 @@
39
39
  "ai-agents",
40
40
  "agentic-coding",
41
41
  "cli",
42
- "developer-tools"
42
+ "developer-tools",
43
+ "windows",
44
+ "powershell",
45
+ "claude-design",
46
+ "mcp",
47
+ "headless",
48
+ "wsl",
49
+ "cmux"
43
50
  ],
44
51
  "author": "Saaransh Menon <saaransh.dev2811@gmail.com>",
45
52
  "license": "MIT",
@@ -52,8 +52,29 @@ export const patterns = {
52
52
  /API Error:?\s*\(?5\d\d/i,
53
53
  /overloaded_error/i,
54
54
  /API Error:?\s*\(?429/i,
55
+ // "API Error: Server is temporarily limiting requests (not your usage
56
+ // limit)" — a server-side throttle. It clears in seconds, so it belongs on
57
+ // this ladder; patterns.js separately refuses to read its parenthetical as
58
+ // a usage limit.
59
+ /temporarily limiting requests/i,
55
60
  ],
56
61
  transientPatterns: [], // claude's transient errors are the overload set
62
+ // Stops that waiting cannot clear: notify once, never enter the ledger
63
+ // (monitor.js). Scheduling a wake for these would burn the attempt cap on a
64
+ // condition only a human can fix.
65
+ //
66
+ // All verbatim from the shipped 2.1.233 bundle. The Claude Design ones matter
67
+ // most for headless/unattended runs: a revived session has no interactive
68
+ // terminal, so an expired design credential produces a stop that can never
69
+ // self-resolve. Anchored to the error phrasings rather than to the bare
70
+ // command name, so an agent explaining "/design-login" is not a stop.
71
+ terminalPatterns: [
72
+ /rejected your \/design-login credential/i,
73
+ /could not refresh the design access token/i,
74
+ /could not save the design credential/i,
75
+ /\/design-login requires an interactive terminal/i,
76
+ /credit balance is too low/i,
77
+ ],
57
78
  };
58
79
 
59
80
  // --- Interactive /rate-limit-options menu (Claude Code only) ---
@@ -94,10 +115,21 @@ export default {
94
115
  experimental: false,
95
116
  patterns,
96
117
  menu: { isPrompt: isRateLimitOptionsPrompt, stepsToWait: menuStepsToWaitOption },
97
- // How to reopen a dead session. messageViaPane: the resume prompt is typed
98
- // into the TUI once it's ready (claude has no resume-with-prompt argv form).
99
- resumeArgs(sessionId) {
100
- return { args: sessionId ? ['--resume', sessionId] : ['-c'], messageViaPane: true };
118
+ // How to reopen a dead session.
119
+ //
120
+ // Default (a real pane): the prompt is typed into the TUI once it's ready.
121
+ // That path is load-bearing on tmux/zellij/herdr and stays exactly as it was.
122
+ //
123
+ // canType: false (headless — no pane to type into): the prompt rides in argv.
124
+ // `claude --resume <id> "<prompt>"` resumes that session id and acts on the
125
+ // prompt, verified against claude 2.1.233 on 2026-08-16. Without a TTY it
126
+ // runs to completion non-interactively, which is what an unattended overnight
127
+ // resume wants anyway.
128
+ resumeArgs(sessionId, message, { canType = true } = {}) {
129
+ const args = sessionId ? ['--resume', sessionId] : ['-c'];
130
+ if (canType) return { args, messageViaPane: true };
131
+ if (message) args.push(message);
132
+ return { args, messageViaPane: false };
101
133
  },
102
134
  // v1: every agent launches the bare TUI and gets the prompt typed once idle.
103
135
  launchArgs(message) { return { args: [], messageViaPane: true }; },
package/src/cli.js CHANGED
@@ -94,6 +94,11 @@ export async function cmdStatus(args = []) {
94
94
  mux: s.mux ?? null, pane: s.pane ?? null, muxSession: s.muxSession ?? null,
95
95
  attempts: s.attempts ?? 0, lastError: s.lastError ?? null,
96
96
  workspaceHold: !!s.workspaceHold,
97
+ // 'unsnooze' | 'native' | null. Since 2026-08-14 Claude Code can resume
98
+ // a five_hour stop by itself, so "resumed" alone no longer says who did
99
+ // it — and standing aside for the provider is a deliberate outcome, not
100
+ // a missing one.
101
+ resumedBy: s.resumedBy ?? null,
97
102
  })),
98
103
  promptQueue: queueList(),
99
104
  }, null, 2));
@@ -175,8 +180,13 @@ export async function cmdStatus(args = []) {
175
180
  let attach = '';
176
181
  const hint = await attachHintFor(s);
177
182
  if (hint) attach = ` · attach: ${hint}`;
183
+ // Claude Code can now resume a five_hour stop itself. When it did, say so:
184
+ // otherwise standing aside (the correct outcome, and the reason nothing was
185
+ // double-resumed) is indistinguishable from unsnooze having done the work.
186
+ const by = s.status === 'resumed' && s.resumedBy === 'native'
187
+ ? ' · resumed itself (unsnooze stood aside)' : '';
178
188
  console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
179
- console.log(` mux ${s.mux ?? '-'} · pane ${pane} · session ${s.muxSession ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${ctx}${hold}${attach}`);
189
+ console.log(` mux ${s.mux ?? '-'} · pane ${pane} · session ${s.muxSession ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${ctx}${hold}${attach}${by}`);
180
190
  }
181
191
  printPromptQueueSection(pendingPromptEntries());
182
192
  return 0;
package/src/config.js CHANGED
@@ -77,7 +77,14 @@ export const LEASE_GRACE_MS = envInt('UNSNOOZE_LEASE_GRACE_MS', 60_000);
77
77
  // it in either one puts the other in a temporal dead zone at import time.
78
78
  // cmux is last: an agent can run tmux inside a cmux surface, so tmux/zellij
79
79
  // must win detection when both are signalled (see multiplexer.js: detect()).
80
- export const MUX_NAMES = ['tmux', 'zellij', 'herdr', 'cmux'];
80
+ // headless is last of all and never auto-detected from the environment: it is
81
+ // the no-multiplexer fallback (native Windows, servers, CI), and a real pane
82
+ // must always beat it.
83
+ export const MUX_NAMES = ['tmux', 'zellij', 'herdr', 'cmux', 'headless'];
84
+
85
+ // Where a headless revive tees the agent's output. There is no pane to scroll
86
+ // back through, so the log is the only record of what an unattended run did.
87
+ export const HEADLESS_LOG_DIR = join(STATE_DIR, 'headless');
81
88
 
82
89
  // Pane scanning
83
90
  export const PANE_SCAN_LINES = envInt('UNSNOOZE_PANE_SCAN_LINES', 12);
package/src/design.js ADDED
@@ -0,0 +1,209 @@
1
+ // Claude Design support (issue #13).
2
+ //
3
+ // Claude Design has three surfaces: the canvas at claude.ai/design, the Claude
4
+ // Desktop sidebar, and — the one that matters here — an official MCP server
5
+ // that Claude Code drives from the terminal:
6
+ //
7
+ // claude mcp add --scope user --transport http claude-design \
8
+ // https://api.anthropic.com/v1/design/mcp
9
+ // /design-login
10
+ //
11
+ // unsnooze deliberately supports only that third surface. The canvas is a web
12
+ // app, and Anthropic's Consumer ToS bars accessing Claude "through automated or
13
+ // non-human means, whether through a bot, script, or otherwise" — driving it
14
+ // with a headless browser risks the user's account, and Claude Code is the
15
+ // documented exemption. There is no version of this file that opens a browser.
16
+ //
17
+ // Design draws from the same 5-hour and weekly pool as chat, Cowork and Claude
18
+ // Code (it had its own weekly allowance once; it does not now), so a design
19
+ // session that stops is an ordinary limit stop and the existing machinery
20
+ // already handles it. What was missing was setup and visibility, which is all
21
+ // this module adds.
22
+
23
+ import { spawnSync } from 'node:child_process';
24
+ import { readFileSync } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import { join } from 'node:path';
27
+
28
+ export const DESIGN_MCP_NAME = 'claude-design';
29
+ export const DESIGN_MCP_URL = 'https://api.anthropic.com/v1/design/mcp';
30
+
31
+ export function designMcpAddArgs() {
32
+ return ['mcp', 'add', '--scope', 'user', '--transport', 'http',
33
+ DESIGN_MCP_NAME, DESIGN_MCP_URL];
34
+ }
35
+
36
+ function defaultRunner(args) {
37
+ const bin = process.env.UNSNOOZE_CLAUDE_BIN || 'claude';
38
+ const r = spawnSync(bin, args, { encoding: 'utf-8' });
39
+ if (r.error) throw r.error;
40
+ return `${r.stdout || ''}${r.stderr || ''}`;
41
+ }
42
+
43
+ // One row per configured MCP server, from `claude mcp list`. Real output:
44
+ //
45
+ // Checking MCP server health…
46
+ // [mcp-sdk] SEP-2352: stored OAuth credential has no 'issuer' stamp (…)
47
+ // claude.ai Notion: https://mcp.notion.com/mcp - ! Needs authentication
48
+ // plugin:context7:context7: https://mcp.context7.com/mcp (HTTP) - ✔ Connected
49
+ //
50
+ // Two shapes make this fussier than a split(': '): names carry both colons
51
+ // (plugin:context7:context7) and spaces (claude.ai Notion), and the SDK prints
52
+ // unrelated diagnostics that look like rows. So the row separator is the LAST
53
+ // ' - ' on the line, and the name is everything before the first COLON-SPACE —
54
+ // which no name contains, because a bare colon inside a name is never followed
55
+ // by a space.
56
+ export function parseMcpList(stdout) {
57
+ if (typeof stdout !== 'string' || stdout === '') return [];
58
+ const rows = [];
59
+ for (const raw of stdout.split('\n')) {
60
+ const line = raw.trim();
61
+ if (!line || line.startsWith('[')) continue; // sdk diagnostics
62
+ const sep = line.lastIndexOf(' - ');
63
+ if (sep === -1) continue;
64
+ const head = line.slice(0, sep);
65
+ const status = line.slice(sep + 3).trim();
66
+ const colon = head.indexOf(': ');
67
+ if (colon === -1) continue;
68
+ const name = head.slice(0, colon).trim();
69
+ if (!name) continue;
70
+ const target = head.slice(colon + 2).trim();
71
+ const needsAuth = /needs? authentication/i.test(status);
72
+ rows.push({
73
+ name,
74
+ target,
75
+ status,
76
+ connected: /connected/i.test(status) && !needsAuth,
77
+ needsAuth,
78
+ });
79
+ }
80
+ return rows;
81
+ }
82
+
83
+ // Registration, answered from disk instead of over the wire.
84
+ //
85
+ // `claude mcp list` health-checks every configured server, which takes seconds
86
+ // — fine for an explicit `unsnooze design`, far too slow for `unsnooze doctor`,
87
+ // which people run casually. `--scope user` writes the TOP-LEVEL mcpServers map
88
+ // in ~/.claude.json; the per-project maps under .projects are a different scope
89
+ // and must not be mistaken for it.
90
+ export function designRegisteredOffline({
91
+ readConfig = () => readFileSync(join(homedir(), '.claude.json'), 'utf-8'),
92
+ } = {}) {
93
+ try {
94
+ const parsed = JSON.parse(readConfig());
95
+ return Boolean(parsed?.mcpServers?.[DESIGN_MCP_NAME]);
96
+ } catch {
97
+ return false; // missing, unreadable or corrupt — treat as not set up
98
+ }
99
+ }
100
+
101
+ // Is this machine ready to run Claude Design work from the terminal?
102
+ //
103
+ // Three distinguishable states, because the remedies differ completely:
104
+ // - claude missing/broken -> nothing to do with Design at all
105
+ // - not registered -> `unsnooze design setup`
106
+ // - registered, logged out -> `/design-login`, and note that this one is
107
+ // NOT a usage limit: no amount of waiting clears it.
108
+ export function designStatus({ runner = defaultRunner } = {}) {
109
+ let out;
110
+ try {
111
+ out = runner(['mcp', 'list']);
112
+ } catch (err) {
113
+ return {
114
+ available: false, registered: false, connected: false, needsAuth: false,
115
+ hint: `could not run \`claude mcp list\` (${err.message}) — is the claude CLI installed?`,
116
+ };
117
+ }
118
+ const row = parseMcpList(out).find(r => r.name === DESIGN_MCP_NAME);
119
+ if (!row) {
120
+ return {
121
+ available: true, registered: false, connected: false, needsAuth: false,
122
+ hint: 'Claude Design is not wired into Claude Code — run `unsnooze design setup`',
123
+ };
124
+ }
125
+ if (row.needsAuth || !row.connected) {
126
+ return {
127
+ available: true, registered: true, connected: false, needsAuth: true,
128
+ hint: 'Claude Design is registered but signed out — run `/design-login` inside Claude Code. '
129
+ + 'This is not a usage limit; waiting will not clear it.',
130
+ };
131
+ }
132
+ return {
133
+ available: true, registered: true, connected: true, needsAuth: false,
134
+ hint: 'Claude Design is connected — design sessions are watched like any other Claude Code session',
135
+ };
136
+ }
137
+
138
+ // Register the MCP server. Editing the user's Claude config is not something to
139
+ // do quietly, so this is only ever reached from an explicit `unsnooze design
140
+ // setup`, and it prints exactly what it ran.
141
+ export function installDesignMcp({ runner = defaultRunner } = {}) {
142
+ const args = designMcpAddArgs();
143
+ try {
144
+ return { ok: true, output: runner(args), args };
145
+ } catch (err) {
146
+ return { ok: false, output: err.message, args };
147
+ }
148
+ }
149
+
150
+ // --- command ---------------------------------------------------------------
151
+
152
+ const USAGE = `unsnooze design — Claude Design from the terminal
153
+
154
+ unsnooze design show whether Claude Design is wired up
155
+ unsnooze design setup register the claude-design MCP server with Claude Code
156
+
157
+ Claude Design's canvas (claude.ai/design, Claude Desktop) is a web surface and
158
+ unsnooze does not automate it: Anthropic's Consumer Terms bar automated access
159
+ to claude.ai, and doing it anyway risks your account. The supported route is the
160
+ claude-design MCP server inside Claude Code, which unsnooze watches like any
161
+ other Claude Code session.
162
+ `;
163
+
164
+ export function cmdDesign(rest = [], { runner = defaultRunner, log = console.log } = {}) {
165
+ const sub = rest[0];
166
+
167
+ if (sub === 'help' || sub === '--help' || sub === '-h') {
168
+ log(USAGE);
169
+ return 0;
170
+ }
171
+
172
+ if (sub === 'setup') {
173
+ const before = designStatus({ runner });
174
+ if (!before.available) {
175
+ log(`unsnooze: ${before.hint}`);
176
+ return 1;
177
+ }
178
+ if (before.registered) {
179
+ log(`unsnooze: ${DESIGN_MCP_NAME} is already registered.`);
180
+ log(`unsnooze: ${before.hint}`);
181
+ return 0;
182
+ }
183
+ log(`unsnooze: running \`claude ${designMcpAddArgs().join(' ')}\``);
184
+ const result = installDesignMcp({ runner });
185
+ if (!result.ok) {
186
+ log(`unsnooze: registration failed — ${result.output}`);
187
+ return 1;
188
+ }
189
+ log('unsnooze: registered. Now run `/design-login` inside Claude Code to sign in.');
190
+ log('unsnooze: design work shares your usual 5-hour and weekly limits, so a');
191
+ log('unsnooze: design session that stops is resumed like any other.');
192
+ return 0;
193
+ }
194
+
195
+ if (sub && sub !== 'status') {
196
+ log(`unsnooze: unknown design subcommand "${sub}"`);
197
+ log(USAGE);
198
+ return 1;
199
+ }
200
+
201
+ const status = designStatus({ runner });
202
+ log(`unsnooze: ${status.hint}`);
203
+ if (status.connected) {
204
+ log('unsnooze: tip — long design runs burn context fast; set');
205
+ log('unsnooze: unsnooze config set launchExtraArgs.claude "--autocompact 400000"');
206
+ log('unsnooze: so a context-heavy session compacts instead of stalling.');
207
+ }
208
+ return status.connected ? 0 : 1;
209
+ }