rivet-design 0.6.6 → 0.6.7

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.
@@ -16,7 +16,11 @@ const index_1 = require("../index");
16
16
  const skillWriter_1 = require("../utils/skillWriter");
17
17
  const portUtils_1 = require("../utils/portUtils");
18
18
  const child_process_1 = require("child_process");
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const path_1 = __importDefault(require("path"));
21
+ const util_1 = require("util");
19
22
  const log = (0, index_core_1.createLogger)('MCPServer');
23
+ const execAsync = (0, util_1.promisify)(child_process_1.exec);
20
24
  // Dev server command + default port per framework
21
25
  const FRAMEWORK_DEV_CONFIG = {
22
26
  nextjs: { devCommand: 'dev', defaultPort: 3000 },
@@ -62,6 +66,244 @@ async function waitForPort(port, host, timeoutMs = 60_000) {
62
66
  }
63
67
  return false;
64
68
  }
69
+ /** Stop a spawned process with graceful SIGTERM and SIGKILL fallback. */
70
+ async function stopChildProcess(child, timeoutMs = DEV_SERVER_STOP_TIMEOUT_MS) {
71
+ if (child.killed || child.exitCode !== null || child.pid == null) {
72
+ return;
73
+ }
74
+ const waitForExit = new Promise((resolve) => {
75
+ const onExit = () => {
76
+ child.off('exit', onExit);
77
+ child.off('error', onExit);
78
+ resolve();
79
+ };
80
+ child.once('exit', onExit);
81
+ child.once('error', onExit);
82
+ });
83
+ try {
84
+ child.kill('SIGTERM');
85
+ }
86
+ catch {
87
+ // no-op; fallback still runs below if process persists
88
+ }
89
+ const timedOut = await Promise.race([
90
+ waitForExit.then(() => false),
91
+ new Promise((resolve) => setTimeout(() => resolve(true), timeoutMs)),
92
+ ]);
93
+ if (!timedOut) {
94
+ return;
95
+ }
96
+ try {
97
+ child.kill('SIGKILL');
98
+ }
99
+ catch {
100
+ // best effort
101
+ }
102
+ await waitForExit;
103
+ }
104
+ const DEV_SERVER_STOP_TIMEOUT_MS = 5000;
105
+ function normalizeFilesystemPath(inputPath) {
106
+ if (!inputPath) {
107
+ return '';
108
+ }
109
+ const resolvedPath = path_1.default.resolve(inputPath);
110
+ try {
111
+ return fs_1.default.realpathSync(resolvedPath);
112
+ }
113
+ catch {
114
+ return resolvedPath;
115
+ }
116
+ }
117
+ function pathsMatchExactly(pathA, pathB) {
118
+ if (!pathA || !pathB) {
119
+ return false;
120
+ }
121
+ return pathA === pathB;
122
+ }
123
+ async function getListeningPortPids(port) {
124
+ try {
125
+ const { stdout } = await execAsync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -Fp`);
126
+ const pids = stdout
127
+ .split('\n')
128
+ .map((line) => line.trim())
129
+ .filter((line) => line.startsWith('p'))
130
+ .map((line) => parseInt(line.slice(1), 10))
131
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
132
+ return [...new Set(pids)];
133
+ }
134
+ catch {
135
+ return [];
136
+ }
137
+ }
138
+ async function getProcessCwd(pid) {
139
+ if (!Number.isInteger(pid) || pid <= 0) {
140
+ return null;
141
+ }
142
+ try {
143
+ const { stdout } = await execAsync(`lsof -a -p ${pid} -d cwd -Fn`);
144
+ const cwdLine = stdout
145
+ .split('\n')
146
+ .map((line) => line.trim())
147
+ .find((line) => line.startsWith('n'));
148
+ if (!cwdLine) {
149
+ return null;
150
+ }
151
+ return cwdLine.slice(1) || null;
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ async function isPortOwnedByProject(port, projectPath) {
158
+ if (!Number.isInteger(port) || port <= 0 || port >= 65536) {
159
+ return {
160
+ matches: false,
161
+ reason: `Invalid port ${port} for project validation.`,
162
+ };
163
+ }
164
+ const normalizedProjectPath = normalizeFilesystemPath(projectPath);
165
+ if (!normalizedProjectPath) {
166
+ return {
167
+ matches: false,
168
+ reason: 'Project path is invalid for strict server validation.',
169
+ };
170
+ }
171
+ const candidatePids = await getListeningPortPids(port);
172
+ if (candidatePids.length === 0) {
173
+ return {
174
+ matches: false,
175
+ reason: `No listening process found on localhost:${port}.`,
176
+ };
177
+ }
178
+ const observedProcessPaths = [];
179
+ for (const pid of candidatePids) {
180
+ const cwd = await getProcessCwd(pid);
181
+ const normalizedCwd = normalizeFilesystemPath(cwd);
182
+ if (!normalizedCwd) {
183
+ observedProcessPaths.push(`${pid}:<unknown_cwd>`);
184
+ continue;
185
+ }
186
+ observedProcessPaths.push(`${pid}:${normalizedCwd}`);
187
+ if (pathsMatchExactly(normalizedProjectPath, normalizedCwd)) {
188
+ return {
189
+ matches: true,
190
+ reason: `Process ${pid} is running from ${normalizedCwd}.`,
191
+ };
192
+ }
193
+ }
194
+ return {
195
+ matches: false,
196
+ reason: `A server is listening on localhost:${port}, but process paths do not match the selected project path. selected=${normalizedProjectPath}; observed=${observedProcessPaths.join(', ') || '<none>'}`,
197
+ selectedProjectPath: normalizedProjectPath,
198
+ observedProcessPaths,
199
+ };
200
+ }
201
+ function detectFrameworkFromHtml(html) {
202
+ const lower = html.toLowerCase();
203
+ const hasViteClient = lower.includes('/@vite/client');
204
+ const hasViteMarkers = lower.includes('vite') &&
205
+ (lower.includes('hmr') || lower.includes('/src/main.'));
206
+ if (hasViteClient || hasViteMarkers) {
207
+ return 'vite';
208
+ }
209
+ const hasNextAssets = lower.includes('/_next/');
210
+ const hasNextMarkers = lower.includes('__next_data__') || lower.includes('next-route-announcer');
211
+ if (hasNextAssets || hasNextMarkers) {
212
+ return 'nextjs';
213
+ }
214
+ return 'unknown';
215
+ }
216
+ function normalizeExpectedFramework(framework) {
217
+ if (!framework)
218
+ return 'unknown';
219
+ const lower = framework.toLowerCase();
220
+ if (lower.includes('vite'))
221
+ return 'vite';
222
+ if (lower.includes('next'))
223
+ return 'nextjs';
224
+ return 'unknown';
225
+ }
226
+ function isFrameworkCompatible(expected, detected) {
227
+ if (expected === 'unknown')
228
+ return true;
229
+ return expected === detected;
230
+ }
231
+ async function validateProjectServer(options) {
232
+ const { host, port, framework, projectPath } = options;
233
+ const signals = [];
234
+ const isReachable = await waitForPort(port, host, 3000);
235
+ if (!isReachable) {
236
+ return {
237
+ valid: false,
238
+ reason: `No server is responding on ${host}:${port}.`,
239
+ detectedFramework: 'unknown',
240
+ signals,
241
+ };
242
+ }
243
+ signals.push('tcp_reachable');
244
+ if (projectPath) {
245
+ const projectMatch = await isPortOwnedByProject(port, projectPath);
246
+ if (!projectMatch.matches) {
247
+ return {
248
+ valid: false,
249
+ reason: projectMatch.reason,
250
+ detectedFramework: 'unknown',
251
+ signals,
252
+ selectedProjectPath: projectMatch.selectedProjectPath,
253
+ observedProcessPaths: projectMatch.observedProcessPaths,
254
+ };
255
+ }
256
+ signals.push('path_match');
257
+ }
258
+ try {
259
+ const response = await fetch(`http://${host}:${port}`, {
260
+ signal: AbortSignal.timeout(3000),
261
+ });
262
+ const html = await response.text();
263
+ const detectedFramework = detectFrameworkFromHtml(html);
264
+ if (detectedFramework === 'vite') {
265
+ signals.push('vite_marker');
266
+ }
267
+ if (detectedFramework === 'nextjs') {
268
+ signals.push('next_marker');
269
+ }
270
+ const expected = normalizeExpectedFramework(framework);
271
+ if (!isFrameworkCompatible(expected, detectedFramework)) {
272
+ return {
273
+ valid: false,
274
+ reason: `Expected ${expected}, but detected ${detectedFramework} on ${host}:${port}.`,
275
+ detectedFramework,
276
+ signals,
277
+ statusCode: response.status,
278
+ };
279
+ }
280
+ if (detectedFramework === 'unknown' && expected !== 'unknown') {
281
+ return {
282
+ valid: false,
283
+ reason: 'Server is reachable but did not expose framework markers needed for validation.',
284
+ detectedFramework,
285
+ signals,
286
+ statusCode: response.status,
287
+ };
288
+ }
289
+ return {
290
+ valid: true,
291
+ reason: `Validated ${host}:${port} as a project server.`,
292
+ detectedFramework,
293
+ signals,
294
+ statusCode: response.status,
295
+ };
296
+ }
297
+ catch (error) {
298
+ const message = error instanceof Error ? error.message : 'Unknown error';
299
+ return {
300
+ valid: false,
301
+ reason: `HTTP validation failed: ${message}`,
302
+ detectedFramework: 'unknown',
303
+ signals,
304
+ };
305
+ }
306
+ }
65
307
  /**
66
308
  * Starts the Rivet MCP server on stdio transport.
67
309
  * Called when the user runs `rivet mcp`.
@@ -179,8 +421,90 @@ const startMCPServer = async (mcpEditor) => {
179
421
  let sessionStartedAt = null;
180
422
  // Track a dev server we auto-started so we can kill it on close
181
423
  let devServerProcess = null;
182
- // Flush telemetry before process exits
424
+ let devServerOwnership = 'none';
425
+ let devServerHost = null;
426
+ let devServerPort = null;
427
+ let isCleanupRunning = false;
428
+ const updateBridgeHealth = (reason, isReachable) => {
429
+ bridge.updateDevServerHealth({
430
+ reason,
431
+ isReachable,
432
+ lastCheckedAt: Date.now(),
433
+ });
434
+ };
435
+ const cleanupSessionResources = async (options) => {
436
+ if (isCleanupRunning) {
437
+ return { totalRoundsCompleted: 0, sessionId: null };
438
+ }
439
+ isCleanupRunning = true;
440
+ try {
441
+ const activeSessionId = bridge.getSessionId();
442
+ const sessionIdToClose = options?.closeSessionId ??
443
+ (bridge.isActive() && activeSessionId ? activeSessionId : null);
444
+ const telemetryContext = bridge.getTelemetryContext();
445
+ const activeRunCorrelation = bridge.completeActiveRunCorrelation();
446
+ let totalRoundsCompleted = 0;
447
+ if (sessionIdToClose && bridge.isActive()) {
448
+ totalRoundsCompleted = bridge.endSession();
449
+ }
450
+ if (serverHandle) {
451
+ try {
452
+ await serverHandle.close();
453
+ }
454
+ catch (error) {
455
+ log.warn('Error stopping Express server:', error);
456
+ }
457
+ serverHandle = null;
458
+ }
459
+ if (devServerOwnership === 'rivet_owned' && devServerProcess) {
460
+ try {
461
+ await stopChildProcess(devServerProcess);
462
+ }
463
+ catch (error) {
464
+ log.warn('Error stopping dev server process:', error);
465
+ }
466
+ }
467
+ devServerProcess = null;
468
+ devServerOwnership = 'none';
469
+ devServerHost = null;
470
+ devServerPort = null;
471
+ updateBridgeHealth('rivet_unreachable', false);
472
+ const durationMs = sessionStartedAt ? Date.now() - sessionStartedAt : 0;
473
+ sessionStartedAt = null;
474
+ if (options?.trackSessionEnd !== false && sessionIdToClose) {
475
+ telemetry.trackMCPSessionEnd({
476
+ sessionId: sessionIdToClose,
477
+ rounds: totalRoundsCompleted,
478
+ durationMs,
479
+ browserSessionId: telemetryContext?.browserSessionId,
480
+ browserDistinctId: telemetryContext?.browserDistinctId,
481
+ });
482
+ if (activeRunCorrelation) {
483
+ telemetry.trackAgentRunFailed({
484
+ traceId: activeRunCorrelation.traceId,
485
+ agentRunId: activeRunCorrelation.agentRunId,
486
+ sessionId: sessionIdToClose,
487
+ posthogSessionId: activeRunCorrelation.browserSessionId,
488
+ durationMs: Date.now() - activeRunCorrelation.startedAt,
489
+ errorType: 'mcp_session_closed',
490
+ errorMessage: 'MCP session closed before apply completion confirmation',
491
+ source: 'mcp',
492
+ });
493
+ }
494
+ }
495
+ await telemetry.flush();
496
+ return {
497
+ totalRoundsCompleted,
498
+ sessionId: sessionIdToClose,
499
+ };
500
+ }
501
+ finally {
502
+ isCleanupRunning = false;
503
+ }
504
+ };
505
+ // Flush telemetry + resources before process exits
183
506
  const shutdown = async () => {
507
+ await cleanupSessionResources({ trackSessionEnd: true });
184
508
  await telemetry.shutdown();
185
509
  };
186
510
  process.on('SIGTERM', async () => {
@@ -215,6 +539,18 @@ const startMCPServer = async (mcpEditor) => {
215
539
  .number()
216
540
  .describe('Preferred starting port for the dev server. Rivet will find the next free port at or above this value. Falls back to the framework default if omitted.')
217
541
  .optional(),
542
+ startupMode: v3_1.z
543
+ .enum(['manual_existing', 'ai_startup'])
544
+ .describe('How to connect the dev server: manual_existing uses an already-running server, ai_startup auto-starts one. Optional; if omitted, Rivet defaults to ai_startup.')
545
+ .optional(),
546
+ existingPort: v3_1.z
547
+ .number()
548
+ .describe('Port of an already-running dev server. Required for startupMode manual_existing, and reused for follow-up calls after nextStep responses.')
549
+ .optional(),
550
+ forceAttachExternal: v3_1.z
551
+ .boolean()
552
+ .describe('Confirmation flag for startupMode manual_existing when validation reports a project mismatch. Set true only after user confirms attaching anyway.')
553
+ .optional(),
218
554
  };
219
555
  const sessionIdArgs = {
220
556
  sessionId: v3_1.z
@@ -232,7 +568,7 @@ const startMCPServer = async (mcpEditor) => {
232
568
  .optional()
233
569
  .describe('How long to wait for the user to send changes (default: 600, max: 600)'),
234
570
  };
235
- mcp.tool('detect_project', 'Detect the project framework, package manager, dev command, and default port. Also reports whether the dev server is currently running. Call this before open_visual_editor to check if the dev server needs to be started.', detectProjectArgs, async (args) => {
571
+ mcp.tool('detect_project', 'Detect the project framework, package manager, dev command, and default port. Also checks whether a project dev server is currently reachable and reports its endpoint when found. Call this before open_visual_editor to decide startupMode.', detectProjectArgs, async (args) => {
236
572
  const projectPath = args.projectPath || process.cwd();
237
573
  const detection = new index_core_1.ProjectDetectionService();
238
574
  let framework;
@@ -260,6 +596,9 @@ const startMCPServer = async (mcpEditor) => {
260
596
  devCommand: 'dev',
261
597
  defaultPort: 3000,
262
598
  };
599
+ const runningServer = framework === 'static'
600
+ ? null
601
+ : await detection.findRunningDevServer(projectPath, [index_1.DEFAULT_PORT]);
263
602
  return {
264
603
  content: [
265
604
  {
@@ -270,6 +609,9 @@ const startMCPServer = async (mcpEditor) => {
270
609
  devCommand: devConfig.devCommand,
271
610
  defaultPort: devConfig.defaultPort,
272
611
  projectPath,
612
+ devServerRunning: Boolean(runningServer),
613
+ detectedPort: runningServer?.port ?? null,
614
+ detectedHost: runningServer?.host ?? null,
273
615
  startCommand: devConfig.devCommand
274
616
  ? `${packageManager} run ${devConfig.devCommand}`
275
617
  : null,
@@ -278,7 +620,7 @@ const startMCPServer = async (mcpEditor) => {
278
620
  ],
279
621
  };
280
622
  });
281
- mcp.tool('open_visual_editor', "Start a Rivet visual editing session. Always call detect_project first to get the framework. Rivet will automatically start the dev server on a free port. Opens the browser with Rivet's visual editor. After calling this, use watch_for_changes in a loop to receive changes as the user works.", openEditorArgs, async (args) => {
623
+ mcp.tool('open_visual_editor', "Start a Rivet visual editing session. Always call detect_project first to get the framework. Supports startupMode manual_existing (validate an existing dev server) or ai_startup (auto-start one on a free port). If startupMode is omitted, Rivet defaults to ai_startup. Some recoverable startup outcomes return a non-error follow-up payload (opened: false, nextStep) rather than failing: nextStep manual_port_required means retry with startupMode manual_existing + existingPort, and nextStep mismatch_confirmation_required means confirm with forceAttachExternal: true before attaching a mismatched server. Opens the browser with Rivet's visual editor when opened is true. After calling this, use watch_for_changes in a loop to receive changes as the user works.", openEditorArgs, async (args) => {
282
624
  if (bridge.isActive()) {
283
625
  return {
284
626
  content: [
@@ -295,13 +637,18 @@ const startMCPServer = async (mcpEditor) => {
295
637
  }
296
638
  const projectPath = args.projectPath || process.cwd();
297
639
  const framework = args.framework ?? 'nextjs';
298
- let devServerPort = 0;
299
- const devServerHost = 'localhost';
640
+ const requestedStartupMode = args.startupMode;
641
+ const startupMode = requestedStartupMode ?? 'ai_startup';
642
+ let nextDevServerPort = null;
643
+ const nextDevServerHost = 'localhost';
644
+ let nextDevServerOwnership = 'none';
645
+ const startupModeSelection = requestedStartupMode ? 'explicit' : 'auto';
300
646
  if (framework !== 'static') {
301
647
  const frameworkConfig = FRAMEWORK_DEV_CONFIG[framework] ?? {
302
648
  devCommand: 'dev',
303
649
  defaultPort: 3000,
304
650
  };
651
+ const detection = new index_core_1.ProjectDetectionService();
305
652
  if (!frameworkConfig.devCommand) {
306
653
  return {
307
654
  content: [
@@ -316,51 +663,180 @@ const startMCPServer = async (mcpEditor) => {
316
663
  isError: true,
317
664
  };
318
665
  }
319
- const detection = new index_core_1.ProjectDetectionService();
320
- const packageManager = await detection.detectPackageManager(projectPath);
321
- const preferredPort = args.startPort ??
322
- detection.readConfiguredPort(projectPath) ??
323
- frameworkConfig.defaultPort;
324
- devServerPort = await (0, portUtils_1.findAvailablePort)(preferredPort);
325
- const { cmd, args: cmdArgs, env, } = buildDevServerCommand(framework, packageManager, frameworkConfig.devCommand, devServerPort);
326
- log.info(`Auto-starting dev server: ${cmd} ${cmdArgs.join(' ')} in ${projectPath}`);
327
- devServerProcess = (0, child_process_1.spawn)(cmd, cmdArgs, {
328
- cwd: projectPath,
329
- env: { ...process.env, ...env },
330
- stdio: 'pipe',
331
- detached: false,
332
- });
333
- devServerProcess.on('error', (err) => {
334
- log.error('Dev server process error:', err);
335
- });
336
- devServerProcess.on('exit', (code) => {
337
- log.info(`Dev server exited with code ${code}`);
338
- devServerProcess = null;
339
- });
340
- devServerProcess.stdout?.on('data', (data) => {
341
- log.debug(`[dev-server] ${data.toString().trim()}`);
342
- });
343
- devServerProcess.stderr?.on('data', (data) => {
344
- log.debug(`[dev-server] ${data.toString().trim()}`);
345
- });
346
- const ready = await waitForPort(devServerPort, devServerHost, 30_000);
347
- if (!ready) {
348
- devServerProcess?.kill();
349
- devServerProcess = null;
350
- return {
351
- content: [
352
- {
353
- type: 'text',
354
- text: JSON.stringify({
355
- error: `Dev server did not become ready on port ${devServerPort} within 30s.`,
356
- suggestion: 'Check that the project has a valid dev script in package.json.',
357
- }),
358
- },
359
- ],
360
- isError: true,
361
- };
666
+ if (startupMode === 'manual_existing') {
667
+ const existingPort = args.existingPort ?? args.startPort ?? null;
668
+ if (!existingPort) {
669
+ return {
670
+ content: [
671
+ {
672
+ type: 'text',
673
+ text: JSON.stringify({
674
+ error: 'startupMode manual_existing requires an existingPort value.',
675
+ suggestion: 'Pass existingPort for your running dev server.',
676
+ }),
677
+ },
678
+ ],
679
+ isError: true,
680
+ };
681
+ }
682
+ const manualValidation = await validateProjectServer({
683
+ host: nextDevServerHost,
684
+ port: existingPort,
685
+ framework,
686
+ projectPath,
687
+ });
688
+ if (!manualValidation.valid) {
689
+ if (args.forceAttachExternal) {
690
+ const isReachable = await waitForPort(existingPort, nextDevServerHost, 3000);
691
+ if (!isReachable) {
692
+ return {
693
+ content: [
694
+ {
695
+ type: 'text',
696
+ text: JSON.stringify({
697
+ error: `Cannot attach to ${nextDevServerHost}:${existingPort} because it is not reachable.`,
698
+ validation: manualValidation,
699
+ }),
700
+ },
701
+ ],
702
+ isError: true,
703
+ };
704
+ }
705
+ nextDevServerPort = existingPort;
706
+ nextDevServerOwnership = 'external';
707
+ log.info(`Force-attached to external dev server on ${nextDevServerHost}:${nextDevServerPort}`);
708
+ updateBridgeHealth('healthy', true);
709
+ }
710
+ else if (manualValidation.selectedProjectPath ||
711
+ (manualValidation.observedProcessPaths?.length ?? 0) > 0) {
712
+ return {
713
+ content: [
714
+ {
715
+ type: 'text',
716
+ text: JSON.stringify({
717
+ opened: false,
718
+ nextStep: 'mismatch_confirmation_required',
719
+ error: manualValidation.reason,
720
+ existingServerPort: existingPort,
721
+ selectedProjectPath: manualValidation.selectedProjectPath,
722
+ observedProcessPaths: manualValidation.observedProcessPaths,
723
+ validation: manualValidation,
724
+ nextAction: 'If the user confirms, call open_visual_editor again with startupMode: "manual_existing", existingPort, and forceAttachExternal: true. Otherwise ask for another port.',
725
+ }),
726
+ },
727
+ ],
728
+ isError: false,
729
+ };
730
+ }
731
+ else {
732
+ return {
733
+ content: [
734
+ {
735
+ type: 'text',
736
+ text: JSON.stringify({
737
+ error: manualValidation.reason,
738
+ validation: manualValidation,
739
+ }),
740
+ },
741
+ ],
742
+ isError: true,
743
+ };
744
+ }
745
+ }
746
+ if (!nextDevServerPort) {
747
+ nextDevServerPort = existingPort;
748
+ nextDevServerOwnership = 'external';
749
+ log.info(`Using existing dev server on ${nextDevServerHost}:${nextDevServerPort}`);
750
+ updateBridgeHealth('healthy', true);
751
+ }
752
+ }
753
+ else {
754
+ const packageManager = await detection.detectPackageManager(projectPath);
755
+ const preferredPort = args.startPort ??
756
+ detection.readConfiguredPort(projectPath) ??
757
+ frameworkConfig.defaultPort;
758
+ nextDevServerPort = await (0, portUtils_1.findAvailablePort)(preferredPort);
759
+ const { cmd, args: cmdArgs, env, } = buildDevServerCommand(framework, packageManager, frameworkConfig.devCommand, nextDevServerPort);
760
+ log.info(`Auto-starting dev server: ${cmd} ${cmdArgs.join(' ')} in ${projectPath}`);
761
+ devServerProcess = (0, child_process_1.spawn)(cmd, cmdArgs, {
762
+ cwd: projectPath,
763
+ env: { ...process.env, ...env },
764
+ stdio: 'pipe',
765
+ detached: false,
766
+ });
767
+ nextDevServerOwnership = 'rivet_owned';
768
+ devServerProcess.on('error', (err) => {
769
+ log.error('Dev server process error:', err);
770
+ });
771
+ devServerProcess.on('exit', (code) => {
772
+ log.info(`Dev server exited with code ${code}`);
773
+ devServerProcess = null;
774
+ if (bridge.isActive()) {
775
+ updateBridgeHealth('upstream_unreachable', false);
776
+ }
777
+ });
778
+ devServerProcess.stdout?.on('data', (data) => {
779
+ log.debug(`[dev-server] ${data.toString().trim()}`);
780
+ });
781
+ devServerProcess.stderr?.on('data', (data) => {
782
+ log.debug(`[dev-server] ${data.toString().trim()}`);
783
+ });
784
+ const ready = await waitForPort(nextDevServerPort, nextDevServerHost, 30_000);
785
+ if (!ready) {
786
+ if (devServerProcess) {
787
+ await stopChildProcess(devServerProcess);
788
+ }
789
+ devServerProcess = null;
790
+ const existingServer = await detection.findRunningDevServer(projectPath, [index_1.DEFAULT_PORT]);
791
+ return {
792
+ content: [
793
+ {
794
+ type: 'text',
795
+ text: JSON.stringify({
796
+ opened: false,
797
+ nextStep: 'manual_port_required',
798
+ error: `Dev server did not become ready on port ${nextDevServerPort} within 30s.`,
799
+ existingServerPort: existingServer?.port ?? null,
800
+ validation: null,
801
+ nextAction: 'Call open_visual_editor again with startupMode: "manual_existing" and existingPort set to a running app server port.',
802
+ }),
803
+ },
804
+ ],
805
+ isError: false,
806
+ };
807
+ }
808
+ const startupValidation = await validateProjectServer({
809
+ host: nextDevServerHost,
810
+ port: nextDevServerPort,
811
+ framework,
812
+ projectPath,
813
+ });
814
+ if (!startupValidation.valid) {
815
+ if (devServerProcess) {
816
+ await stopChildProcess(devServerProcess);
817
+ }
818
+ devServerProcess = null;
819
+ const existingServer = await detection.findRunningDevServer(projectPath, [index_1.DEFAULT_PORT]);
820
+ return {
821
+ content: [
822
+ {
823
+ type: 'text',
824
+ text: JSON.stringify({
825
+ opened: false,
826
+ nextStep: 'manual_port_required',
827
+ error: startupValidation.reason,
828
+ existingServerPort: existingServer?.port ?? null,
829
+ validation: startupValidation,
830
+ nextAction: 'Call open_visual_editor again with startupMode: "manual_existing" and existingPort set to a running app server port.',
831
+ }),
832
+ },
833
+ ],
834
+ isError: false,
835
+ };
836
+ }
837
+ updateBridgeHealth('healthy', true);
838
+ log.info(`Dev server ready on ${nextDevServerHost}:${nextDevServerPort}`);
362
839
  }
363
- log.info(`Dev server ready on ${devServerHost}:${devServerPort}`);
364
840
  }
365
841
  // Find a free port for Rivet's Express server (avoids conflict with desktop or other Rivet instances)
366
842
  let rivetPort;
@@ -381,8 +857,8 @@ const startMCPServer = async (mcpEditor) => {
381
857
  serverHandle = await (0, server_1.startServer)({
382
858
  framework,
383
859
  projectPath,
384
- userPort: devServerPort,
385
- userHost: devServerHost,
860
+ userPort: nextDevServerPort ?? 0,
861
+ userHost: nextDevServerHost,
386
862
  sessionBridge: bridge,
387
863
  telemetry,
388
864
  mcpEditor,
@@ -399,7 +875,9 @@ const startMCPServer = async (mcpEditor) => {
399
875
  }
400
876
  else {
401
877
  const message = error instanceof Error ? error.message : 'Server start failed';
402
- devServerProcess?.kill();
878
+ if (devServerProcess) {
879
+ await stopChildProcess(devServerProcess);
880
+ }
403
881
  devServerProcess = null;
404
882
  return {
405
883
  content: [
@@ -414,7 +892,9 @@ const startMCPServer = async (mcpEditor) => {
414
892
  }
415
893
  }
416
894
  if (!serverStarted) {
417
- devServerProcess?.kill();
895
+ if (devServerProcess) {
896
+ await stopChildProcess(devServerProcess);
897
+ }
418
898
  devServerProcess = null;
419
899
  return {
420
900
  content: [
@@ -430,6 +910,21 @@ const startMCPServer = async (mcpEditor) => {
430
910
  }
431
911
  const sessionId = bridge.startSession(projectPath);
432
912
  sessionStartedAt = Date.now();
913
+ devServerOwnership = nextDevServerOwnership;
914
+ devServerHost = nextDevServerHost;
915
+ devServerPort = nextDevServerPort;
916
+ bridge.setDevServerContext({
917
+ ownership: devServerOwnership,
918
+ host: devServerHost,
919
+ port: devServerPort,
920
+ startedByRivet: devServerOwnership === 'rivet_owned',
921
+ });
922
+ if (devServerOwnership === 'none') {
923
+ updateBridgeHealth('unknown', null);
924
+ }
925
+ else {
926
+ updateBridgeHealth('healthy', true);
927
+ }
433
928
  // Write editor-specific guidance file on first successful open
434
929
  if (mcpEditor === 'cursor') {
435
930
  (0, skillWriter_1.writeCursorRulesIfNeeded)(projectPath);
@@ -440,7 +935,7 @@ const startMCPServer = async (mcpEditor) => {
440
935
  // Track session start
441
936
  telemetry.trackMCPSessionStart({
442
937
  framework,
443
- devServerStarted: framework !== 'static',
938
+ devServerStarted: devServerOwnership === 'rivet_owned',
444
939
  sessionId,
445
940
  mcpEditor,
446
941
  });
@@ -462,7 +957,12 @@ const startMCPServer = async (mcpEditor) => {
462
957
  projectPath,
463
958
  framework,
464
959
  rivetPort,
465
- devServerPort,
960
+ opened: true,
961
+ devServerPort: nextDevServerPort,
962
+ devServerOwnership: nextDevServerOwnership,
963
+ startupMode,
964
+ startupModeSelection,
965
+ requestedStartupMode,
466
966
  next_step: `IMPORTANT: You must call watch_for_changes({ sessionId: "${sessionId}" }) RIGHT NOW to wait for the user's changes. Do not wait for the user to ask — call it immediately. It will block until they click "${sendButtonLabel}" in the browser.`,
467
967
  }),
468
968
  },
@@ -489,6 +989,7 @@ const startMCPServer = async (mcpEditor) => {
489
989
  intent = await bridge.waitForChanges(timeoutMs);
490
990
  }
491
991
  catch {
992
+ const health = bridge.getDevServerHealth();
492
993
  return {
493
994
  content: [
494
995
  {
@@ -496,6 +997,7 @@ const startMCPServer = async (mcpEditor) => {
496
997
  text: JSON.stringify({
497
998
  hasChanges: false,
498
999
  message: 'Timed out waiting for changes. Call again to keep waiting.',
1000
+ devServerHealth: health,
499
1001
  }),
500
1002
  },
501
1003
  ],
@@ -570,51 +1072,10 @@ const startMCPServer = async (mcpEditor) => {
570
1072
  isError: true,
571
1073
  };
572
1074
  }
573
- const telemetryContext = bridge.getTelemetryContext();
574
- const activeRunCorrelation = bridge.completeActiveRunCorrelation();
575
- const totalRoundsCompleted = bridge.endSession();
576
- const durationMs = sessionStartedAt ? Date.now() - sessionStartedAt : 0;
577
- sessionStartedAt = null;
578
- // Stop the Express server
579
- if (serverHandle) {
580
- try {
581
- await serverHandle.close();
582
- }
583
- catch (error) {
584
- log.warn('Error stopping Express server:', error);
585
- }
586
- serverHandle = null;
587
- }
588
- // Kill the dev server if we auto-started it
589
- if (devServerProcess) {
590
- try {
591
- devServerProcess.kill();
592
- }
593
- catch (error) {
594
- log.warn('Error stopping dev server process:', error);
595
- }
596
- devServerProcess = null;
597
- }
598
- telemetry.trackMCPSessionEnd({
599
- sessionId: args.sessionId,
600
- rounds: totalRoundsCompleted,
601
- durationMs,
602
- browserSessionId: telemetryContext?.browserSessionId,
603
- browserDistinctId: telemetryContext?.browserDistinctId,
1075
+ const { totalRoundsCompleted } = await cleanupSessionResources({
1076
+ closeSessionId: args.sessionId,
1077
+ trackSessionEnd: true,
604
1078
  });
605
- if (activeRunCorrelation) {
606
- telemetry.trackAgentRunFailed({
607
- traceId: activeRunCorrelation.traceId,
608
- agentRunId: activeRunCorrelation.agentRunId,
609
- sessionId: args.sessionId,
610
- posthogSessionId: activeRunCorrelation.browserSessionId,
611
- durationMs: Date.now() - activeRunCorrelation.startedAt,
612
- errorType: 'mcp_session_closed',
613
- errorMessage: 'MCP session closed before apply completion confirmation',
614
- source: 'mcp',
615
- });
616
- }
617
- await telemetry.flush();
618
1079
  return {
619
1080
  content: [
620
1081
  {