@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.
- 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/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/types.d.ts +3 -0
- package/dist/service/types.d.ts.map +1 -1
- package/dist/service.d.ts +7 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +283 -11
- package/package.json +5 -1
|
@@ -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;
|
|
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;
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/service/types.d.ts
CHANGED
|
@@ -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;
|
|
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
|
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":"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
|
-
//
|
|
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
|
+
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
|
-
|
|
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
|
-
|
|
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
|
|
413
|
-
|
|
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.
|
|
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": [
|