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/CHANGELOG.md +127 -0
- package/README.md +122 -17
- package/bin/unsnooze.js +5 -1
- package/package.json +12 -4
- package/src/agents/claude.js +43 -4
- package/src/cli.js +12 -1
- package/src/config.js +21 -0
- package/src/design.js +209 -0
- package/src/doctor.js +43 -6
- package/src/fleet.js +5 -1
- package/src/hook.js +23 -8
- package/src/install.js +118 -7
- package/src/launcher.js +85 -14
- package/src/lease.js +15 -1
- package/src/monitor.js +62 -12
- package/src/multiplexer.js +34 -4
- package/src/multiplexers/cmux.js +153 -0
- package/src/multiplexers/headless.js +164 -0
- package/src/multiplexers/herdr.js +613 -0
- package/src/multiplexers/session-name.js +30 -0
- package/src/multiplexers/shell-quote.js +96 -0
- package/src/multiplexers/tmux.js +1 -0
- package/src/multiplexers/zellij.js +1 -0
- package/src/mux-sessions.js +68 -0
- package/src/notify.js +2 -1
- package/src/patterns.js +72 -2
- package/src/powershell.js +45 -0
- package/src/prompt-queue.js +12 -7
- package/src/reap.js +47 -12
- package/src/report.js +16 -3
- package/src/resumer.js +95 -14
- package/src/settings.js +81 -3
- package/src/state.js +24 -0
- package/src/wizard.js +14 -6
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
848
|
+
// cmux has no joinable named-session model — there is no remote command
|
|
849
|
+
// that reattaches to a surface, so omit the hint (see attachHint).
|
|
850
|
+
if (muxName === 'cmux') return null;
|
|
851
|
+
const inner = muxName === 'herdr' ? `herdr session attach ${muxSession}`
|
|
852
|
+
: muxName === 'zellij' ? `zellij attach ${muxSession}` : `tmux new -A -s ${muxSession}`;
|
|
849
853
|
return `ssh -t ${dest} '${inner}'`;
|
|
850
854
|
}
|
|
851
855
|
|
package/src/hook.js
CHANGED
|
@@ -41,6 +41,21 @@ function classify(payload, raw) {
|
|
|
41
41
|
return 'unknown';
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export function hookContext(env = process.env, payload = {}) {
|
|
45
|
+
const managedMux = env.UNSNOOZE_MUX;
|
|
46
|
+
const muxName = managedMux || (env.HERDR_PANE_ID ? 'herdr' : env.ZELLIJ_PANE_ID ? 'zellij' : 'tmux');
|
|
47
|
+
const pane = managedMux
|
|
48
|
+
? (env.UNSNOOZE_PANE || null)
|
|
49
|
+
: (muxName === 'herdr' ? env.HERDR_PANE_ID
|
|
50
|
+
: muxName === 'zellij' ? env.ZELLIJ_PANE_ID : env.TMUX_PANE || payload.tmux_pane) || null;
|
|
51
|
+
const paneOwner = muxName === 'herdr'
|
|
52
|
+
? (managedMux ? env.UNSNOOZE_PANE_OWNER : (env.HERDR_SESSION || 'default'))
|
|
53
|
+
: muxName === 'zellij'
|
|
54
|
+
? (managedMux ? env.UNSNOOZE_PANE_OWNER : env.ZELLIJ_SESSION_NAME) || null
|
|
55
|
+
: null;
|
|
56
|
+
return { muxName, pane, paneOwner };
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
export async function runHook(rest = []) {
|
|
45
60
|
try {
|
|
46
61
|
const agentIdx = rest.indexOf('--agent');
|
|
@@ -57,16 +72,16 @@ export async function runHook(rest = []) {
|
|
|
57
72
|
return 0;
|
|
58
73
|
}
|
|
59
74
|
|
|
60
|
-
const
|
|
61
|
-
const muxName = managedMux || (process.env.ZELLIJ_PANE_ID ? 'zellij' : 'tmux');
|
|
62
|
-
const pane = managedMux
|
|
63
|
-
? (process.env.UNSNOOZE_PANE || null)
|
|
64
|
-
: (muxName === 'zellij' ? process.env.ZELLIJ_PANE_ID : process.env.TMUX_PANE || payload.tmux_pane) || null;
|
|
65
|
-
const paneOwner = muxName === 'zellij'
|
|
66
|
-
? (managedMux ? process.env.UNSNOOZE_PANE_OWNER : process.env.ZELLIJ_SESSION_NAME) || null
|
|
67
|
-
: null;
|
|
75
|
+
const { muxName, pane: rawPane, paneOwner } = hookContext(process.env, payload);
|
|
68
76
|
const leaseId = process.env.UNSNOOZE_LEASE_ID || null;
|
|
69
77
|
const mux = getMultiplexer(muxName, { owner: paneOwner });
|
|
78
|
+
// Same guard as the launcher: a pane we cannot prove we are addressing on
|
|
79
|
+
// the right server is worse than no pane, because every later action —
|
|
80
|
+
// capture, inject, close — would be aimed at whatever occupies that id
|
|
81
|
+
// elsewhere. Record the stop without a pane instead.
|
|
82
|
+
const pane = (typeof mux.paneAddressable !== 'function' || mux.paneAddressable())
|
|
83
|
+
? rawPane : null;
|
|
84
|
+
if (rawPane && !pane) log(`StopFailure: ignoring pane ${rawPane} — not safely addressable`);
|
|
70
85
|
const kind = classify(payload, raw);
|
|
71
86
|
log(`StopFailure: kind=${kind} pane=${pane} session=${payload.session_id || '?'}`);
|
|
72
87
|
|
package/src/install.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|