react-native-ai-debugger 1.1.4 → 1.4.21

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 (144) hide show
  1. package/LICENSE +12 -1
  2. package/README.md +198 -1063
  3. package/build/__tests__/helpers/tap-test-helpers.d.ts +36 -0
  4. package/build/__tests__/helpers/tap-test-helpers.d.ts.map +1 -0
  5. package/build/__tests__/helpers/tap-test-helpers.js +130 -0
  6. package/build/__tests__/helpers/tap-test-helpers.js.map +1 -0
  7. package/build/__tests__/integration/connection-health.test.js +12 -6
  8. package/build/__tests__/integration/connection-health.test.js.map +1 -1
  9. package/build/__tests__/integration/execute-in-app.test.js +2 -1
  10. package/build/__tests__/integration/execute-in-app.test.js.map +1 -1
  11. package/build/__tests__/integration/tap-strategies.test.d.ts +2 -0
  12. package/build/__tests__/integration/tap-strategies.test.d.ts.map +1 -0
  13. package/build/__tests__/integration/tap-strategies.test.js +329 -0
  14. package/build/__tests__/integration/tap-strategies.test.js.map +1 -0
  15. package/build/__tests__/integration/tools.test.js +9 -10
  16. package/build/__tests__/integration/tools.test.js.map +1 -1
  17. package/build/__tests__/unit/burst.test.d.ts +2 -0
  18. package/build/__tests__/unit/burst.test.d.ts.map +1 -0
  19. package/build/__tests__/unit/burst.test.js +50 -0
  20. package/build/__tests__/unit/burst.test.js.map +1 -0
  21. package/build/__tests__/unit/connection-health.test.js +26 -16
  22. package/build/__tests__/unit/connection-health.test.js.map +1 -1
  23. package/build/__tests__/unit/fingerprint.test.d.ts +2 -0
  24. package/build/__tests__/unit/fingerprint.test.d.ts.map +1 -0
  25. package/build/__tests__/unit/fingerprint.test.js +89 -0
  26. package/build/__tests__/unit/fingerprint.test.js.map +1 -0
  27. package/build/__tests__/unit/imageBuffer.test.d.ts +2 -0
  28. package/build/__tests__/unit/imageBuffer.test.d.ts.map +1 -0
  29. package/build/__tests__/unit/imageBuffer.test.js +128 -0
  30. package/build/__tests__/unit/imageBuffer.test.js.map +1 -0
  31. package/build/__tests__/unit/logs.test.js +9 -0
  32. package/build/__tests__/unit/logs.test.js.map +1 -1
  33. package/build/__tests__/unit/networkInterceptor.test.d.ts +2 -0
  34. package/build/__tests__/unit/networkInterceptor.test.d.ts.map +1 -0
  35. package/build/__tests__/unit/networkInterceptor.test.js +104 -0
  36. package/build/__tests__/unit/networkInterceptor.test.js.map +1 -0
  37. package/build/__tests__/unit/screenshot-diff.test.d.ts +2 -0
  38. package/build/__tests__/unit/screenshot-diff.test.d.ts.map +1 -0
  39. package/build/__tests__/unit/screenshot-diff.test.js +97 -0
  40. package/build/__tests__/unit/screenshot-diff.test.js.map +1 -0
  41. package/build/__tests__/unit/tap.test.js +433 -28
  42. package/build/__tests__/unit/tap.test.js.map +1 -1
  43. package/build/core/android.d.ts.map +1 -1
  44. package/build/core/android.js +64 -29
  45. package/build/core/android.js.map +1 -1
  46. package/build/core/appDetection.d.ts +8 -0
  47. package/build/core/appDetection.d.ts.map +1 -0
  48. package/build/core/appDetection.js +91 -0
  49. package/build/core/appDetection.js.map +1 -0
  50. package/build/core/bundle.d.ts.map +1 -1
  51. package/build/core/bundle.js +15 -23
  52. package/build/core/bundle.js.map +1 -1
  53. package/build/core/config.d.ts +8 -0
  54. package/build/core/config.d.ts.map +1 -0
  55. package/build/core/config.js +27 -0
  56. package/build/core/config.js.map +1 -0
  57. package/build/core/connection.d.ts +47 -3
  58. package/build/core/connection.d.ts.map +1 -1
  59. package/build/core/connection.js +519 -124
  60. package/build/core/connection.js.map +1 -1
  61. package/build/core/executor.d.ts +57 -14
  62. package/build/core/executor.d.ts.map +1 -1
  63. package/build/core/executor.js +936 -208
  64. package/build/core/executor.js.map +1 -1
  65. package/build/core/fingerprint.d.ts +4 -0
  66. package/build/core/fingerprint.d.ts.map +1 -0
  67. package/build/core/fingerprint.js +47 -0
  68. package/build/core/fingerprint.js.map +1 -0
  69. package/build/core/guides.d.ts.map +1 -1
  70. package/build/core/guides.js +48 -9
  71. package/build/core/guides.js.map +1 -1
  72. package/build/core/httpServer.js.map +1 -1
  73. package/build/core/imageBuffer.d.ts +37 -0
  74. package/build/core/imageBuffer.d.ts.map +1 -0
  75. package/build/core/imageBuffer.js +58 -0
  76. package/build/core/imageBuffer.js.map +1 -0
  77. package/build/core/index.d.ts +21 -17
  78. package/build/core/index.d.ts.map +1 -1
  79. package/build/core/index.js +21 -19
  80. package/build/core/index.js.map +1 -1
  81. package/build/core/ios.d.ts +36 -7
  82. package/build/core/ios.d.ts.map +1 -1
  83. package/build/core/ios.js +553 -323
  84. package/build/core/ios.js.map +1 -1
  85. package/build/core/license.d.ts +38 -0
  86. package/build/core/license.d.ts.map +1 -0
  87. package/build/core/license.js +265 -0
  88. package/build/core/license.js.map +1 -0
  89. package/build/core/logbox.d.ts +51 -0
  90. package/build/core/logbox.d.ts.map +1 -0
  91. package/build/core/logbox.js +258 -0
  92. package/build/core/logbox.js.map +1 -0
  93. package/build/core/logs.d.ts +1 -0
  94. package/build/core/logs.d.ts.map +1 -1
  95. package/build/core/logs.js +5 -0
  96. package/build/core/logs.js.map +1 -1
  97. package/build/core/metro.d.ts +8 -0
  98. package/build/core/metro.d.ts.map +1 -1
  99. package/build/core/metro.js +28 -0
  100. package/build/core/metro.js.map +1 -1
  101. package/build/core/networkInterceptor.d.ts +30 -0
  102. package/build/core/networkInterceptor.d.ts.map +1 -0
  103. package/build/core/networkInterceptor.js +207 -0
  104. package/build/core/networkInterceptor.js.map +1 -0
  105. package/build/core/ocr.d.ts +8 -3
  106. package/build/core/ocr.d.ts.map +1 -1
  107. package/build/core/ocr.js +116 -15
  108. package/build/core/ocr.js.map +1 -1
  109. package/build/core/posthog.d.ts +5 -0
  110. package/build/core/posthog.d.ts.map +1 -0
  111. package/build/core/posthog.js +46 -0
  112. package/build/core/posthog.js.map +1 -0
  113. package/build/core/sdkBridge.d.ts +67 -0
  114. package/build/core/sdkBridge.d.ts.map +1 -0
  115. package/build/core/sdkBridge.js +148 -0
  116. package/build/core/sdkBridge.js.map +1 -0
  117. package/build/core/state.d.ts +26 -5
  118. package/build/core/state.d.ts.map +1 -1
  119. package/build/core/state.js +83 -10
  120. package/build/core/state.js.map +1 -1
  121. package/build/core/telemetry.d.ts +19 -1
  122. package/build/core/telemetry.d.ts.map +1 -1
  123. package/build/core/telemetry.js +165 -8
  124. package/build/core/telemetry.js.map +1 -1
  125. package/build/core/types.d.ts +11 -0
  126. package/build/core/types.d.ts.map +1 -1
  127. package/build/index.js +1304 -304
  128. package/build/index.js.map +1 -1
  129. package/build/pro/screenshot-diff.d.ts +10 -0
  130. package/build/pro/screenshot-diff.d.ts.map +1 -0
  131. package/build/pro/screenshot-diff.js +38 -0
  132. package/build/pro/screenshot-diff.js.map +1 -0
  133. package/build/pro/tap.d.ts +160 -0
  134. package/build/pro/tap.d.ts.map +1 -0
  135. package/build/pro/tap.js +1215 -0
  136. package/build/pro/tap.js.map +1 -0
  137. package/build/tools/accountTools.d.ts +44 -0
  138. package/build/tools/accountTools.d.ts.map +1 -0
  139. package/build/tools/accountTools.js +217 -0
  140. package/build/tools/accountTools.js.map +1 -0
  141. package/package.json +11 -8
  142. package/build/core/httpServer.js +0 -2459
  143. package/build/core/httpServerProcess.js +0 -153
  144. package/build/httpServerStandalone.js +0 -31
package/build/index.js CHANGED
@@ -3,14 +3,21 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
5
  import { createServer as createHttpServer } from "node:http";
6
+ import { existsSync, unlinkSync } from "fs";
6
7
  import { z } from "zod";
7
8
  import { getGuideOverview, getGuideByTopic, getAvailableTopics } from "./core/guides.js";
8
- import { tap } from "./core/tap.js";
9
- import { logBuffer, networkBuffer, bundleErrorBuffer, connectedApps, getActiveSimulatorUdid, scanMetroPorts, fetchDevices, selectMainDevice, connectToDevice, getConnectedApps, executeInApp, listDebugGlobals, inspectGlobal, reloadApp,
9
+ import { getLicenseStatus, getDashboardUrl, getUsageInfo } from "./core/license.js";
10
+ import { API_BASE_URL } from "./core/config.js";
11
+ import { getPostHogClient, identifyIfDevMode, shutdownPostHog } from "./core/posthog.js";
12
+ import { getInstallationId, getPackageName, getServerVersion, isDevMode, TELEMETRY_JSONL_PATH } from "./core/telemetry.js";
13
+ import { isSDKInstalled, querySDKNetwork, getSDKNetworkEntry, getSDKNetworkStats, clearSDKNetwork, querySDKConsole, getSDKConsoleStats, clearSDKConsole } from "./core/sdkBridge.js";
14
+ import { tap } from "./pro/tap.js";
15
+ import { getActivateLicenseConfig, handleActivateLicense, getDeleteAccountConfig, handleDeleteAccount, } from "./tools/accountTools.js";
16
+ import { logBuffers, networkBuffers, getLogBuffer, getNetworkBuffer, getTotalLogCount, getConnectedAppByDevice, LogBuffer, NetworkBuffer, bundleErrorBuffer, imageBuffer, connectedApps, scanMetroPorts, fetchDevices, filterDebuggableDevices, connectToDevice, getConnectedApps, executeInApp, listDebugGlobals, inspectGlobal, reloadApp,
10
17
  // React Component Inspection
11
18
  getComponentTree, getScreenLayout, inspectComponent, findComponents, inspectAtPoint, toggleElementInspector, isInspectorActive, getInspectorSelection, getFirstConnectedApp, getLogs, searchLogs, getLogSummary, getNetworkRequests, searchNetworkRequests, getNetworkStats, formatRequestDetails,
12
19
  // Connection state
13
- getAllConnectionStates, getAllConnectionMetadata, getRecentGaps, formatDuration, cancelAllReconnectionTimers, clearAllConnectionState, suppressReconnection, clearReconnectionSuppression,
20
+ getAllConnectionStates, getAllConnectionMetadata, getRecentGaps, formatDuration, cancelAllReconnectionTimers, cancelReconnectionTimer, clearAllConnectionState, clearAllCDPMessageTimes, suppressReconnection, suppressReconnectionForKey, clearReconnectionSuppression, purgeStaleConnectionsForPorts,
14
21
  // Context health tracking
15
22
  getContextHealth,
16
23
  // Connection resilience
@@ -20,7 +27,7 @@ connectMetroBuildEvents, disconnectMetroBuildEvents, getBundleErrors, getBundleS
20
27
  // Error screen parsing (OCR fallback)
21
28
  parseErrorScreenText, formatParsedError,
22
29
  // OCR
23
- recognizeText, inferIOSDevicePixelRatio,
30
+ recognizeText, inferIOSDevicePixelRatio, enrichScreenshotWithLayout,
24
31
  // Android
25
32
  listAndroidDevices, androidScreenshot, androidInstallApp, androidLaunchApp, androidListPackages,
26
33
  // Android UI Input (Phase 2)
@@ -35,15 +42,69 @@ listIOSSimulators, iosScreenshot, iosInstallApp, iosLaunchApp, iosOpenUrl, iosTe
35
42
  iosTap, iosSwipe, iosInputText, iosButton, iosKeyEvent, iosKeySequence, iosDescribeAll, iosDescribePoint, IOS_BUTTON_TYPES,
36
43
  // iOS Element Finding (no screenshots)
37
44
  iosFindElement, iosWaitForElement,
38
- // Debug HTTP Server
39
- startDebugHttpServer, getDebugServerPort,
40
45
  // Telemetry
41
- initTelemetry, trackToolInvocation,
46
+ initTelemetry, trackToolInvocation, getTargetPlatform,
42
47
  // Format utilities (TONL)
43
- formatLogsAsTonl, formatNetworkAsTonl } from "./core/index.js";
48
+ formatLogsAsTonl, formatNetworkAsTonl,
49
+ // LogBox detection & control
50
+ detectLogBox, formatLogBoxWarning, dismissLogBox, formatDismissedEntries, pushLogBox, addLogBoxIgnorePatterns, verifyLogPipeline } from "./core/index.js";
51
+ // Legacy package migration: notify once per session via LogBox
52
+ const LEGACY_PACKAGE_NAME = "react-native-ai-debugger";
53
+ let legacyMigrationNotified = false;
54
+ async function notifyLegacyPackageMigration() {
55
+ if (legacyMigrationNotified)
56
+ return;
57
+ if (getPackageName() !== LEGACY_PACKAGE_NAME)
58
+ return;
59
+ legacyMigrationNotified = true;
60
+ try {
61
+ await pushLogBox("You are using the deprecated 'react-native-ai-debugger' package. " +
62
+ "Please update your MCP settings to use 'react-native-ai-devtools' instead. " +
63
+ "See: https://www.npmjs.com/package/react-native-ai-devtools", "warning", true);
64
+ }
65
+ catch {
66
+ // Best-effort — don't break the connection flow
67
+ }
68
+ }
69
+ // Helper: resolve log buffer for a device (or create a merged buffer from all devices)
70
+ function resolveLogBuffer(device) {
71
+ if (device) {
72
+ const app = getConnectedAppByDevice(device);
73
+ if (!app)
74
+ throw new Error(`No connected device matches "${device}"`);
75
+ const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
76
+ return getLogBuffer(deviceName);
77
+ }
78
+ // Merge all logs into a temporary buffer for read operations
79
+ const merged = new LogBuffer(5000);
80
+ for (const buffer of logBuffers.values()) {
81
+ for (const entry of buffer.getAll()) {
82
+ merged.add(entry);
83
+ }
84
+ }
85
+ return merged;
86
+ }
87
+ // Helper: resolve network buffer for a device (or create a merged buffer from all devices)
88
+ function resolveNetworkBuffer(device) {
89
+ if (device) {
90
+ const app = getConnectedAppByDevice(device);
91
+ if (!app)
92
+ throw new Error(`No connected device matches "${device}"`);
93
+ const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
94
+ return getNetworkBuffer(deviceName);
95
+ }
96
+ // Merge all network requests into a temporary buffer for read operations
97
+ const merged = new NetworkBuffer(5000);
98
+ for (const buffer of networkBuffers.values()) {
99
+ for (const req of buffer.getAll({})) {
100
+ merged.set(`${Math.random()}`, req);
101
+ }
102
+ }
103
+ return merged;
104
+ }
44
105
  // Create MCP server
45
106
  const server = new McpServer({
46
- name: "react-native-ai-debugger",
107
+ name: "react-native-ai-devtools",
47
108
  version: "1.0.0"
48
109
  }, {
49
110
  instructions: "React Native debugging MCP server. Call get_usage_guide to learn recommended workflows for all tools. Quick start: scan_metro → get_logs / search_logs (console debugging) → ios_screenshot → tap(text=\"Submit\") or tap(x, y) (interact with UI)."
@@ -115,15 +176,32 @@ function estimateImageTokens(base64Data) {
115
176
  // Registry for dev meta-tool — stores handlers and configs for dynamic dispatch
116
177
  /* eslint-disable @typescript-eslint/no-explicit-any */
117
178
  const toolRegistry = new Map();
118
- function registerToolWithTelemetry(toolName, config, handler) {
179
+ function registerToolWithTelemetry(toolName, config, handler, emptyResultDetector) {
119
180
  toolRegistry.set(toolName, { config, handler });
120
181
  server.registerTool(toolName, config, async (args) => {
182
+ // --- Usage limit check (runs on every tool call) ---
183
+ const usageInfo = getUsageInfo();
184
+ if (usageInfo && !usageInfo.canUse) {
185
+ const isCredits = usageInfo.creditsRemaining !== null;
186
+ const dashboardUrl = `${API_BASE_URL}/dashboard/usage`;
187
+ const message = isCredits
188
+ ? `Credits depleted. Purchase more at ${dashboardUrl}`
189
+ : `Monthly free limit reached (${usageInfo.used}/${usageInfo.limit}). Purchase a credits pack for unlimited usage at ${dashboardUrl}`;
190
+ return { content: [{ type: "text", text: message }] };
191
+ }
121
192
  const startTime = Date.now();
122
193
  let success = true;
123
194
  let errorMessage;
124
195
  let errorContext;
125
196
  let inputTokens;
126
197
  let outputTokens;
198
+ let emptyResult;
199
+ let meaningful;
200
+ let changeRate;
201
+ let tapStrategy;
202
+ let iosDriver;
203
+ let responsePreview;
204
+ let emptyReason;
127
205
  try {
128
206
  inputTokens = Math.ceil(JSON.stringify(args).length / 4);
129
207
  }
@@ -135,10 +213,31 @@ function registerToolWithTelemetry(toolName, config, handler) {
135
213
  // Check if result indicates an error
136
214
  if (result?.isError) {
137
215
  success = false;
138
- errorMessage = result.content?.[0]?.text || "Unknown error";
216
+ // Prefer concise _errorMessage over full response text (which may be large JSON)
217
+ errorMessage = result._errorMessage || result.content?.[0]?.text || "Unknown error";
139
218
  // Extract error context if provided (e.g., the expression that caused a syntax error)
140
219
  errorContext = result._errorContext;
141
220
  }
221
+ // Check for empty result (only on success, only if detector provided)
222
+ if (success && emptyResultDetector) {
223
+ try {
224
+ emptyResult = emptyResultDetector(result);
225
+ }
226
+ catch {
227
+ // Detector failure should never affect tool execution
228
+ }
229
+ }
230
+ // Extract meaningfulness data if provided (tap tool verification)
231
+ if (result?._meaningful !== undefined)
232
+ meaningful = result._meaningful;
233
+ if (result?._changeRate !== undefined)
234
+ changeRate = result._changeRate;
235
+ if (result?._tapStrategy)
236
+ tapStrategy = result._tapStrategy;
237
+ if (result?._iosDriver)
238
+ iosDriver = result._iosDriver;
239
+ if (result?._emptyReason)
240
+ emptyReason = result._emptyReason;
142
241
  if (Array.isArray(result?.content)) {
143
242
  let totalTokens = 0;
144
243
  for (const item of result.content) {
@@ -152,16 +251,37 @@ function registerToolWithTelemetry(toolName, config, handler) {
152
251
  if (totalTokens > 0)
153
252
  outputTokens = totalTokens;
154
253
  }
254
+ // Capture response text preview for local dev dashboard
255
+ if (Array.isArray(result?.content)) {
256
+ const textParts = result.content
257
+ .filter((item) => item.type === "text")
258
+ .map((item) => item.text);
259
+ if (textParts.length > 0) {
260
+ responsePreview = textParts.join("\n").substring(0, 2000);
261
+ }
262
+ }
155
263
  return result;
156
264
  }
157
265
  catch (error) {
158
266
  success = false;
159
267
  errorMessage = error instanceof Error ? error.message : String(error);
268
+ getPostHogClient()?.captureException(error, getInstallationId(), { tool: toolName, server_version: getServerVersion() });
160
269
  throw error;
161
270
  }
162
271
  finally {
163
272
  const duration = Date.now() - startTime;
164
- trackToolInvocation(toolName, success, duration, errorMessage, errorContext, inputTokens, outputTokens);
273
+ trackToolInvocation(toolName, success, duration, errorMessage, errorContext, inputTokens, outputTokens, getTargetPlatform(), emptyResult, meaningful, changeRate, tapStrategy, iosDriver, responsePreview, emptyReason);
274
+ getPostHogClient()?.capture({
275
+ distinctId: getInstallationId(),
276
+ event: toolName,
277
+ properties: {
278
+ success,
279
+ duration,
280
+ server_version: getServerVersion(),
281
+ ...(errorMessage && { error: errorMessage.substring(0, 200) }),
282
+ ...(getTargetPlatform() && { platform: getTargetPlatform() }),
283
+ },
284
+ });
165
285
  }
166
286
  });
167
287
  }
@@ -219,33 +339,90 @@ registerToolWithTelemetry("scan_metro", {
219
339
  ]
220
340
  };
221
341
  }
222
- // Fetch devices from each port and connect
223
- const results = [];
342
+ // Phase 1: Fetch devices from all ports first
343
+ const portDevices = new Map();
224
344
  for (const port of openPorts) {
225
345
  const devices = await fetchDevices(port);
226
- if (devices.length === 0) {
227
- results.push(`Port ${port}: No devices found`);
346
+ const debuggable = filterDebuggableDevices(devices);
347
+ if (debuggable.length > 0) {
348
+ portDevices.set(port, debuggable);
349
+ }
350
+ }
351
+ // Phase 1.5: Purge stale connections for scanned ports
352
+ // When Metro restarts with a different app, device IDs change.
353
+ // Old connections remain in connectedApps — remove them before connecting new devices.
354
+ const purged = purgeStaleConnectionsForPorts(portDevices);
355
+ // Phase 2: Assign each device to the best port
356
+ // If a device appears on multiple ports, prefer the port where it has
357
+ // the fewest OTHER unique devices (i.e. its dedicated Metro server)
358
+ const devicePortAssignment = new Map(); // deviceName -> best port
359
+ const allDeviceNames = new Map();
360
+ for (const [port, devices] of portDevices) {
361
+ for (const device of devices) {
362
+ const name = device.deviceName || device.title;
363
+ const entry = allDeviceNames.get(name);
364
+ if (entry) {
365
+ entry.ports.push(port);
366
+ }
367
+ else {
368
+ allDeviceNames.set(name, { device, ports: [port] });
369
+ }
370
+ }
371
+ }
372
+ for (const [name, { ports }] of allDeviceNames) {
373
+ if (ports.length === 1) {
374
+ devicePortAssignment.set(name, ports[0]);
375
+ }
376
+ else {
377
+ // Prefer the port with fewer OTHER unique devices (the dedicated Metro)
378
+ let bestPort = ports[0];
379
+ let fewestOthers = Infinity;
380
+ for (const port of ports) {
381
+ const othersOnPort = (portDevices.get(port) || [])
382
+ .filter(d => (d.deviceName || d.title) !== name).length;
383
+ if (othersOnPort < fewestOthers) {
384
+ fewestOthers = othersOnPort;
385
+ bestPort = port;
386
+ }
387
+ }
388
+ devicePortAssignment.set(name, bestPort);
389
+ }
390
+ }
391
+ // Phase 3: Connect devices to their assigned ports
392
+ const results = [];
393
+ if (purged.length > 0) {
394
+ results.push(`Purged ${purged.length} stale connection(s): ${purged.join(", ")}`);
395
+ }
396
+ for (const port of openPorts) {
397
+ const devices = portDevices.get(port);
398
+ if (!devices) {
399
+ results.push(`Port ${port}: No debuggable devices found`);
228
400
  continue;
229
401
  }
230
402
  results.push(`Port ${port}: Found ${devices.length} device(s)`);
231
- const mainDevice = selectMainDevice(devices);
232
- if (mainDevice) {
403
+ for (const device of devices) {
404
+ const name = device.deviceName || device.title;
405
+ const assignedPort = devicePortAssignment.get(name);
406
+ if (assignedPort !== port) {
407
+ results.push(` - ${name}: Skipped (assigned to port ${assignedPort})`);
408
+ continue;
409
+ }
233
410
  try {
234
- const connectionResult = await connectToDevice(mainDevice, port);
411
+ const connectionResult = await connectToDevice(device, port);
235
412
  results.push(` - ${connectionResult}`);
236
- // Also connect to Metro build events for this port
237
- try {
238
- await connectMetroBuildEvents(port);
239
- results.push(` - Connected to Metro build events`);
240
- }
241
- catch {
242
- // Build events connection is optional, don't fail the scan
243
- }
244
413
  }
245
414
  catch (error) {
246
- results.push(` - Failed: ${error}`);
415
+ results.push(` - ${name}: Failed - ${error}`);
247
416
  }
248
417
  }
418
+ // Connect to Metro build events for this port
419
+ try {
420
+ await connectMetroBuildEvents(port);
421
+ results.push(` - Connected to Metro build events`);
422
+ }
423
+ catch {
424
+ // Build events connection is optional
425
+ }
249
426
  }
250
427
  return {
251
428
  content: [
@@ -261,33 +438,34 @@ registerToolWithTelemetry("get_apps", {
261
438
  description: "List currently connected React Native apps and their connection status. If no apps are connected, run scan_metro first to establish a connection.",
262
439
  inputSchema: {}
263
440
  }, async () => {
264
- const connections = getConnectedApps();
265
- if (connections.length === 0) {
441
+ const apps = getConnectedApps();
442
+ if (apps.length === 0) {
266
443
  return {
267
444
  content: [
268
445
  {
269
446
  type: "text",
270
- text: 'No apps connected. Run "scan_metro" first to discover and connect to running apps.'
447
+ text: "No connected devices. Run scan_metro to discover and connect to Metro servers."
271
448
  }
272
449
  ]
273
450
  };
274
451
  }
275
- const status = connections.map(({ app, isConnected }) => {
276
- const state = isConnected ? "Connected" : "Disconnected";
277
- return `${app.deviceInfo.title} (${app.deviceInfo.deviceName}): ${state}`;
452
+ const deviceLines = apps
453
+ .filter(({ isConnected }) => isConnected)
454
+ .map(({ app }, i) => {
455
+ const name = app.deviceInfo.deviceName || app.deviceInfo.title;
456
+ const appId = app.deviceInfo.appId || app.deviceInfo.title.split(" (")[0] || "unknown";
457
+ return ` ${i + 1}. ${name} — ${appId} (${app.platform}, port ${app.port})`;
278
458
  });
279
- // Include active iOS simulator info if available
280
- const activeSimulatorUdid = getActiveSimulatorUdid();
281
- const simulatorInfo = activeSimulatorUdid
282
- ? `\nActive iOS Simulator (auto-scoped): ${activeSimulatorUdid}`
283
- : "\nNo iOS simulator linked (iOS tools will use first booted simulator)";
459
+ const text = [
460
+ `Connected devices:`,
461
+ ...deviceLines,
462
+ ``,
463
+ `Use device="${apps[0].app.deviceInfo.deviceName}" to target a specific device.`,
464
+ ``,
465
+ `Total logs in buffer: ${getTotalLogCount()}`
466
+ ].join("\n");
284
467
  return {
285
- content: [
286
- {
287
- type: "text",
288
- text: `Connected apps:\n${status.join("\n")}${simulatorInfo}\n\nTotal logs in buffer: ${logBuffer.size}`
289
- }
290
- ]
468
+ content: [{ type: "text", text }]
291
469
  };
292
470
  });
293
471
  // Tool: Get connection status (detailed health and gap tracking)
@@ -370,6 +548,53 @@ registerToolWithTelemetry("get_connection_status", {
370
548
  content: [{ type: "text", text: lines.join("\n") }]
371
549
  };
372
550
  });
551
+ // Tool: License status
552
+ registerToolWithTelemetry("get_license_status", {
553
+ description: "Get your installation ID and license status. Shows your unique Installation ID (needed to activate Pro in the dashboard), current license tier, and cache validity.",
554
+ inputSchema: {},
555
+ }, async () => {
556
+ const status = getLicenseStatus();
557
+ const lines = [];
558
+ lines.push(`Installation ID: ${status.installationId}`);
559
+ lines.push(`License: ${status.tier.charAt(0).toUpperCase() + status.tier.slice(1)}`);
560
+ if (status.plan) {
561
+ lines.push(`Plan expires: ${status.plan.expiresAt}`);
562
+ }
563
+ lines.push(`Cache valid until: ${status.cacheExpiresAt}`);
564
+ // Usage limits
565
+ const usage = getUsageInfo();
566
+ if (usage) {
567
+ lines.push("");
568
+ lines.push("--- Usage ---");
569
+ if (usage.creditsRemaining !== null) {
570
+ lines.push(`Credits remaining: ${usage.creditsRemaining.toLocaleString()}`);
571
+ lines.push(`Used this month: ${usage.used.toLocaleString()}`);
572
+ }
573
+ else {
574
+ lines.push(`Monthly usage: ${usage.used} / ${usage.limit}`);
575
+ }
576
+ lines.push(`Month: ${usage.monthKey}`);
577
+ if (usage.promotionalPeriod) {
578
+ const endsAt = usage.promotionalPeriodEndsAt
579
+ ? new Date(usage.promotionalPeriodEndsAt).toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric" })
580
+ : "unknown";
581
+ lines.push(`Status: Active (promotional period — no limits until ${endsAt})`);
582
+ }
583
+ else {
584
+ lines.push(`Status: ${usage.canUse ? "Active" : "Limit reached"}`);
585
+ }
586
+ }
587
+ if (status.tier === "free") {
588
+ const dashboardUrl = getDashboardUrl();
589
+ lines.push("");
590
+ if (dashboardUrl) {
591
+ lines.push(`Link your account: ${dashboardUrl}/link?id=${status.installationId}`);
592
+ }
593
+ }
594
+ return {
595
+ content: [{ type: "text", text: lines.join("\n") }],
596
+ };
597
+ });
373
598
  // Tool: Ensure connection health
374
599
  registerToolWithTelemetry("ensure_connection", {
375
600
  description: "Verify or establish a healthy connection to a React Native app. Use before running commands if connection may be stale, or after navigation/reload. This tool runs a health check and will auto-reconnect if needed.",
@@ -393,12 +618,14 @@ registerToolWithTelemetry("ensure_connection", {
393
618
  content: [
394
619
  {
395
620
  type: "text",
396
- text: result.error || "Connection failed: Unknown error"
621
+ text: result.error || "Connection failed: No error details available. Try running scan_metro to check if Metro is running, then ensure_connection with forceRefresh=true."
397
622
  }
398
623
  ],
399
624
  isError: true
400
625
  };
401
626
  }
627
+ // Notify legacy package users (fire-and-forget, once per session)
628
+ notifyLegacyPackageMigration();
402
629
  const lines = [];
403
630
  lines.push("=== Connection Ensured ===\n");
404
631
  if (result.connectionInfo) {
@@ -455,16 +682,71 @@ registerToolWithTelemetry("get_logs", {
455
682
  .boolean()
456
683
  .optional()
457
684
  .default(false)
458
- .describe("Return summary statistics instead of full logs (count by level + last 5 messages). Use for quick overview.")
685
+ .describe("Return summary statistics instead of full logs (count by level + last 5 messages). Use for quick overview."),
686
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
687
+ }
688
+ }, async ({ maxLogs, level, startFromText, maxMessageLength, verbose, format, summary, device }) => {
689
+ // Check if SDK is installed — prefer SDK data for richer logs
690
+ const sdkAvailable = await isSDKInstalled();
691
+ if (sdkAvailable) {
692
+ if (summary) {
693
+ const sdkStats = await getSDKConsoleStats();
694
+ if (sdkStats.success) {
695
+ const s = sdkStats.data;
696
+ const lines = [];
697
+ lines.push(`Total logs: ${s.total}`);
698
+ if (s.byLevel && Object.keys(s.byLevel).length > 0) {
699
+ lines.push("\nBy Level:");
700
+ for (const [lvl, cnt] of Object.entries(s.byLevel))
701
+ lines.push(` ${lvl}: ${cnt}`);
702
+ }
703
+ return { content: [{ type: "text", text: `Log Summary (SDK):\n\n${lines.join("\n")}` }] };
704
+ }
705
+ }
706
+ const sdkResult = await querySDKConsole({ count: maxLogs, level, text: startFromText });
707
+ if (sdkResult.success) {
708
+ const entries = sdkResult.data;
709
+ if (entries.length === 0) {
710
+ return { content: [{ type: "text", text: "No console logs captured yet." }] };
711
+ }
712
+ if (format === "tonl") {
713
+ const tonlLines = entries.map((e) => {
714
+ const time = new Date(e.timestamp).toLocaleTimeString();
715
+ let msg = e.message;
716
+ if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
717
+ msg = msg.slice(0, maxMessageLength) + "...";
718
+ }
719
+ return `${time} [${e.level}] ${msg}`;
720
+ });
721
+ return { content: [{ type: "text", text: `Console Logs (${entries.length} entries, SDK):\n\n${tonlLines.join("\n")}` }] };
722
+ }
723
+ const lines = entries.map((e) => {
724
+ const time = new Date(e.timestamp).toLocaleTimeString();
725
+ let msg = e.message;
726
+ if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
727
+ msg = msg.slice(0, maxMessageLength) + "...";
728
+ }
729
+ return `[${time}] [${e.level.toUpperCase()}] ${msg}`;
730
+ });
731
+ return { content: [{ type: "text", text: `Console Logs (${entries.length} entries, SDK):\n\n${lines.join("\n")}` }] };
732
+ }
459
733
  }
460
- }, async ({ maxLogs, level, startFromText, maxMessageLength, verbose, format, summary }) => {
461
734
  // Return summary if requested
462
735
  if (summary) {
463
- const summaryText = getLogSummary(logBuffer, { lastN: 5, maxMessageLength: 100 });
736
+ const summaryText = getLogSummary(resolveLogBuffer(device), { lastN: 5, maxMessageLength: 100 });
464
737
  let connectionWarning = "";
465
- if (logBuffer.size === 0) {
466
- const status = await checkAndEnsureConnection();
738
+ if (getTotalLogCount() === 0) {
739
+ const status = await checkAndEnsureConnection(device);
467
740
  connectionWarning = status.message ? `\n\n${status.message}` : "";
741
+ if (status.connected) {
742
+ const targetApp = device ? getConnectedAppByDevice(device) : getFirstConnectedApp();
743
+ if (targetApp) {
744
+ const pipeline = await verifyLogPipeline(targetApp);
745
+ if (pipeline.message) {
746
+ connectionWarning += `\n\n${pipeline.message}`;
747
+ }
748
+ }
749
+ }
468
750
  }
469
751
  return {
470
752
  content: [
@@ -475,7 +757,7 @@ registerToolWithTelemetry("get_logs", {
475
757
  ]
476
758
  };
477
759
  }
478
- const { logs, count, formatted } = getLogs(logBuffer, {
760
+ const { logs, count, formatted } = getLogs(resolveLogBuffer(device), {
479
761
  maxLogs,
480
762
  level,
481
763
  startFromText,
@@ -484,9 +766,60 @@ registerToolWithTelemetry("get_logs", {
484
766
  });
485
767
  // Check connection health
486
768
  let connectionWarning = "";
769
+ let emptyReason;
487
770
  if (count === 0) {
488
- const status = await checkAndEnsureConnection();
771
+ const status = await checkAndEnsureConnection(device);
489
772
  connectionWarning = status.message ? `\n\n${status.message}` : "";
773
+ // Track empty reason for telemetry
774
+ emptyReason = "no_logs";
775
+ if (!status.connected) {
776
+ emptyReason = "disconnected";
777
+ }
778
+ else if (status.wasReconnected) {
779
+ emptyReason = "post_reconnect";
780
+ }
781
+ // End-to-end log pipeline verification (with automatic recovery)
782
+ if (status.connected) {
783
+ const targetApp = device ? getConnectedAppByDevice(device) : getFirstConnectedApp();
784
+ if (targetApp) {
785
+ const pipeline = await verifyLogPipeline(targetApp);
786
+ if (pipeline.message) {
787
+ connectionWarning += `\n\n${pipeline.message}`;
788
+ }
789
+ if (!pipeline.ok) {
790
+ emptyReason = "pipeline_failed";
791
+ }
792
+ else if (pipeline.recovered) {
793
+ emptyReason = "pipeline_recovered";
794
+ }
795
+ // If pipeline recovered, re-read the buffer — new logs may have arrived
796
+ if (pipeline.recovered && pipeline.ok) {
797
+ const retryResult = getLogs(resolveLogBuffer(device), {
798
+ maxLogs, level, startFromText, maxMessageLength, verbose
799
+ });
800
+ if (retryResult.count > 0) {
801
+ // Return the recovered logs instead of empty
802
+ return {
803
+ _emptyReason: "pipeline_recovered",
804
+ content: [{
805
+ type: "text",
806
+ text: `React Native Console Logs (${retryResult.count} entries):\n\n${retryResult.formatted}${connectionWarning}`
807
+ }]
808
+ };
809
+ }
810
+ }
811
+ }
812
+ }
813
+ // Add diagnostic metadata for empty results (captured by telemetry via responsePreview)
814
+ if (count === 0) {
815
+ const diagParts = [
816
+ `empty_reason=${status?.wasReconnected ? "post_reconnect" : "no_logs"}`,
817
+ `connection=${getPassiveConnectionStatus().reason}`,
818
+ `device_count=${connectedApps.size}`,
819
+ `buffer_sizes=${JSON.stringify(Object.fromEntries([...logBuffers.entries()].map(([k, v]) => [k, v.size])))}`,
820
+ ];
821
+ connectionWarning += `\n\n[DIAG] ${diagParts.join(", ")}`;
822
+ }
490
823
  }
491
824
  else {
492
825
  const passive = getPassiveConnectionStatus();
@@ -514,6 +847,7 @@ registerToolWithTelemetry("get_logs", {
514
847
  if (format === "tonl") {
515
848
  const tonlOutput = formatLogsAsTonl(logs, { maxMessageLength: verbose ? 0 : maxMessageLength });
516
849
  return {
850
+ ...(emptyReason && { _emptyReason: emptyReason }),
517
851
  content: [
518
852
  {
519
853
  type: "text",
@@ -523,6 +857,7 @@ registerToolWithTelemetry("get_logs", {
523
857
  };
524
858
  }
525
859
  return {
860
+ ...(emptyReason && { _emptyReason: emptyReason }),
526
861
  content: [
527
862
  {
528
863
  type: "text",
@@ -530,7 +865,9 @@ registerToolWithTelemetry("get_logs", {
530
865
  }
531
866
  ]
532
867
  };
533
- });
868
+ },
869
+ // Empty result detector: buffer has no entries at all
870
+ () => getTotalLogCount() === 0);
534
871
  // Tool: Search logs
535
872
  registerToolWithTelemetry("search_logs", {
536
873
  description: "Search console logs for text (case-insensitive)",
@@ -551,14 +888,46 @@ registerToolWithTelemetry("search_logs", {
551
888
  .enum(["text", "tonl"])
552
889
  .optional()
553
890
  .default("tonl")
554
- .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)")
891
+ .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)"),
892
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
893
+ }
894
+ }, async ({ text, maxResults, maxMessageLength, verbose, format, device }) => {
895
+ // Check if SDK is installed — prefer SDK data
896
+ const sdkAvailable = await isSDKInstalled();
897
+ if (sdkAvailable) {
898
+ const sdkResult = await querySDKConsole({ count: maxResults, text });
899
+ if (sdkResult.success) {
900
+ const entries = sdkResult.data;
901
+ if (entries.length === 0) {
902
+ return { content: [{ type: "text", text: `No logs matching "${text}" found.` }] };
903
+ }
904
+ if (format === "tonl") {
905
+ const tonlLines = entries.map((e) => {
906
+ const time = new Date(e.timestamp).toLocaleTimeString();
907
+ let msg = e.message;
908
+ if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
909
+ msg = msg.slice(0, maxMessageLength) + "...";
910
+ }
911
+ return `${time} [${e.level}] ${msg}`;
912
+ });
913
+ return { content: [{ type: "text", text: `Search results for "${text}" (${entries.length} matches, SDK):\n\n${tonlLines.join("\n")}` }] };
914
+ }
915
+ const lines = entries.map((e) => {
916
+ const time = new Date(e.timestamp).toLocaleTimeString();
917
+ let msg = e.message;
918
+ if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
919
+ msg = msg.slice(0, maxMessageLength) + "...";
920
+ }
921
+ return `[${time}] [${e.level.toUpperCase()}] ${msg}`;
922
+ });
923
+ return { content: [{ type: "text", text: `Search results for "${text}" (${entries.length} matches, SDK):\n\n${lines.join("\n")}` }] };
924
+ }
555
925
  }
556
- }, async ({ text, maxResults, maxMessageLength, verbose, format }) => {
557
- const { logs, count, formatted } = searchLogs(logBuffer, text, { maxResults, maxMessageLength, verbose });
926
+ const { logs, count, formatted } = searchLogs(resolveLogBuffer(device), text, { maxResults, maxMessageLength, verbose });
558
927
  // Check connection health
559
928
  let connectionWarning = "";
560
929
  if (count === 0) {
561
- const status = await checkAndEnsureConnection();
930
+ const status = await checkAndEnsureConnection(device);
562
931
  connectionWarning = status.message ? `\n\n${status.message}` : "";
563
932
  }
564
933
  else {
@@ -591,17 +960,32 @@ registerToolWithTelemetry("search_logs", {
591
960
  // Tool: Clear logs
592
961
  registerToolWithTelemetry("clear_logs", {
593
962
  description: "Clear the log buffer",
594
- inputSchema: {}
595
- }, async () => {
596
- const count = logBuffer.clear();
597
- return {
598
- content: [
599
- {
600
- type: "text",
601
- text: `Cleared ${count} log entries from buffer.`
602
- }
603
- ]
604
- };
963
+ inputSchema: {
964
+ device: z.string().optional().describe("Target device name (substring match). Omit to clear all devices. Run get_apps to see connected devices.")
965
+ }
966
+ }, async ({ device }) => {
967
+ if (device) {
968
+ const app = getConnectedAppByDevice(device);
969
+ if (!app)
970
+ throw new Error(`No connected device matches "${device}"`);
971
+ const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
972
+ const count = getLogBuffer(deviceName).clear();
973
+ return { content: [{ type: "text", text: `Cleared ${count} log entries from ${deviceName}.` }] };
974
+ }
975
+ // Clear all
976
+ let total = 0;
977
+ for (const buffer of logBuffers.values()) {
978
+ total += buffer.clear();
979
+ }
980
+ // Also clear SDK buffer if available
981
+ const sdkAvailable = await isSDKInstalled();
982
+ if (sdkAvailable) {
983
+ const sdkResult = await clearSDKConsole();
984
+ if (sdkResult.success && sdkResult.count) {
985
+ total += sdkResult.count;
986
+ }
987
+ }
988
+ return { content: [{ type: "text", text: `Cleared ${total} log entries from all devices.` }] };
605
989
  });
606
990
  // Tool: Connect to specific Metro port
607
991
  registerToolWithTelemetry("connect_metro", {
@@ -662,9 +1046,11 @@ registerToolWithTelemetry("connect_metro", {
662
1046
  });
663
1047
  // Tool: Disconnect from Metro
664
1048
  registerToolWithTelemetry("disconnect_metro", {
665
- description: "Disconnect from all Metro servers and stop auto-reconnection. Use this when you want to switch to the built-in React Native debugger (which requires the CDP slot to be free). Log and network buffers are preserved. Reconnect later with scan_metro.",
666
- inputSchema: {}
667
- }, async () => {
1049
+ description: "Disconnect from Metro servers and stop auto-reconnection. Without device param: disconnects ALL devices. With device param: disconnects only the matching device. Use this to remove stale connections or free the CDP slot for the built-in debugger. Log and network buffers are preserved. Reconnect later with scan_metro.",
1050
+ inputSchema: {
1051
+ device: z.string().optional().describe("Target device name (substring match) to disconnect. Omit to disconnect all devices. Run get_apps to see connected devices.")
1052
+ }
1053
+ }, async ({ device }) => {
668
1054
  const connections = getConnectedApps();
669
1055
  if (connections.length === 0) {
670
1056
  return {
@@ -676,6 +1062,36 @@ registerToolWithTelemetry("disconnect_metro", {
676
1062
  ]
677
1063
  };
678
1064
  }
1065
+ // Targeted disconnect: only disconnect a specific device
1066
+ if (device) {
1067
+ const app = getConnectedAppByDevice(device);
1068
+ if (!app) {
1069
+ return {
1070
+ content: [{ type: "text", text: `No connected device matches "${device}". Run get_apps to see connected devices.` }],
1071
+ isError: true
1072
+ };
1073
+ }
1074
+ // Find and close the matching connection
1075
+ for (const [key, connectedApp] of connectedApps.entries()) {
1076
+ if (connectedApp.ws === app.ws) {
1077
+ // Suppress reconnection for this specific device
1078
+ suppressReconnectionForKey(key);
1079
+ cancelReconnectionTimer(key);
1080
+ try {
1081
+ connectedApp.ws.close();
1082
+ }
1083
+ catch {
1084
+ // Ignore close errors
1085
+ }
1086
+ connectedApps.delete(key);
1087
+ const name = connectedApp.deviceInfo.deviceName || connectedApp.deviceInfo.title;
1088
+ return {
1089
+ content: [{ type: "text", text: `Disconnected from ${name} (port ${connectedApp.port}). Buffers preserved. Use scan_metro to reconnect.` }]
1090
+ };
1091
+ }
1092
+ }
1093
+ }
1094
+ // Disconnect all
679
1095
  const disconnected = [];
680
1096
  // Suppress reconnection BEFORE closing sockets
681
1097
  // (close handlers fire async and would re-schedule reconnection)
@@ -696,6 +1112,7 @@ registerToolWithTelemetry("disconnect_metro", {
696
1112
  disconnectMetroBuildEvents();
697
1113
  // Clear connection state (but NOT log/network buffers)
698
1114
  clearAllConnectionState();
1115
+ clearAllCDPMessageTimes();
699
1116
  const lines = [
700
1117
  `Disconnected from ${disconnected.length} app(s):`,
701
1118
  ...disconnected.map((d) => ` - ${d}`),
@@ -737,10 +1154,11 @@ registerToolWithTelemetry("execute_in_app", {
737
1154
  .boolean()
738
1155
  .optional()
739
1156
  .default(false)
740
- .describe("Disable result truncation. Tip: Be cautious - Redux stores or large state can return 10KB+.")
1157
+ .describe("Disable result truncation. Tip: Be cautious - Redux stores or large state can return 10KB+."),
1158
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
741
1159
  }
742
- }, async ({ expression, awaitPromise, maxResultLength, verbose }) => {
743
- const result = await executeInApp(expression, awaitPromise);
1160
+ }, async ({ expression, awaitPromise, maxResultLength, verbose, device }) => {
1161
+ const result = await executeInApp(expression, awaitPromise, {}, device);
744
1162
  if (!result.success) {
745
1163
  let errorText = `Error: ${result.error}`;
746
1164
  // If the error is a ReferenceError (accessing a global that doesn't exist),
@@ -775,13 +1193,22 @@ registerToolWithTelemetry("execute_in_app", {
775
1193
  }
776
1194
  ]
777
1195
  };
1196
+ },
1197
+ // Empty result detector: successful execution but no meaningful output
1198
+ (result) => {
1199
+ if (result?.isError)
1200
+ return false;
1201
+ const text = result?.content?.[0]?.text;
1202
+ return text === undefined || text === "" || text === "undefined" || text === "null";
778
1203
  });
779
1204
  // Tool: List debug globals available in the app
780
1205
  registerToolWithTelemetry("list_debug_globals", {
781
1206
  description: "List globally available debugging objects in the connected React Native app (Apollo Client, Redux store, React DevTools, etc.). Use this to discover what state management and debugging tools are available.",
782
- inputSchema: {}
783
- }, async () => {
784
- const result = await listDebugGlobals();
1207
+ inputSchema: {
1208
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
1209
+ }
1210
+ }, async ({ device }) => {
1211
+ const result = await listDebugGlobals(device);
785
1212
  if (!result.success) {
786
1213
  return {
787
1214
  content: [
@@ -808,10 +1235,11 @@ registerToolWithTelemetry("inspect_global", {
808
1235
  inputSchema: {
809
1236
  objectName: z
810
1237
  .string()
811
- .describe("Name of the global object to inspect (e.g., '__EXPO_ROUTER__', '__APOLLO_CLIENT__')")
1238
+ .describe("Name of the global object to inspect (e.g., '__EXPO_ROUTER__', '__APOLLO_CLIENT__')"),
1239
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
812
1240
  }
813
- }, async ({ objectName }) => {
814
- const result = await inspectGlobal(objectName);
1241
+ }, async ({ objectName, device }) => {
1242
+ const result = await inspectGlobal(objectName, device);
815
1243
  if (!result.success) {
816
1244
  let errorText = `Error: ${result.error}`;
817
1245
  // If the error is a ReferenceError (accessing a global that doesn't exist),
@@ -843,13 +1271,8 @@ registerToolWithTelemetry("inspect_global", {
843
1271
  // ============================================================================
844
1272
  // Tool: Get the React component tree
845
1273
  registerToolWithTelemetry("get_component_tree", {
846
- description: "Get the React component tree from the running app. **RECOMMENDED**: Use focusedOnly=true with structureOnly=true for a token-efficient overview of just the active screen (~1-2KB). This skips navigation wrappers and global overlays, showing only what's actually visible.",
1274
+ description: "Get the full React component tree from the running app. Shows the complete fiber hierarchy including providers, navigation wrappers, and internal components. For a screen overview with positions and text, use get_screen_layout instead. Use structureOnly=true for compact names-only output.",
847
1275
  inputSchema: {
848
- focusedOnly: z
849
- .boolean()
850
- .optional()
851
- .default(false)
852
- .describe("Return only the focused/active screen subtree, skipping navigation wrappers and overlays. Dramatically reduces output size. (Recommended: true)"),
853
1276
  structureOnly: z
854
1277
  .boolean()
855
1278
  .optional()
@@ -858,7 +1281,7 @@ registerToolWithTelemetry("get_component_tree", {
858
1281
  maxDepth: z
859
1282
  .number()
860
1283
  .optional()
861
- .describe("Maximum tree depth (default: 25 for focusedOnly+structureOnly, 40 for structureOnly, 100 for full mode)"),
1284
+ .describe("Maximum tree depth (default: 5000)"),
862
1285
  includeProps: z
863
1286
  .boolean()
864
1287
  .optional()
@@ -878,17 +1301,18 @@ registerToolWithTelemetry("get_component_tree", {
878
1301
  .enum(["json", "tonl"])
879
1302
  .optional()
880
1303
  .default("tonl")
881
- .describe("Output format: 'json' or 'tonl' (default, compact indented tree). Ignored if structureOnly=true.")
1304
+ .describe("Output format: 'json' or 'tonl' (default, compact indented tree). Ignored if structureOnly=true."),
1305
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
882
1306
  }
883
- }, async ({ focusedOnly, structureOnly, maxDepth, includeProps, includeStyles, hideInternals, format }) => {
1307
+ }, async ({ structureOnly, maxDepth, includeProps, includeStyles, hideInternals, format, device }) => {
884
1308
  const result = await getComponentTree({
885
- focusedOnly,
886
1309
  structureOnly,
887
1310
  maxDepth,
888
1311
  includeProps,
889
1312
  includeStyles,
890
1313
  hideInternals,
891
- format
1314
+ format,
1315
+ device
892
1316
  });
893
1317
  if (!result.success) {
894
1318
  return {
@@ -912,36 +1336,22 @@ registerToolWithTelemetry("get_component_tree", {
912
1336
  });
913
1337
  // Tool: Get full screen layout (all components with layout styles)
914
1338
  registerToolWithTelemetry("get_screen_layout", {
915
- description: "Get layout information for all components on screen. **USE AFTER get_component_tree**: First use get_component_tree(structureOnly=true) to understand structure, then use this tool OR find_components with includeLayout=true to get layout details for specific areas. This tool returns full layout data which can be large for complex screens.",
1339
+ description: "Get a screen map showing visible components as an indented tree with actual screen positions. Uses measureInWindow for real coordinates and filters out off-screen components. Returns meaningful component names with text content and frame data (x,y width x height). Coordinates are in **points** (iOS) or **dp** (Android) — NOT screenshot pixels. Use tap(text=...) or tap(testID=...) to interact with discovered components. Use extended=true to include layout styles (padding, margin, flex, backgroundColor, etc.).",
916
1340
  inputSchema: {
917
- maxDepth: z
918
- .number()
919
- .optional()
920
- .default(65)
921
- .describe("Maximum tree depth to traverse (default: 65, balanced for most screens)"),
922
- componentsOnly: z
1341
+ extended: z
923
1342
  .boolean()
924
1343
  .optional()
925
1344
  .default(false)
926
- .describe("Only show custom components, hide host components (View, Text, etc.)"),
927
- shortPath: z
928
- .boolean()
929
- .optional()
930
- .default(true)
931
- .describe("Show only last 3 path segments instead of full path (default: true)"),
1345
+ .describe("Include layout styles (padding, margin, flex, backgroundColor, borderRadius, etc.) for each component. Default: false for compact output."),
932
1346
  summary: z
933
1347
  .boolean()
934
1348
  .optional()
935
1349
  .default(false)
936
- .describe("Return only component counts by name instead of full element list (default: false)"),
937
- format: z
938
- .enum(["json", "tonl"])
939
- .optional()
940
- .default("tonl")
941
- .describe("Output format: 'json' or 'tonl' (default, pipe-delimited rows, ~40% smaller)")
1350
+ .describe("Return only component counts by name instead of full tree (default: false)"),
1351
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
942
1352
  }
943
- }, async ({ maxDepth, componentsOnly, shortPath, summary, format }) => {
944
- const result = await getScreenLayout({ maxDepth, componentsOnly, shortPath, summary, format });
1353
+ }, async ({ extended, summary, device }) => {
1354
+ const result = await getScreenLayout({ extended, summary, device });
945
1355
  if (!result.success) {
946
1356
  return {
947
1357
  content: [
@@ -964,7 +1374,7 @@ registerToolWithTelemetry("get_screen_layout", {
964
1374
  });
965
1375
  // Tool: Inspect a specific component by name
966
1376
  registerToolWithTelemetry("inspect_component", {
967
- description: "Inspect a specific React component by name. **DRILL-DOWN TOOL**: Use after get_component_tree(structureOnly=true) to inspect specific components. Returns props, style, state (hooks), and optionally children tree. Use childrenDepth to control how deep nested children go.",
1377
+ description: "Inspect a specific React component by name. **DRILL-DOWN TOOL**: Use after get_screen_layout or find_components to identify which component to inspect. Returns props, style, state (hooks), and optionally children tree. Use childrenDepth to control how deep nested children go.",
968
1378
  inputSchema: {
969
1379
  componentName: z
970
1380
  .string()
@@ -990,16 +1400,18 @@ registerToolWithTelemetry("inspect_component", {
990
1400
  .boolean()
991
1401
  .optional()
992
1402
  .default(true)
993
- .describe("Simplify hooks output by hiding effects and reducing depth (default: true)")
1403
+ .describe("Simplify hooks output by hiding effects and reducing depth (default: true)"),
1404
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
994
1405
  }
995
- }, async ({ componentName, index, includeState, includeChildren, childrenDepth, shortPath, simplifyHooks }) => {
1406
+ }, async ({ componentName, index, includeState, includeChildren, childrenDepth, shortPath, simplifyHooks, device }) => {
996
1407
  const result = await inspectComponent(componentName, {
997
1408
  index,
998
1409
  includeState,
999
1410
  includeChildren,
1000
1411
  childrenDepth,
1001
1412
  shortPath,
1002
- simplifyHooks
1413
+ simplifyHooks,
1414
+ device
1003
1415
  });
1004
1416
  if (!result.success) {
1005
1417
  return {
@@ -1023,7 +1435,7 @@ registerToolWithTelemetry("inspect_component", {
1023
1435
  });
1024
1436
  // Tool: Find components matching a pattern
1025
1437
  registerToolWithTelemetry("find_components", {
1026
- description: "Find components matching a name pattern. **TARGETED SEARCH**: Use after get_component_tree(structureOnly=true) to find specific components by pattern and get their layout info. More efficient than get_screen_layout for targeted queries. Use includeLayout=true to get padding/margin/flex styles.",
1438
+ description: "Find components matching a name pattern. **TARGETED SEARCH**: Use after get_screen_layout or get_component_tree(structureOnly=true) to find specific components by pattern. Use includeLayout=true to get padding/margin/flex styles.",
1027
1439
  inputSchema: {
1028
1440
  pattern: z
1029
1441
  .string()
@@ -1044,10 +1456,11 @@ registerToolWithTelemetry("find_components", {
1044
1456
  .enum(["json", "tonl"])
1045
1457
  .optional()
1046
1458
  .default("tonl")
1047
- .describe("Output format: 'json' or 'tonl' (default, pipe-delimited rows, ~40% smaller)")
1459
+ .describe("Output format: 'json' or 'tonl' (default, pipe-delimited rows, ~40% smaller)"),
1460
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
1048
1461
  }
1049
- }, async ({ pattern, maxResults, includeLayout, shortPath, summary, format }) => {
1050
- const result = await findComponents(pattern, { maxResults, includeLayout, shortPath, summary, format });
1462
+ }, async ({ pattern, maxResults, includeLayout, shortPath, summary, format, device }) => {
1463
+ const result = await findComponents(pattern, { maxResults, includeLayout, shortPath, summary, format, device });
1051
1464
  if (!result.success) {
1052
1465
  return {
1053
1466
  content: [
@@ -1071,13 +1484,17 @@ registerToolWithTelemetry("find_components", {
1071
1484
  // Tool: Unified tap — tries fiber, accessibility, OCR, coordinate strategies
1072
1485
  registerToolWithTelemetry("tap", {
1073
1486
  description: "Tap a UI element. Automatically tries multiple strategies: fiber tree (React), accessibility tree (native), and OCR (visual). " +
1074
- "Auto-detects platform (iOS/Android). For coordinates, accepts pixels from screenshot and converts internally.\n\n" +
1487
+ "Auto-detects platform (iOS/Android). For coordinates, pass raw pixel values from screenshot — tap handles all coordinate conversion internally (iOS pixel-to-point, scale factor adjustments).\n\n" +
1488
+ "IMPORTANT: When both iOS and Android devices are connected, specify platform explicitly to avoid tapping on the wrong device.\n\n" +
1075
1489
  "Examples:\n" +
1076
1490
  "- tap(text=\"Submit\") — finds and taps element with matching text\n" +
1077
1491
  "- tap(testID=\"login-btn\") — finds by testID\n" +
1078
1492
  "- tap(component=\"HamburgerIcon\") — finds by React component name\n" +
1079
- "- tap(x=300, y=600) — taps at pixel coordinates from screenshot\n" +
1080
- "- tap(text=\"Menu\", strategy=\"ocr\") — forces OCR strategy only",
1493
+ "- tap(x=300, y=600) — taps at pixel coordinates (LAST RESORT — only use if text/testID/component strategies all fail)\n" +
1494
+ "- tap(text=\"Menu\", strategy=\"ocr\") — forces OCR strategy only\n" +
1495
+ "- tap(x=300, y=600, native=true, platform=\"android\") — taps directly via ADB/simctl without React Native connection\n\n" +
1496
+ "Returns a post-tap screenshot by default (set screenshot=false to disable). " +
1497
+ "For coordinate/accessibility/OCR taps, automatically verifies the tap had a visual effect via before/after screenshot diff (set verify=false to disable).",
1081
1498
  inputSchema: {
1082
1499
  text: z
1083
1500
  .string()
@@ -1113,6 +1530,35 @@ registerToolWithTelemetry("tap", {
1113
1530
  .optional()
1114
1531
  .describe("Max parent levels to traverse when searching by component name (default: 15). " +
1115
1532
  "Increase if your component is deeply wrapped (e.g. inside multiple HOCs/animation wrappers)."),
1533
+ native: z
1534
+ .boolean()
1535
+ .optional()
1536
+ .default(false)
1537
+ .describe("When true, tap coordinates directly via ADB/simctl without requiring a React Native connection. " +
1538
+ "Useful for interacting with native UI, system dialogs, or non-RN apps. Requires x/y coordinates."),
1539
+ platform: z
1540
+ .enum(["ios", "android"])
1541
+ .optional()
1542
+ .describe("Target platform. Required when both iOS and Android devices are connected. Auto-detected if only one platform is available."),
1543
+ screenshot: z
1544
+ .boolean()
1545
+ .optional()
1546
+ .default(true)
1547
+ .describe("Capture and return a post-tap screenshot. Enabled by default. Set to false for fastest execution (no screenshots)."),
1548
+ verify: z
1549
+ .boolean()
1550
+ .optional()
1551
+ .describe("Run before/after screenshot diff to detect if the tap had a meaningful visual effect. " +
1552
+ "Default: true for coordinate/accessibility/ocr strategies, false for fiber. Requires screenshot=true."),
1553
+ burst: z
1554
+ .boolean()
1555
+ .optional()
1556
+ .default(false)
1557
+ .describe("Enable burst screenshot capture for enhanced verification. " +
1558
+ "Captures 4 rapid screenshots (~150ms intervals) after the tap to detect transient visual feedback " +
1559
+ "(press animations, highlights, ripples) that may settle before a standard after-screenshot. " +
1560
+ "Results are stored in the image buffer (use get_images to inspect individual frames). " +
1561
+ "Default: false."),
1116
1562
  },
1117
1563
  }, async (args) => {
1118
1564
  const result = await tap({
@@ -1124,19 +1570,51 @@ registerToolWithTelemetry("tap", {
1124
1570
  y: args.y,
1125
1571
  strategy: args.strategy,
1126
1572
  maxTraversalDepth: args.maxTraversalDepth,
1573
+ native: args.native,
1574
+ platform: args.platform,
1575
+ screenshot: args.screenshot,
1576
+ verify: args.verify,
1577
+ burst: args.burst,
1127
1578
  });
1128
- const text = JSON.stringify(result, null, 2);
1579
+ const { screenshot: screenshotData, ...resultWithoutScreenshot } = result;
1580
+ const text = JSON.stringify(resultWithoutScreenshot, null, 2);
1581
+ // Pack strategy mode + attempted strategies into errorContext for telemetry
1582
+ // e.g. "s=ocr|fiber:no_pressable|ocr:no_match" or "s=auto|fiber:no_pressable|accessibility:not_found|ocr:no_match"
1583
+ const stratPrefix = args.strategy && args.strategy !== "auto" ? `s=${args.strategy}|` : "";
1584
+ const errorContext = result.attempted?.length
1585
+ ? stratPrefix + result.attempted.map(a => `${a.strategy}:${a.reason.slice(0, 40)}`).join("|")
1586
+ : undefined;
1587
+ const content = [
1588
+ { type: "text", text },
1589
+ ];
1590
+ if (screenshotData) {
1591
+ content.push({
1592
+ type: "image",
1593
+ data: screenshotData.image,
1594
+ mimeType: "image/jpeg",
1595
+ });
1596
+ }
1129
1597
  return {
1130
- content: [{ type: "text", text }],
1598
+ content,
1131
1599
  isError: !result.success,
1600
+ _errorMessage: !result.success
1601
+ ? `${JSON.stringify(result.query)}|${result.error || ""}`
1602
+ : undefined,
1603
+ _errorContext: errorContext,
1604
+ _meaningful: result.verification?.meaningful,
1605
+ _changeRate: result.verification?.changeRate,
1606
+ _tapStrategy: result.method,
1607
+ _iosDriver: result.platform === "ios" ? (process.env.IOS_DRIVER?.toLowerCase() || "idb") : undefined,
1132
1608
  };
1133
1609
  });
1134
1610
  // Tool: Toggle Element Inspector programmatically
1135
1611
  registerToolWithTelemetry("toggle_element_inspector", {
1136
1612
  description: "Toggle React Native's Element Inspector overlay on/off. Rarely needed directly — get_inspector_selection auto-enables the inspector when called with coordinates. Use this only when you need manual control over the overlay visibility.",
1137
- inputSchema: {}
1138
- }, async () => {
1139
- const result = await toggleElementInspector();
1613
+ inputSchema: {
1614
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
1615
+ }
1616
+ }, async ({ device }) => {
1617
+ const result = await toggleElementInspector(device);
1140
1618
  if (!result.success) {
1141
1619
  return {
1142
1620
  content: [
@@ -1192,21 +1670,22 @@ registerToolWithTelemetry("get_inspector_selection", {
1192
1670
  y: z
1193
1671
  .number()
1194
1672
  .optional()
1195
- .describe("Y coordinate (in points). If provided with x, auto-taps at this location.")
1673
+ .describe("Y coordinate (in points). If provided with x, auto-taps at this location."),
1674
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
1196
1675
  }
1197
- }, async ({ x, y }) => {
1676
+ }, async ({ x, y, device }) => {
1198
1677
  // If coordinates provided, do the full flow: enable inspector -> tap -> read
1199
1678
  if (x !== undefined && y !== undefined) {
1200
1679
  // Check if inspector is active
1201
- const inspectorActive = await isInspectorActive();
1680
+ const inspectorActive = await isInspectorActive(device);
1202
1681
  // Enable inspector if not active
1203
1682
  if (!inspectorActive) {
1204
- await toggleElementInspector();
1683
+ await toggleElementInspector(device);
1205
1684
  // Wait for inspector to initialize
1206
1685
  await new Promise((resolve) => setTimeout(resolve, 300));
1207
1686
  }
1208
1687
  // Detect platform from connected app
1209
- const app = getFirstConnectedApp();
1688
+ const app = device ? getConnectedAppByDevice(device) : getFirstConnectedApp();
1210
1689
  if (!app) {
1211
1690
  return {
1212
1691
  content: [{ type: "text", text: "No app connected. Run scan_metro first." }],
@@ -1241,7 +1720,7 @@ registerToolWithTelemetry("get_inspector_selection", {
1241
1720
  await new Promise((resolve) => setTimeout(resolve, 200));
1242
1721
  }
1243
1722
  // Read the current selection
1244
- const result = await getInspectorSelection();
1723
+ const result = await getInspectorSelection(device);
1245
1724
  if (!result.success) {
1246
1725
  return {
1247
1726
  content: [{ type: "text", text: `Error: ${result.error}` }],
@@ -1295,10 +1774,11 @@ registerToolWithTelemetry("inspect_at_point", {
1295
1774
  .boolean()
1296
1775
  .optional()
1297
1776
  .default(true)
1298
- .describe("Include position/dimensions (frame) in the output (default: true)")
1777
+ .describe("Include position/dimensions (frame) in the output (default: true)"),
1778
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
1299
1779
  }
1300
- }, async ({ x, y, includeProps, includeFrame }) => {
1301
- const result = await inspectAtPoint(x, y, { includeProps, includeFrame });
1780
+ }, async ({ x, y, includeProps, includeFrame, device }) => {
1781
+ const result = await inspectAtPoint(x, y, { includeProps, includeFrame, device });
1302
1782
  if (!result.success) {
1303
1783
  return {
1304
1784
  content: [
@@ -1343,7 +1823,7 @@ registerToolWithTelemetry("inspect_at_point", {
1343
1823
  });
1344
1824
  // Tool: Get network requests
1345
1825
  registerToolWithTelemetry("get_network_requests", {
1346
- description: "Retrieve captured network requests from connected React Native app. Shows URL, method, status, and timing. Tip: Use summary=true first for stats overview (counts by method, status, domain), then fetch specific requests as needed.",
1826
+ description: "Retrieve captured network requests from connected React Native app. Shows URL, method, status, and timing. Note: On Bridgeless targets (Expo SDK 52+) without the SDK, capture may miss early startup requests. Install react-native-ai-devtools-sdk for full capture with headers and response bodies. Tip: Use summary=true first for stats overview.",
1347
1827
  inputSchema: {
1348
1828
  maxRequests: z
1349
1829
  .number()
@@ -1362,16 +1842,67 @@ registerToolWithTelemetry("get_network_requests", {
1362
1842
  .boolean()
1363
1843
  .optional()
1364
1844
  .default(false)
1365
- .describe("Return statistics only (count, methods, domains, status codes). Use for quick overview.")
1845
+ .describe("Return statistics only (count, methods, domains, status codes). Use for quick overview."),
1846
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
1847
+ }
1848
+ }, async ({ maxRequests, method, urlPattern, status, format, summary, device }) => {
1849
+ // Check if SDK is installed — prefer SDK data over CDP/interceptor buffer
1850
+ const sdkAvailable = await isSDKInstalled();
1851
+ if (sdkAvailable) {
1852
+ if (summary) {
1853
+ const sdkStats = await getSDKNetworkStats();
1854
+ if (sdkStats.success) {
1855
+ const s = sdkStats.data;
1856
+ const lines = [];
1857
+ lines.push(`Total requests: ${s.total}`);
1858
+ lines.push(`Completed: ${s.completed}`);
1859
+ lines.push(`Errors: ${s.errors}`);
1860
+ if (s.avgDuration != null)
1861
+ lines.push(`Avg duration: ${s.avgDuration}ms`);
1862
+ if (s.byMethod && Object.keys(s.byMethod).length > 0) {
1863
+ lines.push("\nBy Method:");
1864
+ for (const [m, c] of Object.entries(s.byMethod))
1865
+ lines.push(` ${m}: ${c}`);
1866
+ }
1867
+ if (s.byStatus && Object.keys(s.byStatus).length > 0) {
1868
+ lines.push("\nBy Status:");
1869
+ for (const [st, c] of Object.entries(s.byStatus))
1870
+ lines.push(` ${st}: ${c}`);
1871
+ }
1872
+ if (s.byDomain && Object.keys(s.byDomain).length > 0) {
1873
+ lines.push("\nBy Domain:");
1874
+ for (const [d, c] of Object.entries(s.byDomain).sort((a, b) => b[1] - a[1]).slice(0, 10))
1875
+ lines.push(` ${d}: ${c}`);
1876
+ }
1877
+ return { content: [{ type: "text", text: `Network Summary (SDK):\n\n${lines.join("\n")}` }] };
1878
+ }
1879
+ }
1880
+ const sdkResult = await querySDKNetwork({ count: maxRequests, method, urlPattern, status });
1881
+ if (sdkResult.success) {
1882
+ const entries = sdkResult.data;
1883
+ if (entries.length === 0) {
1884
+ return { content: [{ type: "text", text: "No network requests captured yet." }] };
1885
+ }
1886
+ const lines = entries.map((r) => {
1887
+ const time = new Date(r.timestamp).toLocaleTimeString();
1888
+ const st = r.status ?? "pending";
1889
+ const dur = r.duration != null ? `${r.duration}ms` : "-";
1890
+ return `[${r.id}] ${time} ${r.method} ${st} ${dur} ${r.url}`;
1891
+ });
1892
+ return { content: [{ type: "text", text: `Network Requests (${entries.length} entries, SDK):\n\n${lines.join("\n")}` }] };
1893
+ }
1366
1894
  }
1367
- }, async ({ maxRequests, method, urlPattern, status, format, summary }) => {
1895
+ // Fallback: read from in-process buffer (CDP/interceptor)
1368
1896
  // Return summary if requested
1369
1897
  if (summary) {
1370
- const stats = getNetworkStats(networkBuffer);
1898
+ const stats = getNetworkStats(resolveNetworkBuffer(device));
1371
1899
  let connectionWarning = "";
1372
- if (networkBuffer.size === 0) {
1373
- const connStatus = await checkAndEnsureConnection();
1900
+ if (resolveNetworkBuffer(device).size === 0) {
1901
+ const connStatus = await checkAndEnsureConnection(device);
1374
1902
  connectionWarning = connStatus.message ? `\n\n${connStatus.message}` : "";
1903
+ if (!sdkAvailable) {
1904
+ connectionWarning += "\n\n[TIP] For full network capture including startup requests and response bodies, install the SDK: npm install react-native-ai-devtools-sdk";
1905
+ }
1375
1906
  }
1376
1907
  return {
1377
1908
  content: [
@@ -1382,7 +1913,7 @@ registerToolWithTelemetry("get_network_requests", {
1382
1913
  ]
1383
1914
  };
1384
1915
  }
1385
- const { requests, count, formatted } = getNetworkRequests(networkBuffer, {
1916
+ const { requests, count, formatted } = getNetworkRequests(resolveNetworkBuffer(device), {
1386
1917
  maxRequests,
1387
1918
  method,
1388
1919
  urlPattern,
@@ -1391,8 +1922,11 @@ registerToolWithTelemetry("get_network_requests", {
1391
1922
  // Check connection health
1392
1923
  let connectionWarning = "";
1393
1924
  if (count === 0) {
1394
- const connStatus = await checkAndEnsureConnection();
1925
+ const connStatus = await checkAndEnsureConnection(device);
1395
1926
  connectionWarning = connStatus.message ? `\n\n${connStatus.message}` : "";
1927
+ if (!sdkAvailable) {
1928
+ connectionWarning += "\n\n[TIP] For full network capture including startup requests and response bodies, install the SDK: npm install react-native-ai-devtools-sdk";
1929
+ }
1396
1930
  }
1397
1931
  else {
1398
1932
  const passive = getPassiveConnectionStatus();
@@ -1435,7 +1969,10 @@ registerToolWithTelemetry("get_network_requests", {
1435
1969
  }
1436
1970
  ]
1437
1971
  };
1438
- });
1972
+ },
1973
+ // Empty result detector: buffer has no entries at all
1974
+ () => { let total = 0; for (const b of networkBuffers.values())
1975
+ total += b.size; return total === 0; });
1439
1976
  // Tool: Search network requests
1440
1977
  registerToolWithTelemetry("search_network", {
1441
1978
  description: "Search network requests by URL pattern (case-insensitive)",
@@ -1446,14 +1983,36 @@ registerToolWithTelemetry("search_network", {
1446
1983
  .enum(["text", "tonl"])
1447
1984
  .optional()
1448
1985
  .default("tonl")
1449
- .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)")
1986
+ .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)"),
1987
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
1988
+ }
1989
+ }, async ({ urlPattern, maxResults, format, device }) => {
1990
+ // Check if SDK is installed — prefer SDK data
1991
+ const sdkAvailable = await isSDKInstalled();
1992
+ if (sdkAvailable) {
1993
+ const sdkResult = await querySDKNetwork({ count: maxResults, urlPattern });
1994
+ if (sdkResult.success) {
1995
+ const entries = sdkResult.data;
1996
+ if (entries.length === 0) {
1997
+ return { content: [{ type: "text", text: `No network requests matching "${urlPattern}" found.` }] };
1998
+ }
1999
+ const lines = entries.map((r) => {
2000
+ const time = new Date(r.timestamp).toLocaleTimeString();
2001
+ const st = r.status ?? "pending";
2002
+ const dur = r.duration != null ? `${r.duration}ms` : "-";
2003
+ return `[${r.id}] ${time} ${r.method} ${st} ${dur} ${r.url}`;
2004
+ });
2005
+ if (format === "tonl") {
2006
+ return { content: [{ type: "text", text: `Network search results for "${urlPattern}" (${entries.length} matches, SDK):\n\n${lines.join("\n")}` }] };
2007
+ }
2008
+ return { content: [{ type: "text", text: `Network search results for "${urlPattern}" (${entries.length} matches, SDK):\n\n${lines.join("\n")}` }] };
2009
+ }
1450
2010
  }
1451
- }, async ({ urlPattern, maxResults, format }) => {
1452
- const { requests, count, formatted } = searchNetworkRequests(networkBuffer, urlPattern, maxResults);
2011
+ const { requests, count, formatted } = searchNetworkRequests(resolveNetworkBuffer(device), urlPattern, maxResults);
1453
2012
  // Check connection health
1454
2013
  let connectionWarning = "";
1455
2014
  if (count === 0) {
1456
- const status = await checkAndEnsureConnection();
2015
+ const status = await checkAndEnsureConnection(device);
1457
2016
  connectionWarning = status.message ? `\n\n${status.message}` : "";
1458
2017
  }
1459
2018
  else {
@@ -1485,7 +2044,7 @@ registerToolWithTelemetry("search_network", {
1485
2044
  });
1486
2045
  // Tool: Get request details
1487
2046
  registerToolWithTelemetry("get_request_details", {
1488
- description: "Get full details of a specific network request including headers, body, and timing. Use get_network_requests first to find the request ID.",
2047
+ description: "Get full details of a specific network request including headers, body, and timing. With the SDK installed, includes full request/response bodies. Without SDK, bodies are not available on most targets. Use get_network_requests first to find the request ID.",
1489
2048
  inputSchema: {
1490
2049
  requestId: z.string().describe("The request ID to get details for"),
1491
2050
  maxBodyLength: z.coerce
@@ -1497,12 +2056,60 @@ registerToolWithTelemetry("get_request_details", {
1497
2056
  .boolean()
1498
2057
  .optional()
1499
2058
  .default(false)
1500
- .describe("Disable body truncation. Tip: Use when you need to inspect full JSON payloads.")
2059
+ .describe("Disable body truncation. Tip: Use when you need to inspect full JSON payloads."),
2060
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
2061
+ }
2062
+ }, async ({ requestId, maxBodyLength, verbose, device }) => {
2063
+ // Check SDK first — it has full headers and body
2064
+ const sdkAvailable = await isSDKInstalled();
2065
+ if (sdkAvailable) {
2066
+ const sdkResult = await getSDKNetworkEntry(requestId);
2067
+ if (sdkResult.success && sdkResult.data) {
2068
+ const r = sdkResult.data;
2069
+ const lines = [];
2070
+ lines.push(`=== ${r.method} ${r.url} ===`);
2071
+ lines.push(`Request ID: ${r.id}`);
2072
+ lines.push(`Time: ${new Date(r.timestamp).toISOString()}`);
2073
+ lines.push(`Status: ${r.status ?? "pending"} ${r.statusText ?? ""}`);
2074
+ if (r.duration != null)
2075
+ lines.push(`Duration: ${r.duration}ms`);
2076
+ if (r.mimeType)
2077
+ lines.push(`Content-Type: ${r.mimeType}`);
2078
+ if (r.error)
2079
+ lines.push(`Error: ${r.error}`);
2080
+ if (r.requestHeaders && Object.keys(r.requestHeaders).length > 0) {
2081
+ lines.push("\n--- Request Headers ---");
2082
+ for (const [k, v] of Object.entries(r.requestHeaders))
2083
+ lines.push(`${k}: ${v}`);
2084
+ }
2085
+ if (r.requestBody) {
2086
+ lines.push("\n--- Request Body ---");
2087
+ let body = r.requestBody;
2088
+ if (!verbose && maxBodyLength > 0 && body.length > maxBodyLength) {
2089
+ body = body.slice(0, maxBodyLength) + `... [truncated: ${r.requestBody.length} chars]`;
2090
+ }
2091
+ lines.push(body);
2092
+ }
2093
+ if (r.responseHeaders && Object.keys(r.responseHeaders).length > 0) {
2094
+ lines.push("\n--- Response Headers ---");
2095
+ for (const [k, v] of Object.entries(r.responseHeaders))
2096
+ lines.push(`${k}: ${v}`);
2097
+ }
2098
+ if (r.responseBody) {
2099
+ lines.push("\n--- Response Body ---");
2100
+ let body = r.responseBody;
2101
+ if (!verbose && maxBodyLength > 0 && body.length > maxBodyLength) {
2102
+ body = body.slice(0, maxBodyLength) + `... [truncated: ${r.responseBody.length} chars]`;
2103
+ }
2104
+ lines.push(body);
2105
+ }
2106
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2107
+ }
1501
2108
  }
1502
- }, async ({ requestId, maxBodyLength, verbose }) => {
1503
- const request = networkBuffer.get(requestId);
2109
+ // Fallback: read from in-process buffer
2110
+ const request = resolveNetworkBuffer(device).get(requestId);
1504
2111
  if (!request) {
1505
- const status = await checkAndEnsureConnection();
2112
+ const status = await checkAndEnsureConnection(device);
1506
2113
  const connectionNote = status.message ? `\n\n${status.message}` : "";
1507
2114
  return {
1508
2115
  content: [
@@ -1526,13 +2133,45 @@ registerToolWithTelemetry("get_request_details", {
1526
2133
  // Tool: Get network stats
1527
2134
  registerToolWithTelemetry("get_network_stats", {
1528
2135
  description: "Get statistics about captured network requests: counts by method, status code, and domain.",
1529
- inputSchema: {}
1530
- }, async () => {
1531
- const stats = getNetworkStats(networkBuffer);
2136
+ inputSchema: {
2137
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
2138
+ }
2139
+ }, async ({ device }) => {
2140
+ // Check if SDK is installed — prefer SDK data
2141
+ const sdkAvailable = await isSDKInstalled();
2142
+ if (sdkAvailable) {
2143
+ const sdkStats = await getSDKNetworkStats();
2144
+ if (sdkStats.success) {
2145
+ const s = sdkStats.data;
2146
+ const lines = [];
2147
+ lines.push(`Total requests: ${s.total}`);
2148
+ lines.push(`Completed: ${s.completed}`);
2149
+ lines.push(`Errors: ${s.errors}`);
2150
+ if (s.avgDuration != null)
2151
+ lines.push(`Avg duration: ${s.avgDuration}ms`);
2152
+ if (s.byMethod && Object.keys(s.byMethod).length > 0) {
2153
+ lines.push("\nBy Method:");
2154
+ for (const [m, c] of Object.entries(s.byMethod))
2155
+ lines.push(` ${m}: ${c}`);
2156
+ }
2157
+ if (s.byStatus && Object.keys(s.byStatus).length > 0) {
2158
+ lines.push("\nBy Status:");
2159
+ for (const [st, c] of Object.entries(s.byStatus))
2160
+ lines.push(` ${st}: ${c}`);
2161
+ }
2162
+ if (s.byDomain && Object.keys(s.byDomain).length > 0) {
2163
+ lines.push("\nBy Domain:");
2164
+ for (const [d, c] of Object.entries(s.byDomain).sort((a, b) => b[1] - a[1]).slice(0, 10))
2165
+ lines.push(` ${d}: ${c}`);
2166
+ }
2167
+ return { content: [{ type: "text", text: `Network Statistics (SDK):\n\n${lines.join("\n")}` }] };
2168
+ }
2169
+ }
2170
+ const stats = getNetworkStats(resolveNetworkBuffer(device));
1532
2171
  // Check connection health
1533
2172
  let connectionWarning = "";
1534
- if (networkBuffer.size === 0) {
1535
- const status = await checkAndEnsureConnection();
2173
+ if (resolveNetworkBuffer(device).size === 0) {
2174
+ const status = await checkAndEnsureConnection(device);
1536
2175
  connectionWarning = status.message ? `\n\n${status.message}` : "";
1537
2176
  }
1538
2177
  else {
@@ -1549,18 +2188,105 @@ registerToolWithTelemetry("get_network_stats", {
1549
2188
  }
1550
2189
  ]
1551
2190
  };
1552
- });
2191
+ },
2192
+ // Empty result detector: buffer has no entries at all
2193
+ () => { let total = 0; for (const b of networkBuffers.values())
2194
+ total += b.size; return total === 0; });
1553
2195
  // Tool: Clear network requests
1554
2196
  registerToolWithTelemetry("clear_network", {
1555
2197
  description: "Clear the network request buffer",
1556
- inputSchema: {}
1557
- }, async () => {
1558
- const count = networkBuffer.clear();
2198
+ inputSchema: {
2199
+ device: z.string().optional().describe("Target device name (substring match). Omit to clear all devices. Run get_apps to see connected devices.")
2200
+ }
2201
+ }, async ({ device }) => {
2202
+ let totalCleared = 0;
2203
+ if (device) {
2204
+ const app = getConnectedAppByDevice(device);
2205
+ if (!app)
2206
+ throw new Error(`No connected device matches "${device}"`);
2207
+ const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
2208
+ totalCleared = getNetworkBuffer(deviceName).clear();
2209
+ }
2210
+ else {
2211
+ for (const buffer of networkBuffers.values()) {
2212
+ totalCleared += buffer.clear();
2213
+ }
2214
+ }
2215
+ // Also clear SDK buffer if available
2216
+ const sdkAvailable = await isSDKInstalled();
2217
+ if (sdkAvailable) {
2218
+ const sdkResult = await clearSDKNetwork();
2219
+ if (sdkResult.success && sdkResult.count) {
2220
+ totalCleared += sdkResult.count;
2221
+ }
2222
+ }
1559
2223
  return {
1560
2224
  content: [
1561
2225
  {
1562
2226
  type: "text",
1563
- text: `Cleared ${count} network requests from buffer.`
2227
+ text: `Cleared ${totalCleared} network requests from buffer.`
2228
+ }
2229
+ ]
2230
+ };
2231
+ });
2232
+ // Tool: Get images from shared image buffer
2233
+ registerToolWithTelemetry("get_images", {
2234
+ description: "Access the shared image buffer containing screenshots from all tools (ios_screenshot, android_screenshot, ocr_screenshot, tap verification). Returns metadata only by default — use id or groupId+frameIndex to retrieve actual image data. Tap burst verification stores frame groups here when burst=true is used.",
2235
+ inputSchema: {
2236
+ list: z.boolean().optional().describe("List all entries and groups (metadata only, no image data)"),
2237
+ id: z.string().optional().describe("Retrieve a specific image by ID (returns image data)"),
2238
+ groupId: z.string().optional().describe("List frames in a group (metadata only), or combine with frameIndex to retrieve a specific frame"),
2239
+ frameIndex: z.coerce.number().optional().describe("Retrieve a specific frame from a group (requires groupId)"),
2240
+ last: z.coerce.number().optional().describe("Return the N most recent entries (metadata only)"),
2241
+ source: z.string().optional().describe("Filter entries by source"),
2242
+ clear: z.boolean().optional().describe("Clear the buffer")
2243
+ }
2244
+ }, async ({ id, groupId, frameIndex, last, source, clear }) => {
2245
+ if (clear) {
2246
+ const count = imageBuffer.clear();
2247
+ return {
2248
+ content: [{ type: "text", text: `Cleared ${count} images from buffer.` }]
2249
+ };
2250
+ }
2251
+ if (id) {
2252
+ const entry = imageBuffer.getById(id);
2253
+ if (!entry) {
2254
+ return {
2255
+ content: [{ type: "text", text: `No image found with id "${id}".` }],
2256
+ isError: true
2257
+ };
2258
+ }
2259
+ const { image, ...meta } = entry;
2260
+ return {
2261
+ content: [
2262
+ { type: "text", text: JSON.stringify(meta, null, 2) },
2263
+ { type: "image", data: image.toString("base64"), mimeType: "image/png" }
2264
+ ]
2265
+ };
2266
+ }
2267
+ if (groupId !== undefined && frameIndex !== undefined) {
2268
+ const entry = imageBuffer.getByGroupFrame(groupId, frameIndex);
2269
+ if (!entry) {
2270
+ return {
2271
+ content: [{ type: "text", text: `No frame ${frameIndex} found in group "${groupId}".` }],
2272
+ isError: true
2273
+ };
2274
+ }
2275
+ const { image, ...meta } = entry;
2276
+ return {
2277
+ content: [
2278
+ { type: "text", text: JSON.stringify(meta, null, 2) },
2279
+ { type: "image", data: image.toString("base64"), mimeType: "image/png" }
2280
+ ]
2281
+ };
2282
+ }
2283
+ const entries = imageBuffer.listEntries({ source, groupId, last });
2284
+ const groups = imageBuffer.listGroups();
2285
+ return {
2286
+ content: [
2287
+ {
2288
+ type: "text",
2289
+ text: JSON.stringify({ entries, groups, total: imageBuffer.size }, null, 2)
1564
2290
  }
1565
2291
  ]
1566
2292
  };
@@ -1568,9 +2294,11 @@ registerToolWithTelemetry("clear_network", {
1568
2294
  // Tool: Reload the app
1569
2295
  registerToolWithTelemetry("reload_app", {
1570
2296
  description: "Reload the React Native app (triggers JavaScript bundle reload like pressing 'r' in Metro). Will auto-connect to Metro if no connection exists. Note: After reload, the app may take a few seconds to fully restart and become responsive — wait before running other tools. IMPORTANT: React Native has Fast Refresh enabled by default - code changes are automatically applied without needing reload. Only use when: (1) logs/behavior don't reflect code changes after a few seconds, (2) app is in broken/error state, or (3) need to reset app state completely (navigation stack, context, etc.).",
1571
- inputSchema: {}
1572
- }, async () => {
1573
- const result = await reloadApp();
2297
+ inputSchema: {
2298
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2299
+ }
2300
+ }, async ({ device }) => {
2301
+ const result = await reloadApp(device);
1574
2302
  if (!result.success) {
1575
2303
  return {
1576
2304
  content: [
@@ -1591,6 +2319,170 @@ registerToolWithTelemetry("reload_app", {
1591
2319
  ]
1592
2320
  };
1593
2321
  });
2322
+ // Tool: LogBox control (replaces dismiss_logbox)
2323
+ registerToolWithTelemetry("logbox", {
2324
+ description: "Interact with React Native's LogBox overlay (dev mode only). " +
2325
+ 'Actions: "dismiss" clears all entries and returns their content. ' +
2326
+ '"push" displays a message in the LogBox error banner (visible to the developer watching the device). ' +
2327
+ '"ignore" adds patterns to suppress future LogBox entries for this session. ' +
2328
+ '"detect" reads current LogBox state without modifying it. ' +
2329
+ "Only works in __DEV__ mode — LogBox does not exist in production builds.",
2330
+ inputSchema: {
2331
+ action: z.enum(["dismiss", "push", "ignore", "detect"]).describe('Action to perform: "dismiss", "push", "ignore", or "detect"'),
2332
+ message: z.string().optional().describe('Message to push into LogBox (required when action="push")'),
2333
+ level: z.enum(["error", "warning"]).optional().describe('LogBox level for push (default: "error"). Only "error" shows a visible bottom banner; "warning" is stored but not visually shown unless LogBox is already open'),
2334
+ expanded: z.boolean().optional().describe('When true, opens the full-screen LogBox detail view instead of the minimized bottom banner. Useful for important messages with clickable URLs (default: false)'),
2335
+ subtitle: z.string().optional().describe('Additional info shown in the call stack area when expanded=true (default: "MCP Server"). Use for context like "License Check", "Usage Limit", etc.'),
2336
+ target: z.enum(["logbox", "metro"]).optional().describe('Where to push the message (default: "logbox"). "logbox" shows on device screen, "metro" outputs to Metro terminal via console.log'),
2337
+ patterns: z.array(z.string()).optional().describe('Patterns to ignore (required when action="ignore"), e.g. ["[APOLLO]", "deprecated"]'),
2338
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2339
+ }
2340
+ }, async ({ action, message, level, expanded, subtitle, target, patterns, device }) => {
2341
+ if (action === "dismiss") {
2342
+ const result = await dismissLogBox(device);
2343
+ if (result === null) {
2344
+ return {
2345
+ content: [
2346
+ {
2347
+ type: "text",
2348
+ text: "LogBox not available. This can happen if: (1) the app is a production build (__DEV__ is false), (2) no React Native app is connected, or (3) the Metro module registry is not accessible."
2349
+ }
2350
+ ]
2351
+ };
2352
+ }
2353
+ if (result.totalDismissed === 0) {
2354
+ return {
2355
+ content: [
2356
+ {
2357
+ type: "text",
2358
+ text: "No LogBox entries to dismiss."
2359
+ }
2360
+ ]
2361
+ };
2362
+ }
2363
+ return {
2364
+ content: [
2365
+ {
2366
+ type: "text",
2367
+ text: formatDismissedEntries(result)
2368
+ }
2369
+ ]
2370
+ };
2371
+ }
2372
+ if (action === "push") {
2373
+ if (!message) {
2374
+ return {
2375
+ content: [
2376
+ {
2377
+ type: "text",
2378
+ text: 'Error: "message" parameter is required when action is "push".'
2379
+ }
2380
+ ],
2381
+ isError: true
2382
+ };
2383
+ }
2384
+ const success = await pushLogBox(message, level || "error", expanded || false, target || "logbox", subtitle, device);
2385
+ if (!success) {
2386
+ return {
2387
+ content: [
2388
+ {
2389
+ type: "text",
2390
+ text: "Failed to push message to LogBox. This can happen if: (1) the app is a production build, (2) no React Native app is connected, or (3) the Metro module registry is not accessible."
2391
+ }
2392
+ ]
2393
+ };
2394
+ }
2395
+ return {
2396
+ content: [
2397
+ {
2398
+ type: "text",
2399
+ text: target === "metro"
2400
+ ? `Message pushed to Metro terminal: "${message}"`
2401
+ : `Message pushed to LogBox as ${level || "error"}: "${message}"`
2402
+ }
2403
+ ]
2404
+ };
2405
+ }
2406
+ if (action === "ignore") {
2407
+ if (!patterns || patterns.length === 0) {
2408
+ return {
2409
+ content: [
2410
+ {
2411
+ type: "text",
2412
+ text: 'Error: "patterns" parameter is required when action is "ignore" (non-empty array of strings).'
2413
+ }
2414
+ ],
2415
+ isError: true
2416
+ };
2417
+ }
2418
+ const activePatterns = await addLogBoxIgnorePatterns(patterns, device);
2419
+ if (activePatterns === null) {
2420
+ return {
2421
+ content: [
2422
+ {
2423
+ type: "text",
2424
+ text: "Failed to add ignore patterns. This can happen if: (1) the app is a production build, (2) no React Native app is connected, or (3) the Metro module registry is not accessible."
2425
+ }
2426
+ ]
2427
+ };
2428
+ }
2429
+ return {
2430
+ content: [
2431
+ {
2432
+ type: "text",
2433
+ text: `Added ignore patterns: ${patterns.map((p) => `"${p}"`).join(", ")}\n\nAll active ignore patterns: ${activePatterns.map((p) => `"${p}"`).join(", ")}`
2434
+ }
2435
+ ]
2436
+ };
2437
+ }
2438
+ if (action === "detect") {
2439
+ const state = await detectLogBox(device);
2440
+ if (state === null) {
2441
+ return {
2442
+ content: [
2443
+ {
2444
+ type: "text",
2445
+ text: "LogBox not available. This can happen if: (1) the app is a production build (__DEV__ is false), (2) no React Native app is connected, or (3) the Metro module registry is not accessible."
2446
+ }
2447
+ ]
2448
+ };
2449
+ }
2450
+ if (state.total === 0) {
2451
+ return {
2452
+ content: [
2453
+ {
2454
+ type: "text",
2455
+ text: "No LogBox entries detected."
2456
+ }
2457
+ ]
2458
+ };
2459
+ }
2460
+ const MAX_MSG_LENGTH = 150;
2461
+ let output = `LogBox state: ${state.total} entr${state.total === 1 ? "y" : "ies"} (${state.errors} error${state.errors !== 1 ? "s" : ""}, ${state.warnings} warning${state.warnings !== 1 ? "s" : ""}, ${state.fatals} fatal${state.fatals !== 1 ? "s" : ""})\n`;
2462
+ for (const entry of state.entries) {
2463
+ const truncated = entry.message.length > MAX_MSG_LENGTH ? entry.message.substring(0, MAX_MSG_LENGTH) + "..." : entry.message;
2464
+ const countStr = entry.count > 1 ? ` (x${entry.count})` : "";
2465
+ output += `\n[${entry.level}] ${truncated}${countStr}`;
2466
+ }
2467
+ return {
2468
+ content: [
2469
+ {
2470
+ type: "text",
2471
+ text: output
2472
+ }
2473
+ ]
2474
+ };
2475
+ }
2476
+ return {
2477
+ content: [
2478
+ {
2479
+ type: "text",
2480
+ text: `Unknown action: "${action}". Use "dismiss", "push", "ignore", or "detect".`
2481
+ }
2482
+ ],
2483
+ isError: true
2484
+ };
2485
+ });
1594
2486
  // ============================================================================
1595
2487
  // Bundle/Build Error Tools
1596
2488
  // ============================================================================
@@ -1776,7 +2668,7 @@ registerToolWithTelemetry("list_android_devices", {
1776
2668
  });
1777
2669
  // Tool: Android screenshot
1778
2670
  registerToolWithTelemetry("android_screenshot", {
1779
- description: "Take a screenshot from an Android device/emulator. Returns the image data that can be displayed.",
2671
+ description: "Take a screenshot from an Android device/emulator. Returns the image and a screen layout overlay showing visible React components with names, text content, hierarchy, and tap-ready pixel coordinates. Use component names for inspect_component/find_components. Use tap(text=...) for interaction — only fall back to tap(x,y) coordinates from the layout if all tap strategies fail.",
1780
2672
  inputSchema: {
1781
2673
  outputPath: z
1782
2674
  .string()
@@ -1833,16 +2725,64 @@ registerToolWithTelemetry("android_screenshot", {
1833
2725
  catch {
1834
2726
  // Use defaults
1835
2727
  }
1836
- infoText += `\n📱 Android uses PIXELS for tap coordinates (same as screenshot)`;
2728
+ // Enrich with screen layout data (component names + tap coordinates)
2729
+ // Android: density ratio = densityDpi / 160 (160dpi = 1dp = 1px baseline)
2730
+ const androidPixelRatio = densityDpi / 160;
2731
+ let layoutText = null;
2732
+ try {
2733
+ layoutText = await enrichScreenshotWithLayout(androidPixelRatio, result.scaleFactor || 1);
2734
+ }
2735
+ catch {
2736
+ // Non-fatal: screenshot works without layout enrichment
2737
+ }
2738
+ infoText += `\n📱 Android uses PIXELS for all coordinates`;
1837
2739
  if (result.scaleFactor && result.scaleFactor > 1) {
1838
- infoText += `\n⚠️ Image was scaled down to fit API limits. Scale factor: ${result.scaleFactor.toFixed(3)}`;
1839
- infoText += `\n📐 To convert image coords to tap coords: multiply by ${result.scaleFactor.toFixed(3)}`;
2740
+ infoText += `\n🖼️ Image was scaled down to fit API limits (scale: ${result.scaleFactor.toFixed(3)})`;
2741
+ infoText += `\n📐 tap() handles coordinate conversion automatically — pass pixel coords from this image directly`;
1840
2742
  }
1841
2743
  else {
1842
2744
  infoText += `\n📐 Screenshot coords = tap coords (no conversion needed)`;
1843
2745
  }
1844
2746
  infoText += `\n⚠️ Status bar: ${statusBarPixels}px (${statusBarDp}dp) from top - app content starts below this`;
1845
2747
  infoText += `\n📊 Display density: ${densityDpi}dpi`;
2748
+ if (layoutText) {
2749
+ infoText += `\n\n📋 Screen Layout (components with tap coordinates in pixels):`;
2750
+ infoText += `\n${layoutText}`;
2751
+ infoText += `\n\n💡 Next steps:`;
2752
+ infoText += `\n • tap(text="Button Label") — tap element by visible text (preferred)`;
2753
+ infoText += `\n • tap(testID="id") or tap(component="Name") — tap by testID or component name`;
2754
+ infoText += `\n • tap(x=<px>, y=<px>) — use tap coordinates from layout above ONLY if text/testID/component tap fails`;
2755
+ infoText += `\n • inspect_component("ComponentName") — inspect a component from the layout above`;
2756
+ }
2757
+ else {
2758
+ infoText += `\n\n💡 Next steps:`;
2759
+ infoText += `\n • tap(text="Button Label") — tap element by visible text`;
2760
+ infoText += `\n • tap(x=<px>, y=<px>) — tap at coordinates from this screenshot`;
2761
+ infoText += `\n • android_describe_all — get full UI tree with exact tap coordinates`;
2762
+ infoText += `\n • android_find_element(text="...") — find element coordinates without tapping`;
2763
+ }
2764
+ // Check for LogBox overlay (uses default CDP device — native deviceId cannot be mapped to CDP device name)
2765
+ try {
2766
+ const logBoxState = await detectLogBox();
2767
+ if (logBoxState && logBoxState.total > 0) {
2768
+ infoText += formatLogBoxWarning(logBoxState);
2769
+ }
2770
+ }
2771
+ catch {
2772
+ // Non-fatal: LogBox detection failure should not break screenshot
2773
+ }
2774
+ imageBuffer.add({
2775
+ id: `android-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2776
+ image: result.data,
2777
+ timestamp: Date.now(),
2778
+ source: "android_screenshot",
2779
+ metadata: {
2780
+ width: result.originalWidth || 0,
2781
+ height: result.originalHeight || 0,
2782
+ scaleFactor: result.scaleFactor || 1,
2783
+ platform: "android",
2784
+ },
2785
+ });
1846
2786
  return {
1847
2787
  content: [
1848
2788
  {
@@ -2090,11 +3030,24 @@ server.registerTool("android_describe_all", {
2090
3030
  }
2091
3031
  }, async ({ deviceId }) => {
2092
3032
  const result = await androidDescribeAll(deviceId);
3033
+ let text = result.success ? result.formatted : `Error: ${result.error}`;
3034
+ // Check for LogBox overlay
3035
+ if (result.success) {
3036
+ try {
3037
+ const logBoxState = await detectLogBox();
3038
+ if (logBoxState && logBoxState.total > 0) {
3039
+ text += formatLogBoxWarning(logBoxState);
3040
+ }
3041
+ }
3042
+ catch {
3043
+ // Non-fatal: LogBox detection failure should not break describe_all
3044
+ }
3045
+ }
2093
3046
  return {
2094
3047
  content: [
2095
3048
  {
2096
3049
  type: "text",
2097
- text: result.success ? result.formatted : `Error: ${result.error}`
3050
+ text
2098
3051
  }
2099
3052
  ],
2100
3053
  isError: !result.success
@@ -2277,7 +3230,7 @@ registerToolWithTelemetry("list_ios_simulators", {
2277
3230
  });
2278
3231
  // Tool: iOS screenshot
2279
3232
  registerToolWithTelemetry("ios_screenshot", {
2280
- description: "Take a screenshot from an iOS simulator. Returns the image data that can be displayed.",
3233
+ description: "Take a screenshot from an iOS simulator. Returns the image and a screen layout overlay showing visible React components with names, text content, hierarchy, and tap-ready pixel coordinates. Use component names for inspect_component/find_components. Use tap(text=...) for interaction — only fall back to tap(x,y) coordinates from the layout if all tap strategies fail.",
2281
3234
  inputSchema: {
2282
3235
  outputPath: z
2283
3236
  .string()
@@ -2361,16 +3314,59 @@ registerToolWithTelemetry("ios_screenshot", {
2361
3314
  pointHeight = Math.round(pixelHeight / scaleFactor);
2362
3315
  }
2363
3316
  const safeAreaOffsetPixels = safeAreaTop * scaleFactor;
3317
+ // Enrich with screen layout data (component names + tap coordinates)
3318
+ let layoutText = null;
3319
+ try {
3320
+ layoutText = await enrichScreenshotWithLayout(scaleFactor, result.scaleFactor || 1);
3321
+ }
3322
+ catch {
3323
+ // Non-fatal: screenshot works without layout enrichment
3324
+ }
2364
3325
  let infoText = `Screenshot captured (${pixelWidth}x${pixelHeight} pixels)`;
2365
- infoText += `\n📱 iOS tap coordinates use POINTS: ${pointWidth}x${pointHeight}`;
2366
- infoText += `\n📐 To convert screenshot coords to tap points:`;
2367
- infoText += `\n tap_x = pixel_x / ${scaleFactor}`;
2368
- infoText += `\n tap_y = pixel_y / ${scaleFactor}`;
3326
+ infoText += `\n📱 iOS screen: ${pointWidth}x${pointHeight} points (${scaleFactor}x scale)`;
3327
+ infoText += `\n📐 tap() handles pixel-to-point conversion automatically — pass pixel coords from this image directly`;
2369
3328
  infoText += `\n⚠️ Status bar + safe area: ${safeAreaTop} points (${safeAreaOffsetPixels} pixels) from top`;
2370
3329
  if (result.scaleFactor && result.scaleFactor > 1) {
2371
3330
  infoText += `\n🖼️ Image was scaled down to fit API limits (scale: ${result.scaleFactor.toFixed(3)})`;
2372
3331
  }
2373
- infoText += `\n💡 Use ios_describe_all to get exact element coordinates`;
3332
+ if (layoutText) {
3333
+ infoText += `\n\n📋 Screen Layout (components with tap coordinates in pixels):`;
3334
+ infoText += `\n${layoutText}`;
3335
+ infoText += `\n\n💡 Next steps:`;
3336
+ infoText += `\n • tap(text="Button Label") — tap element by visible text (preferred)`;
3337
+ infoText += `\n • tap(testID="id") or tap(component="Name") — tap by testID or component name`;
3338
+ infoText += `\n • tap(x=<px>, y=<px>) — use tap coordinates from layout above ONLY if text/testID/component tap fails`;
3339
+ infoText += `\n • inspect_component("ComponentName") — inspect a component from the layout above`;
3340
+ }
3341
+ else {
3342
+ infoText += `\n\n💡 Next steps:`;
3343
+ infoText += `\n • tap(text="Button Label") — tap element by visible text`;
3344
+ infoText += `\n • tap(x=<px>, y=<px>) — tap at coordinates from this screenshot`;
3345
+ infoText += `\n • ios_describe_all — get full UI tree with exact tap coordinates`;
3346
+ infoText += `\n • ios_find_element(label="...") — find element coordinates without tapping`;
3347
+ }
3348
+ // Check for LogBox overlay (uses default CDP device — native UDID cannot be mapped to CDP device name)
3349
+ try {
3350
+ const logBoxState = await detectLogBox();
3351
+ if (logBoxState && logBoxState.total > 0) {
3352
+ infoText += formatLogBoxWarning(logBoxState);
3353
+ }
3354
+ }
3355
+ catch {
3356
+ // Non-fatal: LogBox detection failure should not break screenshot
3357
+ }
3358
+ imageBuffer.add({
3359
+ id: `ios-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3360
+ image: result.data,
3361
+ timestamp: Date.now(),
3362
+ source: "ios_screenshot",
3363
+ metadata: {
3364
+ width: result.originalWidth || 0,
3365
+ height: result.originalHeight || 0,
3366
+ scaleFactor: result.scaleFactor || 1,
3367
+ platform: "ios",
3368
+ },
3369
+ });
2374
3370
  return {
2375
3371
  content: [
2376
3372
  {
@@ -2407,32 +3403,39 @@ registerToolWithTelemetry("ocr_screenshot", {
2407
3403
  .describe("Optional device ID (Android) or UDID (iOS). Uses first available device if not specified.")
2408
3404
  }
2409
3405
  }, async ({ platform, deviceId }) => {
2410
- // Call the HTTP endpoint for OCR (allows hot-reload without session restart)
2411
- // Prefer child process port, fall back to in-process port
2412
- const port = getDebugServerPort();
2413
- if (!port) {
2414
- return {
2415
- content: [
2416
- {
2417
- type: "text",
2418
- text: "Debug HTTP server not running"
2419
- }
2420
- ],
2421
- isError: true
2422
- };
2423
- }
2424
3406
  try {
2425
- const params = new URLSearchParams({ platform, engine: "auto" });
2426
- if (deviceId)
2427
- params.set("deviceId", deviceId);
2428
- const response = await fetch(`http://localhost:${port}/api/ocr?${params}`);
2429
- const ocrResult = await response.json();
3407
+ // Take screenshot
3408
+ const screenshotResult = platform === "android"
3409
+ ? await androidScreenshot(undefined, deviceId)
3410
+ : await iosScreenshot(undefined, deviceId);
3411
+ if (!screenshotResult.success || !screenshotResult.data) {
3412
+ return {
3413
+ content: [
3414
+ {
3415
+ type: "text",
3416
+ text: `Screenshot failed: ${screenshotResult.error || "No image data"}`
3417
+ }
3418
+ ],
3419
+ isError: true
3420
+ };
3421
+ }
3422
+ // Calculate device pixel ratio for iOS
3423
+ const devicePixelRatio = platform === "ios" && screenshotResult.originalWidth && screenshotResult.originalHeight
3424
+ ? inferIOSDevicePixelRatio(screenshotResult.originalWidth, screenshotResult.originalHeight)
3425
+ : 1;
3426
+ // Run OCR on the screenshot
3427
+ const scaleFactor = screenshotResult.scaleFactor || 1;
3428
+ const ocrResult = await recognizeText(screenshotResult.data, {
3429
+ scaleFactor,
3430
+ platform,
3431
+ devicePixelRatio
3432
+ });
2430
3433
  if (!ocrResult.success) {
2431
3434
  return {
2432
3435
  content: [
2433
3436
  {
2434
3437
  type: "text",
2435
- text: `OCR failed: ${ocrResult.error || "Unknown error"}`
3438
+ text: `OCR failed: no text recognized`
2436
3439
  }
2437
3440
  ],
2438
3441
  isError: true
@@ -2455,8 +3458,36 @@ registerToolWithTelemetry("ocr_screenshot", {
2455
3458
  confidence: Math.round(ocrResult.confidence || 0),
2456
3459
  elementCount: elements.length,
2457
3460
  elements,
2458
- note: "tapX/tapY are ready to use with tap commands (already converted for platform)"
3461
+ note: "tapX/tapY are in device pixels — pass directly to tap(x, y) for automatic platform conversion"
2459
3462
  };
3463
+ // Check for LogBox overlay (uses default CDP device — native deviceId cannot be mapped to CDP device name)
3464
+ try {
3465
+ const logBoxState = await detectLogBox();
3466
+ if (logBoxState && logBoxState.total > 0) {
3467
+ result.logBoxWarning = formatLogBoxWarning(logBoxState).trim();
3468
+ }
3469
+ }
3470
+ catch {
3471
+ // Non-fatal: LogBox detection failure should not break OCR
3472
+ }
3473
+ // Store screenshot in image buffer
3474
+ try {
3475
+ imageBuffer.add({
3476
+ id: `ocr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3477
+ image: screenshotResult.data,
3478
+ timestamp: Date.now(),
3479
+ source: "ocr_screenshot",
3480
+ metadata: {
3481
+ width: screenshotResult.originalWidth || 0,
3482
+ height: screenshotResult.originalHeight || 0,
3483
+ scaleFactor,
3484
+ platform,
3485
+ },
3486
+ });
3487
+ }
3488
+ catch {
3489
+ // Non-fatal: image buffer write failure should not break OCR response
3490
+ }
2460
3491
  return {
2461
3492
  content: [
2462
3493
  {
@@ -2471,7 +3502,7 @@ registerToolWithTelemetry("ocr_screenshot", {
2471
3502
  content: [
2472
3503
  {
2473
3504
  type: "text",
2474
- text: `OCR request failed: ${error instanceof Error ? error.message : String(error)}`
3505
+ text: `OCR failed: ${error instanceof Error ? error.message : String(error)}`
2475
3506
  }
2476
3507
  ],
2477
3508
  isError: true
@@ -2688,11 +3719,24 @@ server.registerTool("ios_describe_all", {
2688
3719
  }
2689
3720
  }, async ({ udid }) => {
2690
3721
  const result = await iosDescribeAll(udid);
3722
+ let text = result.success ? result.result : `Error: ${result.error}`;
3723
+ // Check for LogBox overlay
3724
+ if (result.success) {
3725
+ try {
3726
+ const logBoxState = await detectLogBox();
3727
+ if (logBoxState && logBoxState.total > 0) {
3728
+ text += formatLogBoxWarning(logBoxState);
3729
+ }
3730
+ }
3731
+ catch {
3732
+ // Non-fatal: LogBox detection failure should not break describe_all
3733
+ }
3734
+ }
2691
3735
  return {
2692
3736
  content: [
2693
3737
  {
2694
3738
  type: "text",
2695
- text: result.success ? result.result : `Error: ${result.error}`
3739
+ text
2696
3740
  }
2697
3741
  ],
2698
3742
  isError: !result.success
@@ -2758,7 +3802,7 @@ server.registerTool("ios_find_element", {
2758
3802
  ` Value: "${el.value}"`,
2759
3803
  ` Type: ${el.type}`,
2760
3804
  ` Frame: {x: ${el.frame.x}, y: ${el.frame.y}, w: ${el.frame.width}, h: ${el.frame.height}}`,
2761
- ` Center (tap coords): (${el.center.x}, ${el.center.y})`,
3805
+ ` Center (tap coords): (${el.center.x}, ${el.center.y}) — iOS points. Use tap(text="...") or tap(testID="...") for reliable targeting.`,
2762
3806
  ` Enabled: ${el.enabled}`
2763
3807
  ].join("\n");
2764
3808
  return {
@@ -2825,109 +3869,43 @@ server.registerTool("ios_wait_for_element", {
2825
3869
  ` Label: "${el.label}"`,
2826
3870
  ` Value: "${el.value}"`,
2827
3871
  ` Type: ${el.type}`,
2828
- ` Center (tap coords): (${el.center.x}, ${el.center.y})`,
3872
+ ` Center (tap coords): (${el.center.x}, ${el.center.y}) — iOS points. Use tap(text="...") or tap(testID="...") for reliable targeting.`,
2829
3873
  ` Enabled: ${el.enabled}`
2830
3874
  ].join("\n");
2831
3875
  return {
2832
3876
  content: [{ type: "text", text: info }]
2833
3877
  };
2834
3878
  });
2835
- // Tool: Get debug server info
2836
- registerToolWithTelemetry("get_debug_server", {
2837
- description: "Get the debug HTTP server URL. Use this to find where you can access logs, network requests, and other debug data via HTTP.",
2838
- inputSchema: {}
2839
- }, async () => {
2840
- const port = getDebugServerPort();
2841
- if (!port) {
3879
+ // Tool: Activate Pro license
3880
+ registerToolWithTelemetry("activate_license", getActivateLicenseConfig(), handleActivateLicense);
3881
+ // Tool: Delete account
3882
+ registerToolWithTelemetry("delete_account", getDeleteAccountConfig(), handleDeleteAccount);
3883
+ // Dev-only tool: reset local telemetry data
3884
+ if (isDevMode()) {
3885
+ registerToolWithTelemetry("reset_telemetry", {
3886
+ description: "Clear local telemetry data file (/tmp/rn-devtools-telemetry.jsonl). Only available in development mode.",
3887
+ inputSchema: {},
3888
+ }, async () => {
3889
+ if (existsSync(TELEMETRY_JSONL_PATH)) {
3890
+ unlinkSync(TELEMETRY_JSONL_PATH);
3891
+ return {
3892
+ content: [{ type: "text", text: "Local telemetry data cleared." }],
3893
+ };
3894
+ }
2842
3895
  return {
2843
- content: [
2844
- {
2845
- type: "text",
2846
- text: "Debug HTTP server is not running."
2847
- }
2848
- ],
2849
- isError: true
3896
+ content: [{ type: "text", text: "No local telemetry data file found." }],
2850
3897
  };
2851
- }
2852
- const info = {
2853
- url: `http://localhost:${port}`,
2854
- endpoints: {
2855
- status: `http://localhost:${port}/api/status`,
2856
- logs: `http://localhost:${port}/api/logs`,
2857
- network: `http://localhost:${port}/api/network`,
2858
- bundleErrors: `http://localhost:${port}/api/bundle-errors`,
2859
- apps: `http://localhost:${port}/api/apps`
2860
- }
2861
- };
2862
- return {
2863
- content: [
2864
- {
2865
- type: "text",
2866
- text: `Debug HTTP server running at:\n\n${JSON.stringify(info, null, 2)}`
2867
- }
2868
- ]
2869
- };
2870
- });
2871
- // Tool: Restart HTTP server (hot-reload)
2872
- registerToolWithTelemetry("restart_http_server", {
2873
- description: "Note: HTTP server now runs in-process to share state. To apply code changes, restart the MCP session.",
2874
- inputSchema: {}
2875
- }, async () => {
2876
- const port = getDebugServerPort();
2877
- return {
2878
- content: [
2879
- {
2880
- type: "text",
2881
- text: `HTTP server is running in-process on port ${port}. To apply code changes, rebuild with 'npm run build' and restart the MCP session. The in-process mode is required for the dashboard to show logs, network requests, and connected apps.`
2882
- }
2883
- ]
2884
- };
2885
- });
2886
- /**
2887
- * Auto-connect to Metro bundler on startup
2888
- * Scans common ports and connects to any running Metro servers
2889
- */
2890
- async function autoConnectToMetro() {
2891
- console.error("[rn-ai-debugger] Auto-scanning for Metro servers...");
2892
- try {
2893
- const openPorts = await scanMetroPorts();
2894
- if (openPorts.length === 0) {
2895
- console.error("[rn-ai-debugger] No Metro servers found on startup. Use scan_metro to connect later.");
2896
- return;
2897
- }
2898
- for (const port of openPorts) {
2899
- try {
2900
- const devices = await fetchDevices(port);
2901
- const mainDevice = selectMainDevice(devices);
2902
- if (mainDevice) {
2903
- await connectToDevice(mainDevice, port);
2904
- console.error(`[rn-ai-debugger] Auto-connected to ${mainDevice.title} on port ${port}`);
2905
- // Also connect to Metro build events
2906
- try {
2907
- await connectMetroBuildEvents(port);
2908
- }
2909
- catch {
2910
- // Build events connection is optional
2911
- }
2912
- }
2913
- }
2914
- catch (error) {
2915
- console.error(`[rn-ai-debugger] Failed to auto-connect on port ${port}: ${error}`);
2916
- }
2917
- }
2918
- }
2919
- catch (error) {
2920
- console.error(`[rn-ai-debugger] Auto-connect error: ${error}`);
2921
- }
3898
+ });
2922
3899
  }
2923
3900
  // Main function
2924
3901
  async function main() {
2925
3902
  // Initialize telemetry (checks opt-out env var, loads/creates installation ID)
3903
+ // License validation is lazy — runs on first tool use via ensureLicense()
2926
3904
  initTelemetry();
2927
- // Start debug HTTP server in-process (shares state with MCP server)
2928
- // Note: Child process mode doesn't work because state (logs, network, apps) isn't shared
2929
- await startDebugHttpServer();
2930
- console.error("[rn-ai-debugger] HTTP server started in-process");
3905
+ identifyIfDevMode(getInstallationId());
3906
+ // --- Eager usage check (pre-loads usage cache for tool-level gate) ---
3907
+ const { ensureLicense } = await import("./core/license.js");
3908
+ await ensureLicense();
2931
3909
  const useHttp = process.argv.includes("--http");
2932
3910
  const httpPort = parseInt(process.env.MCP_HTTP_PORT || "8600", 10);
2933
3911
  if (useHttp) {
@@ -2988,25 +3966,47 @@ async function main() {
2988
3966
  res.end("Not found");
2989
3967
  });
2990
3968
  httpServer.listen(httpPort, () => {
2991
- console.error(`[rn-ai-debugger] MCP HTTP server listening on http://localhost:${httpPort}/mcp`);
3969
+ console.error(`[rn-ai-devtools] MCP HTTP server listening on http://localhost:${httpPort}/mcp`);
2992
3970
  });
2993
3971
  }
2994
3972
  else {
2995
3973
  // Stdio transport mode — default for production
2996
3974
  const transport = new StdioServerTransport();
2997
3975
  await server.connect(transport);
2998
- console.error("[rn-ai-debugger] Server started on stdio");
3976
+ console.error("[rn-ai-devtools] Server started on stdio");
2999
3977
  }
3000
- // Auto-connect to Metro in background (non-blocking)
3001
- // Use setImmediate to ensure MCP server is fully ready first
3002
- setImmediate(() => {
3003
- autoConnectToMetro().catch((err) => {
3004
- console.error("[rn-ai-debugger] Auto-connect failed:", err);
3005
- });
3006
- });
3007
3978
  }
3008
3979
  main().catch((error) => {
3009
- console.error("[rn-ai-debugger] Fatal error:", error);
3980
+ console.error("[rn-ai-devtools] Fatal error:", error);
3010
3981
  process.exit(1);
3011
3982
  });
3983
+ // Graceful shutdown: close CDP connections so the slot is freed for other sessions
3984
+ function gracefulShutdown() {
3985
+ suppressReconnection();
3986
+ cancelAllReconnectionTimers();
3987
+ for (const [key, app] of connectedApps.entries()) {
3988
+ try {
3989
+ app.ws.close();
3990
+ }
3991
+ catch {
3992
+ // Ignore close errors during shutdown
3993
+ }
3994
+ connectedApps.delete(key);
3995
+ }
3996
+ disconnectMetroBuildEvents();
3997
+ clearAllConnectionState();
3998
+ clearAllCDPMessageTimes();
3999
+ shutdownPostHog().catch(() => { });
4000
+ }
4001
+ process.on("beforeExit", () => {
4002
+ shutdownPostHog().catch(() => { });
4003
+ });
4004
+ process.on("SIGINT", () => {
4005
+ gracefulShutdown();
4006
+ process.exit(0);
4007
+ });
4008
+ process.on("SIGTERM", () => {
4009
+ gracefulShutdown();
4010
+ process.exit(0);
4011
+ });
3012
4012
  //# sourceMappingURL=index.js.map