unsnooze 1.14.4 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ }
package/src/doctor.js CHANGED
@@ -14,6 +14,8 @@ import { CLAUDE_SETTINGS, RESUMER_LOCK } from './config.js';
14
14
  import { getMultiplexer } from './multiplexer.js';
15
15
  import { makeLogger } from './logger.js';
16
16
  import { shouldUseTui, formatDoctorTui } from './tui.js';
17
+ import { designRegisteredOffline } from './design.js';
18
+ import { powershellProfilePath } from './powershell.js';
17
19
 
18
20
  const log = makeLogger('doctor');
19
21
 
@@ -104,11 +106,31 @@ function defaultHookInstalled(settingsPath = CLAUDE_SETTINGS) {
104
106
  }
105
107
  }
106
108
 
107
- function defaultWrappersInstalled() {
108
- return [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')].some(rc => {
109
- try { return readFileSync(rc, 'utf-8').includes('# >>> unsnooze >>>'); }
110
- catch { return false; }
111
- });
109
+ const WRAPPER_MARK = '# >>> unsnooze >>>';
110
+
111
+ function readOrEmpty(path) {
112
+ try { return readFileSync(path, 'utf-8'); } catch { return ''; }
113
+ }
114
+
115
+ // The wrapper shadowing the real CLI is unsnooze's entry point, so "are the
116
+ // wrappers installed" is a health question — but it has to be asked of the
117
+ // right file. A PowerShell user has no ~/.zshrc, so checking only the POSIX rc
118
+ // files made every native-Windows `unsnooze doctor` report the wrappers
119
+ // missing and point the user at an install they had already run.
120
+ function defaultWrappersInstalled({
121
+ platform = process.platform,
122
+ rcContent = readOrEmpty,
123
+ profileContent = null,
124
+ } = {}) {
125
+ const rcs = [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')];
126
+ if (rcs.some(rc => rcContent(rc).includes(WRAPPER_MARK))) return true;
127
+ if (platform !== 'win32') return false;
128
+ const readProfile = profileContent
129
+ || (() => {
130
+ const p = powershellProfilePath({ platform });
131
+ return p ? readOrEmpty(p) : '';
132
+ });
133
+ return readProfile().includes(WRAPPER_MARK);
112
134
  }
113
135
 
114
136
  function daemonRunning() {
@@ -136,8 +158,11 @@ export async function runDoctor({
136
158
  csgStateDir = join(homedir(), '.claude-session-guard'),
137
159
  csgBinPath = defaultCsgPackagePath({ runner }),
138
160
  mux = getMultiplexer(),
161
+ designRegistered = designRegisteredOffline,
139
162
  hookInstalled = defaultHookInstalled,
140
163
  wrappersInstalled = defaultWrappersInstalled,
164
+ rcContent = undefined,
165
+ profileContent = undefined,
141
166
  autostartDir = null,
142
167
  } = {}) {
143
168
  const findings = [];
@@ -188,7 +213,7 @@ export async function runDoctor({
188
213
  detail: ' run: unsnooze install --yes',
189
214
  });
190
215
  }
191
- if (!wrappersInstalled()) {
216
+ if (!wrappersInstalled({ platform, ...(rcContent ? { rcContent } : {}), ...(profileContent ? { profileContent } : {}) })) {
192
217
  findings.push({
193
218
  id: 'wrappers-missing', kind: 'health',
194
219
  title: 'shell wrappers are not installed',
@@ -230,6 +255,18 @@ export async function runDoctor({
230
255
  detail: '',
231
256
  });
232
257
 
258
+ // Claude Design (issue #13). Informational only — Design is optional, so a
259
+ // machine without it is not unhealthy. Read from config rather than
260
+ // `claude mcp list`, which health-checks every server and would put seconds
261
+ // onto every doctor run.
262
+ findings.push({
263
+ id: 'claude-design', kind: 'info',
264
+ title: designRegistered()
265
+ ? 'Claude Design: MCP server registered (run `unsnooze design` to check sign-in)'
266
+ : 'Claude Design: not set up (optional — `unsnooze design setup`)',
267
+ detail: '',
268
+ });
269
+
233
270
  const healthy = findings.every(f => f.kind === 'info');
234
271
  return { findings, healthy };
235
272
  }
package/src/fleet.js CHANGED
@@ -845,7 +845,11 @@ export const MUX_SESSION_RE = /^[A-Za-z0-9_.-]{1,64}$/;
845
845
  // validation — callers must treat null as "omit the attach line".
846
846
  export function attachHintRemote(dest, muxName, muxSession) {
847
847
  if (!validHostToken(dest) || typeof muxSession !== 'string' || !MUX_SESSION_RE.test(muxSession)) return null;
848
- const inner = muxName === 'zellij' ? `zellij attach ${muxSession}` : `tmux new -A -s ${muxSession}`;
848
+ // cmux has no joinable named-session model — there is no remote command
849
+ // that reattaches to a surface, so omit the hint (see attachHint).
850
+ if (muxName === 'cmux') return null;
851
+ const inner = muxName === 'herdr' ? `herdr session attach ${muxSession}`
852
+ : muxName === 'zellij' ? `zellij attach ${muxSession}` : `tmux new -A -s ${muxSession}`;
849
853
  return `ssh -t ${dest} '${inner}'`;
850
854
  }
851
855
 
package/src/hook.js CHANGED
@@ -41,6 +41,21 @@ function classify(payload, raw) {
41
41
  return 'unknown';
42
42
  }
43
43
 
44
+ export function hookContext(env = process.env, payload = {}) {
45
+ const managedMux = env.UNSNOOZE_MUX;
46
+ const muxName = managedMux || (env.HERDR_PANE_ID ? 'herdr' : env.ZELLIJ_PANE_ID ? 'zellij' : 'tmux');
47
+ const pane = managedMux
48
+ ? (env.UNSNOOZE_PANE || null)
49
+ : (muxName === 'herdr' ? env.HERDR_PANE_ID
50
+ : muxName === 'zellij' ? env.ZELLIJ_PANE_ID : env.TMUX_PANE || payload.tmux_pane) || null;
51
+ const paneOwner = muxName === 'herdr'
52
+ ? (managedMux ? env.UNSNOOZE_PANE_OWNER : (env.HERDR_SESSION || 'default'))
53
+ : muxName === 'zellij'
54
+ ? (managedMux ? env.UNSNOOZE_PANE_OWNER : env.ZELLIJ_SESSION_NAME) || null
55
+ : null;
56
+ return { muxName, pane, paneOwner };
57
+ }
58
+
44
59
  export async function runHook(rest = []) {
45
60
  try {
46
61
  const agentIdx = rest.indexOf('--agent');
@@ -57,16 +72,16 @@ export async function runHook(rest = []) {
57
72
  return 0;
58
73
  }
59
74
 
60
- const managedMux = process.env.UNSNOOZE_MUX;
61
- const muxName = managedMux || (process.env.ZELLIJ_PANE_ID ? 'zellij' : 'tmux');
62
- const pane = managedMux
63
- ? (process.env.UNSNOOZE_PANE || null)
64
- : (muxName === 'zellij' ? process.env.ZELLIJ_PANE_ID : process.env.TMUX_PANE || payload.tmux_pane) || null;
65
- const paneOwner = muxName === 'zellij'
66
- ? (managedMux ? process.env.UNSNOOZE_PANE_OWNER : process.env.ZELLIJ_SESSION_NAME) || null
67
- : null;
75
+ const { muxName, pane: rawPane, paneOwner } = hookContext(process.env, payload);
68
76
  const leaseId = process.env.UNSNOOZE_LEASE_ID || null;
69
77
  const mux = getMultiplexer(muxName, { owner: paneOwner });
78
+ // Same guard as the launcher: a pane we cannot prove we are addressing on
79
+ // the right server is worse than no pane, because every later action —
80
+ // capture, inject, close — would be aimed at whatever occupies that id
81
+ // elsewhere. Record the stop without a pane instead.
82
+ const pane = (typeof mux.paneAddressable !== 'function' || mux.paneAddressable())
83
+ ? rawPane : null;
84
+ if (rawPane && !pane) log(`StopFailure: ignoring pane ${rawPane} — not safely addressable`);
70
85
  const kind = classify(payload, raw);
71
86
  log(`StopFailure: kind=${kind} pane=${pane} session=${payload.session_id || '?'}`);
72
87
 
package/src/install.js CHANGED
@@ -19,6 +19,7 @@ import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
19
19
  import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
20
20
  import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
21
21
  import { uninstallStatuslineShim } from './usage.js';
22
+ import { powershellProfilePath } from './powershell.js';
22
23
 
23
24
  const FENCE_OPEN = '# >>> unsnooze >>>';
24
25
  const FENCE_CLOSE = '# <<< unsnooze <<<';
@@ -67,18 +68,36 @@ function isLegacy(entry) {
67
68
  return (entry.hooks || []).some(h => /claude-auto-retry|csg\.js _hook-stopfailure/.test(h.command || ''));
68
69
  }
69
70
 
71
+ // The StopFailure hook command, per platform.
72
+ //
73
+ // Guarded like the shell wrapper: a vanished entry point must exit 0, not
74
+ // spray MODULE_NOT_FOUND errors into every Claude Code turn. The guard has to
75
+ // be written in the shell that will actually run it — Claude Code runs hooks
76
+ // through cmd.exe on native Windows, where `test` does not exist, so the POSIX
77
+ // form silently failed on every turn and took the hook detection channel with
78
+ // it. That mattered little while Windows had no multiplexer to watch with; it
79
+ // matters entirely now that headless leans on the hook.
80
+ export function hookCommand({ platform = process.platform, bin = UNSNOOZE_BIN, agent = null } = {}) {
81
+ const agentFlag = agent ? ` --agent ${agent}` : '';
82
+ if (platform === 'win32') {
83
+ return `if exist "${bin}" (node "${bin}" _hook-stopfailure${agentFlag}) else (exit 0)`;
84
+ }
85
+ return `test -f "${bin}" && node "${bin}" _hook-stopfailure${agentFlag} || exit 0`;
86
+ }
87
+
70
88
  // agent/matcher options cover CLIs with Claude-shaped hook config in their own
71
89
  // settings.json (qwen); the default stays byte-identical for claude.
72
- export function mergeHookIntoSettings(settingsJson, { agent = null, matcher = 'overloaded|server_error|rate_limit' } = {}) {
90
+ export function mergeHookIntoSettings(settingsJson, {
91
+ agent = null,
92
+ matcher = 'overloaded|server_error|rate_limit',
93
+ platform = process.platform,
94
+ } = {}) {
73
95
  const settings = JSON.parse(settingsJson);
74
96
  settings.hooks = settings.hooks || {};
75
97
  const list = (settings.hooks.StopFailure || []).filter(e => !isLegacy(e) && !isOurs(e));
76
- const agentFlag = agent ? ` --agent ${agent}` : '';
77
98
  list.push({
78
99
  matcher,
79
- // Guarded like the shell wrapper: a vanished entry point must exit 0, not
80
- // spray MODULE_NOT_FOUND errors into every Claude Code turn.
81
- hooks: [{ type: 'command', command: `test -f "${UNSNOOZE_BIN}" && node "${UNSNOOZE_BIN}" _hook-stopfailure${agentFlag} || exit 0`, timeout: 5 }],
100
+ hooks: [{ type: 'command', command: hookCommand({ platform, agent }), timeout: 5 }],
82
101
  });
83
102
  settings.hooks.StopFailure = list;
84
103
  return JSON.stringify(settings, null, 2) + '\n';
@@ -115,6 +134,38 @@ ${fns}
115
134
  ${FENCE_CLOSE}`;
116
135
  }
117
136
 
137
+ // The PowerShell twin of wrapperBlock(), for native Windows where there is no
138
+ // ~/.zshrc or ~/.bashrc to put a function in. Nobody types `unsnooze claude` —
139
+ // the wrapper shadowing the real CLI *is* the entry point, so without this
140
+ // nothing on native Windows routes through unsnooze at all.
141
+ //
142
+ // PowerShell comments are `#`, so the same fence markers work and
143
+ // stripFencedBlock() removes it unchanged.
144
+ export function powershellWrapperBlock(agents = ['claude'], bin = UNSNOOZE_BIN) {
145
+ const fns = agents.map(id => `Remove-Item -Path Alias:${id} -Force -ErrorAction SilentlyContinue
146
+ function ${id} {
147
+ if ($env:UNSNOOZE_ACTIVE -eq '1' -or -not (Test-Path -LiteralPath '${bin}')) {
148
+ $real = Get-Command ${id} -CommandType Application -ErrorAction SilentlyContinue |
149
+ Select-Object -First 1
150
+ if ($real) { & $real.Source @args } else { Write-Error '${id}: not found' }
151
+ return
152
+ }
153
+ & node '${bin}' _run ${id} @args
154
+ }`).join('\n');
155
+ return `${FENCE_OPEN}
156
+ # unsnooze wrappers: route every interactive launch of the CLIs below through
157
+ # unsnooze so limit stops are recorded and auto-resumed.
158
+ ${fns}
159
+ ${FENCE_CLOSE}`;
160
+ }
161
+
162
+ // installZshrcBlock's PowerShell twin. Same fence, same replace-don't-append
163
+ // behaviour, so re-running setup never stacks a second copy of the wrappers.
164
+ export function installPowershellBlock(content, agents = ['claude'], bin = UNSNOOZE_BIN) {
165
+ const { content: cleaned } = stripFencedBlock(content, FENCE_OPEN, FENCE_CLOSE);
166
+ return cleaned.replace(/\n+$/, '\n') + '\n' + powershellWrapperBlock(agents, bin) + '\n';
167
+ }
168
+
118
169
  export function stripFencedBlock(content, open, close) {
119
170
  const lines = content.split('\n');
120
171
  const out = [];
@@ -148,6 +199,9 @@ export function installZshrcBlock(content, agents = ['claude']) {
148
199
  // user unit on Linux/WSL.
149
200
 
150
201
  export const DAEMON_LABEL = 'com.unsnooze.daemon';
202
+ // Task Scheduler has no reverse-DNS convention and shows this name to the user
203
+ // in taskschd.msc, so it is a plain word rather than the launchd label.
204
+ export const WINDOWS_TASK_NAME = 'unsnooze';
151
205
 
152
206
  // Is this process running under the supervisor we install, rather than from
153
207
  // somebody's shell? It decides whether exiting is safe: a supervised daemon
@@ -163,7 +217,15 @@ export const DAEMON_LABEL = 'com.unsnooze.daemon';
163
217
  export function isSupervised({ platform = process.platform, env = process.env } = {}) {
164
218
  if (platform === 'darwin') return env.XPC_SERVICE_NAME === DAEMON_LABEL;
165
219
  if (platform === 'linux') return typeof env.INVOCATION_ID === 'string' && env.INVOCATION_ID !== '';
166
- return false; // nowhere else do we install a supervisor to be run by
220
+ // win32 stays false on purpose, even though we now install a Scheduled Task.
221
+ // launchd KeepAlive and systemd Restart=always bring the daemon straight
222
+ // back; a logon-triggered task does not restart anything until the next
223
+ // logon. Answering true here would let the version-skew guard exit 0 into
224
+ // nothing and stop Windows watching silently — the exact failure mode of
225
+ // issue #8, just with a different trigger. The cost is that a Windows daemon
226
+ // keeps running pre-upgrade code until the user logs back in; that is the
227
+ // lesser of the two, and `unsnooze doctor` reports the skew.
228
+ return false;
167
229
  }
168
230
 
169
231
  // Env overrides keep tests/e2e away from the real LaunchAgents / systemd dirs.
@@ -323,8 +385,20 @@ function defaultActivate(cmd, args) {
323
385
  export function installDaemonAutostart({
324
386
  platform = process.platform, dir = null, activate = defaultActivate, path = undefined,
325
387
  } = {}) {
388
+ // Native Windows has no unit *file* — the Task Scheduler holds the record —
389
+ // so it is handled before the file-based platforms below. It matters more
390
+ // here than anywhere else: the transcript watcher lives in the daemon, and
391
+ // headless has no pane monitor to fall back on, so without this a Windows
392
+ // machine only ever catches limit stops through the StopFailure hook.
393
+ if (platform === 'win32') {
394
+ // /f overwrites an existing task rather than failing with "already exists",
395
+ // which is what re-running setup does every time.
396
+ activate('schtasks', ['/create', '/f', '/tn', WINDOWS_TASK_NAME, '/sc', 'onlogon',
397
+ '/tr', `"${process.execPath}" "${UNSNOOZE_BIN}" daemon`]);
398
+ return `Scheduled Task \\${WINDOWS_TASK_NAME}`;
399
+ }
326
400
  const target = autostartUnitPath({ platform, dir });
327
- if (!target) return null; // native Windows: no supported multiplexer to revive into
401
+ if (!target) return null;
328
402
  // Safety interlock. Every unit we generate carries the SAME label, so running
329
403
  // the real launchctl/systemctl against a unit written somewhere else (a test
330
404
  // fixture, a staging copy) does not create a second job — it HIJACKS the
@@ -403,6 +477,11 @@ export function healDaemonAutostart({
403
477
  }
404
478
 
405
479
  export function uninstallDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
480
+ if (platform === 'win32') {
481
+ // /f so a missing task is not an interactive prompt on an uninstall path.
482
+ activate('schtasks', ['/delete', '/f', '/tn', WINDOWS_TASK_NAME]);
483
+ return `Scheduled Task \\${WINDOWS_TASK_NAME}`;
484
+ }
406
485
  if (platform === 'darwin') {
407
486
  const target = join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`);
408
487
  if (!existsSync(target)) return null;
@@ -516,6 +595,22 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
516
595
  console.log(`unsnooze: wrappers (${agents.join(', ')}) installed in ${rc}${oldRemoved ? ' (legacy wrapper block removed)' : ''}`);
517
596
  }
518
597
 
598
+ // 3b. PowerShell profile (native Windows). The rc loop above only ever
599
+ // reaches ~/.zshrc and ~/.bashrc, which a PowerShell user does not have —
600
+ // without this nothing routes their `claude` through unsnooze at all.
601
+ const psProfile = powershellProfilePath();
602
+ if (psProfile) {
603
+ try {
604
+ mkdirSync(dirname(psProfile), { recursive: true });
605
+ const before = existsSync(psProfile) ? readFileSync(psProfile, 'utf-8') : '';
606
+ if (existsSync(psProfile)) backupOnce(psProfile);
607
+ atomicWrite(psProfile, installPowershellBlock(before, agents, UNSNOOZE_BIN));
608
+ console.log(`unsnooze: PowerShell wrappers (${agents.join(', ')}) installed in ${psProfile}`);
609
+ } catch (err) {
610
+ console.log(`unsnooze: could not write the PowerShell profile (${err.message})`);
611
+ }
612
+ }
613
+
519
614
  // 4. Daemon autostart (GUI-session watching), opt-in via --daemon / wizard.
520
615
  if (opts.daemon) {
521
616
  const target = installDaemonAutostart();
@@ -575,6 +670,22 @@ export function cmdUninstall(rest) {
575
670
  }
576
671
  }
577
672
 
673
+ // The PowerShell profile is a wrapper site too — leaving its block behind
674
+ // would keep shadowing `claude` with a function pointing at a bin that
675
+ // uninstall just removed.
676
+ try {
677
+ const psProfile = powershellProfilePath();
678
+ if (psProfile && existsSync(psProfile)) {
679
+ const { content, found } = stripFencedBlock(readFileSync(psProfile, 'utf-8'), FENCE_OPEN, FENCE_CLOSE);
680
+ if (found) {
681
+ atomicWrite(psProfile, content);
682
+ console.log(`unsnooze: PowerShell wrappers removed from ${psProfile}`);
683
+ }
684
+ }
685
+ } catch (err) {
686
+ console.log(`unsnooze: could not clean the PowerShell profile (${err.message})`);
687
+ }
688
+
578
689
  const autostart = uninstallDaemonAutostart();
579
690
  if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
580
691