rivet-design 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/bin/rivet.js +2 -0
  2. package/dist/index.js +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/mcp/server.d.ts.map +1 -1
  5. package/dist/mcp/server.js +564 -100
  6. package/dist/mcp/server.js.map +1 -1
  7. package/dist/routes/mcp.d.ts.map +1 -1
  8. package/dist/routes/mcp.js +40 -1
  9. package/dist/routes/mcp.js.map +1 -1
  10. package/dist/services/GatewayClient.d.ts.map +1 -1
  11. package/dist/services/GatewayClient.js +11 -2
  12. package/dist/services/GatewayClient.js.map +1 -1
  13. package/dist/services/ProjectDetectionService.d.ts +4 -0
  14. package/dist/services/ProjectDetectionService.d.ts.map +1 -1
  15. package/dist/services/ProjectDetectionService.js +85 -7
  16. package/dist/services/ProjectDetectionService.js.map +1 -1
  17. package/dist/services/SessionBridgeService.d.ts +27 -0
  18. package/dist/services/SessionBridgeService.d.ts.map +1 -1
  19. package/dist/services/SessionBridgeService.js +54 -0
  20. package/dist/services/SessionBridgeService.js.map +1 -1
  21. package/dist/services/TelemetryService.d.ts.map +1 -1
  22. package/dist/services/TelemetryService.js +15 -0
  23. package/dist/services/TelemetryService.js.map +1 -1
  24. package/dist/services/agent/AgentCore.js +2 -2
  25. package/dist/services/agent/AgentCore.js.map +1 -1
  26. package/dist/services/agent/AgentModService.d.ts +6 -0
  27. package/dist/services/agent/AgentModService.d.ts.map +1 -1
  28. package/dist/services/agent/AgentModService.js +12 -19
  29. package/dist/services/agent/AgentModService.js.map +1 -1
  30. package/dist/utils/logger.d.ts +1 -0
  31. package/dist/utils/logger.d.ts.map +1 -1
  32. package/dist/utils/logger.js +20 -2
  33. package/dist/utils/logger.js.map +1 -1
  34. package/package.json +12 -3
  35. package/src/ui/dist/assets/{main-jMwZV-Yc.js → main-DqoeOHsu.js} +101 -101
  36. package/src/ui/dist/assets/main-XaYyn4YU.css +1 -0
  37. package/src/ui/dist/index.html +2 -2
  38. package/src/ui/dist/assets/main-Bui5ryn7.css +0 -1
@@ -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`.
@@ -146,8 +388,11 @@ const APPLY_GUIDE = 'Read each file in sourceFiles, then apply every change in t
146
388
  'If a change has imageCount > 0, the corresponding reference images appear as image content items immediately following this text in the tool result — view them before making changes. ' +
147
389
  'CRITICAL WORKFLOW: 1. Use report_feedback to share a brief plan. 2. Edit the files. 3. Use report_feedback with refresh_git: true to summarize what you changed. 4. ONLY THEN wait for the next round.';
148
390
  const startMCPServer = async (mcpEditor) => {
149
- // Redirect all logging to stderr so stdout is reserved for MCP protocol
391
+ // Redirect all logging to stderr so stdout is reserved for MCP protocol,
392
+ // then silence all output — users shouldn't see Rivet logs in their terminal.
393
+ // Error capture (_onError) still fires for PostHog since it bypasses loglevel.
150
394
  (0, index_core_1.redirectLogsToStderr)();
395
+ (0, index_core_1.setLogLevel)('SILENT');
151
396
  const sendButtonLabel = getSendButtonLabel(mcpEditor);
152
397
  const bridge = new SessionBridgeService_1.SessionBridgeService();
153
398
  const telemetry = new TelemetryService_1.TelemetryService();
@@ -179,8 +424,90 @@ const startMCPServer = async (mcpEditor) => {
179
424
  let sessionStartedAt = null;
180
425
  // Track a dev server we auto-started so we can kill it on close
181
426
  let devServerProcess = null;
182
- // Flush telemetry before process exits
427
+ let devServerOwnership = 'none';
428
+ let devServerHost = null;
429
+ let devServerPort = null;
430
+ let isCleanupRunning = false;
431
+ const updateBridgeHealth = (reason, isReachable) => {
432
+ bridge.updateDevServerHealth({
433
+ reason,
434
+ isReachable,
435
+ lastCheckedAt: Date.now(),
436
+ });
437
+ };
438
+ const cleanupSessionResources = async (options) => {
439
+ if (isCleanupRunning) {
440
+ return { totalRoundsCompleted: 0, sessionId: null };
441
+ }
442
+ isCleanupRunning = true;
443
+ try {
444
+ const activeSessionId = bridge.getSessionId();
445
+ const sessionIdToClose = options?.closeSessionId ??
446
+ (bridge.isActive() && activeSessionId ? activeSessionId : null);
447
+ const telemetryContext = bridge.getTelemetryContext();
448
+ const activeRunCorrelation = bridge.completeActiveRunCorrelation();
449
+ let totalRoundsCompleted = 0;
450
+ if (sessionIdToClose && bridge.isActive()) {
451
+ totalRoundsCompleted = bridge.endSession();
452
+ }
453
+ if (serverHandle) {
454
+ try {
455
+ await serverHandle.close();
456
+ }
457
+ catch (error) {
458
+ log.warn('Error stopping Express server:', error);
459
+ }
460
+ serverHandle = null;
461
+ }
462
+ if (devServerOwnership === 'rivet_owned' && devServerProcess) {
463
+ try {
464
+ await stopChildProcess(devServerProcess);
465
+ }
466
+ catch (error) {
467
+ log.warn('Error stopping dev server process:', error);
468
+ }
469
+ }
470
+ devServerProcess = null;
471
+ devServerOwnership = 'none';
472
+ devServerHost = null;
473
+ devServerPort = null;
474
+ updateBridgeHealth('rivet_unreachable', false);
475
+ const durationMs = sessionStartedAt ? Date.now() - sessionStartedAt : 0;
476
+ sessionStartedAt = null;
477
+ if (options?.trackSessionEnd !== false && sessionIdToClose) {
478
+ telemetry.trackMCPSessionEnd({
479
+ sessionId: sessionIdToClose,
480
+ rounds: totalRoundsCompleted,
481
+ durationMs,
482
+ browserSessionId: telemetryContext?.browserSessionId,
483
+ browserDistinctId: telemetryContext?.browserDistinctId,
484
+ });
485
+ if (activeRunCorrelation) {
486
+ telemetry.trackAgentRunFailed({
487
+ traceId: activeRunCorrelation.traceId,
488
+ agentRunId: activeRunCorrelation.agentRunId,
489
+ sessionId: sessionIdToClose,
490
+ posthogSessionId: activeRunCorrelation.browserSessionId,
491
+ durationMs: Date.now() - activeRunCorrelation.startedAt,
492
+ errorType: 'mcp_session_closed',
493
+ errorMessage: 'MCP session closed before apply completion confirmation',
494
+ source: 'mcp',
495
+ });
496
+ }
497
+ }
498
+ await telemetry.flush();
499
+ return {
500
+ totalRoundsCompleted,
501
+ sessionId: sessionIdToClose,
502
+ };
503
+ }
504
+ finally {
505
+ isCleanupRunning = false;
506
+ }
507
+ };
508
+ // Flush telemetry + resources before process exits
183
509
  const shutdown = async () => {
510
+ await cleanupSessionResources({ trackSessionEnd: true });
184
511
  await telemetry.shutdown();
185
512
  };
186
513
  process.on('SIGTERM', async () => {
@@ -215,6 +542,18 @@ const startMCPServer = async (mcpEditor) => {
215
542
  .number()
216
543
  .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
544
  .optional(),
545
+ startupMode: v3_1.z
546
+ .enum(['manual_existing', 'ai_startup'])
547
+ .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.')
548
+ .optional(),
549
+ existingPort: v3_1.z
550
+ .number()
551
+ .describe('Port of an already-running dev server. Required for startupMode manual_existing, and reused for follow-up calls after nextStep responses.')
552
+ .optional(),
553
+ forceAttachExternal: v3_1.z
554
+ .boolean()
555
+ .describe('Confirmation flag for startupMode manual_existing when validation reports a project mismatch. Set true only after user confirms attaching anyway.')
556
+ .optional(),
218
557
  };
219
558
  const sessionIdArgs = {
220
559
  sessionId: v3_1.z
@@ -232,7 +571,7 @@ const startMCPServer = async (mcpEditor) => {
232
571
  .optional()
233
572
  .describe('How long to wait for the user to send changes (default: 600, max: 600)'),
234
573
  };
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) => {
574
+ 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
575
  const projectPath = args.projectPath || process.cwd();
237
576
  const detection = new index_core_1.ProjectDetectionService();
238
577
  let framework;
@@ -260,6 +599,9 @@ const startMCPServer = async (mcpEditor) => {
260
599
  devCommand: 'dev',
261
600
  defaultPort: 3000,
262
601
  };
602
+ const runningServer = framework === 'static'
603
+ ? null
604
+ : await detection.findRunningDevServer(projectPath, [index_1.DEFAULT_PORT]);
263
605
  return {
264
606
  content: [
265
607
  {
@@ -270,6 +612,9 @@ const startMCPServer = async (mcpEditor) => {
270
612
  devCommand: devConfig.devCommand,
271
613
  defaultPort: devConfig.defaultPort,
272
614
  projectPath,
615
+ devServerRunning: Boolean(runningServer),
616
+ detectedPort: runningServer?.port ?? null,
617
+ detectedHost: runningServer?.host ?? null,
273
618
  startCommand: devConfig.devCommand
274
619
  ? `${packageManager} run ${devConfig.devCommand}`
275
620
  : null,
@@ -278,7 +623,7 @@ const startMCPServer = async (mcpEditor) => {
278
623
  ],
279
624
  };
280
625
  });
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) => {
626
+ 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
627
  if (bridge.isActive()) {
283
628
  return {
284
629
  content: [
@@ -295,13 +640,18 @@ const startMCPServer = async (mcpEditor) => {
295
640
  }
296
641
  const projectPath = args.projectPath || process.cwd();
297
642
  const framework = args.framework ?? 'nextjs';
298
- let devServerPort = 0;
299
- const devServerHost = 'localhost';
643
+ const requestedStartupMode = args.startupMode;
644
+ const startupMode = requestedStartupMode ?? 'ai_startup';
645
+ let nextDevServerPort = null;
646
+ const nextDevServerHost = 'localhost';
647
+ let nextDevServerOwnership = 'none';
648
+ const startupModeSelection = requestedStartupMode ? 'explicit' : 'auto';
300
649
  if (framework !== 'static') {
301
650
  const frameworkConfig = FRAMEWORK_DEV_CONFIG[framework] ?? {
302
651
  devCommand: 'dev',
303
652
  defaultPort: 3000,
304
653
  };
654
+ const detection = new index_core_1.ProjectDetectionService();
305
655
  if (!frameworkConfig.devCommand) {
306
656
  return {
307
657
  content: [
@@ -316,51 +666,180 @@ const startMCPServer = async (mcpEditor) => {
316
666
  isError: true,
317
667
  };
318
668
  }
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
- };
669
+ if (startupMode === 'manual_existing') {
670
+ const existingPort = args.existingPort ?? args.startPort ?? null;
671
+ if (!existingPort) {
672
+ return {
673
+ content: [
674
+ {
675
+ type: 'text',
676
+ text: JSON.stringify({
677
+ error: 'startupMode manual_existing requires an existingPort value.',
678
+ suggestion: 'Pass existingPort for your running dev server.',
679
+ }),
680
+ },
681
+ ],
682
+ isError: true,
683
+ };
684
+ }
685
+ const manualValidation = await validateProjectServer({
686
+ host: nextDevServerHost,
687
+ port: existingPort,
688
+ framework,
689
+ projectPath,
690
+ });
691
+ if (!manualValidation.valid) {
692
+ if (args.forceAttachExternal) {
693
+ const isReachable = await waitForPort(existingPort, nextDevServerHost, 3000);
694
+ if (!isReachable) {
695
+ return {
696
+ content: [
697
+ {
698
+ type: 'text',
699
+ text: JSON.stringify({
700
+ error: `Cannot attach to ${nextDevServerHost}:${existingPort} because it is not reachable.`,
701
+ validation: manualValidation,
702
+ }),
703
+ },
704
+ ],
705
+ isError: true,
706
+ };
707
+ }
708
+ nextDevServerPort = existingPort;
709
+ nextDevServerOwnership = 'external';
710
+ log.info(`Force-attached to external dev server on ${nextDevServerHost}:${nextDevServerPort}`);
711
+ updateBridgeHealth('healthy', true);
712
+ }
713
+ else if (manualValidation.selectedProjectPath ||
714
+ (manualValidation.observedProcessPaths?.length ?? 0) > 0) {
715
+ return {
716
+ content: [
717
+ {
718
+ type: 'text',
719
+ text: JSON.stringify({
720
+ opened: false,
721
+ nextStep: 'mismatch_confirmation_required',
722
+ error: manualValidation.reason,
723
+ existingServerPort: existingPort,
724
+ selectedProjectPath: manualValidation.selectedProjectPath,
725
+ observedProcessPaths: manualValidation.observedProcessPaths,
726
+ validation: manualValidation,
727
+ nextAction: 'If the user confirms, call open_visual_editor again with startupMode: "manual_existing", existingPort, and forceAttachExternal: true. Otherwise ask for another port.',
728
+ }),
729
+ },
730
+ ],
731
+ isError: false,
732
+ };
733
+ }
734
+ else {
735
+ return {
736
+ content: [
737
+ {
738
+ type: 'text',
739
+ text: JSON.stringify({
740
+ error: manualValidation.reason,
741
+ validation: manualValidation,
742
+ }),
743
+ },
744
+ ],
745
+ isError: true,
746
+ };
747
+ }
748
+ }
749
+ if (!nextDevServerPort) {
750
+ nextDevServerPort = existingPort;
751
+ nextDevServerOwnership = 'external';
752
+ log.info(`Using existing dev server on ${nextDevServerHost}:${nextDevServerPort}`);
753
+ updateBridgeHealth('healthy', true);
754
+ }
755
+ }
756
+ else {
757
+ const packageManager = await detection.detectPackageManager(projectPath);
758
+ const preferredPort = args.startPort ??
759
+ detection.readConfiguredPort(projectPath) ??
760
+ frameworkConfig.defaultPort;
761
+ nextDevServerPort = await (0, portUtils_1.findAvailablePort)(preferredPort);
762
+ const { cmd, args: cmdArgs, env, } = buildDevServerCommand(framework, packageManager, frameworkConfig.devCommand, nextDevServerPort);
763
+ log.info(`Auto-starting dev server: ${cmd} ${cmdArgs.join(' ')} in ${projectPath}`);
764
+ devServerProcess = (0, child_process_1.spawn)(cmd, cmdArgs, {
765
+ cwd: projectPath,
766
+ env: { ...process.env, ...env },
767
+ stdio: 'pipe',
768
+ detached: false,
769
+ });
770
+ nextDevServerOwnership = 'rivet_owned';
771
+ devServerProcess.on('error', (err) => {
772
+ log.error('Dev server process error:', err);
773
+ });
774
+ devServerProcess.on('exit', (code) => {
775
+ log.info(`Dev server exited with code ${code}`);
776
+ devServerProcess = null;
777
+ if (bridge.isActive()) {
778
+ updateBridgeHealth('upstream_unreachable', false);
779
+ }
780
+ });
781
+ devServerProcess.stdout?.on('data', (data) => {
782
+ log.debug(`[dev-server] ${data.toString().trim()}`);
783
+ });
784
+ devServerProcess.stderr?.on('data', (data) => {
785
+ log.debug(`[dev-server] ${data.toString().trim()}`);
786
+ });
787
+ const ready = await waitForPort(nextDevServerPort, nextDevServerHost, 30_000);
788
+ if (!ready) {
789
+ if (devServerProcess) {
790
+ await stopChildProcess(devServerProcess);
791
+ }
792
+ devServerProcess = null;
793
+ const existingServer = await detection.findRunningDevServer(projectPath, [index_1.DEFAULT_PORT]);
794
+ return {
795
+ content: [
796
+ {
797
+ type: 'text',
798
+ text: JSON.stringify({
799
+ opened: false,
800
+ nextStep: 'manual_port_required',
801
+ error: `Dev server did not become ready on port ${nextDevServerPort} within 30s.`,
802
+ existingServerPort: existingServer?.port ?? null,
803
+ validation: null,
804
+ nextAction: 'Call open_visual_editor again with startupMode: "manual_existing" and existingPort set to a running app server port.',
805
+ }),
806
+ },
807
+ ],
808
+ isError: false,
809
+ };
810
+ }
811
+ const startupValidation = await validateProjectServer({
812
+ host: nextDevServerHost,
813
+ port: nextDevServerPort,
814
+ framework,
815
+ projectPath,
816
+ });
817
+ if (!startupValidation.valid) {
818
+ if (devServerProcess) {
819
+ await stopChildProcess(devServerProcess);
820
+ }
821
+ devServerProcess = null;
822
+ const existingServer = await detection.findRunningDevServer(projectPath, [index_1.DEFAULT_PORT]);
823
+ return {
824
+ content: [
825
+ {
826
+ type: 'text',
827
+ text: JSON.stringify({
828
+ opened: false,
829
+ nextStep: 'manual_port_required',
830
+ error: startupValidation.reason,
831
+ existingServerPort: existingServer?.port ?? null,
832
+ validation: startupValidation,
833
+ nextAction: 'Call open_visual_editor again with startupMode: "manual_existing" and existingPort set to a running app server port.',
834
+ }),
835
+ },
836
+ ],
837
+ isError: false,
838
+ };
839
+ }
840
+ updateBridgeHealth('healthy', true);
841
+ log.info(`Dev server ready on ${nextDevServerHost}:${nextDevServerPort}`);
362
842
  }
363
- log.info(`Dev server ready on ${devServerHost}:${devServerPort}`);
364
843
  }
365
844
  // Find a free port for Rivet's Express server (avoids conflict with desktop or other Rivet instances)
366
845
  let rivetPort;
@@ -381,8 +860,8 @@ const startMCPServer = async (mcpEditor) => {
381
860
  serverHandle = await (0, server_1.startServer)({
382
861
  framework,
383
862
  projectPath,
384
- userPort: devServerPort,
385
- userHost: devServerHost,
863
+ userPort: nextDevServerPort ?? 0,
864
+ userHost: nextDevServerHost,
386
865
  sessionBridge: bridge,
387
866
  telemetry,
388
867
  mcpEditor,
@@ -399,7 +878,9 @@ const startMCPServer = async (mcpEditor) => {
399
878
  }
400
879
  else {
401
880
  const message = error instanceof Error ? error.message : 'Server start failed';
402
- devServerProcess?.kill();
881
+ if (devServerProcess) {
882
+ await stopChildProcess(devServerProcess);
883
+ }
403
884
  devServerProcess = null;
404
885
  return {
405
886
  content: [
@@ -414,7 +895,9 @@ const startMCPServer = async (mcpEditor) => {
414
895
  }
415
896
  }
416
897
  if (!serverStarted) {
417
- devServerProcess?.kill();
898
+ if (devServerProcess) {
899
+ await stopChildProcess(devServerProcess);
900
+ }
418
901
  devServerProcess = null;
419
902
  return {
420
903
  content: [
@@ -430,6 +913,21 @@ const startMCPServer = async (mcpEditor) => {
430
913
  }
431
914
  const sessionId = bridge.startSession(projectPath);
432
915
  sessionStartedAt = Date.now();
916
+ devServerOwnership = nextDevServerOwnership;
917
+ devServerHost = nextDevServerHost;
918
+ devServerPort = nextDevServerPort;
919
+ bridge.setDevServerContext({
920
+ ownership: devServerOwnership,
921
+ host: devServerHost,
922
+ port: devServerPort,
923
+ startedByRivet: devServerOwnership === 'rivet_owned',
924
+ });
925
+ if (devServerOwnership === 'none') {
926
+ updateBridgeHealth('unknown', null);
927
+ }
928
+ else {
929
+ updateBridgeHealth('healthy', true);
930
+ }
433
931
  // Write editor-specific guidance file on first successful open
434
932
  if (mcpEditor === 'cursor') {
435
933
  (0, skillWriter_1.writeCursorRulesIfNeeded)(projectPath);
@@ -440,7 +938,7 @@ const startMCPServer = async (mcpEditor) => {
440
938
  // Track session start
441
939
  telemetry.trackMCPSessionStart({
442
940
  framework,
443
- devServerStarted: framework !== 'static',
941
+ devServerStarted: devServerOwnership === 'rivet_owned',
444
942
  sessionId,
445
943
  mcpEditor,
446
944
  });
@@ -462,7 +960,12 @@ const startMCPServer = async (mcpEditor) => {
462
960
  projectPath,
463
961
  framework,
464
962
  rivetPort,
465
- devServerPort,
963
+ opened: true,
964
+ devServerPort: nextDevServerPort,
965
+ devServerOwnership: nextDevServerOwnership,
966
+ startupMode,
967
+ startupModeSelection,
968
+ requestedStartupMode,
466
969
  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
970
  }),
468
971
  },
@@ -489,6 +992,7 @@ const startMCPServer = async (mcpEditor) => {
489
992
  intent = await bridge.waitForChanges(timeoutMs);
490
993
  }
491
994
  catch {
995
+ const health = bridge.getDevServerHealth();
492
996
  return {
493
997
  content: [
494
998
  {
@@ -496,6 +1000,7 @@ const startMCPServer = async (mcpEditor) => {
496
1000
  text: JSON.stringify({
497
1001
  hasChanges: false,
498
1002
  message: 'Timed out waiting for changes. Call again to keep waiting.',
1003
+ devServerHealth: health,
499
1004
  }),
500
1005
  },
501
1006
  ],
@@ -570,51 +1075,10 @@ const startMCPServer = async (mcpEditor) => {
570
1075
  isError: true,
571
1076
  };
572
1077
  }
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,
1078
+ const { totalRoundsCompleted } = await cleanupSessionResources({
1079
+ closeSessionId: args.sessionId,
1080
+ trackSessionEnd: true,
604
1081
  });
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
1082
  return {
619
1083
  content: [
620
1084
  {