@hover-dev/core 0.16.0 → 0.18.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.
Files changed (181) hide show
  1. package/README.md +26 -55
  2. package/dist/agentDirectives.d.ts +55 -0
  3. package/dist/agentDirectives.d.ts.map +1 -0
  4. package/dist/agentDirectives.js +276 -0
  5. package/dist/engine.d.ts +28 -0
  6. package/dist/engine.d.ts.map +1 -0
  7. package/dist/engine.js +27 -0
  8. package/dist/memory/businessMemory.d.ts +29 -0
  9. package/dist/memory/businessMemory.d.ts.map +1 -0
  10. package/dist/memory/businessMemory.js +125 -0
  11. package/dist/playwright/launchChrome.d.ts +18 -0
  12. package/dist/playwright/launchChrome.d.ts.map +1 -1
  13. package/dist/playwright/launchChrome.js +46 -3
  14. package/dist/qa/candidates.d.ts +32 -0
  15. package/dist/qa/candidates.d.ts.map +1 -0
  16. package/dist/qa/candidates.js +20 -0
  17. package/dist/qa/intensity.d.ts +33 -0
  18. package/dist/qa/intensity.d.ts.map +1 -0
  19. package/dist/qa/intensity.js +25 -0
  20. package/dist/qa/qaReport.d.ts +19 -0
  21. package/dist/qa/qaReport.d.ts.map +1 -0
  22. package/dist/qa/qaReport.js +50 -0
  23. package/dist/sessions/sessions.d.ts +125 -0
  24. package/dist/sessions/sessions.d.ts.map +1 -0
  25. package/dist/sessions/sessions.js +175 -0
  26. package/dist/specs/authFixture.d.ts +30 -0
  27. package/dist/specs/authFixture.d.ts.map +1 -0
  28. package/dist/specs/authFixture.js +145 -0
  29. package/dist/specs/detectSharedFlows.d.ts +1 -1
  30. package/dist/specs/detectSharedFlows.d.ts.map +1 -1
  31. package/dist/specs/detectSharedFlows.js +20 -21
  32. package/dist/specs/generatePageObject.d.ts +1 -1
  33. package/dist/specs/generatePageObject.d.ts.map +1 -1
  34. package/dist/specs/healPrompt.d.ts +19 -0
  35. package/dist/specs/healPrompt.d.ts.map +1 -0
  36. package/dist/specs/healPrompt.js +48 -0
  37. package/dist/specs/humanSteps.d.ts +4 -8
  38. package/dist/specs/humanSteps.d.ts.map +1 -1
  39. package/dist/specs/humanSteps.js +6 -1
  40. package/dist/specs/optimizeSpec.d.ts +15 -8
  41. package/dist/specs/optimizeSpec.d.ts.map +1 -1
  42. package/dist/specs/optimizeSpec.js +71 -41
  43. package/dist/specs/pageObjectManifest.d.ts +3 -1
  44. package/dist/specs/pageObjectManifest.d.ts.map +1 -1
  45. package/dist/specs/pageObjectManifest.js +24 -19
  46. package/dist/specs/replayGrounded.d.ts +45 -0
  47. package/dist/specs/replayGrounded.d.ts.map +1 -0
  48. package/dist/specs/replayGrounded.js +155 -0
  49. package/dist/specs/runFailures.d.ts +34 -0
  50. package/dist/specs/runFailures.d.ts.map +1 -0
  51. package/dist/specs/runFailures.js +93 -0
  52. package/dist/specs/seeds.d.ts +16 -15
  53. package/dist/specs/seeds.d.ts.map +1 -1
  54. package/dist/specs/seeds.js +86 -54
  55. package/dist/specs/sidecar.d.ts +34 -6
  56. package/dist/specs/sidecar.d.ts.map +1 -1
  57. package/dist/specs/sidecar.js +79 -9
  58. package/dist/specs/specStep.d.ts +21 -0
  59. package/dist/specs/specStep.d.ts.map +1 -0
  60. package/dist/specs/specStep.js +1 -0
  61. package/dist/specs/text.d.ts +8 -6
  62. package/dist/specs/text.d.ts.map +1 -1
  63. package/dist/specs/text.js +10 -7
  64. package/dist/specs/writeSpec.d.ts +62 -1
  65. package/dist/specs/writeSpec.d.ts.map +1 -1
  66. package/dist/specs/writeSpec.js +596 -21
  67. package/package.json +9 -29
  68. package/dist/agents/aider.d.ts +0 -16
  69. package/dist/agents/aider.d.ts.map +0 -1
  70. package/dist/agents/aider.js +0 -161
  71. package/dist/agents/argv.d.ts +0 -11
  72. package/dist/agents/argv.d.ts.map +0 -1
  73. package/dist/agents/argv.js +0 -23
  74. package/dist/agents/claude.d.ts +0 -3
  75. package/dist/agents/claude.d.ts.map +0 -1
  76. package/dist/agents/claude.js +0 -195
  77. package/dist/agents/codex.d.ts +0 -19
  78. package/dist/agents/codex.d.ts.map +0 -1
  79. package/dist/agents/codex.js +0 -216
  80. package/dist/agents/cursor.d.ts +0 -18
  81. package/dist/agents/cursor.d.ts.map +0 -1
  82. package/dist/agents/cursor.js +0 -220
  83. package/dist/agents/detect.d.ts +0 -46
  84. package/dist/agents/detect.d.ts.map +0 -1
  85. package/dist/agents/detect.js +0 -80
  86. package/dist/agents/gemini.d.ts +0 -17
  87. package/dist/agents/gemini.d.ts.map +0 -1
  88. package/dist/agents/gemini.js +0 -186
  89. package/dist/agents/index.d.ts +0 -6
  90. package/dist/agents/index.d.ts.map +0 -1
  91. package/dist/agents/index.js +0 -5
  92. package/dist/agents/invoke.d.ts +0 -12
  93. package/dist/agents/invoke.d.ts.map +0 -1
  94. package/dist/agents/invoke.js +0 -96
  95. package/dist/agents/qwen.d.ts +0 -17
  96. package/dist/agents/qwen.d.ts.map +0 -1
  97. package/dist/agents/qwen.js +0 -172
  98. package/dist/agents/registry.d.ts +0 -19
  99. package/dist/agents/registry.d.ts.map +0 -1
  100. package/dist/agents/registry.js +0 -34
  101. package/dist/agents/shared.d.ts +0 -28
  102. package/dist/agents/shared.d.ts.map +0 -1
  103. package/dist/agents/shared.js +0 -35
  104. package/dist/agents/types.d.ts +0 -186
  105. package/dist/agents/types.d.ts.map +0 -1
  106. package/dist/agents/types.js +0 -23
  107. package/dist/index.d.ts +0 -3
  108. package/dist/index.d.ts.map +0 -1
  109. package/dist/index.js +0 -2
  110. package/dist/mcp/sourceFence.d.ts +0 -23
  111. package/dist/mcp/sourceFence.d.ts.map +0 -1
  112. package/dist/mcp/sourceFence.js +0 -75
  113. package/dist/mcp/sourceServer.d.ts +0 -3
  114. package/dist/mcp/sourceServer.d.ts.map +0 -1
  115. package/dist/mcp/sourceServer.js +0 -116
  116. package/dist/playwright/cdpStatus.d.ts +0 -29
  117. package/dist/playwright/cdpStatus.d.ts.map +0 -1
  118. package/dist/playwright/cdpStatus.js +0 -119
  119. package/dist/playwright/preflight.d.ts +0 -31
  120. package/dist/playwright/preflight.d.ts.map +0 -1
  121. package/dist/playwright/preflight.js +0 -82
  122. package/dist/playwright/preflightCache.d.ts +0 -27
  123. package/dist/playwright/preflightCache.d.ts.map +0 -1
  124. package/dist/playwright/preflightCache.js +0 -21
  125. package/dist/playwright/raiseWindow.d.ts +0 -10
  126. package/dist/playwright/raiseWindow.d.ts.map +0 -1
  127. package/dist/playwright/raiseWindow.js +0 -158
  128. package/dist/playwright/resolveMcpConfig.d.ts +0 -55
  129. package/dist/playwright/resolveMcpConfig.d.ts.map +0 -1
  130. package/dist/playwright/resolveMcpConfig.js +0 -66
  131. package/dist/plugin-api.d.ts +0 -235
  132. package/dist/plugin-api.d.ts.map +0 -1
  133. package/dist/plugin-api.js +0 -52
  134. package/dist/runSession.d.ts +0 -42
  135. package/dist/runSession.d.ts.map +0 -1
  136. package/dist/runSession.js +0 -81
  137. package/dist/scripts/bench-multi-tab.d.ts +0 -2
  138. package/dist/scripts/bench-multi-tab.d.ts.map +0 -1
  139. package/dist/scripts/bench-multi-tab.js +0 -192
  140. package/dist/scripts/bench-ttfb.d.ts +0 -2
  141. package/dist/scripts/bench-ttfb.d.ts.map +0 -1
  142. package/dist/scripts/bench-ttfb.js +0 -127
  143. package/dist/scripts/start-chrome.d.ts +0 -3
  144. package/dist/scripts/start-chrome.d.ts.map +0 -1
  145. package/dist/scripts/start-chrome.js +0 -23
  146. package/dist/service/cdpHandlers.d.ts +0 -44
  147. package/dist/service/cdpHandlers.d.ts.map +0 -1
  148. package/dist/service/cdpHandlers.js +0 -85
  149. package/dist/service/cdpHint.d.ts +0 -48
  150. package/dist/service/cdpHint.d.ts.map +0 -1
  151. package/dist/service/cdpHint.js +0 -216
  152. package/dist/service/conventions.d.ts +0 -8
  153. package/dist/service/conventions.d.ts.map +0 -1
  154. package/dist/service/conventions.js +0 -42
  155. package/dist/service/saveHandlers.d.ts +0 -52
  156. package/dist/service/saveHandlers.d.ts.map +0 -1
  157. package/dist/service/saveHandlers.js +0 -75
  158. package/dist/service/types.d.ts +0 -58
  159. package/dist/service/types.d.ts.map +0 -1
  160. package/dist/service/types.js +0 -26
  161. package/dist/service.d.ts +0 -50
  162. package/dist/service.d.ts.map +0 -1
  163. package/dist/service.js +0 -1065
  164. package/dist/skills/writeSkill.d.ts +0 -27
  165. package/dist/skills/writeSkill.d.ts.map +0 -1
  166. package/dist/skills/writeSkill.js +0 -13
  167. package/dist/specs/extractPageObjects.d.ts +0 -18
  168. package/dist/specs/extractPageObjects.d.ts.map +0 -1
  169. package/dist/specs/extractPageObjects.js +0 -98
  170. package/dist/specs/listSpecs.d.ts +0 -52
  171. package/dist/specs/listSpecs.d.ts.map +0 -1
  172. package/dist/specs/listSpecs.js +0 -139
  173. package/dist/specs/optimizationSuggestion.d.ts +0 -26
  174. package/dist/specs/optimizationSuggestion.d.ts.map +0 -1
  175. package/dist/specs/optimizationSuggestion.js +0 -28
  176. package/dist/specs/optimizeSpecWithAgent.d.ts +0 -11
  177. package/dist/specs/optimizeSpecWithAgent.d.ts.map +0 -1
  178. package/dist/specs/optimizeSpecWithAgent.js +0 -40
  179. package/dist/specs/writeCaseCsv.d.ts +0 -28
  180. package/dist/specs/writeCaseCsv.d.ts.map +0 -1
  181. package/dist/specs/writeCaseCsv.js +0 -134
package/dist/service.js DELETED
@@ -1,1065 +0,0 @@
1
- /**
2
- * Local Hover WebSocket service.
3
- *
4
- * One process per Vite dev server. Started by vite-plugin-hover's
5
- * configureServer hook, torn down on closeBundle. Binds to 127.0.0.1 only.
6
- *
7
- * Wire protocol (newline-free JSON over WebSocket):
8
- *
9
- * server → client
10
- * { type: 'hello', payload: { agentId, model, version } }
11
- * { type: 'event', payload: InvokeEvent } // see agents/types.ts
12
- * { type: 'cdp-status', payload: { state, reason?, matchingTabUrl?, browser?, launching? } }
13
- * { type: 'specs-list', payload: { specs: SpecSummary[] } }
14
- * { type: 'seeds-list', payload: { seeds: { name, note, signature, code, source }[] } }
15
- * { type: 'spec-saved', payload: { name, path } }
16
- * { type: 'spec-exists', payload: { slug, existingPath } }
17
- * { type: 'case-csv-saved', payload: { name, path } }
18
- * { type: 'case-csv-exists', payload: { slug, existingPath } }
19
- * { type: 'error', payload: { message } }
20
- *
21
- * client → server
22
- * { type: 'command', payload: { text, sessionId?, reRecord?: { slug } } }
23
- * // when reRecord.slug is set, the
24
- * // service collects tool_use events
25
- * // into a step list and on a clean
26
- * // session_end overwrites
27
- * // __vibe_tests__/<slug>.spec.ts
28
- * { type: 'cancel' }
29
- * { type: 'check-cdp', payload: { pageUrl } } // "is this widget in the debug Chrome?"
30
- * { type: 'launch-chrome', payload: { pageUrl } } // start debug Chrome, navigate to pageUrl
31
- * { type: 'focus-debug', payload: { pageUrl } } // bringToFront the matching tab in debug Chrome
32
- * { type: 'save-spec', payload: { name, description, steps, assertions?, overwrite? } }
33
- * { type: 'save-case-csv', payload: { name, description, steps, assertions?, jiraProjectKey?, labels?, overwrite? } }
34
- * { type: 'list-specs' } // ask for every spec under __vibe_tests__/, with parsed JSDoc headers
35
- * { type: 'list-seeds' } // ask for built-in + .hover/rules/ translation seeds (read-only)
36
- * { type: 'list-agents' } // ask for the full agent registry + install status
37
- * { type: 'switch-agent', payload: { agentId } } // set the service's current agent; broadcasts to all connections
38
- *
39
- * server → client (in addition to those documented in the file body):
40
- * { type: 'agents', payload: { current: string, available: AgentAvailability[] } }
41
- * { type: 'modes', payload: { current: string|null, available: ModeEntry[] } }
42
- * { type: '<plugin-namespaced>', payload: <plugin-specific> }
43
- *
44
- * client → server (plugin-aware additions):
45
- * { type: 'set-mode', payload: { modeId: string|null } } // null = exit moded operation
46
- * { type: 'list-modes' }
47
- */
48
- import { WebSocketServer, WebSocket } from 'ws';
49
- import { fileURLToPath } from 'node:url';
50
- import { dirname, resolve } from 'node:path';
51
- import { runSession } from './runSession.js';
52
- import { readConventions } from './service/conventions.js';
53
- import { optimizeSpecWithAgent } from './specs/optimizeSpecWithAgent.js';
54
- import { promoteOptimized, discardOptimized } from './specs/optimizeSpec.js';
55
- import { listAgentAvailability, pickPrimaryAgent, } from './agents/detect.js';
56
- import { getAgent } from './agents/registry.js';
57
- import { getPreflight, invalidatePreflight } from './playwright/preflightCache.js';
58
- import { resolveMcpConfig, mcpToolPrefix } from './playwright/resolveMcpConfig.js';
59
- import { launchDebugChrome } from './playwright/launchChrome.js';
60
- import { listSpecs } from './specs/listSpecs.js';
61
- import { readSeeds, BUILTIN_SEEDS } from './specs/seeds.js';
62
- import { send, sendIfOpen } from './service/types.js';
63
- import { buildCdpHint, buildCdpHintResume } from './service/cdpHint.js';
64
- import { handleCheckCdp, handleLaunchChrome, handleFocusDebug, } from './service/cdpHandlers.js';
65
- import { handleSaveArtifact, SPEC_CONFIG, CASE_CSV_CONFIG, } from './service/saveHandlers.js';
66
- import { CURRENT_API_VERSION, } from './plugin-api.js';
67
- /** The source-reader MCP server (codeContext). Id → the `mcp__hover_source`
68
- * tool prefix; script path resolved relative to this module so it works from
69
- * dist/. Spawned only when codeContext is enabled. */
70
- const SOURCE_MCP_ID = 'hover-source';
71
- const SOURCE_MCP_SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), 'mcp', 'sourceServer.js');
72
- // ClientMessage + send moved to ./service/types.ts so the cdp + save
73
- // handler modules can share them. See those files for the wire shape.
74
- const PROTOCOL_VERSION = 1;
75
- const PORT_RETRIES = 10;
76
- /** CJK-presence test — mirrors voice.js's detectLanguage. Any Han character
77
- * in the prompt flips the agent's prose output to Chinese. */
78
- const CJK_RE = /[一-鿿]/;
79
- /** Appended to the agent's system prompt when the user's prompt contains CJK,
80
- * so the human-facing prose (verification summary / ## Findings / step
81
- * narration) comes back in Chinese — matching how Voice mode picks a Chinese
82
- * TTS voice for the same prompt. Deliberately scoped to PROSE only: the agent
83
- * must still use the page's real (often English) accessible names, labels,
84
- * and selectors when driving the browser. */
85
- const ZH_OUTPUT_DIRECTIVE = '用户使用中文下达指令。请用简体中文撰写所有面向用户的文字输出:验证结论摘要、' +
86
- '`## Findings` 区块(bug / 问题 / 备注)、以及每一步的中文描述。' +
87
- '注意:这只影响你写给用户看的文字。操作浏览器时仍要使用页面真实的(通常是英文的)' +
88
- '角色名、标签、可访问名称和选择器——不要把它们翻译成中文。';
89
- /**
90
- * Try to bind a WebSocketServer to <host>:<port>. Resolves with the wss on
91
- * success; rejects with the bind error (typically EADDRINUSE) on failure.
92
- */
93
- function bind(host, port) {
94
- return new Promise((resolve, reject) => {
95
- const wss = new WebSocketServer({ host, port });
96
- const onError = (err) => {
97
- wss.off('listening', onListening);
98
- reject(err);
99
- };
100
- const onListening = () => {
101
- wss.off('error', onError);
102
- resolve(wss);
103
- };
104
- wss.once('error', onError);
105
- wss.once('listening', onListening);
106
- });
107
- }
108
- /**
109
- * Find a free port in [start, start+attempts) and bind a WebSocketServer to
110
- * it. Each example app that loads vite-plugin-hover runs its own service —
111
- * with auto-bump, multiple Vite dev servers can coexist (basic-app on 51789,
112
- * stock-registration on 51790, etc.) and each widget connects only to its
113
- * own service. The widget reads the actual port from window.__HOVER_PORT__.
114
- */
115
- async function pickAndBind(host, start, attempts) {
116
- let lastErr = null;
117
- for (let i = 0; i < attempts; i++) {
118
- try {
119
- return await bind(host, start + i);
120
- }
121
- catch (err) {
122
- lastErr = err;
123
- if (err.code !== 'EADDRINUSE')
124
- throw err;
125
- }
126
- }
127
- throw new Error(`[hover] no free port in [${start}, ${start + attempts}): ${lastErr?.message ?? ''}`);
128
- }
129
- export async function startService(opts) {
130
- const requestedPort = opts.port;
131
- // Resolve the primary agent. Honor an explicit opts.agentId (or HOVER_AGENT
132
- // env var) when set AND installed; otherwise fall back to whichever
133
- // registered agent the user actually has on PATH, in registry order. This
134
- // is what lets a user with only codex installed open a Hover dev server
135
- // without needing to set HOVER_AGENT=codex.
136
- const preferred = opts.agentId ?? process.env.HOVER_AGENT;
137
- const primary = await pickPrimaryAgent(preferred);
138
- let currentAgentId = primary?.descriptor.id ?? preferred ?? 'claude';
139
- // Optional model API key the widget supplied (set-api-key). Held in memory
140
- // for this service's lifetime only — never written to disk, never logged.
141
- // Injected into the spawned CLI's env so a user without a logged-in
142
- // subscription can drive Hover on their own key.
143
- let currentApiKey = process.env.ANTHROPIC_API_KEY ?? process.env.OPENAI_API_KEY ?? undefined;
144
- if (!primary) {
145
- // Nothing installed — still bind so the widget can show a helpful
146
- // "install one of these" dialog. Commands will fail with
147
- // AgentNotInstalledError at invoke time.
148
- process.stderr.write(`[hover] no supported agent CLI found on PATH (looked for: ` +
149
- `${(await listAgentAvailability()).map(a => a.id).join(', ')}). ` +
150
- `The widget will open but commands will fail until you install one.\n`);
151
- }
152
- else if (preferred && preferred !== primary.descriptor.id) {
153
- process.stderr.write(`[hover] requested agent "${preferred}" is not installed; falling back to "${primary.descriptor.id}".\n`);
154
- }
155
- const model = opts.model ?? 'sonnet';
156
- // No default budget cap — long real-world flows (form filling, multi-step
157
- // checkouts) routinely run past the old $0.50 ceiling and got cut off
158
- // mid-run. The widget shows the running $ counter in the header instead,
159
- // so the user can hit Stop when they've seen enough. Pass maxBudgetUsd
160
- // explicitly (or via the Vite plugin option) if a hard ceiling is needed.
161
- const maxBudgetUsd = opts.maxBudgetUsd;
162
- const optimizeMode = opts.optimizeMode ?? 'suggest';
163
- const cdpUrl = opts.cdpUrl ?? 'http://localhost:9222';
164
- const devRoot = opts.devRoot ?? process.cwd();
165
- const wss = await pickAndBind('127.0.0.1', requestedPort, PORT_RETRIES);
166
- const port = wss.address().port;
167
- // Build a fresh MCP config per command, so the currently-active mode's
168
- // contributed servers (plus runtime env from setMcpServerEnv) land in
169
- // the file the agent reads. `opts.mcpConfig` still wins if the host
170
- // forced an explicit one, but in that case mode-contributed servers
171
- // are silently dropped — we log a warning the first time it happens.
172
- let warnedExplicitMcpOverride = false;
173
- const buildMcpConfig = () => {
174
- if (opts.mcpConfig) {
175
- const activePlugin = currentModeId ? pluginsByModeId.get(currentModeId) : null;
176
- if (activePlugin?.mcpServers?.length && !warnedExplicitMcpOverride) {
177
- process.stderr.write(`[hover] explicit opts.mcpConfig overrides plugin-contributed MCP servers ` +
178
- `(plugin "${activePlugin.name}" wanted ${activePlugin.mcpServers
179
- .map((s) => s.id)
180
- .join(', ')}).\n`);
181
- warnedExplicitMcpOverride = true;
182
- }
183
- return opts.mcpConfig;
184
- }
185
- const extra = [];
186
- if (currentModeId) {
187
- for (const p of plugins) {
188
- for (const srv of p.mcpServers ?? []) {
189
- const scope = srv.activeInModes ?? (p.mode ? [p.mode.id] : []);
190
- const inMode = scope.includes('*') || scope.includes(currentModeId);
191
- if (!inMode)
192
- continue;
193
- extra.push({
194
- id: srv.id,
195
- command: srv.command,
196
- args: srv.args,
197
- env: {
198
- ...(srv.env ?? {}),
199
- ...(mcpEnvOverrides.get(srv.id) ?? {}),
200
- },
201
- });
202
- }
203
- }
204
- }
205
- // codeContext (opt-in, all modes): the fenced read-only source reader.
206
- if (opts.codeContext) {
207
- extra.push({
208
- id: SOURCE_MCP_ID,
209
- command: process.execPath,
210
- args: [SOURCE_MCP_SCRIPT],
211
- env: { HOVER_PROJECT_ROOT: devRoot },
212
- });
213
- }
214
- // Single-Chrome model: the Playwright MCP always points at the one debug
215
- // Chrome on the normal cdpUrl. (Pre-single-Chrome this branched to a
216
- // mode-specific port like 9333; there's no second Chrome anymore.)
217
- return resolveMcpConfig({
218
- cdpUrl,
219
- port,
220
- extra,
221
- // Suffix the filename by the mode so different mode toggles within
222
- // one service produce distinct config files (debugging aid).
223
- suffix: currentModeId ?? undefined,
224
- });
225
- };
226
- // Surface post-listen errors instead of crashing the host process.
227
- wss.on('error', err => {
228
- process.stderr.write(`[hover] WebSocketServer error: ${err.message}\n`);
229
- });
230
- // ──────────────────────────────────────────────────────────────────
231
- // Plugin registry
232
- // ──────────────────────────────────────────────────────────────────
233
- // Validate + index plugins once at startup. Reasons we fail loud here
234
- // (rather than at first use): mode-id collisions are a configuration
235
- // bug, not a runtime one — the widget mode-picker would silently miss
236
- // entries, which is worse than a startup error the user has to fix.
237
- const plugins = opts.plugins ?? [];
238
- const pluginsByName = new Map();
239
- const pluginsByModeId = new Map();
240
- for (const p of plugins) {
241
- if (p.apiVersion !== CURRENT_API_VERSION) {
242
- throw new Error(`[hover] plugin "${p.name}" targets apiVersion ${String(p.apiVersion)} but this Hover supports ${CURRENT_API_VERSION}.`);
243
- }
244
- if (pluginsByName.has(p.name)) {
245
- throw new Error(`[hover] duplicate plugin name: ${p.name}`);
246
- }
247
- pluginsByName.set(p.name, p);
248
- if (p.mode) {
249
- if (pluginsByModeId.has(p.mode.id)) {
250
- throw new Error(`[hover] two plugins contribute the same mode id "${p.mode.id}": ` +
251
- `${pluginsByModeId.get(p.mode.id)?.name} and ${p.name}`);
252
- }
253
- pluginsByModeId.set(p.mode.id, p);
254
- }
255
- }
256
- /** id of the currently-active mode, or null for normal (unmoded) mode. */
257
- let currentModeId = null;
258
- /**
259
- * The single in-flight agent run, held at SERVICE scope (not per-connection)
260
- * so it SURVIVES the widget's WS dropping. The widget lives in the page the
261
- * agent drives, so any agent navigation (a pentest payload in the URL, an
262
- * HMR reload) tears the widget down and closes its socket — but the agent is
263
- * still happily driving the tab over CDP and recording findings server-side.
264
- * Killing it on every navigation made pentest mode (which navigates
265
- * constantly) unusable. Instead: detach on close, keep streaming to whichever
266
- * ws is attached, and only abort if no widget reconnects within the grace
267
- * window. Single active run — Hover binds 127.0.0.1 for one local user.
268
- */
269
- const RECONNECT_GRACE_MS = 15_000;
270
- let activeRun = null;
271
- /** Send a run event to whichever ws is currently attached (survives reconnect). */
272
- const emitToRun = (msg) => {
273
- const c = activeRun?.client;
274
- if (c && c.readyState === WebSocket.OPEN)
275
- send(c, msg);
276
- };
277
- /** Chrome-proxy settings a plugin's `hover:service:start` hook set on us
278
- * (security's resident MITM). RESIDENT for the whole session — set once
279
- * before Chrome launches, never cleared on mode change — so the single
280
- * debug Chrome is born with `--proxy-server` + the SPKI pin and entering
281
- * Security mode is just a runtime flip of the proxy, not a Chrome relaunch.
282
- * Read by `effectiveLaunchExtras()` and threaded into every cdp handler
283
- * (check-cdp / launch-chrome / focus-debug) plus the initial auto-launch. */
284
- let residentChromeProxy = null;
285
- /** Runtime env overrides keyed by mcpServer id, set by plugin
286
- * activate hooks (via ctx.setMcpServerEnv). Cleared on mode change.
287
- * Merged with the manifest-declared env when the agent's spawn-time
288
- * MCP config is built. */
289
- const mcpEnvOverrides = new Map();
290
- /** The cdp-handler extras (proxy) threaded into launch-chrome / check-cdp /
291
- * focus-debug and the initial auto-launch. In the single-Chrome model this
292
- * is driven purely by the RESIDENT proxy (set in `hover:service:start`),
293
- * NOT by the active mode — there is one Chrome on the normal CDP port that
294
- * is always proxied; entering Security mode flips the proxy's behaviour,
295
- * it does not relaunch Chrome on a different port. Returns undefined when
296
- * no plugin set a resident proxy (the common no-security case), so plain
297
- * Hover is byte-for-byte unchanged. */
298
- const effectiveLaunchExtras = () => {
299
- if (!residentChromeProxy)
300
- return undefined;
301
- return { proxy: residentChromeProxy };
302
- };
303
- /** Send the current mode catalogue to one ws (or all if undefined). */
304
- const broadcastModes = (target) => {
305
- const available = plugins
306
- .filter((p) => Boolean(p.mode))
307
- .map((p) => ({
308
- id: p.mode.id,
309
- label: p.mode.label,
310
- description: p.mode.description,
311
- // Widget retints to this while the mode is engaged (falls back to
312
- // security orange in the widget when absent).
313
- accent: p.mode.accent,
314
- pluginName: p.name,
315
- }));
316
- const payload = { current: currentModeId, available };
317
- const targets = target ? [target] : [...wss.clients];
318
- for (const client of targets) {
319
- if (client.readyState === WebSocket.OPEN) {
320
- send(client, { type: 'modes', payload });
321
- }
322
- }
323
- };
324
- /** Broadcast helper passed to plugin hooks. Plugin-side events should
325
- * be namespaced ("security:flow:added") to avoid collisions with
326
- * core's protocol vocabulary. */
327
- const broadcastPluginEvent = (event) => {
328
- for (const client of wss.clients) {
329
- if (client.readyState === WebSocket.OPEN) {
330
- send(client, event);
331
- }
332
- }
333
- };
334
- const switchMode = async (newModeId) => {
335
- if (newModeId === currentModeId)
336
- return;
337
- // Tear down old mode
338
- if (currentModeId) {
339
- const old = pluginsByModeId.get(currentModeId);
340
- if (old?.hooks?.['hover:mode:deactivate']) {
341
- try {
342
- await old.hooks['hover:mode:deactivate']({
343
- devRoot,
344
- broadcast: broadcastPluginEvent,
345
- modeId: currentModeId,
346
- });
347
- }
348
- catch (err) {
349
- process.stderr.write(`[hover] plugin "${old.name}" deactivate failed: ${err instanceof Error ? err.message : String(err)}\n`);
350
- }
351
- }
352
- }
353
- // NOTE: neither residentChromeProxy NOR mcpEnvOverrides is cleared here.
354
- // In the single-Chrome model both are RESIDENT — set once in
355
- // service:start (e.g. security's HOVER_SECURITY_API base + token), they
356
- // must survive every mode toggle so the agent's spawned MCP server can
357
- // always reach the control plane. Clearing them on mode change was the
358
- // pre-resident behaviour and would leave the security MCP server with no
359
- // env → it exits with "failed". Mode changes now only flip plugin runtime
360
- // state via the plugin's own activate/deactivate hooks.
361
- currentModeId = null;
362
- // Bring up new mode
363
- if (newModeId) {
364
- const next = pluginsByModeId.get(newModeId);
365
- if (!next) {
366
- throw new Error(`[hover] unknown modeId "${newModeId}"`);
367
- }
368
- currentModeId = newModeId;
369
- if (next.hooks?.['hover:mode:activate']) {
370
- const ctx = {
371
- devRoot,
372
- broadcast: broadcastPluginEvent,
373
- modeId: newModeId,
374
- setChromeProxy(proxy) {
375
- // Retained for API compatibility. In the single-Chrome model the
376
- // proxy is normally set once in service:start; if an activate hook
377
- // still calls this, treat it as updating the resident proxy.
378
- residentChromeProxy = proxy;
379
- },
380
- setMcpServerEnv(id, env) {
381
- mcpEnvOverrides.set(id, env);
382
- },
383
- };
384
- try {
385
- await next.hooks['hover:mode:activate'](ctx);
386
- }
387
- catch (err) {
388
- // Activate failed half-way — roll back state so we don't
389
- // pretend to be in `newModeId` with no sidecars running.
390
- // Widget still trusts the broadcast below to learn we're back
391
- // to default. The error is rethrown so the caller can surface
392
- // it to the user. residentChromeProxy and mcpEnvOverrides are NOT
393
- // touched — both are owned by service:start, independent of mode
394
- // activation (clearing the env would break the resident security
395
- // MCP server).
396
- currentModeId = null;
397
- broadcastModes();
398
- throw err;
399
- }
400
- }
401
- }
402
- broadcastModes();
403
- };
404
- // Cache the agent-availability list. The PATH scan is cheap (one `which`
405
- // per registered agent) but we still don't want to re-run it on every
406
- // hello; a single Vite dev server typically sees the widget connect and
407
- // reconnect dozens of times during HMR.
408
- let agentAvailabilityCache = null;
409
- const getAvailability = async (refresh) => {
410
- if (refresh || agentAvailabilityCache === null) {
411
- agentAvailabilityCache = await listAgentAvailability();
412
- }
413
- return agentAvailabilityCache;
414
- };
415
- // The CDP preflight cache (shared between this service's command path
416
- // and the widget's `check-cdp` ping via `cdpStatus.checkCdpStatus`)
417
- // lives in ./playwright/preflightCache.ts. 30-second TTL, keyed by
418
- // cdpUrl. See that file for the rationale.
419
- const broadcastAgents = async () => {
420
- const available = await getAvailability(false);
421
- const payload = { current: currentAgentId, available };
422
- for (const client of wss.clients) {
423
- if (client.readyState === WebSocket.OPEN) {
424
- send(client, { type: 'agents', payload });
425
- }
426
- }
427
- };
428
- wss.on('connection', ws => {
429
- send(ws, {
430
- type: 'hello',
431
- payload: { agentId: currentAgentId, model, version: PROTOCOL_VERSION, optimizeMode },
432
- });
433
- // Send the agent list as a follow-up event so the widget can render the
434
- // dropdown immediately on connect / reconnect (e.g. after HMR). The
435
- // socket may have closed between scheduling and firing, so guard the
436
- // send and catch any availability-probe rejection — otherwise it
437
- // surfaces as an unhandled rejection in strict-mode Node.
438
- void getAvailability(false)
439
- .then(available => {
440
- sendIfOpen(ws, {
441
- type: 'agents',
442
- payload: { current: currentAgentId, available },
443
- });
444
- })
445
- .catch(err => {
446
- console.warn('[hover] agents broadcast failed:', err);
447
- });
448
- // Send the mode catalogue too, so the widget can render the mode
449
- // toggle immediately. Empty list when no plugins are loaded.
450
- broadcastModes(ws);
451
- // Re-attach to a run that's still in flight (the previous widget dropped —
452
- // most commonly the agent navigated and reloaded the page the widget lives
453
- // in). Cancel the pending abort, point the run's event stream at this fresh
454
- // socket, and tell the widget so it can restore its "running" UI. Without
455
- // this the run would be killed on every agent navigation.
456
- // Only re-attach during a genuine reconnect GAP (the prior client is gone).
457
- // If a live client is still attached, this is a SECOND widget (e.g. the
458
- // user's regular tab alongside the debug-Chrome tab — both inject a widget
459
- // on the same origin and open their own socket). Seizing the stream would
460
- // silence the first widget and let the second's close abort a healthy run,
461
- // so leave a second concurrent widget in idle UI rather than hijacking.
462
- if (activeRun && activeRun.client === null) {
463
- if (activeRun.graceTimer) {
464
- clearTimeout(activeRun.graceTimer);
465
- activeRun.graceTimer = null;
466
- }
467
- activeRun.client = ws;
468
- send(ws, { type: 'run-active', payload: { prompt: activeRun.prompt } });
469
- }
470
- // If the widget's socket closes while a run it owns is in flight, DON'T
471
- // abort — the agent is still driving the tab over CDP. Detach this ws and
472
- // start a grace window; a reconnecting widget (above) cancels the abort.
473
- // Only if nobody comes back do we abort, so we still never leave an orphan.
474
- ws.on('close', () => {
475
- if (activeRun && activeRun.client === ws) {
476
- activeRun.client = null;
477
- activeRun.graceTimer = setTimeout(() => {
478
- activeRun?.abort.abort();
479
- }, RECONNECT_GRACE_MS);
480
- }
481
- });
482
- const cancel = () => {
483
- if (!activeRun)
484
- return;
485
- activeRun.cancelled = true;
486
- activeRun.abort.abort();
487
- // Send a synthetic session_end so the widget resets to idle immediately.
488
- // The for-await loop below short-circuits on `cancelled`, so no events
489
- // from the dying child will arrive after this.
490
- //
491
- // `cancelled: true` is the load-bearing field — it lets the widget
492
- // distinguish "user pressed Stop" from "agent crashed". `isError`
493
- // stays false because the agent didn't fail: the user chose to
494
- // end the run. The widget renders this as a neutral "Stopped"
495
- // state rather than a red Failed card.
496
- emitToRun({
497
- type: 'event',
498
- payload: {
499
- kind: 'session_end',
500
- isError: false,
501
- cancelled: true,
502
- summary: 'cancelled by user',
503
- },
504
- });
505
- };
506
- ws.on('message', async (data) => {
507
- let msg;
508
- try {
509
- msg = JSON.parse(data.toString());
510
- }
511
- catch {
512
- return;
513
- }
514
- if (msg.type === 'cancel') {
515
- cancel();
516
- return;
517
- }
518
- if (msg.type === 'list-modes') {
519
- broadcastModes(ws);
520
- return;
521
- }
522
- if (msg.type === 'set-mode') {
523
- if (activeRun) {
524
- send(ws, {
525
- type: 'error',
526
- payload: { message: 'set-mode: a command is already running; stop it first' },
527
- });
528
- return;
529
- }
530
- const wanted = msg.payload?.modeId ?? null;
531
- if (wanted !== null && typeof wanted !== 'string') {
532
- send(ws, {
533
- type: 'error',
534
- payload: { message: 'set-mode: modeId must be a string or null' },
535
- });
536
- return;
537
- }
538
- if (wanted !== null && !pluginsByModeId.has(wanted)) {
539
- send(ws, {
540
- type: 'error',
541
- payload: { message: `set-mode: unknown modeId "${wanted}"` },
542
- });
543
- return;
544
- }
545
- try {
546
- await switchMode(wanted);
547
- }
548
- catch (err) {
549
- send(ws, {
550
- type: 'error',
551
- payload: {
552
- message: `set-mode failed: ${err instanceof Error ? err.message : String(err)}`,
553
- },
554
- });
555
- }
556
- return;
557
- }
558
- if (msg.type === 'list-agents') {
559
- // Force a refresh — the user may have just installed a new CLI
560
- // and clicked the dropdown to see the change.
561
- const available = await getAvailability(true);
562
- send(ws, { type: 'agents', payload: { current: currentAgentId, available } });
563
- return;
564
- }
565
- if (msg.type === 'switch-agent') {
566
- const wanted = msg.payload?.agentId;
567
- if (typeof wanted !== 'string' || !wanted) {
568
- send(ws, { type: 'error', payload: { message: 'switch-agent: agentId is required' } });
569
- return;
570
- }
571
- if (!getAgent(wanted)) {
572
- send(ws, { type: 'error', payload: { message: `switch-agent: unknown agent "${wanted}"` } });
573
- return;
574
- }
575
- // Refuse to switch mid-flight; the user's running command would
576
- // otherwise outlive its own descriptor and the events it produces
577
- // would be parsed against the wrong wire format.
578
- if (activeRun) {
579
- send(ws, {
580
- type: 'error',
581
- payload: { message: 'switch-agent: a command is already running; stop it first' },
582
- });
583
- return;
584
- }
585
- const available = await getAvailability(false);
586
- const entry = available.find(a => a.id === wanted);
587
- if (!entry?.installed) {
588
- send(ws, {
589
- type: 'error',
590
- payload: {
591
- message: `switch-agent: "${wanted}" is not installed. ${entry?.installHint ? `Install: ${entry.installHint}` : ''}`.trim(),
592
- },
593
- });
594
- return;
595
- }
596
- currentAgentId = wanted;
597
- await broadcastAgents();
598
- return;
599
- }
600
- if (msg.type === 'set-api-key') {
601
- // The widget supplies (or clears) a model API key. Stored in memory
602
- // only and injected into the spawned CLI's env at invoke time — never
603
- // persisted, never logged, never echoed back. Empty/missing clears it.
604
- const key = msg.payload?.key;
605
- currentApiKey = typeof key === 'string' && key.trim() ? key.trim() : undefined;
606
- const envVar = getAgent(currentAgentId)?.apiKeyEnv;
607
- send(ws, { type: 'api-key-status', payload: { hasKey: !!currentApiKey, envVar } });
608
- return;
609
- }
610
- if (msg.type === 'list-specs') {
611
- // Widget asks for every spec under <devRoot>/__vibe_tests__/ so it
612
- // can render the Specs tab in the Saved-sessions overlay. Each
613
- // summary carries `originalPrompt` (parsed from the JSDoc header)
614
- // so the Re-record button can resubmit it as a normal command.
615
- const specs = await listSpecs(devRoot);
616
- send(ws, { type: 'specs-list', payload: { specs } });
617
- return;
618
- }
619
- if (msg.type === 'list-seeds') {
620
- // Widget's Seeds tab: show which translation seeds Hover sees — the
621
- // built-in set + whatever the user dropped in <devRoot>/.hover/rules/.
622
- // Read-only; users add seeds by hand (no download path).
623
- const builtinNames = new Set(BUILTIN_SEEDS.map(s => s.name));
624
- const seeds = (await readSeeds(devRoot)).map(s => ({
625
- name: s.name,
626
- note: s.note ?? '',
627
- signature: s.signature,
628
- code: s.example?.code ?? '',
629
- source: builtinNames.has(s.name) ? 'builtin' : 'project',
630
- }));
631
- send(ws, { type: 'seeds-list', payload: { seeds } });
632
- return;
633
- }
634
- if (msg.type === 'save-spec') {
635
- await handleSaveArtifact(ws, msg, devRoot, SPEC_CONFIG);
636
- return;
637
- }
638
- if (msg.type === 'save-case-csv') {
639
- await handleSaveArtifact(ws, msg, devRoot, CASE_CSV_CONFIG);
640
- return;
641
- }
642
- // Stage 7 (F7) widget flow: optimize a saved spec, then promote/discard
643
- // the candidate after the human reviews the diff. optimizeSpecWithAgent
644
- // spawns the codegen LLM (no browser, no MCP); the original spec is never
645
- // touched until an explicit promote.
646
- if (msg.type === 'optimize-spec') {
647
- const slug = msg.payload?.slug;
648
- if (typeof slug !== 'string' || !slug) {
649
- send(ws, { type: 'error', payload: { message: 'optimize-spec: slug is required' } });
650
- return;
651
- }
652
- try {
653
- const res = await optimizeSpecWithAgent(devRoot, slug, {
654
- agentId: currentAgentId, model, maxBudgetUsd, apiKey: currentApiKey,
655
- });
656
- send(ws, { type: 'optimize-result', payload: { slug, original: res.original, candidate: res.code } });
657
- }
658
- catch (err) {
659
- const reason = err instanceof Error ? err.message : String(err);
660
- send(ws, { type: 'optimize-failed', payload: { slug, reason } });
661
- }
662
- return;
663
- }
664
- if (msg.type === 'promote-optimized') {
665
- const slug = msg.payload?.slug;
666
- if (typeof slug !== 'string' || !slug) {
667
- send(ws, { type: 'error', payload: { message: 'promote-optimized: slug is required' } });
668
- return;
669
- }
670
- try {
671
- const path = await promoteOptimized(devRoot, slug);
672
- send(ws, { type: 'optimized-promoted', payload: { slug, path } });
673
- send(ws, { type: 'specs-list', payload: { specs: await listSpecs(devRoot) } });
674
- }
675
- catch (err) {
676
- const m = err instanceof Error ? err.message : String(err);
677
- send(ws, { type: 'error', payload: { message: `promote-optimized: ${m}` } });
678
- }
679
- return;
680
- }
681
- if (msg.type === 'discard-optimized') {
682
- const slug = msg.payload?.slug;
683
- if (typeof slug !== 'string' || !slug) {
684
- send(ws, { type: 'error', payload: { message: 'discard-optimized: slug is required' } });
685
- return;
686
- }
687
- await discardOptimized(devRoot, slug);
688
- send(ws, { type: 'optimized-discarded', payload: { slug } });
689
- return;
690
- }
691
- // v0.12 — plugin-contributed save handlers. Lookup is O(plugins),
692
- // which is fine because there's at most a handful of plugins ever
693
- // loaded. Each plugin's manifest declares `saveHandlers[].type`
694
- // as the WS message type the widget sends; we match exactly.
695
- if (typeof msg.type === 'string' && msg.type.startsWith('save:')) {
696
- for (const p of plugins) {
697
- const handler = p.saveHandlers?.find((h) => h.type === msg.type);
698
- if (!handler)
699
- continue;
700
- try {
701
- const result = await handler.handle({ devRoot, payload: msg.payload });
702
- send(ws, {
703
- type: `${msg.type}:saved`,
704
- payload: { name: result.slug, path: result.path },
705
- });
706
- }
707
- catch (err) {
708
- const m = err instanceof Error ? err.message : String(err);
709
- send(ws, {
710
- type: 'error',
711
- payload: { message: `${msg.type}: ${m}` },
712
- });
713
- }
714
- return;
715
- }
716
- // No plugin matched — surface as a normal error rather than
717
- // silently swallowing.
718
- send(ws, {
719
- type: 'error',
720
- payload: { message: `no plugin registered for save type "${msg.type}"` },
721
- });
722
- return;
723
- }
724
- if (msg.type === 'check-cdp') {
725
- await handleCheckCdp(ws, msg, cdpUrl, effectiveLaunchExtras());
726
- return;
727
- }
728
- if (msg.type === 'launch-chrome') {
729
- await handleLaunchChrome(ws, msg, cdpUrl, effectiveLaunchExtras());
730
- return;
731
- }
732
- if (msg.type === 'focus-debug') {
733
- await handleFocusDebug(ws, msg, cdpUrl, effectiveLaunchExtras());
734
- return;
735
- }
736
- if (msg.type !== 'command')
737
- return;
738
- const text = msg.payload?.text;
739
- const resumeSessionId = typeof msg.payload?.sessionId === 'string' && msg.payload.sessionId.length > 0
740
- ? msg.payload.sessionId
741
- : undefined;
742
- // Re-record mode: when the client (widget Specs tab or hover CLI)
743
- // passes `reRecord: { slug }`, runSession collects the tool_use events
744
- // into a SpecStep[] and, on a clean finish, we overwrite the existing
745
- // __vibe_tests__/<slug>.spec.ts. Same flow the widget uses for "Save as
746
- // Spec", but the spec already exists and is being regenerated for the
747
- // current UI.
748
- const reRecordSlug = msg.payload && typeof msg.payload === 'object' && 'reRecord' in msg.payload
749
- ? msg.payload.reRecord?.slug
750
- : undefined;
751
- if (typeof text !== 'string' || !text.trim())
752
- return;
753
- if (activeRun) {
754
- send(ws, {
755
- type: 'error',
756
- payload: { message: 'A command is already running.' },
757
- });
758
- return;
759
- }
760
- const run = {
761
- abort: new AbortController(),
762
- cancelled: false,
763
- client: ws,
764
- graceTimer: null,
765
- prompt: text,
766
- };
767
- activeRun = run;
768
- try {
769
- // Build the MCP config first — it's pure local file IO and lets
770
- // us assert plugin-contributed servers landed in the config even
771
- // when CDP preflight subsequently fails (useful for smoke tests
772
- // that don't have a real debug Chrome wired up).
773
- const mcpConfig = buildMcpConfig();
774
- // Preflight: refuse to invoke if CDP isn't reachable. Otherwise the
775
- // Playwright MCP server would silently launch its own Chromium —
776
- // and Hover's premise is to drive the user's existing Chrome (with
777
- // their dev state, cookies, devtools open), never spawn a fresh one.
778
- const cdp = await getPreflight(cdpUrl);
779
- if (!cdp.ok) {
780
- send(ws, {
781
- type: 'event',
782
- payload: {
783
- kind: 'session_end',
784
- isError: true,
785
- summary: cdp.reason,
786
- },
787
- });
788
- return;
789
- }
790
- // Build a system-prompt addendum telling the agent about the user's
791
- // current tab. The most common waste we observed: agent calls
792
- // browser_navigate to the same URL the user is already on, triggering
793
- // a wasteful full-page reload that also destroys the Hover widget
794
- // momentarily (the widget re-injects + recovers, but the agent's
795
- // own session sometimes gets confused).
796
- // First turn pays the full rules + narration block; follow-up
797
- // turns (`resumeSessionId` set) get only the volatile tab list.
798
- // The static rules are already in the prior turn's context, and
799
- // re-sending them fragments Anthropic's prompt-cache fingerprint
800
- // (cache hits require byte-identical system prompts across turns).
801
- // See cdpHint.ts for the why.
802
- let appendSystemPrompt = resumeSessionId
803
- ? buildCdpHintResume(cdp.tabs)
804
- : buildCdpHint(cdp.tabs);
805
- // Knowledge layer (F5): on the first turn, fold in the project's
806
- // .hover/conventions.md (static, like cdpHint's rules — skipped on
807
- // resume to keep the prompt cache intact). The service reads the file;
808
- // the agent never gains filesystem access (D2).
809
- if (!resumeSessionId) {
810
- const conventions = await readConventions(devRoot);
811
- if (conventions)
812
- appendSystemPrompt = `${appendSystemPrompt}\n\n${conventions}`;
813
- }
814
- // Add plugin-contributed prompt additions whose scope includes the
815
- // current mode (or '*' for always-on). Walks ALL loaded plugins,
816
- // not just the active-mode plugin — a plugin that contributes
817
- // an always-on prompt without contributing a mode is a valid
818
- // shape (e.g. a future "always remind the agent of these
819
- // project conventions" plugin).
820
- for (const p of plugins) {
821
- for (const add of p.systemPromptAdditions ?? []) {
822
- // Default scope: if the plugin has a mode, the prompt is
823
- // gated to that mode; if it doesn't have a mode, the prompt
824
- // is always-on (treated as if activeInModes was '*').
825
- const scope = add.activeInModes ?? (p.mode ? [p.mode.id] : ['*']);
826
- const inScope = scope.includes('*') ||
827
- (currentModeId !== null && scope.includes(currentModeId));
828
- if (inScope) {
829
- appendSystemPrompt = `${appendSystemPrompt}\n\n${add.text}`;
830
- }
831
- }
832
- }
833
- // codeContext: tell the agent the fenced source reader exists, so it
834
- // proactively reads the real code (better selectors/routes when
835
- // authoring; white-box confirmation when probing) instead of only
836
- // guessing from the rendered DOM.
837
- if (opts.codeContext) {
838
- appendSystemPrompt = `${appendSystemPrompt}\n\nYou also have read-only access to this project's source via mcp__hover_source (read_source / list_source), fenced to the repo (secrets, keys, .env, .git, node_modules and build output are refused). Use it to read the actual component / route / API code — write tests against the real selectors and, when probing for security issues, confirm a finding against the server code (the query, the authz check) rather than guessing from the page alone.`;
839
- }
840
- // Mirror the prompt's language in the agent's *prose* output — the
841
- // verification summary (Result card), the ## Findings block, and the
842
- // step narration — the same way Voice mode mirrors it in TTS. A
843
- // Chinese prompt should produce a Chinese report. This does NOT change
844
- // how the agent operates the browser: selectors, role names, and the
845
- // app's own (often English) UI text are unaffected — only the agent's
846
- // human-facing writing follows the user. Detection mirrors voice.js's
847
- // detectLanguage (CJK presence → zh).
848
- if (CJK_RE.test(text)) {
849
- appendSystemPrompt = `${appendSystemPrompt}\n\n${ZH_OUTPUT_DIRECTIVE}`;
850
- }
851
- // Snapshot the agent id so a switch-agent message during the run
852
- // can't smear two agents across one invocation. (We also gate
853
- // switch-agent on an active run, but defense in depth.) runSession gates
854
- // the allow/deny lists on the agent's sandboxStrength internally.
855
- const invokedAgentId = currentAgentId;
856
- // Active mode's plugin-contributed MCP server ids — added to the
857
- // hard-sandbox allow list so Claude can actually call them. Claude
858
- // sanitises non-alphanumeric chars in the id when forming tool
859
- // names (e.g. "@hover-dev/security:flows" → "mcp__hover_dev_security_flows"),
860
- // and `--allowedTools mcp__foo` matches every tool under that
861
- // prefix. We pass the prefix `mcp__<sanitized>` so all of the
862
- // server's tools are reachable.
863
- const activePluginMcpIds = [];
864
- if (currentModeId) {
865
- for (const p of plugins) {
866
- for (const srv of p.mcpServers ?? []) {
867
- const scope = srv.activeInModes ?? (p.mode ? [p.mode.id] : []);
868
- if (scope.includes('*') || scope.includes(currentModeId)) {
869
- activePluginMcpIds.push(mcpToolPrefix(srv.id));
870
- }
871
- }
872
- }
873
- }
874
- // codeContext: the fenced source reader is allowed in every mode.
875
- if (opts.codeContext)
876
- activePluginMcpIds.push(mcpToolPrefix(SOURCE_MCP_ID));
877
- const runResult = await runSession({
878
- agentId: invokedAgentId,
879
- prompt: text,
880
- sessionId: resumeSessionId,
881
- mcpConfig,
882
- // cwd = devRoot so the agent runs against the project (and Claude
883
- // Code reads its CLAUDE.md, if any).
884
- cwd: devRoot,
885
- appendSystemPrompt,
886
- // mcp__playwright covers every browser tool; active-mode plugin MCP
887
- // servers are appended. (Save-as-Skill retired → no Skill tool.)
888
- allowedToolsExtra: activePluginMcpIds,
889
- maxBudgetUsd,
890
- model,
891
- apiKey: currentApiKey,
892
- signal: run.abort.signal,
893
- }, (ev) => {
894
- // Stream to whichever ws is attached NOW — survives the widget
895
- // reconnecting mid-run (emitToRun is a no-op during a reconnect gap).
896
- if (run.cancelled)
897
- return;
898
- emitToRun({ type: 'event', payload: ev });
899
- });
900
- // Re-record: write a fresh spec from the steps runSession accumulated
901
- // (`user` → `step`* → `done`). Only on a clean, non-cancelled finish —
902
- // a cancelled/aborted run throws out of runSession into the catch
903
- // below, and an errored agent leaves the original spec untouched.
904
- if (reRecordSlug && !run.cancelled) {
905
- if (runResult.isError) {
906
- emitToRun({
907
- type: 'error',
908
- payload: {
909
- message: `Re-record failed: ${runResult.summary || 'agent reported an error'}. ` +
910
- `Original spec left unchanged.`,
911
- },
912
- });
913
- }
914
- else {
915
- try {
916
- const { writeSpec } = await import('./specs/writeSpec.js');
917
- const written = await writeSpec({
918
- devRoot,
919
- name: reRecordSlug,
920
- steps: runResult.steps,
921
- overwrite: true,
922
- });
923
- emitToRun({
924
- type: 'spec-saved',
925
- payload: { name: reRecordSlug, path: written.path },
926
- });
927
- }
928
- catch (e) {
929
- const m = e instanceof Error ? e.message : String(e);
930
- emitToRun({
931
- type: 'error',
932
- payload: { message: `Re-record could not write spec: ${m}` },
933
- });
934
- }
935
- }
936
- }
937
- }
938
- catch (err) {
939
- // A user-initiated cancel() already sent a synthetic session_end
940
- // {cancelled:true}. The subsequent AbortError surfacing here would
941
- // otherwise produce a second session_end{isError:true}, leaving the
942
- // widget to reconcile two terminal events for one run. CDP isn't
943
- // suspect either — the user just stopped — so skip preflight
944
- // invalidation too.
945
- if (!run.cancelled) {
946
- const message = err instanceof Error ? err.message : String(err);
947
- const errorEvent = {
948
- kind: 'session_end',
949
- isError: true,
950
- summary: message,
951
- };
952
- emitToRun({ type: 'event', payload: errorEvent });
953
- // Force the next command to re-probe CDP. The error could be from
954
- // Chrome dying, MCP spawning a stray Chromium, the user closing
955
- // their debug window — anything that would make a cached "all
956
- // healthy" result lie.
957
- invalidatePreflight(cdpUrl);
958
- }
959
- }
960
- finally {
961
- if (run.graceTimer)
962
- clearTimeout(run.graceTimer);
963
- activeRun = null;
964
- }
965
- });
966
- });
967
- // ───────────────────────── service:start + single Chrome ─────────────────
968
- // Fire plugin `hover:service:start` hooks BEFORE launching Chrome, so a
969
- // plugin (security) can boot its resident proxy and call setChromeProxy.
970
- // residentChromeProxy is then baked into the one auto-launched Chrome.
971
- for (const p of plugins) {
972
- const hook = p.hooks?.['hover:service:start'];
973
- if (!hook)
974
- continue;
975
- try {
976
- await hook({
977
- devRoot,
978
- broadcast: broadcastPluginEvent,
979
- setChromeProxy(proxy) {
980
- residentChromeProxy = proxy;
981
- },
982
- setMcpServerEnv(id, env) {
983
- mcpEnvOverrides.set(id, env);
984
- },
985
- });
986
- }
987
- catch (err) {
988
- process.stderr.write(`[hover] plugin "${p.name}" service:start failed: ${err instanceof Error ? err.message : String(err)}\n`);
989
- }
990
- }
991
- // Auto-launch the single debug Chrome here (moved out of the bundler shims
992
- // so it happens AFTER service:start and can carry residentChromeProxy).
993
- // Fire-and-forget — startup must not block on Chrome, and a launch failure
994
- // is non-fatal (the widget's amber ✨ lets the user retry on demand).
995
- if (opts.autoLaunchChrome) {
996
- const launchPort = (() => {
997
- try {
998
- return Number(new URL(cdpUrl).port) || 9222;
999
- }
1000
- catch {
1001
- return 9222;
1002
- }
1003
- })();
1004
- const launchUrl = opts.devUrl ?? cdpUrl;
1005
- launchDebugChrome({
1006
- url: launchUrl,
1007
- port: launchPort,
1008
- proxy: residentChromeProxy ?? undefined,
1009
- })
1010
- .then((r) => {
1011
- if (!r.ok) {
1012
- process.stderr.write(`[hover] auto-launch Chrome failed: ${r.reason}\n`);
1013
- }
1014
- })
1015
- .catch((err) => {
1016
- process.stderr.write(`[hover] auto-launch Chrome error: ${err instanceof Error ? err.message : String(err)}\n`);
1017
- });
1018
- }
1019
- return {
1020
- port,
1021
- async close() {
1022
- // Kill any in-flight run FIRST. The run is held at service scope and is
1023
- // only torn down by aborting its signal (invoke.ts SIGTERMs the agent
1024
- // child on abort). wss.close() below stops the listener but does NOT
1025
- // terminate established client sockets, so no ws.on('close') fires — so
1026
- // without this the agent child would keep driving the debug Chrome as an
1027
- // orphan after the dev server is gone, and a pending grace timer would
1028
- // fire abort() 15s into the void.
1029
- if (activeRun) {
1030
- if (activeRun.graceTimer)
1031
- clearTimeout(activeRun.graceTimer);
1032
- activeRun.cancelled = true;
1033
- activeRun.abort.abort();
1034
- activeRun = null;
1035
- }
1036
- // Deactivate the active mode first, then run every plugin's
1037
- // shutdown hook (regardless of which mode is active — a plugin may
1038
- // own background state even outside its mode). Best-effort: log
1039
- // and continue on individual failures so one buggy plugin doesn't
1040
- // strand the others' sidecars.
1041
- if (currentModeId) {
1042
- try {
1043
- await switchMode(null);
1044
- }
1045
- catch (err) {
1046
- process.stderr.write(`[hover] error deactivating mode during shutdown: ${err instanceof Error ? err.message : String(err)}\n`);
1047
- }
1048
- }
1049
- for (const p of plugins) {
1050
- const hook = p.hooks?.['hover:service:shutdown'];
1051
- if (!hook)
1052
- continue;
1053
- try {
1054
- await hook({ devRoot, broadcast: broadcastPluginEvent });
1055
- }
1056
- catch (err) {
1057
- process.stderr.write(`[hover] plugin "${p.name}" shutdown failed: ${err instanceof Error ? err.message : String(err)}\n`);
1058
- }
1059
- }
1060
- await new Promise((res, rej) => {
1061
- wss.close(err => (err ? rej(err) : res()));
1062
- });
1063
- },
1064
- };
1065
- }