@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.
- package/README.md +26 -55
- package/dist/agentDirectives.d.ts +55 -0
- package/dist/agentDirectives.d.ts.map +1 -0
- package/dist/agentDirectives.js +276 -0
- package/dist/engine.d.ts +28 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +27 -0
- package/dist/memory/businessMemory.d.ts +29 -0
- package/dist/memory/businessMemory.d.ts.map +1 -0
- package/dist/memory/businessMemory.js +125 -0
- package/dist/playwright/launchChrome.d.ts +18 -0
- package/dist/playwright/launchChrome.d.ts.map +1 -1
- package/dist/playwright/launchChrome.js +46 -3
- package/dist/qa/candidates.d.ts +32 -0
- package/dist/qa/candidates.d.ts.map +1 -0
- package/dist/qa/candidates.js +20 -0
- package/dist/qa/intensity.d.ts +33 -0
- package/dist/qa/intensity.d.ts.map +1 -0
- package/dist/qa/intensity.js +25 -0
- package/dist/qa/qaReport.d.ts +19 -0
- package/dist/qa/qaReport.d.ts.map +1 -0
- package/dist/qa/qaReport.js +50 -0
- package/dist/sessions/sessions.d.ts +125 -0
- package/dist/sessions/sessions.d.ts.map +1 -0
- package/dist/sessions/sessions.js +175 -0
- package/dist/specs/authFixture.d.ts +30 -0
- package/dist/specs/authFixture.d.ts.map +1 -0
- package/dist/specs/authFixture.js +145 -0
- package/dist/specs/detectSharedFlows.d.ts +1 -1
- package/dist/specs/detectSharedFlows.d.ts.map +1 -1
- package/dist/specs/detectSharedFlows.js +20 -21
- package/dist/specs/generatePageObject.d.ts +1 -1
- package/dist/specs/generatePageObject.d.ts.map +1 -1
- package/dist/specs/healPrompt.d.ts +19 -0
- package/dist/specs/healPrompt.d.ts.map +1 -0
- package/dist/specs/healPrompt.js +48 -0
- package/dist/specs/humanSteps.d.ts +4 -8
- package/dist/specs/humanSteps.d.ts.map +1 -1
- package/dist/specs/humanSteps.js +6 -1
- package/dist/specs/optimizeSpec.d.ts +15 -8
- package/dist/specs/optimizeSpec.d.ts.map +1 -1
- package/dist/specs/optimizeSpec.js +71 -41
- package/dist/specs/pageObjectManifest.d.ts +3 -1
- package/dist/specs/pageObjectManifest.d.ts.map +1 -1
- package/dist/specs/pageObjectManifest.js +24 -19
- package/dist/specs/replayGrounded.d.ts +45 -0
- package/dist/specs/replayGrounded.d.ts.map +1 -0
- package/dist/specs/replayGrounded.js +155 -0
- package/dist/specs/runFailures.d.ts +34 -0
- package/dist/specs/runFailures.d.ts.map +1 -0
- package/dist/specs/runFailures.js +93 -0
- package/dist/specs/seeds.d.ts +16 -15
- package/dist/specs/seeds.d.ts.map +1 -1
- package/dist/specs/seeds.js +86 -54
- package/dist/specs/sidecar.d.ts +34 -6
- package/dist/specs/sidecar.d.ts.map +1 -1
- package/dist/specs/sidecar.js +79 -9
- package/dist/specs/specStep.d.ts +21 -0
- package/dist/specs/specStep.d.ts.map +1 -0
- package/dist/specs/specStep.js +1 -0
- package/dist/specs/text.d.ts +8 -6
- package/dist/specs/text.d.ts.map +1 -1
- package/dist/specs/text.js +10 -7
- package/dist/specs/writeSpec.d.ts +62 -1
- package/dist/specs/writeSpec.d.ts.map +1 -1
- package/dist/specs/writeSpec.js +596 -21
- package/package.json +9 -29
- package/dist/agents/aider.d.ts +0 -16
- package/dist/agents/aider.d.ts.map +0 -1
- package/dist/agents/aider.js +0 -161
- package/dist/agents/argv.d.ts +0 -11
- package/dist/agents/argv.d.ts.map +0 -1
- package/dist/agents/argv.js +0 -23
- package/dist/agents/claude.d.ts +0 -3
- package/dist/agents/claude.d.ts.map +0 -1
- package/dist/agents/claude.js +0 -195
- package/dist/agents/codex.d.ts +0 -19
- package/dist/agents/codex.d.ts.map +0 -1
- package/dist/agents/codex.js +0 -216
- package/dist/agents/cursor.d.ts +0 -18
- package/dist/agents/cursor.d.ts.map +0 -1
- package/dist/agents/cursor.js +0 -220
- package/dist/agents/detect.d.ts +0 -46
- package/dist/agents/detect.d.ts.map +0 -1
- package/dist/agents/detect.js +0 -80
- package/dist/agents/gemini.d.ts +0 -17
- package/dist/agents/gemini.d.ts.map +0 -1
- package/dist/agents/gemini.js +0 -186
- package/dist/agents/index.d.ts +0 -6
- package/dist/agents/index.d.ts.map +0 -1
- package/dist/agents/index.js +0 -5
- package/dist/agents/invoke.d.ts +0 -12
- package/dist/agents/invoke.d.ts.map +0 -1
- package/dist/agents/invoke.js +0 -96
- package/dist/agents/qwen.d.ts +0 -17
- package/dist/agents/qwen.d.ts.map +0 -1
- package/dist/agents/qwen.js +0 -172
- package/dist/agents/registry.d.ts +0 -19
- package/dist/agents/registry.d.ts.map +0 -1
- package/dist/agents/registry.js +0 -34
- package/dist/agents/shared.d.ts +0 -28
- package/dist/agents/shared.d.ts.map +0 -1
- package/dist/agents/shared.js +0 -35
- package/dist/agents/types.d.ts +0 -186
- package/dist/agents/types.d.ts.map +0 -1
- package/dist/agents/types.js +0 -23
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -2
- package/dist/mcp/sourceFence.d.ts +0 -23
- package/dist/mcp/sourceFence.d.ts.map +0 -1
- package/dist/mcp/sourceFence.js +0 -75
- package/dist/mcp/sourceServer.d.ts +0 -3
- package/dist/mcp/sourceServer.d.ts.map +0 -1
- package/dist/mcp/sourceServer.js +0 -116
- package/dist/playwright/cdpStatus.d.ts +0 -29
- package/dist/playwright/cdpStatus.d.ts.map +0 -1
- package/dist/playwright/cdpStatus.js +0 -119
- package/dist/playwright/preflight.d.ts +0 -31
- package/dist/playwright/preflight.d.ts.map +0 -1
- package/dist/playwright/preflight.js +0 -82
- package/dist/playwright/preflightCache.d.ts +0 -27
- package/dist/playwright/preflightCache.d.ts.map +0 -1
- package/dist/playwright/preflightCache.js +0 -21
- package/dist/playwright/raiseWindow.d.ts +0 -10
- package/dist/playwright/raiseWindow.d.ts.map +0 -1
- package/dist/playwright/raiseWindow.js +0 -158
- package/dist/playwright/resolveMcpConfig.d.ts +0 -55
- package/dist/playwright/resolveMcpConfig.d.ts.map +0 -1
- package/dist/playwright/resolveMcpConfig.js +0 -66
- package/dist/plugin-api.d.ts +0 -235
- package/dist/plugin-api.d.ts.map +0 -1
- package/dist/plugin-api.js +0 -52
- package/dist/runSession.d.ts +0 -42
- package/dist/runSession.d.ts.map +0 -1
- package/dist/runSession.js +0 -81
- package/dist/scripts/bench-multi-tab.d.ts +0 -2
- package/dist/scripts/bench-multi-tab.d.ts.map +0 -1
- package/dist/scripts/bench-multi-tab.js +0 -192
- package/dist/scripts/bench-ttfb.d.ts +0 -2
- package/dist/scripts/bench-ttfb.d.ts.map +0 -1
- package/dist/scripts/bench-ttfb.js +0 -127
- package/dist/scripts/start-chrome.d.ts +0 -3
- package/dist/scripts/start-chrome.d.ts.map +0 -1
- package/dist/scripts/start-chrome.js +0 -23
- package/dist/service/cdpHandlers.d.ts +0 -44
- package/dist/service/cdpHandlers.d.ts.map +0 -1
- package/dist/service/cdpHandlers.js +0 -85
- package/dist/service/cdpHint.d.ts +0 -48
- package/dist/service/cdpHint.d.ts.map +0 -1
- package/dist/service/cdpHint.js +0 -216
- package/dist/service/conventions.d.ts +0 -8
- package/dist/service/conventions.d.ts.map +0 -1
- package/dist/service/conventions.js +0 -42
- package/dist/service/saveHandlers.d.ts +0 -52
- package/dist/service/saveHandlers.d.ts.map +0 -1
- package/dist/service/saveHandlers.js +0 -75
- package/dist/service/types.d.ts +0 -58
- package/dist/service/types.d.ts.map +0 -1
- package/dist/service/types.js +0 -26
- package/dist/service.d.ts +0 -50
- package/dist/service.d.ts.map +0 -1
- package/dist/service.js +0 -1065
- package/dist/skills/writeSkill.d.ts +0 -27
- package/dist/skills/writeSkill.d.ts.map +0 -1
- package/dist/skills/writeSkill.js +0 -13
- package/dist/specs/extractPageObjects.d.ts +0 -18
- package/dist/specs/extractPageObjects.d.ts.map +0 -1
- package/dist/specs/extractPageObjects.js +0 -98
- package/dist/specs/listSpecs.d.ts +0 -52
- package/dist/specs/listSpecs.d.ts.map +0 -1
- package/dist/specs/listSpecs.js +0 -139
- package/dist/specs/optimizationSuggestion.d.ts +0 -26
- package/dist/specs/optimizationSuggestion.d.ts.map +0 -1
- package/dist/specs/optimizationSuggestion.js +0 -28
- package/dist/specs/optimizeSpecWithAgent.d.ts +0 -11
- package/dist/specs/optimizeSpecWithAgent.d.ts.map +0 -1
- package/dist/specs/optimizeSpecWithAgent.js +0 -40
- package/dist/specs/writeCaseCsv.d.ts +0 -28
- package/dist/specs/writeCaseCsv.d.ts.map +0 -1
- 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
|
-
}
|