@hover-dev/core 0.5.0 → 0.7.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.
@@ -9,6 +9,18 @@ export interface LaunchOptions {
9
9
  readyTimeoutMs?: number;
10
10
  /** Poll interval while waiting (default 300ms). */
11
11
  pollMs?: number;
12
+ /** Security-mode proxy config. When provided, Chrome is launched with
13
+ * `--proxy-server=127.0.0.1:<proxyPort>` and the given SPKI pinned via
14
+ * `--ignore-certificate-errors-spki-list` so HTTPS certs from the
15
+ * Hover MITM CA validate without polluting the OS trust store. The
16
+ * caller is expected to pick a different `userDataDir` and `port` from
17
+ * the normal-mode launch so the two profiles don't share state. */
18
+ proxy?: {
19
+ /** Local mockttp port the proxy is listening on. */
20
+ port: number;
21
+ /** Base64 SHA-256 of the MITM CA's SubjectPublicKeyInfo. */
22
+ spki: string;
23
+ };
12
24
  }
13
25
  export type LaunchResult = {
14
26
  ok: true;
@@ -1 +1 @@
1
- {"version":3,"file":"launchChrome.d.ts","sourceRoot":"","sources":["../../src/playwright/launchChrome.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,aAAa;IAC5B,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,cAAc,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACxE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAMlC,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,IAAI,CA0ChD;AAiCD;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CAwDvF"}
1
+ {"version":3,"file":"launchChrome.d.ts","sourceRoot":"","sources":["../../src/playwright/launchChrome.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,aAAa;IAC5B,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;wEAKoE;IACpE,KAAK,CAAC,EAAE;QACN,oDAAoD;QACpD,IAAI,EAAE,MAAM,CAAC;QACb,4DAA4D;QAC5D,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;CACH;AAED,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,cAAc,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACxE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAMlC,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,IAAI,CA0ChD;AAiCD;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CA8DvF"}
@@ -111,8 +111,11 @@ export async function launchDebugChrome(opts = {}) {
111
111
  `--user-data-dir=${userDataDir}`,
112
112
  '--no-first-run',
113
113
  '--no-default-browser-check',
114
- url,
115
114
  ];
115
+ if (opts.proxy) {
116
+ args.push(`--proxy-server=127.0.0.1:${opts.proxy.port}`, `--ignore-certificate-errors-spki-list=${opts.proxy.spki}`);
117
+ }
118
+ args.push(url);
116
119
  const child = spawn(chrome, args, {
117
120
  detached: true,
118
121
  stdio: 'ignore',
@@ -18,10 +18,27 @@
18
18
  * which lets multiple Hover services (one per example app) coexist
19
19
  * without stepping on each other's CDP endpoint.
20
20
  */
21
+ export interface ExtraMcpServer {
22
+ /** Stable id of the server. Becomes the JSON key under mcpServers; also
23
+ * the prefix Claude exposes its tools under (`mcp__<id>__<tool>`). */
24
+ id: string;
25
+ command: string;
26
+ args?: string[];
27
+ env?: Record<string, string>;
28
+ }
21
29
  export declare function resolveMcpConfig(opts: {
22
30
  /** CDP URL passed to the MCP server's `--cdp-endpoint` flag. */
23
31
  cdpUrl: string;
24
32
  /** Service port — used to namespace the temp config file. */
25
33
  port: number;
34
+ /** Additional MCP servers contributed by active plugins. Each becomes
35
+ * a key under the mcpServers object. The id is also used to name the
36
+ * tool prefix Claude exposes (e.g. `mcp__hover_security__list_flows`),
37
+ * but Claude sanitises non-alphanumeric chars to underscores, so the
38
+ * caller does NOT need to do that. */
39
+ extra?: ExtraMcpServer[];
40
+ /** Suffix for the output filename so multiple parallel configs from
41
+ * the same service (e.g. mode toggle round-trips) don't share state. */
42
+ suffix?: string;
26
43
  }): string;
27
44
  //# sourceMappingURL=resolveMcpConfig.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"resolveMcpConfig.d.ts","sourceRoot":"","sources":["../../src/playwright/resolveMcpConfig.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE;IACrC,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,MAAM,CAsCT"}
1
+ {"version":3,"file":"resolveMcpConfig.d.ts","sourceRoot":"","sources":["../../src/playwright/resolveMcpConfig.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,cAAc;IAC7B;2EACuE;IACvE,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9B;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE;IACrC,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAC;IACb;;;;2CAIuC;IACvC,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC;IACzB;6EACyE;IACzE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,MAAM,CAgDT"}
@@ -3,26 +3,6 @@ import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import process from 'node:process';
6
- /**
7
- * Resolve a ready-to-use MCP config file path that points at the local
8
- * `@playwright/mcp` package via an absolute Node-resolved path.
9
- *
10
- * Why this exists: Hover originally shipped a static `mcp.config.json`
11
- * with `"command": "npx", "args": ["-y", "@playwright/mcp@latest", …]`.
12
- * That meant every `claude -p` invocation kicked off a registry lookup
13
- * for `@latest` plus a tarball metadata round-trip before the MCP server
14
- * even started — adding 300 ms - 2 s of dead air to first-token latency
15
- * on every command (verified via `time npx -y @playwright/mcp@latest`).
16
- *
17
- * The fix is to (a) declare `@playwright/mcp` as a real dependency of
18
- * `@hover-dev/core` so npm resolves it locally at install time, and
19
- * (b) write a synthetic config file pointing `node <abs-path>/cli.js`
20
- * at the resolved location. No registry hit on the hot path.
21
- *
22
- * The config file is written to `<tmpdir>/hover/mcp-config-<port>.json`,
23
- * which lets multiple Hover services (one per example app) coexist
24
- * without stepping on each other's CDP endpoint.
25
- */
26
6
  export function resolveMcpConfig(opts) {
27
7
  // Resolve the package's main file, then walk back to its package root.
28
8
  // Using `package.json` as the resolution target is the documented
@@ -46,17 +26,27 @@ export function resolveMcpConfig(opts) {
46
26
  // pin to that file directly via Node so the user doesn't need the
47
27
  // bin shim on PATH and we skip yet another resolution layer.
48
28
  const cliPath = resolve(pkgRoot, 'cli.js');
49
- const config = {
50
- mcpServers: {
51
- playwright: {
52
- command: process.execPath, // current Node binary
53
- args: [cliPath, '--cdp-endpoint', opts.cdpUrl],
54
- },
29
+ const mcpServers = {
30
+ playwright: {
31
+ command: process.execPath, // current Node binary
32
+ args: [cliPath, '--cdp-endpoint', opts.cdpUrl],
55
33
  },
56
34
  };
35
+ for (const extra of opts.extra ?? []) {
36
+ // Claude sanitises the key for tool naming; we keep the raw id here
37
+ // because mcp-config consumers (claude / codex) accept arbitrary
38
+ // strings and do their own normalisation.
39
+ mcpServers[extra.id] = {
40
+ command: extra.command,
41
+ args: extra.args,
42
+ env: extra.env,
43
+ };
44
+ }
45
+ const config = { mcpServers };
57
46
  const outDir = resolve(tmpdir(), 'hover');
58
47
  mkdirSync(outDir, { recursive: true });
59
- const outPath = resolve(outDir, `mcp-config-${opts.port}.json`);
48
+ const suffix = opts.suffix ? `-${opts.suffix}` : '';
49
+ const outPath = resolve(outDir, `mcp-config-${opts.port}${suffix}.json`);
60
50
  writeFileSync(outPath, JSON.stringify(config, null, 2), 'utf-8');
61
51
  return outPath;
62
52
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Hover plugin API — the public contract third-party packages target.
3
+ *
4
+ * Plugins are *mostly declarative*: they ship a manifest describing what
5
+ * resources they contribute (a mode, MCP servers, Chrome flags, agent
6
+ * prompt fragments, widget event schemas). For genuinely time-bound work
7
+ * — booting a sidecar like mockttp when a mode activates, tearing it down
8
+ * when the mode deactivates or the service shuts down — they register
9
+ * namespaced lifecycle hooks.
10
+ *
11
+ * Patterned after Astro Integrations (declarative manifest + namespaced
12
+ * hooks: `astro:config:setup` etc). The `apiVersion` literal lets us
13
+ * evolve the manifest and reject mismatched plugins at load time with a
14
+ * clear error rather than silent breakage.
15
+ *
16
+ * Stability:
17
+ * - `apiVersion: 1` is what this file declares; breaking changes bump.
18
+ * - Adding new optional fields or new hook names is non-breaking.
19
+ * - Plugin authors should import only from this module; deep imports
20
+ * into `@hover-dev/core` internals are not part of the contract.
21
+ */
22
+ /**
23
+ * The Hover plugin API version this build of @hover-dev/core understands.
24
+ * Plugins declare which version they target via their manifest; mismatches
25
+ * are rejected at load time.
26
+ */
27
+ export type HoverApiVersion = 1;
28
+ export declare const CURRENT_API_VERSION: HoverApiVersion;
29
+ export interface HoverPluginMode {
30
+ /** Globally unique id (across all loaded plugins). Lowercase kebab. */
31
+ id: string;
32
+ /** Human-readable label shown in the widget mode-picker. */
33
+ label: string;
34
+ /** One-liner help text shown in the dropdown. */
35
+ description?: string;
36
+ /** Mode ids this mode cannot be active alongside. Two plugins both
37
+ * needing an exclusive proxy would set each other here. */
38
+ conflictsWith?: string[];
39
+ }
40
+ export interface HoverPluginMcpServer {
41
+ /** Stable, namespaced id (`@hover-dev/security:flows`). Host enforces
42
+ * uniqueness across all loaded plugins. */
43
+ id: string;
44
+ command: string;
45
+ args?: string[];
46
+ env?: Record<string, string>;
47
+ /** Modes in which this MCP is exposed to the agent. Default: only the
48
+ * plugin's own mode. Use `['*']` to mean "always on". */
49
+ activeInModes?: string[];
50
+ }
51
+ export interface HoverPluginChromeFlags {
52
+ /** Extra args appended to the Chrome launch argv. */
53
+ args?: string[];
54
+ /** Custom user-data-dir for this mode. Strongly recommended when proxy
55
+ * is set, so the secured profile doesn't share cookies with normal mode. */
56
+ userDataDir?: string;
57
+ /** Custom CDP port for this mode. Strongly recommended for the same
58
+ * reason — keeps the two modes' Chromes addressable independently. */
59
+ cdpPort?: number;
60
+ /** When present, Chrome is launched with --proxy-server + the
61
+ * --ignore-certificate-errors-spki-list pin so the proxy's MITM CA is
62
+ * accepted without polluting the OS trust store. */
63
+ proxy?: {
64
+ port: number;
65
+ spki: string;
66
+ };
67
+ /** Modes in which these flags apply. Default: only the plugin's own mode. */
68
+ activeInModes?: string[];
69
+ }
70
+ export interface HoverPluginSystemPromptAddition {
71
+ text: string;
72
+ /** Modes in which this paragraph is included in the agent's system
73
+ * prompt. Default: only the plugin's own mode. */
74
+ activeInModes?: string[];
75
+ }
76
+ export interface HoverBroadcast {
77
+ /** Push a JSON event to every WebSocket-connected widget. Event `type`
78
+ * should be namespaced by the plugin (`security:flow:added`). */
79
+ (event: {
80
+ type: string;
81
+ payload?: unknown;
82
+ }): void;
83
+ }
84
+ export interface HoverHookCtxBase {
85
+ /** Absolute path of the user's project root (Vite's `server.config.root`,
86
+ * Astro's project dir, etc.). Use this for persisting CA material, not
87
+ * process.cwd(). */
88
+ devRoot: string;
89
+ /** Push a custom event to every connected widget. */
90
+ broadcast: HoverBroadcast;
91
+ }
92
+ /** Fired when this plugin's mode becomes active. The plugin may boot
93
+ * sidecars (mockttp, profilers, …) here and return any settings that
94
+ * affect downstream subsystems (Chrome relaunch, MCP server env vars). */
95
+ export interface ModeActivateCtx extends HoverHookCtxBase {
96
+ modeId: string;
97
+ /** Tell the host "Chrome should be relaunched with these proxy settings"
98
+ * for the duration of this mode. Pass null to clear. */
99
+ setChromeProxy(proxy: {
100
+ port: number;
101
+ spki: string;
102
+ } | null): void;
103
+ /** Set additional env vars on one of this plugin's declared MCP servers.
104
+ * The MCP server isn't actually spawned until the agent runs a command,
105
+ * so plugins use this in activate() to pass runtime data (port numbers,
106
+ * auth tokens) that didn't exist at manifest-construction time.
107
+ * Merged on top of any env declared in the manifest; subsequent calls
108
+ * for the same id replace previous overrides. */
109
+ setMcpServerEnv(id: string, env: Record<string, string>): void;
110
+ }
111
+ /** Fired when this plugin's mode is being deactivated. The plugin
112
+ * MUST stop any sidecar it started in activate. */
113
+ export interface ModeDeactivateCtx extends HoverHookCtxBase {
114
+ modeId: string;
115
+ }
116
+ /** Fired exactly once when the host service is shutting down for any
117
+ * reason. Hooks must release subprocesses and file handles. */
118
+ export type ShutdownCtx = HoverHookCtxBase;
119
+ export interface HoverHooks {
120
+ 'hover:mode:activate'?: (ctx: ModeActivateCtx) => void | Promise<void>;
121
+ 'hover:mode:deactivate'?: (ctx: ModeDeactivateCtx) => void | Promise<void>;
122
+ 'hover:service:shutdown'?: (ctx: ShutdownCtx) => void | Promise<void>;
123
+ }
124
+ export interface HoverPluginManifest {
125
+ /** Always 1 in this build. Future versions may add 2, 3, … */
126
+ apiVersion: HoverApiVersion;
127
+ /** Globally unique plugin name. Use the npm package name. */
128
+ name: string;
129
+ /** Optional widget mode contributed by this plugin. */
130
+ mode?: HoverPluginMode;
131
+ /** Extra MCP servers exposed to the agent in the indicated modes. */
132
+ mcpServers?: HoverPluginMcpServer[];
133
+ /** Chrome launch overrides for the indicated modes. */
134
+ chromeFlags?: HoverPluginChromeFlags;
135
+ /** System-prompt paragraphs concatenated into the agent's prompt in
136
+ * the indicated modes. */
137
+ systemPromptAdditions?: HoverPluginSystemPromptAddition[];
138
+ /** Names of custom event types this plugin broadcasts. Documented
139
+ * here so the widget side can be tree-shaken to skip handlers for
140
+ * events that no loaded plugin will ever produce. */
141
+ widgetEventTypes?: string[];
142
+ hooks?: HoverHooks;
143
+ }
144
+ /**
145
+ * Branded factory that wraps a plugin manifest factory. The wrapper
146
+ * - asserts `apiVersion` matches this core's version at construction time
147
+ * (catches authors who copy-pasted from a tutorial for a different core),
148
+ * - returns a `(opts) => manifest` so call sites read `securityMode()` /
149
+ * `perfMode({ sampleHz: 100 })` uniformly.
150
+ *
151
+ * Use:
152
+ *
153
+ * export default defineHoverPlugin<MyOpts>((opts) => ({
154
+ * apiVersion: 1,
155
+ * name: '@hover-dev/security',
156
+ * mode: { id: 'security', label: 'Security testing' },
157
+ * ...
158
+ * }));
159
+ */
160
+ export declare function defineHoverPlugin<TOpts = void>(factory: (opts: TOpts) => HoverPluginManifest): (opts: TOpts) => HoverPluginManifest;
161
+ //# sourceMappingURL=plugin-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-api.d.ts","sourceRoot":"","sources":["../src/plugin-api.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Hover plugin API — the public contract third-party packages target.
3
+ *
4
+ * Plugins are *mostly declarative*: they ship a manifest describing what
5
+ * resources they contribute (a mode, MCP servers, Chrome flags, agent
6
+ * prompt fragments, widget event schemas). For genuinely time-bound work
7
+ * — booting a sidecar like mockttp when a mode activates, tearing it down
8
+ * when the mode deactivates or the service shuts down — they register
9
+ * namespaced lifecycle hooks.
10
+ *
11
+ * Patterned after Astro Integrations (declarative manifest + namespaced
12
+ * hooks: `astro:config:setup` etc). The `apiVersion` literal lets us
13
+ * evolve the manifest and reject mismatched plugins at load time with a
14
+ * clear error rather than silent breakage.
15
+ *
16
+ * Stability:
17
+ * - `apiVersion: 1` is what this file declares; breaking changes bump.
18
+ * - Adding new optional fields or new hook names is non-breaking.
19
+ * - Plugin authors should import only from this module; deep imports
20
+ * into `@hover-dev/core` internals are not part of the contract.
21
+ */
22
+ export const CURRENT_API_VERSION = 1;
23
+ // ──────────────────────────────────────────────────────────────────────
24
+ // Author helper
25
+ // ──────────────────────────────────────────────────────────────────────
26
+ /**
27
+ * Branded factory that wraps a plugin manifest factory. The wrapper
28
+ * - asserts `apiVersion` matches this core's version at construction time
29
+ * (catches authors who copy-pasted from a tutorial for a different core),
30
+ * - returns a `(opts) => manifest` so call sites read `securityMode()` /
31
+ * `perfMode({ sampleHz: 100 })` uniformly.
32
+ *
33
+ * Use:
34
+ *
35
+ * export default defineHoverPlugin<MyOpts>((opts) => ({
36
+ * apiVersion: 1,
37
+ * name: '@hover-dev/security',
38
+ * mode: { id: 'security', label: 'Security testing' },
39
+ * ...
40
+ * }));
41
+ */
42
+ export function defineHoverPlugin(factory) {
43
+ return (opts) => {
44
+ const manifest = factory(opts);
45
+ if (manifest.apiVersion !== CURRENT_API_VERSION) {
46
+ throw new Error(`[hover] plugin "${manifest.name}" targets apiVersion ` +
47
+ `${String(manifest.apiVersion)} but this Hover supports ` +
48
+ `${CURRENT_API_VERSION}. Update either the plugin or @hover-dev/core.`);
49
+ }
50
+ return manifest;
51
+ };
52
+ }
@@ -32,6 +32,9 @@ 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: {
@@ -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,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;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"}
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":"AAoEA,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,CAknB/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';
@@ -46,6 +52,7 @@ import { send } 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,168 @@ 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
+ return resolveMcpConfig({
171
+ cdpUrl,
172
+ port,
173
+ extra,
174
+ // Suffix the filename by the mode so different mode toggles within
175
+ // one service produce distinct config files (debugging aid).
176
+ suffix: currentModeId ?? undefined,
177
+ });
178
+ };
130
179
  // Surface post-listen errors instead of crashing the host process.
131
180
  wss.on('error', err => {
132
181
  process.stderr.write(`[hover] WebSocketServer error: ${err.message}\n`);
133
182
  });
183
+ // ──────────────────────────────────────────────────────────────────
184
+ // Plugin registry
185
+ // ──────────────────────────────────────────────────────────────────
186
+ // Validate + index plugins once at startup. Reasons we fail loud here
187
+ // (rather than at first use): mode-id collisions are a configuration
188
+ // bug, not a runtime one — the widget mode-picker would silently miss
189
+ // entries, which is worse than a startup error the user has to fix.
190
+ const plugins = opts.plugins ?? [];
191
+ const pluginsByName = new Map();
192
+ const pluginsByModeId = new Map();
193
+ for (const p of plugins) {
194
+ if (p.apiVersion !== CURRENT_API_VERSION) {
195
+ throw new Error(`[hover] plugin "${p.name}" targets apiVersion ${String(p.apiVersion)} but this Hover supports ${CURRENT_API_VERSION}.`);
196
+ }
197
+ if (pluginsByName.has(p.name)) {
198
+ throw new Error(`[hover] duplicate plugin name: ${p.name}`);
199
+ }
200
+ pluginsByName.set(p.name, p);
201
+ if (p.mode) {
202
+ if (pluginsByModeId.has(p.mode.id)) {
203
+ throw new Error(`[hover] two plugins contribute the same mode id "${p.mode.id}": ` +
204
+ `${pluginsByModeId.get(p.mode.id)?.name} and ${p.name}`);
205
+ }
206
+ pluginsByModeId.set(p.mode.id, p);
207
+ }
208
+ }
209
+ /** id of the currently-active mode, or null for normal (unmoded) mode. */
210
+ let currentModeId = null;
211
+ /** Chrome-proxy settings the active mode's activate hook set on us.
212
+ * Surfaced to launchDebugChrome calls when the widget asks us to
213
+ * launch a debug Chrome. */
214
+ let modeChromeProxy = null;
215
+ /** Runtime env overrides keyed by mcpServer id, set by plugin
216
+ * activate hooks (via ctx.setMcpServerEnv). Cleared on mode change.
217
+ * Merged with the manifest-declared env when the agent's spawn-time
218
+ * MCP config is built. */
219
+ const mcpEnvOverrides = new Map();
220
+ /** Send the current mode catalogue to one ws (or all if undefined). */
221
+ const broadcastModes = (target) => {
222
+ const available = plugins
223
+ .filter((p) => Boolean(p.mode))
224
+ .map((p) => ({
225
+ id: p.mode.id,
226
+ label: p.mode.label,
227
+ description: p.mode.description,
228
+ pluginName: p.name,
229
+ }));
230
+ const payload = { current: currentModeId, available };
231
+ const targets = target ? [target] : [...wss.clients];
232
+ for (const client of targets) {
233
+ if (client.readyState === WebSocket.OPEN) {
234
+ send(client, { type: 'modes', payload });
235
+ }
236
+ }
237
+ };
238
+ /** Broadcast helper passed to plugin hooks. Plugin-side events should
239
+ * be namespaced ("security:flow:added") to avoid collisions with
240
+ * core's protocol vocabulary. */
241
+ const broadcastPluginEvent = (event) => {
242
+ for (const client of wss.clients) {
243
+ if (client.readyState === WebSocket.OPEN) {
244
+ send(client, event);
245
+ }
246
+ }
247
+ };
248
+ const switchMode = async (newModeId) => {
249
+ if (newModeId === currentModeId)
250
+ return;
251
+ // Tear down old mode
252
+ if (currentModeId) {
253
+ const old = pluginsByModeId.get(currentModeId);
254
+ if (old?.hooks?.['hover:mode:deactivate']) {
255
+ try {
256
+ await old.hooks['hover:mode:deactivate']({
257
+ devRoot,
258
+ broadcast: broadcastPluginEvent,
259
+ modeId: currentModeId,
260
+ });
261
+ }
262
+ catch (err) {
263
+ process.stderr.write(`[hover] plugin "${old.name}" deactivate failed: ${err instanceof Error ? err.message : String(err)}\n`);
264
+ }
265
+ }
266
+ }
267
+ modeChromeProxy = null;
268
+ mcpEnvOverrides.clear();
269
+ currentModeId = null;
270
+ // Bring up new mode
271
+ if (newModeId) {
272
+ const next = pluginsByModeId.get(newModeId);
273
+ if (!next) {
274
+ throw new Error(`[hover] unknown modeId "${newModeId}"`);
275
+ }
276
+ currentModeId = newModeId;
277
+ if (next.hooks?.['hover:mode:activate']) {
278
+ const ctx = {
279
+ devRoot,
280
+ broadcast: broadcastPluginEvent,
281
+ modeId: newModeId,
282
+ setChromeProxy(proxy) {
283
+ modeChromeProxy = proxy;
284
+ },
285
+ setMcpServerEnv(id, env) {
286
+ mcpEnvOverrides.set(id, env);
287
+ },
288
+ };
289
+ await next.hooks['hover:mode:activate'](ctx);
290
+ }
291
+ }
292
+ broadcastModes();
293
+ };
134
294
  // Cache the agent-availability list. The PATH scan is cheap (one `which`
135
295
  // per registered agent) but we still don't want to re-run it on every
136
296
  // hello; a single Vite dev server typically sees the widget connect and
@@ -165,6 +325,9 @@ export async function startService(opts) {
165
325
  void getAvailability(false).then(available => {
166
326
  send(ws, { type: 'agents', payload: { current: currentAgentId, available } });
167
327
  });
328
+ // Send the mode catalogue too, so the widget can render the mode
329
+ // toggle immediately. Empty list when no plugins are loaded.
330
+ broadcastModes(ws);
168
331
  let busy = false;
169
332
  let inflight = null;
170
333
  let cancelled = false;
@@ -210,6 +373,46 @@ export async function startService(opts) {
210
373
  cancel();
211
374
  return;
212
375
  }
376
+ if (msg.type === 'list-modes') {
377
+ broadcastModes(ws);
378
+ return;
379
+ }
380
+ if (msg.type === 'set-mode') {
381
+ if (busy) {
382
+ send(ws, {
383
+ type: 'error',
384
+ payload: { message: 'set-mode: a command is already running; stop it first' },
385
+ });
386
+ return;
387
+ }
388
+ const wanted = msg.payload?.modeId ?? null;
389
+ if (wanted !== null && typeof wanted !== 'string') {
390
+ send(ws, {
391
+ type: 'error',
392
+ payload: { message: 'set-mode: modeId must be a string or null' },
393
+ });
394
+ return;
395
+ }
396
+ if (wanted !== null && !pluginsByModeId.has(wanted)) {
397
+ send(ws, {
398
+ type: 'error',
399
+ payload: { message: `set-mode: unknown modeId "${wanted}"` },
400
+ });
401
+ return;
402
+ }
403
+ try {
404
+ await switchMode(wanted);
405
+ }
406
+ catch (err) {
407
+ send(ws, {
408
+ type: 'error',
409
+ payload: {
410
+ message: `set-mode failed: ${err instanceof Error ? err.message : String(err)}`,
411
+ },
412
+ });
413
+ }
414
+ return;
415
+ }
213
416
  if (msg.type === 'list-agents') {
214
417
  // Force a refresh — the user may have just installed a new CLI
215
418
  // and clicked the dropdown to see the change.
@@ -300,6 +503,11 @@ export async function startService(opts) {
300
503
  cancelled = false;
301
504
  inflight = new AbortController();
302
505
  try {
506
+ // Build the MCP config first — it's pure local file IO and lets
507
+ // us assert plugin-contributed servers landed in the config even
508
+ // when CDP preflight subsequently fails (useful for smoke tests
509
+ // that don't have a real debug Chrome wired up).
510
+ const mcpConfig = buildMcpConfig();
303
511
  // Preflight: refuse to invoke if CDP isn't reachable. Otherwise the
304
512
  // Playwright MCP server would silently launch its own Chromium —
305
513
  // and Hover's premise is to drive the user's existing Chrome (with
@@ -328,9 +536,25 @@ export async function startService(opts) {
328
536
  // re-sending them fragments Anthropic's prompt-cache fingerprint
329
537
  // (cache hits require byte-identical system prompts across turns).
330
538
  // See cdpHint.ts for the why.
331
- const appendSystemPrompt = resumeSessionId
539
+ let appendSystemPrompt = resumeSessionId
332
540
  ? buildCdpHintResume(cdp.tabs)
333
541
  : buildCdpHint(cdp.tabs);
542
+ // Add the active mode's plugin prompt additions, if any. We only
543
+ // include additions whose `activeInModes` is the current mode or
544
+ // '*' (always-on); plugins that contribute prompts but no mode
545
+ // are treated as mode-scoped to their own plugin's mode by
546
+ // default (handled by the empty activeInModes case below).
547
+ const activePlugin = currentModeId ? pluginsByModeId.get(currentModeId) : null;
548
+ if (activePlugin?.systemPromptAdditions) {
549
+ for (const add of activePlugin.systemPromptAdditions) {
550
+ const inMode = !add.activeInModes ||
551
+ add.activeInModes.includes('*') ||
552
+ (currentModeId !== null && add.activeInModes.includes(currentModeId));
553
+ if (inMode) {
554
+ appendSystemPrompt = `${appendSystemPrompt}\n\n${add.text}`;
555
+ }
556
+ }
557
+ }
334
558
  // Snapshot the agent id so a switch-agent message during the run
335
559
  // can't smear two agents across one invocation. (We also gate
336
560
  // switch-agent on `busy`, but defense in depth.)
@@ -343,6 +567,25 @@ export async function startService(opts) {
343
567
  // a soft one gets nothing and relies on its descriptor's built-in
344
568
  // sandbox flags + developer_instructions.
345
569
  const isHardSandbox = invokedDescriptor?.sandboxStrength === 'hard';
570
+ // Active mode's plugin-contributed MCP server ids — added to the
571
+ // hard-sandbox allow list so Claude can actually call them. Claude
572
+ // sanitises non-alphanumeric chars in the id when forming tool
573
+ // names (e.g. "@hover-dev/security:flows" → "mcp__hover_dev_security_flows"),
574
+ // and `--allowedTools mcp__foo` matches every tool under that
575
+ // prefix. We pass the prefix `mcp__<sanitized>` so all of the
576
+ // server's tools are reachable.
577
+ const sanitize = (s) => s.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
578
+ const activePluginMcpIds = [];
579
+ if (currentModeId) {
580
+ for (const p of plugins) {
581
+ for (const srv of p.mcpServers ?? []) {
582
+ const scope = srv.activeInModes ?? (p.mode ? [p.mode.id] : []);
583
+ if (scope.includes('*') || scope.includes(currentModeId)) {
584
+ activePluginMcpIds.push(`mcp__${sanitize(srv.id)}`);
585
+ }
586
+ }
587
+ }
588
+ }
346
589
  for await (const ev of invokeAgent({
347
590
  agentId: invokedAgentId,
348
591
  prompt: text,
@@ -354,8 +597,11 @@ export async function startService(opts) {
354
597
  appendSystemPrompt,
355
598
  // Skill stays in the allow list so saved skills under
356
599
  // <devRoot>/.claude/skills/ can be invoked. mcp__playwright covers
357
- // every browser tool.
358
- allowedTools: isHardSandbox ? ['mcp__playwright', 'Skill'] : undefined,
600
+ // every browser tool. Plugin-contributed MCPs are appended when
601
+ // the corresponding mode is active.
602
+ allowedTools: isHardSandbox
603
+ ? ['mcp__playwright', 'Skill', ...activePluginMcpIds]
604
+ : undefined,
359
605
  disallowedTools: isHardSandbox
360
606
  ? [
361
607
  // file / shell / data access — never appropriate for browser driving
@@ -409,8 +655,34 @@ export async function startService(opts) {
409
655
  });
410
656
  return {
411
657
  port,
412
- close: () => new Promise((res, rej) => {
413
- wss.close(err => (err ? rej(err) : res()));
414
- }),
658
+ async close() {
659
+ // Deactivate the active mode first, then run every plugin's
660
+ // shutdown hook (regardless of which mode is active — a plugin may
661
+ // own background state even outside its mode). Best-effort: log
662
+ // and continue on individual failures so one buggy plugin doesn't
663
+ // strand the others' sidecars.
664
+ if (currentModeId) {
665
+ try {
666
+ await switchMode(null);
667
+ }
668
+ catch (err) {
669
+ process.stderr.write(`[hover] error deactivating mode during shutdown: ${err instanceof Error ? err.message : String(err)}\n`);
670
+ }
671
+ }
672
+ for (const p of plugins) {
673
+ const hook = p.hooks?.['hover:service:shutdown'];
674
+ if (!hook)
675
+ continue;
676
+ try {
677
+ await hook({ devRoot, broadcast: broadcastPluginEvent });
678
+ }
679
+ catch (err) {
680
+ process.stderr.write(`[hover] plugin "${p.name}" shutdown failed: ${err instanceof Error ? err.message : String(err)}\n`);
681
+ }
682
+ }
683
+ await new Promise((res, rej) => {
684
+ wss.close(err => (err ? rej(err) : res()));
685
+ });
686
+ },
415
687
  };
416
688
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hover-dev/core",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Hover's local Node service: agent invocation, Playwright CDP preflight, WebSocket bridge.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hyperyond",
@@ -38,6 +38,10 @@
38
38
  "./launch-chrome": {
39
39
  "types": "./dist/playwright/launchChrome.d.ts",
40
40
  "import": "./dist/playwright/launchChrome.js"
41
+ },
42
+ "./plugin-api": {
43
+ "types": "./dist/plugin-api.d.ts",
44
+ "import": "./dist/plugin-api.js"
41
45
  }
42
46
  },
43
47
  "files": [