@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.
@@ -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
- const result = await cfg.write({
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 = {
@@ -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 type { WebSocket } from 'ws';
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,KAAK,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AACpC,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;KAClB,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"}
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"}
@@ -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
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AA+DA,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;CAClB;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,CAiV/E"}
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
- // Resolve a CDP-pinned MCP config pointing at our local
126
- // `@playwright/mcp` install. See resolveMcpConfig.ts for the rationale
127
- // (avoids `npx -y @playwright/mcp@latest`'s registry round-trip on
128
- // every command 300 ms - 2 s of hot-path latency).
129
- const mcpConfig = opts.mcpConfig ?? resolveMcpConfig({ cdpUrl, port });
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
- void getAvailability(false).then(available => {
166
- send(ws, { type: 'agents', payload: { current: currentAgentId, available } });
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
- const cdp = await getPreflight(cdpUrl);
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
- const appendSystemPrompt = resumeSessionId
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
- allowedTools: isHardSandbox ? ['mcp__playwright', 'Skill'] : undefined,
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
- // file / shell / data access — never appropriate for browser driving
362
- 'Bash', 'BashOutput', 'KillBash',
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
- const message = err instanceof Error ? err.message : String(err);
390
- const errorEvent = {
391
- kind: 'session_end',
392
- isError: true,
393
- summary: message,
394
- };
395
- if (ws.readyState === WebSocket.OPEN) {
396
- send(ws, { type: 'event', payload: errorEvent });
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: () => new Promise((res, rej) => {
413
- wss.close(err => (err ? rej(err) : res()));
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
  }