react-native-ai-devtools 1.4.12 → 1.4.14

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 (36) hide show
  1. package/build/__tests__/integration/connection-health.test.js +12 -6
  2. package/build/__tests__/integration/connection-health.test.js.map +1 -1
  3. package/build/__tests__/unit/connection-health.test.js +26 -16
  4. package/build/__tests__/unit/connection-health.test.js.map +1 -1
  5. package/build/__tests__/unit/logs.test.js +9 -0
  6. package/build/__tests__/unit/logs.test.js.map +1 -1
  7. package/build/core/bundle.d.ts.map +1 -1
  8. package/build/core/bundle.js +15 -24
  9. package/build/core/bundle.js.map +1 -1
  10. package/build/core/connection.d.ts +32 -2
  11. package/build/core/connection.d.ts.map +1 -1
  12. package/build/core/connection.js +328 -134
  13. package/build/core/connection.js.map +1 -1
  14. package/build/core/index.d.ts +4 -4
  15. package/build/core/index.d.ts.map +1 -1
  16. package/build/core/index.js +3 -3
  17. package/build/core/index.js.map +1 -1
  18. package/build/core/logs.d.ts +1 -0
  19. package/build/core/logs.d.ts.map +1 -1
  20. package/build/core/logs.js +5 -0
  21. package/build/core/logs.js.map +1 -1
  22. package/build/core/metro.d.ts +7 -0
  23. package/build/core/metro.d.ts.map +1 -1
  24. package/build/core/metro.js +25 -0
  25. package/build/core/metro.js.map +1 -1
  26. package/build/core/state.d.ts +16 -2
  27. package/build/core/state.d.ts.map +1 -1
  28. package/build/core/state.js +34 -6
  29. package/build/core/state.js.map +1 -1
  30. package/build/core/telemetry.d.ts +4 -1
  31. package/build/core/telemetry.d.ts.map +1 -1
  32. package/build/core/telemetry.js +51 -2
  33. package/build/core/telemetry.js.map +1 -1
  34. package/build/index.js +110 -16
  35. package/build/index.js.map +1 -1
  36. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import WebSocket from "ws";
2
- import { connectedApps, pendingExecutions, getNextMessageId, getLogBuffer, getNetworkBuffer, setActiveSimulatorUdid, clearActiveSimulatorIfSource, updateLastCDPMessageTime, getLastCDPMessageTime } from "./state.js";
2
+ import { connectedApps, pendingExecutions, getNextMessageId, getLogBuffer, getNetworkBuffer, setActiveSimulatorUdid, clearActiveSimulatorIfSource, updateLastCDPMessageTime, getLastCDPMessageTime, clearLastCDPMessageTime } from "./state.js";
3
3
  import { mapConsoleType } from "./logs.js";
4
4
  import { injectNetworkInterceptor, sendNetworkEnable, isInterceptorEvent, applyInterceptedEvent } from "./networkInterceptor.js";
5
5
  import { findSimulatorByName } from "./ios.js";
@@ -37,6 +37,74 @@ export function clearReconnectionSuppression() {
37
37
  const STALE_ACTIVITY_THRESHOLD_MS = 30_000;
38
38
  const PING_INTERVAL_MS = 1_000; // WebSocket ping/pong keepalive interval
39
39
  const RECONNECT_SETTLE_MS = 500;
40
+ /**
41
+ * Creates a WebSocket connection with Origin header fallback.
42
+ * RN 0.85+ Metro requires the Origin header, but Expo SDK 55 Bridgeless
43
+ * rejects connections that include one. The rejection can manifest as:
44
+ * - Connection refused before open (error/close before open event)
45
+ * - Immediate close after open (open fires, then close with code 1006)
46
+ * Tries with Origin first, falls back to without on quick failure.
47
+ */
48
+ export function createWebSocketWithOriginFallback(url, timeoutMs = 5000) {
49
+ const STABILIZE_MS = 500; // Wait after open to detect immediate close
50
+ return new Promise((resolve, reject) => {
51
+ const wsUrl = new URL(url);
52
+ const origin = `http://${wsUrl.hostname}:${wsUrl.port}`;
53
+ let settled = false;
54
+ const tryConnect = (withOrigin) => {
55
+ const options = withOrigin ? { headers: { Origin: origin } } : undefined;
56
+ const ws = new WebSocket(url, options);
57
+ let opened = false;
58
+ ws.on("open", () => {
59
+ opened = true;
60
+ if (withOrigin) {
61
+ // Wait briefly to detect immediate close (Expo SDK 55 pattern)
62
+ setTimeout(() => {
63
+ if (settled)
64
+ return;
65
+ if (ws.readyState === WebSocket.OPEN) {
66
+ settled = true;
67
+ console.error("[rn-ai-debugger] WebSocket connected with Origin header");
68
+ resolve(ws);
69
+ }
70
+ // If not OPEN, the close handler will trigger fallback
71
+ }, STABILIZE_MS);
72
+ }
73
+ else {
74
+ if (settled)
75
+ return;
76
+ settled = true;
77
+ console.error("[rn-ai-debugger] WebSocket connected without Origin header");
78
+ resolve(ws);
79
+ }
80
+ });
81
+ const handleFailure = (error) => {
82
+ if (settled)
83
+ return;
84
+ ws.removeAllListeners();
85
+ ws.terminate();
86
+ if (withOrigin) {
87
+ const reason = opened ? "immediately closed after open" : "rejected before open";
88
+ console.error(`[rn-ai-debugger] Origin header connection ${reason}, retrying without...`);
89
+ tryConnect(false);
90
+ }
91
+ else {
92
+ settled = true;
93
+ reject(error || new Error("WebSocket connection failed"));
94
+ }
95
+ };
96
+ ws.on("error", (err) => handleFailure(err));
97
+ ws.on("close", () => handleFailure());
98
+ };
99
+ tryConnect(true);
100
+ setTimeout(() => {
101
+ if (!settled) {
102
+ settled = true;
103
+ reject(new Error("WebSocket connection timed out"));
104
+ }
105
+ }, timeoutMs);
106
+ });
107
+ }
40
108
  // Helper to find appKey from device info by searching connectedApps
41
109
  function findAppKeyForDevice(device) {
42
110
  for (const [key, app] of connectedApps.entries()) {
@@ -171,6 +239,10 @@ function formatPreview(preview) {
171
239
  }
172
240
  // Format a single CDP console argument (sync — without object resolution)
173
241
  function formatConsoleArg(arg) {
242
+ // Explicit undefined (matches browser: console.log(undefined) → "undefined")
243
+ if (arg.type === "undefined") {
244
+ return "undefined";
245
+ }
174
246
  // Primitives
175
247
  if (arg.type === "string" || arg.type === "number" || arg.type === "boolean") {
176
248
  return String(arg.value);
@@ -187,7 +259,11 @@ function formatConsoleArg(arg) {
187
259
  if (arg.description) {
188
260
  return arg.description;
189
261
  }
190
- return "[object]";
262
+ // Object without any resolved data
263
+ if (arg.type === "object") {
264
+ return "[Object]";
265
+ }
266
+ return `[${arg.type || "unknown"}]`;
191
267
  }
192
268
  // Fetch object properties via CDP Runtime.getProperties
193
269
  function fetchObjectProperties(ws, objectId, depth = 2) {
@@ -307,8 +383,11 @@ async function resolveConsoleArgs(ws, args) {
307
383
  }
308
384
  // Handle CDP messages
309
385
  export function handleCDPMessage(message, device, ws) {
310
- // Track last CDP activity for connection liveness detection
311
- updateLastCDPMessageTime(new Date());
386
+ // Track last CDP activity for connection liveness detection (per-device)
387
+ const cdpAppKey = findAppKeyForDevice(device);
388
+ if (cdpAppKey) {
389
+ updateLastCDPMessageTime(cdpAppKey, new Date());
390
+ }
312
391
  const deviceName = device.deviceName || device.title || "unknown";
313
392
  // Handle responses to our requests (e.g., Runtime.evaluate)
314
393
  if (typeof message.id === "number") {
@@ -387,25 +466,21 @@ export function handleCDPMessage(message, device, ws) {
387
466
  if (hasObjectArgs) {
388
467
  // Resolve object args asynchronously via CDP
389
468
  resolveConsoleArgs(ws, args).then((messageText) => {
390
- if (messageText.trim()) {
391
- getLogBuffer(deviceName).add({
392
- timestamp: new Date(),
393
- level,
394
- message: messageText,
395
- args: args.map((a) => a.value)
396
- });
397
- }
469
+ getLogBuffer(deviceName).add({
470
+ timestamp: new Date(),
471
+ level,
472
+ message: messageText || "[console call with empty resolution]",
473
+ args: args.map((a) => a.value)
474
+ });
398
475
  }).catch(() => {
399
476
  // Fallback to sync formatting on error
400
477
  const messageText = args.map(formatConsoleArg).join(" ");
401
- if (messageText.trim()) {
402
- getLogBuffer(deviceName).add({
403
- timestamp: new Date(),
404
- level,
405
- message: messageText,
406
- args: args.map((a) => a.value)
407
- });
408
- }
478
+ getLogBuffer(deviceName).add({
479
+ timestamp: new Date(),
480
+ level,
481
+ message: messageText || "[console call with unresolvable args]",
482
+ args: args.map((a) => a.value)
483
+ });
409
484
  });
410
485
  }
411
486
  else {
@@ -518,7 +593,7 @@ export function handleCDPMessage(message, device, ws) {
518
593
  // Connect to a device via CDP WebSocket
519
594
  export async function connectToDevice(device, port, options = {}) {
520
595
  const { isReconnection = false, reconnectionConfig = DEFAULT_RECONNECTION_CONFIG } = options;
521
- return new Promise((resolve, reject) => {
596
+ return new Promise(async (resolve, reject) => {
522
597
  const appKey = `${port}-${device.id}`;
523
598
  // Check if already connected with a valid WebSocket
524
599
  const existingApp = connectedApps.get(appKey);
@@ -558,88 +633,79 @@ export async function connectToDevice(device, port, options = {}) {
558
633
  webSocketUrl: device.webSocketDebuggerUrl
559
634
  });
560
635
  try {
561
- // Metro requires a valid Origin header matching the server's host
562
- const wsUrl = new URL(device.webSocketDebuggerUrl);
563
- const origin = `http://${wsUrl.hostname}:${wsUrl.port}`;
564
- const ws = new WebSocket(device.webSocketDebuggerUrl, { headers: { Origin: origin } });
565
- ws.on("open", async () => {
566
- // Release connection lock
567
- connectionLocks.delete(appKey);
568
- connectedApps.set(appKey, { ws, deviceInfo: device, port, platform: "android" });
569
- // Initialize or update connection state
570
- // Note: We do NOT reset reconnectionAttempts here - that happens
571
- // only when connection has been stable for MIN_STABLE_CONNECTION_MS
572
- if (isReconnection) {
573
- closeConnectionGap(appKey);
574
- updateConnectionState(appKey, {
575
- status: "connected",
576
- lastConnectedTime: new Date()
577
- // reconnectionAttempts NOT reset here - see ws.on("close") for stable connection check
578
- });
579
- // Reset context health for reconnection
580
- initContextHealth(appKey);
581
- console.error(`[rn-ai-debugger] Reconnected to ${device.title}`);
582
- }
583
- else {
584
- initConnectionState(appKey);
585
- initContextHealth(appKey);
586
- console.error(`[rn-ai-debugger] Connected to ${device.title}`);
587
- }
588
- // Enable Runtime domain to receive console messages
589
- ws.send(JSON.stringify({
590
- id: getNextMessageId(),
591
- method: "Runtime.enable"
592
- }));
593
- // Also enable Log domain
594
- ws.send(JSON.stringify({
595
- id: getNextMessageId(),
596
- method: "Log.enable"
597
- }));
598
- // Inject JS network interceptor (immediate capture, may fail if context not ready)
599
- injectNetworkInterceptor(ws);
600
- // Also try CDP Network.enable (takes priority if supported)
601
- const networkEnableId = sendNetworkEnable(ws);
602
- pendingNetworkEnableIds.add(networkEnableId);
603
- // Try to resolve iOS simulator UDID from device name
604
- // This enables automatic device scoping for iOS tools
605
- if (device.deviceName) {
606
- const simulatorUdid = await findSimulatorByName(device.deviceName);
607
- if (simulatorUdid) {
608
- setActiveSimulatorUdid(simulatorUdid, appKey);
609
- // Update platform to ios now that simulator is confirmed
610
- const connectedApp = connectedApps.get(appKey);
611
- if (connectedApp) {
612
- connectedApp.platform = "ios";
613
- connectedApp.simulatorUdid = simulatorUdid;
614
- }
615
- console.error(`[rn-ai-debugger] Linked to iOS simulator: ${simulatorUdid}`);
636
+ const ws = await createWebSocketWithOriginFallback(device.webSocketDebuggerUrl);
637
+ // Connection established — run setup
638
+ connectionLocks.delete(appKey);
639
+ connectedApps.set(appKey, { ws, deviceInfo: device, port, platform: "android" });
640
+ // Initialize or update connection state
641
+ // Note: We do NOT reset reconnectionAttempts here - that happens
642
+ // only when connection has been stable for MIN_STABLE_CONNECTION_MS
643
+ if (isReconnection) {
644
+ closeConnectionGap(appKey);
645
+ updateConnectionState(appKey, {
646
+ status: "connected",
647
+ lastConnectedTime: new Date()
648
+ // reconnectionAttempts NOT reset here - see ws.on("close") for stable connection check
649
+ });
650
+ // Reset context health for reconnection
651
+ initContextHealth(appKey);
652
+ console.error(`[rn-ai-debugger] Reconnected to ${device.title}`);
653
+ }
654
+ else {
655
+ initConnectionState(appKey);
656
+ initContextHealth(appKey);
657
+ console.error(`[rn-ai-debugger] Connected to ${device.title}`);
658
+ }
659
+ // Enable Runtime domain to receive console messages
660
+ ws.send(JSON.stringify({
661
+ id: getNextMessageId(),
662
+ method: "Runtime.enable"
663
+ }));
664
+ // Also enable Log domain
665
+ ws.send(JSON.stringify({
666
+ id: getNextMessageId(),
667
+ method: "Log.enable"
668
+ }));
669
+ // Inject JS network interceptor (immediate capture, may fail if context not ready)
670
+ injectNetworkInterceptor(ws);
671
+ // Also try CDP Network.enable (takes priority if supported)
672
+ const networkEnableId = sendNetworkEnable(ws);
673
+ pendingNetworkEnableIds.add(networkEnableId);
674
+ // Try to resolve iOS simulator UDID from device name
675
+ // This enables automatic device scoping for iOS tools
676
+ if (device.deviceName) {
677
+ const simulatorUdid = await findSimulatorByName(device.deviceName);
678
+ if (simulatorUdid) {
679
+ setActiveSimulatorUdid(simulatorUdid, appKey);
680
+ // Update platform to ios now that simulator is confirmed
681
+ const connectedApp = connectedApps.get(appKey);
682
+ if (connectedApp) {
683
+ connectedApp.platform = "ios";
684
+ connectedApp.simulatorUdid = simulatorUdid;
616
685
  }
686
+ console.error(`[rn-ai-debugger] Linked to iOS simulator: ${simulatorUdid}`);
617
687
  }
618
- // Fire-and-forget app detection (500ms delayed, non-blocking)
619
- const appForDetection = connectedApps.get(appKey);
620
- if (appForDetection) {
621
- scheduleAppDetection(appForDetection);
622
- }
623
- // Start WebSocket ping/pong keepalive to detect dead connections
624
- // (especially important for physical devices over Wi-Fi)
625
- let pongReceived = true;
626
- const pingInterval = setInterval(() => {
627
- if (!pongReceived) {
628
- console.error(`[rn-ai-debugger] No pong from ${device.title}, terminating connection`);
629
- clearInterval(pingInterval);
630
- ws.terminate();
631
- return;
632
- }
633
- pongReceived = false;
634
- ws.ping();
635
- }, PING_INTERVAL_MS);
636
- ws.on("pong", () => {
637
- pongReceived = true;
638
- });
639
- ws.on("close", () => {
688
+ }
689
+ // Fire-and-forget app detection (500ms delayed, non-blocking)
690
+ const appForDetection = connectedApps.get(appKey);
691
+ if (appForDetection) {
692
+ scheduleAppDetection(appForDetection);
693
+ }
694
+ // Start WebSocket ping/pong keepalive to detect dead connections
695
+ // (especially important for physical devices over Wi-Fi)
696
+ let pongReceived = true;
697
+ const pingInterval = setInterval(() => {
698
+ if (!pongReceived) {
699
+ console.error(`[rn-ai-debugger] No pong from ${device.title}, terminating connection`);
640
700
  clearInterval(pingInterval);
641
- });
642
- resolve(`Connected to ${device.title} (${device.deviceName})`);
701
+ ws.terminate();
702
+ return;
703
+ }
704
+ pongReceived = false;
705
+ ws.ping();
706
+ }, PING_INTERVAL_MS);
707
+ ws.on("pong", () => {
708
+ pongReceived = true;
643
709
  });
644
710
  ws.on("message", (data) => {
645
711
  try {
@@ -651,9 +717,11 @@ export async function connectToDevice(device, port, options = {}) {
651
717
  }
652
718
  });
653
719
  ws.on("close", () => {
720
+ clearInterval(pingInterval);
654
721
  // Release connection lock if still held
655
722
  connectionLocks.delete(appKey);
656
723
  connectedApps.delete(appKey);
724
+ clearLastCDPMessageTime(appKey);
657
725
  // Clear active simulator UDID if this connection set it
658
726
  clearActiveSimulatorIfSource(appKey);
659
727
  // Keep log and network buffers across reconnections
@@ -687,41 +755,23 @@ export async function connectToDevice(device, port, options = {}) {
687
755
  }
688
756
  });
689
757
  ws.on("error", (error) => {
690
- // Release connection lock
691
- connectionLocks.delete(appKey);
692
- // Cancel any pending reconnection timer to prevent orphaned loops
693
- cancelReconnectionTimer(appKey);
694
- connectedApps.delete(appKey);
695
- // Clear active simulator UDID if this connection set it
696
- clearActiveSimulatorIfSource(appKey);
697
- // Extract error message safely - some WebSocket errors may not have a message
698
- const errorMsg = error?.message || error?.toString() || 'Unknown WebSocket error';
699
- // Only reject if this is initial connection, not reconnection attempt
700
- if (!isReconnection) {
701
- reject(`Failed to connect to ${device.title}: ${errorMsg}`);
702
- }
703
- else {
704
- console.error(`[rn-ai-debugger] Reconnection error: ${errorMsg}`);
705
- }
758
+ console.error(`[rn-ai-debugger] WebSocket error for ${device.title}: ${error?.message || error}`);
706
759
  });
707
- // Timeout after 5 seconds
708
- setTimeout(() => {
709
- if (ws.readyState !== WebSocket.OPEN) {
710
- // Release connection lock on timeout
711
- connectionLocks.delete(appKey);
712
- ws.terminate();
713
- if (!isReconnection) {
714
- reject(`Connection to ${device.title} timed out`);
715
- }
716
- }
717
- }, 5000);
760
+ resolve(`Connected to ${device.title} (${device.deviceName})`);
718
761
  }
719
762
  catch (error) {
720
- // Release connection lock on exception
763
+ // Connection failed (both with and without Origin header)
721
764
  connectionLocks.delete(appKey);
765
+ cancelReconnectionTimer(appKey);
766
+ connectedApps.delete(appKey);
767
+ clearLastCDPMessageTime(appKey);
768
+ clearActiveSimulatorIfSource(appKey);
769
+ const errorMsg = error instanceof Error ? error.message : String(error);
722
770
  if (!isReconnection) {
723
- const errorMessage = error instanceof Error ? error.message : (error ? String(error) : "Unknown error");
724
- reject(`Failed to create WebSocket connection: ${errorMessage}`);
771
+ reject(`Failed to connect to ${device.title}: ${errorMsg}`);
772
+ }
773
+ else {
774
+ console.error(`[rn-ai-debugger] Reconnection error: ${errorMsg}`);
725
775
  }
726
776
  }
727
777
  });
@@ -887,6 +937,115 @@ export async function runQuickHealthCheck(app) {
887
937
  }
888
938
  });
889
939
  }
940
+ const LOG_PIPELINE_MARKER_PREFIX = "__rn_devtools_health_";
941
+ /**
942
+ * Inject a sentinel console.log via CDP and wait for it to appear in the log buffer.
943
+ * Returns true if the marker arrives within TIMEOUT_MS.
944
+ */
945
+ async function sendAndWaitForMarker(app, buffer) {
946
+ const marker = `${LOG_PIPELINE_MARKER_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
947
+ const TIMEOUT_MS = 3000;
948
+ try {
949
+ app.ws.send(JSON.stringify({
950
+ id: getNextMessageId(),
951
+ method: "Runtime.evaluate",
952
+ params: { expression: `console.log("${marker}")`, returnByValue: true },
953
+ }));
954
+ }
955
+ catch {
956
+ return false;
957
+ }
958
+ // Poll the buffer for the marker (check every 50ms, up to TIMEOUT_MS)
959
+ const startTime = Date.now();
960
+ while (Date.now() - startTime < TIMEOUT_MS) {
961
+ if (buffer.getAll().some(entry => entry.message.includes(marker))) {
962
+ buffer.removeByText(marker);
963
+ return true;
964
+ }
965
+ await new Promise(resolve => setTimeout(resolve, 50));
966
+ }
967
+ return false;
968
+ }
969
+ /**
970
+ * End-to-end log pipeline verification with automatic recovery.
971
+ *
972
+ * 1. Injects a sentinel console.log via CDP and verifies it appears in the buffer.
973
+ * 2. If the marker doesn't arrive, attempts recovery:
974
+ * a. Re-sends Runtime.enable + Log.enable (fixes missing domain subscription)
975
+ * b. Retries the marker once
976
+ * 3. If still broken, force-reconnects via ensureConnection and retries.
977
+ *
978
+ * Returns { ok, recovered, message } — the caller decides what to show.
979
+ */
980
+ export async function verifyLogPipeline(app) {
981
+ const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
982
+ const buffer = getLogBuffer(deviceName);
983
+ // --- Attempt 1: quick check ---
984
+ if (await sendAndWaitForMarker(app, buffer)) {
985
+ return { ok: true, recovered: false, message: null };
986
+ }
987
+ // --- Attempt 2: re-enable Runtime/Log domains and retry ---
988
+ try {
989
+ app.ws.send(JSON.stringify({ id: getNextMessageId(), method: "Runtime.enable" }));
990
+ app.ws.send(JSON.stringify({ id: getNextMessageId(), method: "Log.enable" }));
991
+ }
992
+ catch {
993
+ // WS send failed — fall through to reconnect
994
+ }
995
+ // Brief pause for domain activation
996
+ await new Promise(resolve => setTimeout(resolve, 300));
997
+ if (await sendAndWaitForMarker(app, buffer)) {
998
+ return { ok: true, recovered: true, message: "[CONNECTION] Log pipeline was stale — re-enabled CDP domains. Logs are flowing again." };
999
+ }
1000
+ // --- Attempt 3: force reconnect and retry ---
1001
+ const appKey = `${app.port}-${app.deviceInfo.id}`;
1002
+ const port = app.port;
1003
+ cancelReconnectionTimer(appKey);
1004
+ try {
1005
+ app.ws.close();
1006
+ }
1007
+ catch { /* ignore */ }
1008
+ connectedApps.delete(appKey);
1009
+ const result = await ensureConnection({ port, forceRefresh: false, healthCheck: true });
1010
+ if (!result.connected || !result.healthCheckPassed) {
1011
+ return {
1012
+ ok: false,
1013
+ recovered: false,
1014
+ message: "[CONNECTION] Log pipeline broken and reconnection failed. Ensure the app is running, then call scan_metro.",
1015
+ };
1016
+ }
1017
+ // Wait for the new connection to settle
1018
+ await new Promise(resolve => setTimeout(resolve, 500));
1019
+ // Get the fresh app reference after reconnection
1020
+ const freshApp = getFirstConnectedApp();
1021
+ if (!freshApp) {
1022
+ return {
1023
+ ok: false,
1024
+ recovered: false,
1025
+ message: "[CONNECTION] Reconnected but no app available. Call scan_metro.",
1026
+ };
1027
+ }
1028
+ const freshDeviceName = freshApp.deviceInfo.deviceName || freshApp.deviceInfo.title || "unknown";
1029
+ const freshBuffer = getLogBuffer(freshDeviceName);
1030
+ if (await sendAndWaitForMarker(freshApp, freshBuffer)) {
1031
+ return {
1032
+ ok: true,
1033
+ recovered: true,
1034
+ message: "[CONNECTION] Log pipeline was broken — reconnected and verified. Logs are flowing again.",
1035
+ };
1036
+ }
1037
+ return {
1038
+ ok: false,
1039
+ recovered: false,
1040
+ message: "[WARNING] Log pipeline verification failed after reconnection. Console events are not reaching the buffer. Try reload_app or scan_metro.",
1041
+ };
1042
+ }
1043
+ /**
1044
+ * Check if a log message is a health check marker (for filtering from output).
1045
+ */
1046
+ export function isHealthCheckMarker(message) {
1047
+ return message.includes(LOG_PIPELINE_MARKER_PREFIX);
1048
+ }
890
1049
  /**
891
1050
  * Find the first available Metro port
892
1051
  */
@@ -1038,7 +1197,28 @@ export async function ensureConnection(options = {}) {
1038
1197
  }),
1039
1198
  };
1040
1199
  }
1041
- export function getPassiveConnectionStatus() {
1200
+ export function getPassiveConnectionStatus(targetAppKey) {
1201
+ if (targetAppKey) {
1202
+ // Check a specific device's connection
1203
+ const app = connectedApps.get(targetAppKey);
1204
+ if (!app || app.ws.readyState !== WebSocket.OPEN) {
1205
+ return { connected: false, needsPing: false, reason: "no_connection" };
1206
+ }
1207
+ const health = getContextHealth(targetAppKey);
1208
+ if (health?.isStale) {
1209
+ return { connected: false, needsPing: false, reason: "context_stale" };
1210
+ }
1211
+ const lastMessage = getLastCDPMessageTime(targetAppKey);
1212
+ if (!lastMessage) {
1213
+ return { connected: false, needsPing: false, reason: "no_activity" };
1214
+ }
1215
+ const elapsed = Date.now() - lastMessage.getTime();
1216
+ if (elapsed > STALE_ACTIVITY_THRESHOLD_MS) {
1217
+ return { connected: true, needsPing: true, reason: "activity_stale" };
1218
+ }
1219
+ return { connected: true, needsPing: false, reason: "ok" };
1220
+ }
1221
+ // Default: check first connected app (backwards compatible)
1042
1222
  if (!hasConnectedApp()) {
1043
1223
  return { connected: false, needsPing: false, reason: "no_connection" };
1044
1224
  }
@@ -1060,13 +1240,27 @@ export function getPassiveConnectionStatus() {
1060
1240
  }
1061
1241
  return { connected: true, needsPing: false, reason: "ok" };
1062
1242
  }
1063
- export async function checkAndEnsureConnection() {
1064
- const passive = getPassiveConnectionStatus();
1243
+ export async function checkAndEnsureConnection(device) {
1244
+ // Resolve targeted device to appKey if specified
1245
+ let targetAppKey;
1246
+ let targetApp = null;
1247
+ if (device) {
1248
+ try {
1249
+ targetApp = getConnectedAppByDevice(device);
1250
+ if (targetApp) {
1251
+ targetAppKey = `${targetApp.port}-${targetApp.deviceInfo.id}`;
1252
+ }
1253
+ }
1254
+ catch {
1255
+ // Device not found — fall through to reconnection
1256
+ }
1257
+ }
1258
+ const passive = getPassiveConnectionStatus(targetAppKey);
1065
1259
  if (passive.connected && !passive.needsPing) {
1066
1260
  return { connected: true, wasReconnected: false, message: null };
1067
1261
  }
1068
1262
  if (passive.connected && passive.needsPing) {
1069
- const app = getFirstConnectedApp();
1263
+ const app = targetApp ?? getFirstConnectedApp();
1070
1264
  if (app) {
1071
1265
  const healthy = await runQuickHealthCheck(app);
1072
1266
  if (healthy) {