@hover-dev/core 0.6.0 → 0.7.2
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/dist/agents/claude.d.ts.map +1 -1
- package/dist/agents/claude.js +23 -0
- package/dist/agents/types.d.ts +5 -0
- package/dist/agents/types.d.ts.map +1 -1
- package/dist/playwright/launchChrome.d.ts +12 -0
- package/dist/playwright/launchChrome.d.ts.map +1 -1
- package/dist/playwright/launchChrome.js +4 -1
- package/dist/playwright/raiseWindow.js +20 -5
- package/dist/playwright/resolveMcpConfig.d.ts +17 -0
- package/dist/playwright/resolveMcpConfig.d.ts.map +1 -1
- package/dist/playwright/resolveMcpConfig.js +17 -27
- package/dist/plugin-api.d.ts +161 -0
- package/dist/plugin-api.d.ts.map +1 -0
- package/dist/plugin-api.js +52 -0
- package/dist/service/cdpHandlers.d.ts +12 -3
- package/dist/service/cdpHandlers.d.ts.map +1 -1
- package/dist/service/cdpHandlers.js +24 -9
- package/dist/service/saveHandlers.d.ts.map +1 -1
- package/dist/service/saveHandlers.js +15 -4
- package/dist/service/types.d.ts +13 -1
- package/dist/service/types.d.ts.map +1 -1
- package/dist/service/types.js +12 -0
- package/dist/service.d.ts +7 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +389 -50
- package/package.json +5 -1
|
@@ -28,14 +28,12 @@ export async function handleSaveArtifact(ws, msg, devRoot, cfg) {
|
|
|
28
28
|
send(ws, { type: 'error', payload: { message: `${cfg.requestName}: no steps to save` } });
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
|
+
let result;
|
|
31
32
|
try {
|
|
32
|
-
|
|
33
|
+
result = await cfg.write({
|
|
33
34
|
devRoot, name, description, steps, assertions,
|
|
34
35
|
payload: msg.payload, overwrite,
|
|
35
36
|
});
|
|
36
|
-
send(ws, { type: cfg.savedType, payload: { name: result.slug, path: result.path } });
|
|
37
|
-
if (cfg.onSaved)
|
|
38
|
-
await cfg.onSaved(ws, devRoot);
|
|
39
37
|
}
|
|
40
38
|
catch (err) {
|
|
41
39
|
if (err instanceof cfg.ExistsError) {
|
|
@@ -44,6 +42,19 @@ export async function handleSaveArtifact(ws, msg, devRoot, cfg) {
|
|
|
44
42
|
}
|
|
45
43
|
const message = err instanceof Error ? err.message : String(err);
|
|
46
44
|
send(ws, { type: 'error', payload: { message: `${cfg.requestName} failed: ${message}` } });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
send(ws, { type: cfg.savedType, payload: { name: result.slug, path: result.path } });
|
|
48
|
+
// The artifact is already on disk; an onSaved failure (e.g. listSkills
|
|
49
|
+
// re-scan) shouldn't surface as if the save itself failed — log and move on.
|
|
50
|
+
if (cfg.onSaved) {
|
|
51
|
+
try {
|
|
52
|
+
await cfg.onSaved(ws, devRoot);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
56
|
+
console.warn(`[hover] ${cfg.requestName} onSaved failed: ${message}`);
|
|
57
|
+
}
|
|
47
58
|
}
|
|
48
59
|
}
|
|
49
60
|
export const SKILL_CONFIG = {
|
package/dist/service/types.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* back to the widget. Centralised so the JSON.stringify happens in exactly
|
|
10
10
|
* one place.
|
|
11
11
|
*/
|
|
12
|
-
import
|
|
12
|
+
import { WebSocket } from 'ws';
|
|
13
13
|
import type { SkillStep } from '../skills/writeSkill.js';
|
|
14
14
|
import type { SpecAssertion } from '../specs/writeSpec.js';
|
|
15
15
|
export interface ClientMessage {
|
|
@@ -32,10 +32,22 @@ export interface ClientMessage {
|
|
|
32
32
|
pageUrl?: string;
|
|
33
33
|
/** switch-agent only — id of the agent to switch the service to. */
|
|
34
34
|
agentId?: string;
|
|
35
|
+
/** set-mode only — id of the plugin-contributed mode to activate,
|
|
36
|
+
* or null to return to normal (unmoded) operation. */
|
|
37
|
+
modeId?: string | null;
|
|
35
38
|
};
|
|
36
39
|
}
|
|
37
40
|
export declare function send(ws: WebSocket, message: {
|
|
38
41
|
type: string;
|
|
39
42
|
payload?: unknown;
|
|
40
43
|
}): void;
|
|
44
|
+
/** Send a message only if the socket is still open. Use this from delayed
|
|
45
|
+
* callbacks (promise `.then`, timers) where the client may have disconnected
|
|
46
|
+
* between scheduling and firing — calling `ws.send` on a closed socket
|
|
47
|
+
* is a silent no-op for some states and throws for others, so a single
|
|
48
|
+
* guarded helper makes the intent obvious and prevents surprises. */
|
|
49
|
+
export declare function sendIfOpen(ws: WebSocket, message: {
|
|
50
|
+
type: string;
|
|
51
|
+
payload?: unknown;
|
|
52
|
+
}): boolean;
|
|
41
53
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/service/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/service/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AAC/B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE;QACR,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,KAAK,CAAC,EAAE,SAAS,EAAE,CAAC;QACpB,UAAU,CAAC,EAAE,aAAa,EAAE,CAAC;QAC7B,SAAS,CAAC,EAAE,OAAO,CAAC;QACpB;uDAC+C;QAC/C,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB;;2DAEmD;QACnD,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,oEAAoE;QACpE,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB;+DACuD;QACvD,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KACxB,CAAC;CACH;AAED,wBAAgB,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAEtF;AAED;;;;sEAIsE;AACtE,wBAAgB,UAAU,CACxB,EAAE,EAAE,SAAS,EACb,OAAO,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAC3C,OAAO,CAIT"}
|
package/dist/service/types.js
CHANGED
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
* back to the widget. Centralised so the JSON.stringify happens in exactly
|
|
10
10
|
* one place.
|
|
11
11
|
*/
|
|
12
|
+
import { WebSocket } from 'ws';
|
|
12
13
|
export function send(ws, message) {
|
|
13
14
|
ws.send(JSON.stringify(message));
|
|
14
15
|
}
|
|
16
|
+
/** Send a message only if the socket is still open. Use this from delayed
|
|
17
|
+
* callbacks (promise `.then`, timers) where the client may have disconnected
|
|
18
|
+
* between scheduling and firing — calling `ws.send` on a closed socket
|
|
19
|
+
* is a silent no-op for some states and throws for others, so a single
|
|
20
|
+
* guarded helper makes the intent obvious and prevents surprises. */
|
|
21
|
+
export function sendIfOpen(ws, message) {
|
|
22
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
23
|
+
return false;
|
|
24
|
+
ws.send(JSON.stringify(message));
|
|
25
|
+
return true;
|
|
26
|
+
}
|
package/dist/service.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type HoverPluginManifest } from './plugin-api.js';
|
|
1
2
|
export interface ServiceOptions {
|
|
2
3
|
port: number;
|
|
3
4
|
agentId?: string;
|
|
@@ -11,6 +12,12 @@ export interface ServiceOptions {
|
|
|
11
12
|
* In Vite plugin context, set to `server.config.root` so Claude
|
|
12
13
|
* auto-discovers skills the user previously saved from this project. */
|
|
13
14
|
devRoot?: string;
|
|
15
|
+
/** Plugins contributed by the bundler-plugin wrapper. Each manifest can
|
|
16
|
+
* add a widget mode, MCP servers, Chrome flags, and lifecycle hooks.
|
|
17
|
+
* Empty array (default) means "no plugins, behaviour identical to
|
|
18
|
+
* pre-plugin Hover" — important for the long tail of users who never
|
|
19
|
+
* install one. */
|
|
20
|
+
plugins?: HoverPluginManifest[];
|
|
14
21
|
}
|
|
15
22
|
export interface ServiceHandle {
|
|
16
23
|
/** The port the WebSocketServer actually bound to. May differ from
|
package/dist/service.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAqEA,OAAO,EAEL,KAAK,mBAAmB,EAEzB,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;6EAGyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;uBAImB;IACnB,OAAO,CAAC,EAAE,mBAAmB,EAAE,CAAC;CACjC;AAED,MAAM,WAAW,aAAa;IAC5B;4EACwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAiDD,wBAAsB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CAmrB/E"}
|
package/dist/service.js
CHANGED
|
@@ -34,6 +34,12 @@
|
|
|
34
34
|
*
|
|
35
35
|
* server → client (in addition to those documented in the file body):
|
|
36
36
|
* { type: 'agents', payload: { current: string, available: AgentAvailability[] } }
|
|
37
|
+
* { type: 'modes', payload: { current: string|null, available: ModeEntry[] } }
|
|
38
|
+
* { type: '<plugin-namespaced>', payload: <plugin-specific> }
|
|
39
|
+
*
|
|
40
|
+
* client → server (plugin-aware additions):
|
|
41
|
+
* { type: 'set-mode', payload: { modeId: string|null } } // null = exit moded operation
|
|
42
|
+
* { type: 'list-modes' }
|
|
37
43
|
*/
|
|
38
44
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
39
45
|
import { invokeAgent } from './agents/invoke.js';
|
|
@@ -42,10 +48,11 @@ import { getAgent } from './agents/registry.js';
|
|
|
42
48
|
import { getPreflight, invalidatePreflight } from './playwright/preflightCache.js';
|
|
43
49
|
import { resolveMcpConfig } from './playwright/resolveMcpConfig.js';
|
|
44
50
|
import { listSkills } from './skills/writeSkill.js';
|
|
45
|
-
import { send } from './service/types.js';
|
|
51
|
+
import { send, sendIfOpen } from './service/types.js';
|
|
46
52
|
import { buildCdpHint, buildCdpHintResume } from './service/cdpHint.js';
|
|
47
53
|
import { handleCheckCdp, handleLaunchChrome, handleFocusDebug, } from './service/cdpHandlers.js';
|
|
48
54
|
import { handleSaveArtifact, SKILL_CONFIG, SPEC_CONFIG, CASE_CSV_CONFIG, } from './service/saveHandlers.js';
|
|
55
|
+
import { CURRENT_API_VERSION, } from './plugin-api.js';
|
|
49
56
|
// ClientMessage + send moved to ./service/types.ts so the cdp + save
|
|
50
57
|
// handler modules can share them. See those files for the wire shape.
|
|
51
58
|
const PROTOCOL_VERSION = 1;
|
|
@@ -122,15 +129,219 @@ export async function startService(opts) {
|
|
|
122
129
|
const devRoot = opts.devRoot ?? process.cwd();
|
|
123
130
|
const wss = await pickAndBind('127.0.0.1', requestedPort, PORT_RETRIES);
|
|
124
131
|
const port = wss.address().port;
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
132
|
+
// Build a fresh MCP config per command, so the currently-active mode's
|
|
133
|
+
// contributed servers (plus runtime env from setMcpServerEnv) land in
|
|
134
|
+
// the file the agent reads. `opts.mcpConfig` still wins if the host
|
|
135
|
+
// forced an explicit one, but in that case mode-contributed servers
|
|
136
|
+
// are silently dropped — we log a warning the first time it happens.
|
|
137
|
+
let warnedExplicitMcpOverride = false;
|
|
138
|
+
const buildMcpConfig = () => {
|
|
139
|
+
if (opts.mcpConfig) {
|
|
140
|
+
const activePlugin = currentModeId ? pluginsByModeId.get(currentModeId) : null;
|
|
141
|
+
if (activePlugin?.mcpServers?.length && !warnedExplicitMcpOverride) {
|
|
142
|
+
process.stderr.write(`[hover] explicit opts.mcpConfig overrides plugin-contributed MCP servers ` +
|
|
143
|
+
`(plugin "${activePlugin.name}" wanted ${activePlugin.mcpServers
|
|
144
|
+
.map((s) => s.id)
|
|
145
|
+
.join(', ')}).\n`);
|
|
146
|
+
warnedExplicitMcpOverride = true;
|
|
147
|
+
}
|
|
148
|
+
return opts.mcpConfig;
|
|
149
|
+
}
|
|
150
|
+
const extra = [];
|
|
151
|
+
if (currentModeId) {
|
|
152
|
+
for (const p of plugins) {
|
|
153
|
+
for (const srv of p.mcpServers ?? []) {
|
|
154
|
+
const scope = srv.activeInModes ?? (p.mode ? [p.mode.id] : []);
|
|
155
|
+
const inMode = scope.includes('*') || scope.includes(currentModeId);
|
|
156
|
+
if (!inMode)
|
|
157
|
+
continue;
|
|
158
|
+
extra.push({
|
|
159
|
+
id: srv.id,
|
|
160
|
+
command: srv.command,
|
|
161
|
+
args: srv.args,
|
|
162
|
+
env: {
|
|
163
|
+
...(srv.env ?? {}),
|
|
164
|
+
...(mcpEnvOverrides.get(srv.id) ?? {}),
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// In an active mode, the Playwright MCP must point at THAT mode's
|
|
171
|
+
// Chrome (e.g. security mode's 9333), not the default 9222.
|
|
172
|
+
// effectiveLaunchExtras().cdpPort is the source of truth.
|
|
173
|
+
const extras = effectiveLaunchExtras();
|
|
174
|
+
const effectiveCdpUrl = extras?.cdpPort
|
|
175
|
+
? `http://localhost:${extras.cdpPort}`
|
|
176
|
+
: cdpUrl;
|
|
177
|
+
return resolveMcpConfig({
|
|
178
|
+
cdpUrl: effectiveCdpUrl,
|
|
179
|
+
port,
|
|
180
|
+
extra,
|
|
181
|
+
// Suffix the filename by the mode so different mode toggles within
|
|
182
|
+
// one service produce distinct config files (debugging aid).
|
|
183
|
+
suffix: currentModeId ?? undefined,
|
|
184
|
+
});
|
|
185
|
+
};
|
|
130
186
|
// Surface post-listen errors instead of crashing the host process.
|
|
131
187
|
wss.on('error', err => {
|
|
132
188
|
process.stderr.write(`[hover] WebSocketServer error: ${err.message}\n`);
|
|
133
189
|
});
|
|
190
|
+
// ──────────────────────────────────────────────────────────────────
|
|
191
|
+
// Plugin registry
|
|
192
|
+
// ──────────────────────────────────────────────────────────────────
|
|
193
|
+
// Validate + index plugins once at startup. Reasons we fail loud here
|
|
194
|
+
// (rather than at first use): mode-id collisions are a configuration
|
|
195
|
+
// bug, not a runtime one — the widget mode-picker would silently miss
|
|
196
|
+
// entries, which is worse than a startup error the user has to fix.
|
|
197
|
+
const plugins = opts.plugins ?? [];
|
|
198
|
+
const pluginsByName = new Map();
|
|
199
|
+
const pluginsByModeId = new Map();
|
|
200
|
+
for (const p of plugins) {
|
|
201
|
+
if (p.apiVersion !== CURRENT_API_VERSION) {
|
|
202
|
+
throw new Error(`[hover] plugin "${p.name}" targets apiVersion ${String(p.apiVersion)} but this Hover supports ${CURRENT_API_VERSION}.`);
|
|
203
|
+
}
|
|
204
|
+
if (pluginsByName.has(p.name)) {
|
|
205
|
+
throw new Error(`[hover] duplicate plugin name: ${p.name}`);
|
|
206
|
+
}
|
|
207
|
+
pluginsByName.set(p.name, p);
|
|
208
|
+
if (p.mode) {
|
|
209
|
+
if (pluginsByModeId.has(p.mode.id)) {
|
|
210
|
+
throw new Error(`[hover] two plugins contribute the same mode id "${p.mode.id}": ` +
|
|
211
|
+
`${pluginsByModeId.get(p.mode.id)?.name} and ${p.name}`);
|
|
212
|
+
}
|
|
213
|
+
pluginsByModeId.set(p.mode.id, p);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** id of the currently-active mode, or null for normal (unmoded) mode. */
|
|
217
|
+
let currentModeId = null;
|
|
218
|
+
/** Chrome-proxy settings the active mode's activate hook set on us.
|
|
219
|
+
* Read by `effectiveLaunchExtras()` and threaded into the cdp handlers
|
|
220
|
+
* (check-cdp / launch-chrome / focus-debug) so the secured Chrome on
|
|
221
|
+
* 9333 actually gets `--proxy-server` + SPKI pin when the user clicks
|
|
222
|
+
* Launch from the widget. */
|
|
223
|
+
let modeChromeProxy = null;
|
|
224
|
+
/** Runtime env overrides keyed by mcpServer id, set by plugin
|
|
225
|
+
* activate hooks (via ctx.setMcpServerEnv). Cleared on mode change.
|
|
226
|
+
* Merged with the manifest-declared env when the agent's spawn-time
|
|
227
|
+
* MCP config is built. */
|
|
228
|
+
const mcpEnvOverrides = new Map();
|
|
229
|
+
/** The cdp-handler extras (port, userDataDir, proxy) for the active
|
|
230
|
+
* mode's chromeFlags manifest field, or undefined when no mode is
|
|
231
|
+
* active. The widget's launch-chrome / check-cdp / focus-debug paths
|
|
232
|
+
* all consume these so a Chrome relaunch obeys the mode's needs. */
|
|
233
|
+
const effectiveLaunchExtras = () => {
|
|
234
|
+
if (!currentModeId)
|
|
235
|
+
return undefined;
|
|
236
|
+
const plugin = pluginsByModeId.get(currentModeId);
|
|
237
|
+
const flags = plugin?.chromeFlags;
|
|
238
|
+
if (!flags && !modeChromeProxy)
|
|
239
|
+
return undefined;
|
|
240
|
+
// Belt + suspenders — flags.activeInModes is honoured if set, but
|
|
241
|
+
// since chromeFlags lives on the plugin that contributed this mode,
|
|
242
|
+
// the default of "applies in own mode" matches what we want.
|
|
243
|
+
if (flags?.activeInModes && !flags.activeInModes.includes('*') && !flags.activeInModes.includes(currentModeId)) {
|
|
244
|
+
// Plugin explicitly restricted its chromeFlags to a different mode.
|
|
245
|
+
// Honour that and only carry modeChromeProxy (set by setChromeProxy).
|
|
246
|
+
return modeChromeProxy ? { proxy: modeChromeProxy } : undefined;
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
cdpPort: flags?.cdpPort,
|
|
250
|
+
userDataDir: flags?.userDataDir,
|
|
251
|
+
// modeChromeProxy wins over flags.proxy because it's the runtime
|
|
252
|
+
// value the activate hook computed (after starting mockttp);
|
|
253
|
+
// flags.proxy is only ever set by tests stubbing the manifest.
|
|
254
|
+
proxy: modeChromeProxy ?? flags?.proxy,
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
/** Send the current mode catalogue to one ws (or all if undefined). */
|
|
258
|
+
const broadcastModes = (target) => {
|
|
259
|
+
const available = plugins
|
|
260
|
+
.filter((p) => Boolean(p.mode))
|
|
261
|
+
.map((p) => ({
|
|
262
|
+
id: p.mode.id,
|
|
263
|
+
label: p.mode.label,
|
|
264
|
+
description: p.mode.description,
|
|
265
|
+
pluginName: p.name,
|
|
266
|
+
}));
|
|
267
|
+
const payload = { current: currentModeId, available };
|
|
268
|
+
const targets = target ? [target] : [...wss.clients];
|
|
269
|
+
for (const client of targets) {
|
|
270
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
271
|
+
send(client, { type: 'modes', payload });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
/** Broadcast helper passed to plugin hooks. Plugin-side events should
|
|
276
|
+
* be namespaced ("security:flow:added") to avoid collisions with
|
|
277
|
+
* core's protocol vocabulary. */
|
|
278
|
+
const broadcastPluginEvent = (event) => {
|
|
279
|
+
for (const client of wss.clients) {
|
|
280
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
281
|
+
send(client, event);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const switchMode = async (newModeId) => {
|
|
286
|
+
if (newModeId === currentModeId)
|
|
287
|
+
return;
|
|
288
|
+
// Tear down old mode
|
|
289
|
+
if (currentModeId) {
|
|
290
|
+
const old = pluginsByModeId.get(currentModeId);
|
|
291
|
+
if (old?.hooks?.['hover:mode:deactivate']) {
|
|
292
|
+
try {
|
|
293
|
+
await old.hooks['hover:mode:deactivate']({
|
|
294
|
+
devRoot,
|
|
295
|
+
broadcast: broadcastPluginEvent,
|
|
296
|
+
modeId: currentModeId,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
process.stderr.write(`[hover] plugin "${old.name}" deactivate failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
modeChromeProxy = null;
|
|
305
|
+
mcpEnvOverrides.clear();
|
|
306
|
+
currentModeId = null;
|
|
307
|
+
// Bring up new mode
|
|
308
|
+
if (newModeId) {
|
|
309
|
+
const next = pluginsByModeId.get(newModeId);
|
|
310
|
+
if (!next) {
|
|
311
|
+
throw new Error(`[hover] unknown modeId "${newModeId}"`);
|
|
312
|
+
}
|
|
313
|
+
currentModeId = newModeId;
|
|
314
|
+
if (next.hooks?.['hover:mode:activate']) {
|
|
315
|
+
const ctx = {
|
|
316
|
+
devRoot,
|
|
317
|
+
broadcast: broadcastPluginEvent,
|
|
318
|
+
modeId: newModeId,
|
|
319
|
+
setChromeProxy(proxy) {
|
|
320
|
+
modeChromeProxy = proxy;
|
|
321
|
+
},
|
|
322
|
+
setMcpServerEnv(id, env) {
|
|
323
|
+
mcpEnvOverrides.set(id, env);
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
try {
|
|
327
|
+
await next.hooks['hover:mode:activate'](ctx);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
// Activate failed half-way — roll back state so we don't
|
|
331
|
+
// pretend to be in `newModeId` with no sidecars running.
|
|
332
|
+
// Widget still trusts the broadcast below to learn we're back
|
|
333
|
+
// to default. The error is rethrown so the caller can surface
|
|
334
|
+
// it to the user.
|
|
335
|
+
modeChromeProxy = null;
|
|
336
|
+
mcpEnvOverrides.clear();
|
|
337
|
+
currentModeId = null;
|
|
338
|
+
broadcastModes();
|
|
339
|
+
throw err;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
broadcastModes();
|
|
344
|
+
};
|
|
134
345
|
// Cache the agent-availability list. The PATH scan is cheap (one `which`
|
|
135
346
|
// per registered agent) but we still don't want to re-run it on every
|
|
136
347
|
// hello; a single Vite dev server typically sees the widget connect and
|
|
@@ -161,10 +372,23 @@ export async function startService(opts) {
|
|
|
161
372
|
payload: { agentId: currentAgentId, model, version: PROTOCOL_VERSION },
|
|
162
373
|
});
|
|
163
374
|
// Send the agent list as a follow-up event so the widget can render the
|
|
164
|
-
// dropdown immediately on connect / reconnect (e.g. after HMR).
|
|
165
|
-
|
|
166
|
-
|
|
375
|
+
// dropdown immediately on connect / reconnect (e.g. after HMR). The
|
|
376
|
+
// socket may have closed between scheduling and firing, so guard the
|
|
377
|
+
// send and catch any availability-probe rejection — otherwise it
|
|
378
|
+
// surfaces as an unhandled rejection in strict-mode Node.
|
|
379
|
+
void getAvailability(false)
|
|
380
|
+
.then(available => {
|
|
381
|
+
sendIfOpen(ws, {
|
|
382
|
+
type: 'agents',
|
|
383
|
+
payload: { current: currentAgentId, available },
|
|
384
|
+
});
|
|
385
|
+
})
|
|
386
|
+
.catch(err => {
|
|
387
|
+
console.warn('[hover] agents broadcast failed:', err);
|
|
167
388
|
});
|
|
389
|
+
// Send the mode catalogue too, so the widget can render the mode
|
|
390
|
+
// toggle immediately. Empty list when no plugins are loaded.
|
|
391
|
+
broadcastModes(ws);
|
|
168
392
|
let busy = false;
|
|
169
393
|
let inflight = null;
|
|
170
394
|
let cancelled = false;
|
|
@@ -210,6 +434,46 @@ export async function startService(opts) {
|
|
|
210
434
|
cancel();
|
|
211
435
|
return;
|
|
212
436
|
}
|
|
437
|
+
if (msg.type === 'list-modes') {
|
|
438
|
+
broadcastModes(ws);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (msg.type === 'set-mode') {
|
|
442
|
+
if (busy) {
|
|
443
|
+
send(ws, {
|
|
444
|
+
type: 'error',
|
|
445
|
+
payload: { message: 'set-mode: a command is already running; stop it first' },
|
|
446
|
+
});
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const wanted = msg.payload?.modeId ?? null;
|
|
450
|
+
if (wanted !== null && typeof wanted !== 'string') {
|
|
451
|
+
send(ws, {
|
|
452
|
+
type: 'error',
|
|
453
|
+
payload: { message: 'set-mode: modeId must be a string or null' },
|
|
454
|
+
});
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (wanted !== null && !pluginsByModeId.has(wanted)) {
|
|
458
|
+
send(ws, {
|
|
459
|
+
type: 'error',
|
|
460
|
+
payload: { message: `set-mode: unknown modeId "${wanted}"` },
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
await switchMode(wanted);
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
send(ws, {
|
|
469
|
+
type: 'error',
|
|
470
|
+
payload: {
|
|
471
|
+
message: `set-mode failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
213
477
|
if (msg.type === 'list-agents') {
|
|
214
478
|
// Force a refresh — the user may have just installed a new CLI
|
|
215
479
|
// and clicked the dropdown to see the change.
|
|
@@ -270,15 +534,15 @@ export async function startService(opts) {
|
|
|
270
534
|
return;
|
|
271
535
|
}
|
|
272
536
|
if (msg.type === 'check-cdp') {
|
|
273
|
-
await handleCheckCdp(ws, msg, cdpUrl);
|
|
537
|
+
await handleCheckCdp(ws, msg, cdpUrl, effectiveLaunchExtras());
|
|
274
538
|
return;
|
|
275
539
|
}
|
|
276
540
|
if (msg.type === 'launch-chrome') {
|
|
277
|
-
await handleLaunchChrome(ws, msg, cdpUrl);
|
|
541
|
+
await handleLaunchChrome(ws, msg, cdpUrl, effectiveLaunchExtras());
|
|
278
542
|
return;
|
|
279
543
|
}
|
|
280
544
|
if (msg.type === 'focus-debug') {
|
|
281
|
-
await handleFocusDebug(ws, msg, cdpUrl);
|
|
545
|
+
await handleFocusDebug(ws, msg, cdpUrl, effectiveLaunchExtras());
|
|
282
546
|
return;
|
|
283
547
|
}
|
|
284
548
|
if (msg.type !== 'command')
|
|
@@ -300,11 +564,22 @@ export async function startService(opts) {
|
|
|
300
564
|
cancelled = false;
|
|
301
565
|
inflight = new AbortController();
|
|
302
566
|
try {
|
|
567
|
+
// Build the MCP config first — it's pure local file IO and lets
|
|
568
|
+
// us assert plugin-contributed servers landed in the config even
|
|
569
|
+
// when CDP preflight subsequently fails (useful for smoke tests
|
|
570
|
+
// that don't have a real debug Chrome wired up).
|
|
571
|
+
const mcpConfig = buildMcpConfig();
|
|
303
572
|
// Preflight: refuse to invoke if CDP isn't reachable. Otherwise the
|
|
304
573
|
// Playwright MCP server would silently launch its own Chromium —
|
|
305
574
|
// and Hover's premise is to drive the user's existing Chrome (with
|
|
306
575
|
// their dev state, cookies, devtools open), never spawn a fresh one.
|
|
307
|
-
|
|
576
|
+
// In an active mode, the relevant CDP endpoint may be the mode's
|
|
577
|
+
// own port (e.g. 9333 for security), not the default cdpUrl.
|
|
578
|
+
const preflightExtras = effectiveLaunchExtras();
|
|
579
|
+
const preflightCdpUrl = preflightExtras?.cdpPort
|
|
580
|
+
? `http://localhost:${preflightExtras.cdpPort}`
|
|
581
|
+
: cdpUrl;
|
|
582
|
+
const cdp = await getPreflight(preflightCdpUrl);
|
|
308
583
|
if (!cdp.ok) {
|
|
309
584
|
send(ws, {
|
|
310
585
|
type: 'event',
|
|
@@ -328,9 +603,28 @@ export async function startService(opts) {
|
|
|
328
603
|
// re-sending them fragments Anthropic's prompt-cache fingerprint
|
|
329
604
|
// (cache hits require byte-identical system prompts across turns).
|
|
330
605
|
// See cdpHint.ts for the why.
|
|
331
|
-
|
|
606
|
+
let appendSystemPrompt = resumeSessionId
|
|
332
607
|
? buildCdpHintResume(cdp.tabs)
|
|
333
608
|
: buildCdpHint(cdp.tabs);
|
|
609
|
+
// Add plugin-contributed prompt additions whose scope includes the
|
|
610
|
+
// current mode (or '*' for always-on). Walks ALL loaded plugins,
|
|
611
|
+
// not just the active-mode plugin — a plugin that contributes
|
|
612
|
+
// an always-on prompt without contributing a mode is a valid
|
|
613
|
+
// shape (e.g. a future "always remind the agent of these
|
|
614
|
+
// project conventions" plugin).
|
|
615
|
+
for (const p of plugins) {
|
|
616
|
+
for (const add of p.systemPromptAdditions ?? []) {
|
|
617
|
+
// Default scope: if the plugin has a mode, the prompt is
|
|
618
|
+
// gated to that mode; if it doesn't have a mode, the prompt
|
|
619
|
+
// is always-on (treated as if activeInModes was '*').
|
|
620
|
+
const scope = add.activeInModes ?? (p.mode ? [p.mode.id] : ['*']);
|
|
621
|
+
const inScope = scope.includes('*') ||
|
|
622
|
+
(currentModeId !== null && scope.includes(currentModeId));
|
|
623
|
+
if (inScope) {
|
|
624
|
+
appendSystemPrompt = `${appendSystemPrompt}\n\n${add.text}`;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
334
628
|
// Snapshot the agent id so a switch-agent message during the run
|
|
335
629
|
// can't smear two agents across one invocation. (We also gate
|
|
336
630
|
// switch-agent on `busy`, but defense in depth.)
|
|
@@ -343,6 +637,25 @@ export async function startService(opts) {
|
|
|
343
637
|
// a soft one gets nothing and relies on its descriptor's built-in
|
|
344
638
|
// sandbox flags + developer_instructions.
|
|
345
639
|
const isHardSandbox = invokedDescriptor?.sandboxStrength === 'hard';
|
|
640
|
+
// Active mode's plugin-contributed MCP server ids — added to the
|
|
641
|
+
// hard-sandbox allow list so Claude can actually call them. Claude
|
|
642
|
+
// sanitises non-alphanumeric chars in the id when forming tool
|
|
643
|
+
// names (e.g. "@hover-dev/security:flows" → "mcp__hover_dev_security_flows"),
|
|
644
|
+
// and `--allowedTools mcp__foo` matches every tool under that
|
|
645
|
+
// prefix. We pass the prefix `mcp__<sanitized>` so all of the
|
|
646
|
+
// server's tools are reachable.
|
|
647
|
+
const sanitize = (s) => s.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
648
|
+
const activePluginMcpIds = [];
|
|
649
|
+
if (currentModeId) {
|
|
650
|
+
for (const p of plugins) {
|
|
651
|
+
for (const srv of p.mcpServers ?? []) {
|
|
652
|
+
const scope = srv.activeInModes ?? (p.mode ? [p.mode.id] : []);
|
|
653
|
+
if (scope.includes('*') || scope.includes(currentModeId)) {
|
|
654
|
+
activePluginMcpIds.push(`mcp__${sanitize(srv.id)}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
346
659
|
for await (const ev of invokeAgent({
|
|
347
660
|
agentId: invokedAgentId,
|
|
348
661
|
prompt: text,
|
|
@@ -354,27 +667,15 @@ export async function startService(opts) {
|
|
|
354
667
|
appendSystemPrompt,
|
|
355
668
|
// Skill stays in the allow list so saved skills under
|
|
356
669
|
// <devRoot>/.claude/skills/ can be invoked. mcp__playwright covers
|
|
357
|
-
// every browser tool.
|
|
358
|
-
|
|
670
|
+
// every browser tool. Plugin-contributed MCPs are appended when
|
|
671
|
+
// the corresponding mode is active.
|
|
672
|
+
allowedTools: isHardSandbox
|
|
673
|
+
? ['mcp__playwright', 'Skill', ...activePluginMcpIds]
|
|
674
|
+
: undefined,
|
|
359
675
|
disallowedTools: isHardSandbox
|
|
360
|
-
?
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
'Edit', 'MultiEdit', 'Write', 'Read', 'NotebookEdit',
|
|
364
|
-
'Grep', 'Glob', 'Task', 'TodoWrite',
|
|
365
|
-
'WebFetch', 'WebSearch',
|
|
366
|
-
// plan / worktree / cron / notification — irrelevant in -p mode
|
|
367
|
-
'EnterPlanMode', 'ExitPlanMode',
|
|
368
|
-
'EnterWorktree', 'ExitWorktree',
|
|
369
|
-
'CronCreate', 'CronDelete', 'CronList',
|
|
370
|
-
'PushNotification', 'RemoteTrigger',
|
|
371
|
-
// task & tool introspection added in claude 2.1.x — let through and
|
|
372
|
-
// the agent will burn turns exploring instead of executing
|
|
373
|
-
'ToolSearch',
|
|
374
|
-
'Monitor', 'TaskOutput', 'TaskStop',
|
|
375
|
-
'AskUserQuestion',
|
|
376
|
-
'ShareOnboardingGuide',
|
|
377
|
-
]
|
|
676
|
+
? (invokedDescriptor?.defaultDisallowedTools
|
|
677
|
+
? [...invokedDescriptor.defaultDisallowedTools]
|
|
678
|
+
: undefined)
|
|
378
679
|
: undefined,
|
|
379
680
|
maxBudgetUsd,
|
|
380
681
|
model,
|
|
@@ -386,20 +687,32 @@ export async function startService(opts) {
|
|
|
386
687
|
}
|
|
387
688
|
}
|
|
388
689
|
catch (err) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if (
|
|
396
|
-
|
|
690
|
+
// A user-initiated cancel() already sent a synthetic session_end
|
|
691
|
+
// {cancelled:true}. The subsequent AbortError surfacing here would
|
|
692
|
+
// otherwise produce a second session_end{isError:true}, leaving the
|
|
693
|
+
// widget to reconcile two terminal events for one run. CDP isn't
|
|
694
|
+
// suspect either — the user just stopped — so skip preflight
|
|
695
|
+
// invalidation too.
|
|
696
|
+
if (!cancelled) {
|
|
697
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
698
|
+
const errorEvent = {
|
|
699
|
+
kind: 'session_end',
|
|
700
|
+
isError: true,
|
|
701
|
+
summary: message,
|
|
702
|
+
};
|
|
703
|
+
sendIfOpen(ws, { type: 'event', payload: errorEvent });
|
|
704
|
+
// Force the next command to re-probe CDP. The error could be from
|
|
705
|
+
// Chrome dying, MCP spawning a stray Chromium, the user closing
|
|
706
|
+
// their debug window — anything that would make a cached "all
|
|
707
|
+
// healthy" result lie. Invalidate the mode-effective URL (see
|
|
708
|
+
// preflightCdpUrl above) — not the static cdpUrl — so security
|
|
709
|
+
// mode invalidations don't no-op against the default port.
|
|
710
|
+
const invalExtras = effectiveLaunchExtras();
|
|
711
|
+
const invalCdpUrl = invalExtras?.cdpPort
|
|
712
|
+
? `http://localhost:${invalExtras.cdpPort}`
|
|
713
|
+
: cdpUrl;
|
|
714
|
+
invalidatePreflight(invalCdpUrl);
|
|
397
715
|
}
|
|
398
|
-
// Force the next command to re-probe CDP. The error could be from
|
|
399
|
-
// Chrome dying, MCP spawning a stray Chromium, the user closing
|
|
400
|
-
// their debug window — anything that would make a cached "all
|
|
401
|
-
// healthy" result lie.
|
|
402
|
-
invalidatePreflight(cdpUrl);
|
|
403
716
|
}
|
|
404
717
|
finally {
|
|
405
718
|
busy = false;
|
|
@@ -409,8 +722,34 @@ export async function startService(opts) {
|
|
|
409
722
|
});
|
|
410
723
|
return {
|
|
411
724
|
port,
|
|
412
|
-
close
|
|
413
|
-
|
|
414
|
-
|
|
725
|
+
async close() {
|
|
726
|
+
// Deactivate the active mode first, then run every plugin's
|
|
727
|
+
// shutdown hook (regardless of which mode is active — a plugin may
|
|
728
|
+
// own background state even outside its mode). Best-effort: log
|
|
729
|
+
// and continue on individual failures so one buggy plugin doesn't
|
|
730
|
+
// strand the others' sidecars.
|
|
731
|
+
if (currentModeId) {
|
|
732
|
+
try {
|
|
733
|
+
await switchMode(null);
|
|
734
|
+
}
|
|
735
|
+
catch (err) {
|
|
736
|
+
process.stderr.write(`[hover] error deactivating mode during shutdown: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
for (const p of plugins) {
|
|
740
|
+
const hook = p.hooks?.['hover:service:shutdown'];
|
|
741
|
+
if (!hook)
|
|
742
|
+
continue;
|
|
743
|
+
try {
|
|
744
|
+
await hook({ devRoot, broadcast: broadcastPluginEvent });
|
|
745
|
+
}
|
|
746
|
+
catch (err) {
|
|
747
|
+
process.stderr.write(`[hover] plugin "${p.name}" shutdown failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
await new Promise((res, rej) => {
|
|
751
|
+
wss.close(err => (err ? rej(err) : res()));
|
|
752
|
+
});
|
|
753
|
+
},
|
|
415
754
|
};
|
|
416
755
|
}
|