execbro 1.10.24 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +1 -1
  2. package/build/__tests__/helpers/fake-cdp-server.d.ts +9 -0
  3. package/build/__tests__/helpers/fake-cdp-server.d.ts.map +1 -1
  4. package/build/__tests__/helpers/fake-cdp-server.js +20 -2
  5. package/build/__tests__/helpers/fake-cdp-server.js.map +1 -1
  6. package/build/__tests__/integration/connection-health.test.js +8 -1
  7. package/build/__tests__/integration/connection-health.test.js.map +1 -1
  8. package/build/__tests__/integration/native-logs.test.d.ts +2 -0
  9. package/build/__tests__/integration/native-logs.test.d.ts.map +1 -0
  10. package/build/__tests__/integration/native-logs.test.js +39 -0
  11. package/build/__tests__/integration/native-logs.test.js.map +1 -0
  12. package/build/__tests__/integration/socket-close-midflight.test.d.ts +2 -0
  13. package/build/__tests__/integration/socket-close-midflight.test.d.ts.map +1 -0
  14. package/build/__tests__/integration/socket-close-midflight.test.js +106 -0
  15. package/build/__tests__/integration/socket-close-midflight.test.js.map +1 -0
  16. package/build/__tests__/integration/stale-target-probe.test.d.ts +2 -0
  17. package/build/__tests__/integration/stale-target-probe.test.d.ts.map +1 -0
  18. package/build/__tests__/integration/stale-target-probe.test.js +85 -0
  19. package/build/__tests__/integration/stale-target-probe.test.js.map +1 -0
  20. package/build/__tests__/unit/clearLogsNative.test.d.ts +2 -0
  21. package/build/__tests__/unit/clearLogsNative.test.d.ts.map +1 -0
  22. package/build/__tests__/unit/clearLogsNative.test.js +43 -0
  23. package/build/__tests__/unit/clearLogsNative.test.js.map +1 -0
  24. package/build/__tests__/unit/componentFormatNaming.test.d.ts +2 -0
  25. package/build/__tests__/unit/componentFormatNaming.test.d.ts.map +1 -0
  26. package/build/__tests__/unit/componentFormatNaming.test.js +32 -0
  27. package/build/__tests__/unit/componentFormatNaming.test.js.map +1 -0
  28. package/build/__tests__/unit/deviceResolution.test.d.ts +2 -0
  29. package/build/__tests__/unit/deviceResolution.test.d.ts.map +1 -0
  30. package/build/__tests__/unit/deviceResolution.test.js +108 -0
  31. package/build/__tests__/unit/deviceResolution.test.js.map +1 -0
  32. package/build/__tests__/unit/executor.test.js +48 -12
  33. package/build/__tests__/unit/executor.test.js.map +1 -1
  34. package/build/__tests__/unit/getLogsEmptyContract.test.d.ts +2 -0
  35. package/build/__tests__/unit/getLogsEmptyContract.test.d.ts.map +1 -0
  36. package/build/__tests__/unit/getLogsEmptyContract.test.js +112 -0
  37. package/build/__tests__/unit/getLogsEmptyContract.test.js.map +1 -0
  38. package/build/__tests__/unit/getLogsNativeSource.test.d.ts +2 -0
  39. package/build/__tests__/unit/getLogsNativeSource.test.d.ts.map +1 -0
  40. package/build/__tests__/unit/getLogsNativeSource.test.js +138 -0
  41. package/build/__tests__/unit/getLogsNativeSource.test.js.map +1 -0
  42. package/build/__tests__/unit/jsLogEvents.test.d.ts +2 -0
  43. package/build/__tests__/unit/jsLogEvents.test.d.ts.map +1 -0
  44. package/build/__tests__/unit/jsLogEvents.test.js +58 -0
  45. package/build/__tests__/unit/jsLogEvents.test.js.map +1 -0
  46. package/build/__tests__/unit/logDiagnosis.test.d.ts +2 -0
  47. package/build/__tests__/unit/logDiagnosis.test.d.ts.map +1 -0
  48. package/build/__tests__/unit/logDiagnosis.test.js +81 -0
  49. package/build/__tests__/unit/logDiagnosis.test.js.map +1 -0
  50. package/build/__tests__/unit/logEventFormat.test.d.ts +2 -0
  51. package/build/__tests__/unit/logEventFormat.test.d.ts.map +1 -0
  52. package/build/__tests__/unit/logEventFormat.test.js +210 -0
  53. package/build/__tests__/unit/logEventFormat.test.js.map +1 -0
  54. package/build/__tests__/unit/logEvents.test.d.ts +2 -0
  55. package/build/__tests__/unit/logEvents.test.d.ts.map +1 -0
  56. package/build/__tests__/unit/logEvents.test.js +118 -0
  57. package/build/__tests__/unit/logEvents.test.js.map +1 -0
  58. package/build/__tests__/unit/logGrouping.test.d.ts +2 -0
  59. package/build/__tests__/unit/logGrouping.test.d.ts.map +1 -0
  60. package/build/__tests__/unit/logGrouping.test.js +104 -0
  61. package/build/__tests__/unit/logGrouping.test.js.map +1 -0
  62. package/build/__tests__/unit/logOwnership.test.d.ts +2 -0
  63. package/build/__tests__/unit/logOwnership.test.d.ts.map +1 -0
  64. package/build/__tests__/unit/logOwnership.test.js +118 -0
  65. package/build/__tests__/unit/logOwnership.test.js.map +1 -0
  66. package/build/__tests__/unit/logRelevance.test.d.ts +2 -0
  67. package/build/__tests__/unit/logRelevance.test.d.ts.map +1 -0
  68. package/build/__tests__/unit/logRelevance.test.js +30 -0
  69. package/build/__tests__/unit/logRelevance.test.js.map +1 -0
  70. package/build/__tests__/unit/logSourceAndroid.test.d.ts +2 -0
  71. package/build/__tests__/unit/logSourceAndroid.test.d.ts.map +1 -0
  72. package/build/__tests__/unit/logSourceAndroid.test.js +61 -0
  73. package/build/__tests__/unit/logSourceAndroid.test.js.map +1 -0
  74. package/build/__tests__/unit/logSourceIos.test.d.ts +2 -0
  75. package/build/__tests__/unit/logSourceIos.test.d.ts.map +1 -0
  76. package/build/__tests__/unit/logSourceIos.test.js +64 -0
  77. package/build/__tests__/unit/logSourceIos.test.js.map +1 -0
  78. package/build/__tests__/unit/logs.test.js +23 -0
  79. package/build/__tests__/unit/logs.test.js.map +1 -1
  80. package/build/__tests__/unit/nativeLogs.test.d.ts +2 -0
  81. package/build/__tests__/unit/nativeLogs.test.d.ts.map +1 -0
  82. package/build/__tests__/unit/nativeLogs.test.js +230 -0
  83. package/build/__tests__/unit/nativeLogs.test.js.map +1 -0
  84. package/build/__tests__/unit/pendingExecutions.test.d.ts +2 -0
  85. package/build/__tests__/unit/pendingExecutions.test.d.ts.map +1 -0
  86. package/build/__tests__/unit/pendingExecutions.test.js +70 -0
  87. package/build/__tests__/unit/pendingExecutions.test.js.map +1 -0
  88. package/build/__tests__/unit/reloadAppAutoConnect.test.d.ts +2 -0
  89. package/build/__tests__/unit/reloadAppAutoConnect.test.d.ts.map +1 -0
  90. package/build/__tests__/unit/reloadAppAutoConnect.test.js +63 -0
  91. package/build/__tests__/unit/reloadAppAutoConnect.test.js.map +1 -0
  92. package/build/__tests__/unit/toolsJson.test.js +11 -0
  93. package/build/__tests__/unit/toolsJson.test.js.map +1 -1
  94. package/build/__tests__/unit/transportClassify.test.js +108 -7
  95. package/build/__tests__/unit/transportClassify.test.js.map +1 -1
  96. package/build/core/componentSearch.d.ts +1 -1
  97. package/build/core/componentSearch.d.ts.map +1 -1
  98. package/build/core/componentSearch.js +8 -8
  99. package/build/core/componentSearch.js.map +1 -1
  100. package/build/core/componentTree.d.ts +1 -1
  101. package/build/core/componentTree.d.ts.map +1 -1
  102. package/build/core/componentTree.js +8 -8
  103. package/build/core/componentTree.js.map +1 -1
  104. package/build/core/connection.d.ts +30 -0
  105. package/build/core/connection.d.ts.map +1 -1
  106. package/build/core/connection.js +97 -28
  107. package/build/core/connection.js.map +1 -1
  108. package/build/core/debugGlobals.d.ts.map +1 -1
  109. package/build/core/debugGlobals.js +89 -32
  110. package/build/core/debugGlobals.js.map +1 -1
  111. package/build/core/errors.d.ts +7 -1
  112. package/build/core/errors.d.ts.map +1 -1
  113. package/build/core/errors.js +8 -1
  114. package/build/core/errors.js.map +1 -1
  115. package/build/core/guides.d.ts.map +1 -1
  116. package/build/core/guides.js +25 -0
  117. package/build/core/guides.js.map +1 -1
  118. package/build/core/index.d.ts +0 -2
  119. package/build/core/index.d.ts.map +1 -1
  120. package/build/core/index.js +0 -2
  121. package/build/core/index.js.map +1 -1
  122. package/build/core/jsExecute.d.ts +76 -1
  123. package/build/core/jsExecute.d.ts.map +1 -1
  124. package/build/core/jsExecute.js +193 -32
  125. package/build/core/jsExecute.js.map +1 -1
  126. package/build/core/jsLogEvents.d.ts +13 -0
  127. package/build/core/jsLogEvents.d.ts.map +1 -0
  128. package/build/core/jsLogEvents.js +65 -0
  129. package/build/core/jsLogEvents.js.map +1 -0
  130. package/build/core/logDiagnosis.d.ts +65 -0
  131. package/build/core/logDiagnosis.d.ts.map +1 -0
  132. package/build/core/logDiagnosis.js +51 -0
  133. package/build/core/logDiagnosis.js.map +1 -0
  134. package/build/core/logEventFormat.d.ts +14 -0
  135. package/build/core/logEventFormat.d.ts.map +1 -0
  136. package/build/core/logEventFormat.js +85 -0
  137. package/build/core/logEventFormat.js.map +1 -0
  138. package/build/core/logEvents.d.ts +115 -0
  139. package/build/core/logEvents.d.ts.map +1 -0
  140. package/build/core/logEvents.js +146 -0
  141. package/build/core/logEvents.js.map +1 -0
  142. package/build/core/logGrouping.d.ts +17 -0
  143. package/build/core/logGrouping.d.ts.map +1 -0
  144. package/build/core/logGrouping.js +134 -0
  145. package/build/core/logGrouping.js.map +1 -0
  146. package/build/core/logOwnership.d.ts +29 -0
  147. package/build/core/logOwnership.d.ts.map +1 -0
  148. package/build/core/logOwnership.js +83 -0
  149. package/build/core/logOwnership.js.map +1 -0
  150. package/build/core/logRelevance.d.ts +13 -0
  151. package/build/core/logRelevance.d.ts.map +1 -0
  152. package/build/core/logRelevance.js +23 -0
  153. package/build/core/logRelevance.js.map +1 -0
  154. package/build/core/logSourceAndroid.d.ts +27 -0
  155. package/build/core/logSourceAndroid.d.ts.map +1 -0
  156. package/build/core/logSourceAndroid.js +89 -0
  157. package/build/core/logSourceAndroid.js.map +1 -0
  158. package/build/core/logSourceIos.d.ts +26 -0
  159. package/build/core/logSourceIos.d.ts.map +1 -0
  160. package/build/core/logSourceIos.js +119 -0
  161. package/build/core/logSourceIos.js.map +1 -0
  162. package/build/core/logs.d.ts +5 -1
  163. package/build/core/logs.d.ts.map +1 -1
  164. package/build/core/logs.js +8 -1
  165. package/build/core/logs.js.map +1 -1
  166. package/build/core/nativeLogs.d.ts +75 -0
  167. package/build/core/nativeLogs.d.ts.map +1 -0
  168. package/build/core/nativeLogs.js +260 -0
  169. package/build/core/nativeLogs.js.map +1 -0
  170. package/build/core/register.d.ts.map +1 -1
  171. package/build/core/register.js +22 -6
  172. package/build/core/register.js.map +1 -1
  173. package/build/core/screenLayout.d.ts +1 -1
  174. package/build/core/screenLayout.d.ts.map +1 -1
  175. package/build/core/screenLayout.js +3 -3
  176. package/build/core/screenLayout.js.map +1 -1
  177. package/build/core/state.d.ts +10 -0
  178. package/build/core/state.d.ts.map +1 -1
  179. package/build/core/state.js +21 -0
  180. package/build/core/state.js.map +1 -1
  181. package/build/core/types.d.ts +11 -2
  182. package/build/core/types.d.ts.map +1 -1
  183. package/build/tools/componentTools.js +6 -6
  184. package/build/tools/componentTools.js.map +1 -1
  185. package/build/tools/connectionTools.d.ts.map +1 -1
  186. package/build/tools/connectionTools.js +7 -3
  187. package/build/tools/connectionTools.js.map +1 -1
  188. package/build/tools/executionTools.js +7 -7
  189. package/build/tools/executionTools.js.map +1 -1
  190. package/build/tools/logTools.d.ts +25 -0
  191. package/build/tools/logTools.d.ts.map +1 -1
  192. package/build/tools/logTools.js +316 -132
  193. package/build/tools/logTools.js.map +1 -1
  194. package/build/tools/networkTools.d.ts.map +1 -1
  195. package/build/tools/networkTools.js +65 -54
  196. package/build/tools/networkTools.js.map +1 -1
  197. package/package.json +5 -4
  198. package/tools.json +447 -0
  199. package/build/core/format.d.ts +0 -8
  200. package/build/core/format.d.ts.map +0 -1
  201. package/build/core/format.js +0 -34
  202. package/build/core/format.js.map +0 -1
@@ -1,14 +1,120 @@
1
1
  import { z } from "zod";
2
2
  import { registerToolWithTelemetry } from "../core/register.js";
3
- import { getLogs, searchLogs, getLogSummary, getTotalLogCount, getConnectedAppByDevice, getFirstConnectedApp, formatLogsAsTonl, checkAndEnsureConnection, metroMissingHintIfAbsent, logBuffers, verifyLogPipeline, getPassiveConnectionStatus, connectedApps, getRecentGaps, formatDuration, getLogBuffer, } from "../core/index.js";
3
+ import { getLogs, searchLogs, getLogSummary, getTotalLogCount, getConnectedAppByDevice, getFirstConnectedApp, checkAndEnsureConnection, metroMissingHintIfAbsent, logBuffers, verifyLogPipeline, getPassiveConnectionStatus, connectedApps, getRecentGaps, formatDuration, getLogBuffer, } from "../core/index.js";
4
4
  import { resolveLogBuffer } from "../core/toolHelpers.js";
5
+ import { diagnoseEmptyLogs } from "../core/logDiagnosis.js";
5
6
  import { UserInputError } from "../core/errors.js";
6
7
  import { isSDKInstalled, querySDKConsole, clearSDKConsole, getSDKConsoleStats, } from "../core/sdkBridge.js";
8
+ import { findLogEvent, nativeLogBuffers, getNativeLogBuffer } from "../core/logEvents.js";
9
+ import { jsEventsFromEntries } from "../core/jsLogEvents.js";
10
+ import { formatEventList, formatEventDetails } from "../core/logEventFormat.js";
11
+ import { collectNativeEvents, deviceKeyOf } from "../core/nativeLogs.js";
12
+ // Binds the live connection/pipeline probes for `diagnoseEmptyLogs`. Kept here
13
+ // so the diagnosis logic itself stays free of module-level state and testable.
14
+ function logDiagnosisDeps(device) {
15
+ return {
16
+ checkConnection: () => checkAndEnsureConnection(device),
17
+ resolveApp: () => (device ? getConnectedAppByDevice(device) : getFirstConnectedApp()),
18
+ verifyPipeline: verifyLogPipeline,
19
+ };
20
+ }
21
+ /**
22
+ * Parse "5m" / "90s" / an ISO timestamp into an absolute Date.
23
+ *
24
+ * Distinguishes "not provided" from "provided but unparseable" — the latter
25
+ * silently falling back to the default watermark would look, from the
26
+ * caller's side, like their filter was honored when it was actually ignored.
27
+ */
28
+ function parseSince(since) {
29
+ if (!since)
30
+ return { date: undefined };
31
+ const rel = since.match(/^(\d+)([smh])$/);
32
+ if (rel) {
33
+ const mult = { s: 1000, m: 60_000, h: 3_600_000 }[rel[2]];
34
+ return { date: new Date(Date.now() - Number(rel[1]) * mult) };
35
+ }
36
+ const parsed = new Date(since);
37
+ if (Number.isNaN(parsed.getTime())) {
38
+ return {
39
+ date: undefined,
40
+ note: `[NOTE] since="${since}" was not recognized (use an ISO timestamp or a duration like "5m"/"90s"/"2h") — showing the default window instead.`,
41
+ };
42
+ }
43
+ return { date: parsed };
44
+ }
45
+ /**
46
+ * Bound the events shown on the native/merged path to `maxLogs`, applied
47
+ * AFTER kind-filtering and keeping the newest — when investigating a crash
48
+ * the latest one is the subject. Never truncates silently: an agent that
49
+ * cannot see the list was capped will assume it has the full picture and
50
+ * reason from a partial one. Exported (not a `__`-prefixed test hook) because
51
+ * it is a genuinely reusable, side-effect-free piece of the response shape.
52
+ */
53
+ export function capEventsForDisplay(events, maxLogs) {
54
+ const capped = maxLogs > 0 && events.length > maxLogs ? events.slice(-maxLogs) : events;
55
+ const omitted = events.length - capped.length;
56
+ const note = omitted > 0
57
+ ? `\n\n[${omitted} older event${omitted === 1 ? "" : "s"} not shown — raise maxLogs or narrow with kind/minLevel/since]`
58
+ : "";
59
+ return { capped, note };
60
+ }
61
+ /**
62
+ * Native acquisition costs a ~1-1.5s subprocess per device, so it must never
63
+ * run on the hot path. It earns its cost in exactly one situation: the JS
64
+ * buffer is empty AND the app is not connected — which is what a native crash
65
+ * looks like from here. Mirrors get_bundle_errors' screenshot fallback, which
66
+ * likewise fires only when the cheap path has demonstrably failed.
67
+ */
68
+ function shouldEscalateToNative(bufferEmpty, connected) {
69
+ return bufferEmpty && !connected;
70
+ }
71
+ const ESCALATION_COOLDOWN_MS = 30_000;
72
+ /**
73
+ * Negative-result cooldown for the empty-and-disconnected escalation, keyed by
74
+ * the `device` argument that produced the miss.
75
+ *
76
+ * A miss (buffer empty, disconnected, no crash found) still costs a
77
+ * subprocess per device. Without a cooldown, every get_logs call made while
78
+ * the app stays crashed-and-disconnected would re-pay that cost forever. 30s
79
+ * is short enough that a real crash occurring during the cooldown is still
80
+ * caught by the very next get_logs call once it expires, and long enough to
81
+ * stop a tight investigation loop from thrashing the device.
82
+ *
83
+ * Keying matters: a miss for device="A" says nothing about device B, and a
84
+ * process-wide latch would let one device's quiet period suppress another
85
+ * device's real crash. An undefined device queries every device at once, so
86
+ * caching that miss under a single key is correct.
87
+ *
88
+ * Cleared for a key whenever its escalation DOES find crashes, so a genuine
89
+ * crash is never suppressed by a stale miss.
90
+ */
91
+ const lastEmptyEscalationAt = new Map();
92
+ function escalationKey(device) {
93
+ return device ?? "*";
94
+ }
95
+ function isEscalationCoolingDown(device) {
96
+ const at = lastEmptyEscalationAt.get(escalationKey(device));
97
+ return at !== undefined && Date.now() - at < ESCALATION_COOLDOWN_MS;
98
+ }
99
+ /**
100
+ * Test-only. Exercises the per-device cooldown map directly, so the
101
+ * per-device keying can be pinned without standing up a full get_logs
102
+ * invocation (device connection, CDP session, etc).
103
+ */
104
+ export const __escalationCooldownTestHooks = {
105
+ isEscalationCoolingDown,
106
+ recordMiss: (device) => {
107
+ lastEmptyEscalationAt.set(escalationKey(device), Date.now());
108
+ },
109
+ reset: () => {
110
+ lastEmptyEscalationAt.clear();
111
+ },
112
+ };
7
113
  export function registerLogTools(server) {
8
114
  // Tool: Get console logs
9
115
  registerToolWithTelemetry(server, "get_logs", {
10
116
  description: "Retrieve console logs from connected React Native app. Tip: Use summary=true first for a quick overview (counts by level + last 5 messages), then fetch specific logs as needed.\n" +
11
- "PURPOSE: Pull captured console output (log/warn/error/info/debug) from the in-memory buffer for the connected app.\n" +
117
+ "PURPOSE: Pull captured console output (log/warn/error/info/debug) from the in-memory buffer, and optionally native device logs (crashes, ANRs) via source=\"native\".\n" +
12
118
  "WHEN TO USE: Start of any log-driven investigation, verifying a code change picked up via Fast Refresh, or confirming a reported error actually fires.\n" +
13
119
  "WORKFLOW: scan_metro -> get_logs(summary=true) -> narrow with search_logs(text=\"...\") or get_logs(level=\"error\") -> clear_logs between reproductions.\n" +
14
120
  "LIMITATIONS: Circular buffer (~500 entries). Only captures logs emitted after the app connected; pre-connect logs are lost.\n" +
@@ -37,24 +143,79 @@ export function registerLogTools(server) {
37
143
  .optional()
38
144
  .default(false)
39
145
  .describe("Disable all truncation and return full messages. Tip: Use with lower maxLogs (e.g., 10) to avoid token overload when inspecting large objects."),
40
- format: z
41
- .enum(["text", "tonl"])
42
- .optional()
43
- .default("tonl")
44
- .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format, ~30-50% smaller)"),
45
146
  summary: z
46
147
  .boolean()
47
148
  .optional()
48
149
  .default(false)
49
150
  .describe("Return summary statistics instead of full logs (count by level + last 5 messages). Use for quick overview."),
50
- device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
151
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices."),
152
+ source: z
153
+ .enum(["js", "native", "all"])
154
+ .optional()
155
+ .default("js")
156
+ .describe("Which log stream to read. 'js' (default) = console output over CDP, instant. " +
157
+ "'native' = device logs (Android logcat / iOS os_log) filtered to this app — surfaces crashes, ANRs and OOM kills that never reach JS; costs ~1-1.5s per device. " +
158
+ "'all' = both, merged. When the JS buffer is empty and the app is disconnected, native crash events are consulted automatically."),
159
+ kind: z
160
+ .enum(["crash", "anr", "exception", "lifecycle", "message"])
161
+ .optional()
162
+ .describe("Filter native/merged events by kind. Omit for all kinds."),
163
+ minLevel: z
164
+ .enum(["debug", "info", "warn", "error", "fatal"])
165
+ .optional()
166
+ .default("warn")
167
+ .describe("Relevance floor for native events (default: warn). Crashes and ANRs are always returned regardless. Lower to 'debug' to see native library loading."),
168
+ since: z
169
+ .string()
170
+ .optional()
171
+ .describe("Native acquisition window — ISO timestamp or a duration like \"5m\". Widens the device query; already-seen events are still deduped.")
51
172
  }
52
- }, async ({ maxLogs, level, startFromText, maxMessageLength, verbose, format, summary, device }) => {
173
+ }, async ({ maxLogs, level, startFromText, maxMessageLength, verbose, summary, device, source, kind, minLevel, since }) => {
53
174
  // Check if SDK is installed — prefer SDK data for richer logs.
54
175
  // `device` must be threaded through: without it the SDK bridge reads
55
176
  // whichever app happens to be first, so a multi-device session
56
177
  // silently answers with the wrong device's buffer.
57
178
  const sdkAvailable = await isSDKInstalled(device);
179
+ // Prepended to every return's text below when source==="all".
180
+ let nativePrefix = "";
181
+ // Set when source==="native"/"all" already paid for a native fetch
182
+ // this call, so the empty+disconnected escalation below can reuse
183
+ // it instead of paying twice.
184
+ let nativeEventsForEscalation;
185
+ // Explicit native / merged read — the caller asked for the cost.
186
+ if (source === "native" || source === "all") {
187
+ const sinceParsed = parseSince(since);
188
+ const nativeOpts = { device, minLevel, since: sinceParsed.date };
189
+ const { events, notes } = await collectNativeEvents(nativeOpts);
190
+ nativeEventsForEscalation = events;
191
+ const filtered = kind ? events.filter((e) => e.kind === kind) : events;
192
+ const { capped, note: cappedNote } = capEventsForDisplay(filtered, maxLogs);
193
+ const showDevice = nativeLogBuffers.size > 1;
194
+ const noteText = notes.length > 0 ? `\n\n${notes.join("\n")}` : "";
195
+ const sinceNote = sinceParsed.note ? `\n\n${sinceParsed.note}` : "";
196
+ const rendered = formatEventList(capped, { showDevice, maxLength: maxMessageLength, verbose });
197
+ if (source === "native") {
198
+ // Emptiness is judged on the UNFILTERED events: a `kind`
199
+ // filter matching nothing is a filter outcome, not a
200
+ // capture failure — mirrors the js-buffer `filtered_out`
201
+ // handling below. Capping is a display bound, not a
202
+ // capture/filter outcome, so it plays no part in either
203
+ // signal.
204
+ const nativeEmpty = events.length === 0;
205
+ const nativeEmptyReason = !nativeEmpty && filtered.length === 0 ? "filtered_out" : undefined;
206
+ return {
207
+ _emptyResult: nativeEmpty,
208
+ ...(nativeEmptyReason && { _emptyReason: nativeEmptyReason }),
209
+ content: [{
210
+ type: "text",
211
+ text: `Native Log Events (${capped.length}):\n\n${rendered}${cappedNote}${noteText}${sinceNote}\n\nUse get_log_details("<id>") for a full backtrace.`
212
+ }]
213
+ };
214
+ }
215
+ // source === "all": fall through so JS output is appended below,
216
+ // carrying the native block with it.
217
+ nativePrefix = `Native Log Events (${capped.length}):\n\n${rendered}${cappedNote}${noteText}${sinceNote}\n\n`;
218
+ }
58
219
  if (sdkAvailable) {
59
220
  if (summary) {
60
221
  const sdkStats = await getSDKConsoleStats(device);
@@ -69,23 +230,14 @@ export function registerLogTools(server) {
69
230
  for (const [lvl, cnt] of Object.entries(s.byLevel))
70
231
  lines.push(` ${lvl}: ${cnt}`);
71
232
  }
72
- return { content: [{ type: "text", text: `Log Summary (SDK):\n\n${lines.join("\n")}` }] };
233
+ // Served real SDK data — not empty, regardless of what the
234
+ // (separate) CDP buffer happens to hold.
235
+ return { _emptyResult: false, content: [{ type: "text", text: `${nativePrefix}Log Summary (SDK):\n\n${lines.join("\n")}` }] };
73
236
  }
74
237
  }
75
238
  const sdkResult = await querySDKConsole({ count: maxLogs, level, text: startFromText }, device);
76
239
  if (sdkResult.success && sdkResult.data.length > 0) {
77
240
  const entries = sdkResult.data;
78
- if (format === "tonl") {
79
- const tonlLines = entries.map((e) => {
80
- const time = new Date(e.timestamp).toLocaleTimeString();
81
- let msg = e.message;
82
- if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
83
- msg = msg.slice(0, maxMessageLength) + "...";
84
- }
85
- return `${time} [${e.level}] ${msg}`;
86
- });
87
- return { content: [{ type: "text", text: `Console Logs (${entries.length} entries, SDK):\n\n${tonlLines.join("\n")}` }] };
88
- }
89
241
  const lines = entries.map((e) => {
90
242
  const time = new Date(e.timestamp).toLocaleTimeString();
91
243
  let msg = e.message;
@@ -94,99 +246,122 @@ export function registerLogTools(server) {
94
246
  }
95
247
  return `[${time}] [${e.level.toUpperCase()}] ${msg}`;
96
248
  });
97
- return { content: [{ type: "text", text: `Console Logs (${entries.length} entries, SDK):\n\n${lines.join("\n")}` }] };
249
+ return { _emptyResult: false, content: [{ type: "text", text: `${nativePrefix}Console Logs (${entries.length} entries, SDK):\n\n${lines.join("\n")}` }] };
98
250
  }
99
251
  }
100
252
  // Return summary if requested
101
253
  if (summary) {
102
- const summaryText = getLogSummary(resolveLogBuffer(device), { lastN: 5, maxMessageLength: 100 });
254
+ const buffer = resolveLogBuffer(device);
255
+ const summaryText = getLogSummary(buffer, { lastN: 5, maxMessageLength: 100 });
256
+ // Judge emptiness by the buffer this summary was actually built
257
+ // from, not the global count across every device.
258
+ const summaryEmpty = buffer.size === 0;
103
259
  let connectionWarning = "";
104
- if (getTotalLogCount() === 0) {
105
- const status = await checkAndEnsureConnection(device);
106
- connectionWarning = status.message ? `\n\n${status.message}` : "";
107
- if (status.connected) {
108
- const targetApp = device ? getConnectedAppByDevice(device) : getFirstConnectedApp();
109
- if (targetApp) {
110
- const pipeline = await verifyLogPipeline(targetApp);
111
- if (pipeline.message) {
112
- connectionWarning += `\n\n${pipeline.message}`;
113
- }
114
- }
115
- }
260
+ let emptyReason;
261
+ if (summaryEmpty) {
262
+ const diagnosis = await diagnoseEmptyLogs(logDiagnosisDeps(device));
263
+ connectionWarning = diagnosis.warning;
264
+ emptyReason = diagnosis.reason;
116
265
  connectionWarning += await metroMissingHintIfAbsent("get_logs");
117
266
  }
118
267
  return {
268
+ _emptyResult: summaryEmpty,
269
+ ...(emptyReason && { _emptyReason: emptyReason }),
119
270
  content: [
120
271
  {
121
272
  type: "text",
122
- text: `Log Summary:\n\n${summaryText}${connectionWarning}`
273
+ text: `${nativePrefix}Log Summary:\n\n${summaryText}${connectionWarning}`
123
274
  }
124
275
  ]
125
276
  };
126
277
  }
127
- const { logs, count, formatted } = getLogs(resolveLogBuffer(device), {
278
+ // Resolved once: for the all-devices case this copies every buffered
279
+ // entry into a merged buffer, so it is not free to call repeatedly.
280
+ const logBuffer = resolveLogBuffer(device);
281
+ const { logs, count } = getLogs(logBuffer, {
128
282
  maxLogs,
129
283
  level,
130
284
  startFromText,
131
285
  maxMessageLength,
132
286
  verbose
133
287
  });
288
+ // `_emptyResult` measures CAPTURE reliability, not whether this
289
+ // particular call returned rows — see the 2026-03-19 empty-result
290
+ // spec. A filter that matches nothing is a natural outcome, so it is
291
+ // reported via `_emptyReason` (`filtered_out`) without setting the
292
+ // empty flag. Keeps the empty rate comparable across time.
293
+ const bufferEmpty = logBuffer.size === 0;
134
294
  // Check connection health
135
295
  let connectionWarning = "";
136
296
  let emptyReason;
137
- if (count === 0) {
138
- const status = await checkAndEnsureConnection(device);
139
- connectionWarning = status.message ? `\n\n${status.message}` : "";
140
- // Track empty reason for telemetry
141
- emptyReason = "no_logs";
142
- if (!status.connected) {
143
- emptyReason = "disconnected";
144
- }
145
- else if (status.wasReconnected) {
146
- emptyReason = "post_reconnect";
147
- }
148
- // End-to-end log pipeline verification (with automatic recovery)
149
- if (status.connected) {
150
- const targetApp = device ? getConnectedAppByDevice(device) : getFirstConnectedApp();
151
- if (targetApp) {
152
- const pipeline = await verifyLogPipeline(targetApp);
153
- if (pipeline.message) {
154
- connectionWarning += `\n\n${pipeline.message}`;
155
- }
156
- if (!pipeline.ok) {
157
- emptyReason = "pipeline_failed";
158
- }
159
- else if (pipeline.recovered) {
160
- emptyReason = "pipeline_recovered";
161
- }
162
- // If pipeline recovered, re-read the buffer — new logs may have arrived
163
- if (pipeline.recovered && pipeline.ok) {
164
- const retryResult = getLogs(resolveLogBuffer(device), {
165
- maxLogs, level, startFromText, maxMessageLength, verbose
166
- });
167
- if (retryResult.count > 0) {
168
- // Return the recovered logs instead of empty
169
- return {
170
- _emptyReason: "pipeline_recovered",
171
- content: [{
172
- type: "text",
173
- text: `React Native Console Logs (${retryResult.count} entries):\n\n${retryResult.formatted}${connectionWarning}`
174
- }]
175
- };
176
- }
177
- }
297
+ if (count === 0 && !bufferEmpty) {
298
+ // Capture is demonstrably working — the level/startFromText filter
299
+ // simply matched nothing. Report that instead of probing the
300
+ // connection and blaming it for a filter miss.
301
+ emptyReason = "filtered_out";
302
+ connectionWarning = "";
303
+ }
304
+ else if (bufferEmpty) {
305
+ const diagnosis = await diagnoseEmptyLogs(logDiagnosisDeps(device));
306
+ connectionWarning = diagnosis.warning;
307
+ emptyReason = diagnosis.reason;
308
+ // If the pipeline recovered, re-read the buffer — new logs may have arrived
309
+ if (diagnosis.recovered) {
310
+ const retryResult = getLogs(resolveLogBuffer(device), {
311
+ maxLogs, level, startFromText, maxMessageLength, verbose
312
+ });
313
+ if (retryResult.count > 0) {
314
+ // Route through the same event pipeline as the final JS
315
+ // return below — retryResult.formatted is legacy text with
316
+ // no [jN] ids, which would leave get_log_details unusable
317
+ // for whatever this call surfaces.
318
+ const retryEvents = jsEventsFromEntries(retryResult.logs, device ?? "all devices");
319
+ const retryFiltered = kind ? retryEvents.filter((e) => e.kind === kind) : retryEvents;
320
+ const retryRendered = formatEventList(retryFiltered, { showDevice: logBuffers.size > 1, maxLength: maxMessageLength, verbose });
321
+ // Return the recovered logs instead of empty
322
+ return {
323
+ _emptyResult: false,
324
+ _emptyReason: "pipeline_recovered",
325
+ content: [{
326
+ type: "text",
327
+ text: `${nativePrefix}React Native Console Logs (${retryFiltered.length} entries):\n\n${retryRendered}${connectionWarning}`
328
+ }]
329
+ };
178
330
  }
179
331
  }
180
- // Add diagnostic metadata for empty results (captured by telemetry via responsePreview)
181
- if (count === 0) {
182
- const diagParts = [
183
- `empty_reason=${status?.wasReconnected ? "post_reconnect" : "no_logs"}`,
184
- `connection=${getPassiveConnectionStatus().reason}`,
185
- `device_count=${connectedApps.size}`,
186
- `buffer_sizes=${JSON.stringify(Object.fromEntries([...logBuffers.entries()].map(([k, v]) => [k, v.size])))}`,
187
- ];
188
- connectionWarning += `\n\n[DIAG] ${diagParts.join(", ")}`;
332
+ if (shouldEscalateToNative(bufferEmpty, getPassiveConnectionStatus().connected) && !isEscalationCoolingDown(device)) {
333
+ // Safe to reuse a fetch taken at a higher minLevel: isRelevant
334
+ // exempts crash/anr from the floor entirely, so they are present
335
+ // regardless of what the caller asked for.
336
+ // Reuse the native fetch already paid for by source==="all"
337
+ // instead of running the subprocess a second time.
338
+ const events = nativeEventsForEscalation
339
+ ?? (await collectNativeEvents({ device, minLevel: "warn" })).events;
340
+ const crashes = events.filter((e) => e.kind === "crash" || e.kind === "anr");
341
+ if (crashes.length > 0) {
342
+ lastEmptyEscalationAt.delete(escalationKey(device));
343
+ return {
344
+ _emptyResult: false,
345
+ _emptyReason: "native_crash_found",
346
+ content: [{
347
+ type: "text",
348
+ text: `${nativePrefix}React Native Console Logs (0 entries) — app is not connected.\n\n` +
349
+ `Native crash found:\n${formatEventList(crashes, { showDevice: nativeLogBuffers.size > 1, maxLength: maxMessageLength, verbose })}\n\n` +
350
+ `Use get_log_details("${crashes[0].id}") for the full backtrace.`
351
+ }]
352
+ };
353
+ }
354
+ lastEmptyEscalationAt.set(escalationKey(device), Date.now());
189
355
  }
356
+ // Diagnostic metadata for empty results (local dev dashboard only —
357
+ // responsePreview is never sent to the remote telemetry backend).
358
+ const diagParts = [
359
+ `empty_reason=${emptyReason}`,
360
+ `connection=${getPassiveConnectionStatus().reason}`,
361
+ `device_count=${connectedApps.size}`,
362
+ `buffer_sizes=${JSON.stringify(Object.fromEntries([...logBuffers.entries()].map(([k, v]) => [k, v.size])))}`,
363
+ ];
364
+ connectionWarning += `\n\n[DIAG] ${diagParts.join(", ")}`;
190
365
  connectionWarning += await metroMissingHintIfAbsent("get_logs");
191
366
  }
192
367
  else {
@@ -211,30 +386,23 @@ export function registerLogTools(server) {
211
386
  }
212
387
  }
213
388
  const startNote = startFromText ? ` (starting from "${startFromText}")` : "";
214
- // Use TONL format if requested
215
- if (format === "tonl") {
216
- const tonlOutput = formatLogsAsTonl(logs, { maxMessageLength: verbose ? 0 : maxMessageLength });
217
- return {
218
- ...(emptyReason && { _emptyReason: emptyReason }),
219
- content: [
220
- {
221
- type: "text",
222
- text: `React Native Console Logs (${count} entries)${startNote}:\n\n${tonlOutput}${gapWarning}${connectionWarning}`
223
- }
224
- ]
225
- };
226
- }
389
+ const jsEvents = jsEventsFromEntries(logs, device ?? "all devices");
390
+ const jsFiltered = kind ? jsEvents.filter((e) => e.kind === kind) : jsEvents;
391
+ const jsRendered = formatEventList(jsFiltered, { showDevice: logBuffers.size > 1, maxLength: maxMessageLength, verbose });
227
392
  return {
393
+ _emptyResult: bufferEmpty,
228
394
  ...(emptyReason && { _emptyReason: emptyReason }),
229
395
  content: [
230
396
  {
231
397
  type: "text",
232
- text: `React Native Console Logs (${count} entries)${startNote}:\n\n${formatted}${gapWarning}${connectionWarning}`
398
+ text: `${nativePrefix}React Native Console Logs (${jsFiltered.length} entries)${startNote}:\n\n${jsRendered}${gapWarning}${connectionWarning}`
233
399
  }
234
400
  ]
235
401
  };
236
402
  },
237
- // Empty result detector: buffer has no entries at all
403
+ // Fallback only — every return path above sets `_emptyResult` explicitly,
404
+ // which takes precedence. Retained so a future path that forgets to set it
405
+ // still reports something rather than nothing.
238
406
  () => getTotalLogCount() === 0);
239
407
  // Tool: Search logs
240
408
  registerToolWithTelemetry(server, "search_logs", {
@@ -259,14 +427,9 @@ export function registerLogTools(server) {
259
427
  .default(500)
260
428
  .describe("Max characters per message (default: 500, set to 0 for unlimited)"),
261
429
  verbose: z.boolean().optional().default(false).describe("Disable all truncation and return full messages"),
262
- format: z
263
- .enum(["text", "tonl"])
264
- .optional()
265
- .default("tonl")
266
- .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)"),
267
430
  device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
268
431
  }
269
- }, async ({ text, maxResults, maxMessageLength, verbose, format, device }) => {
432
+ }, async ({ text, maxResults, maxMessageLength, verbose, device }) => {
270
433
  // Check if SDK is installed — prefer SDK data. See get_logs: the
271
434
  // device must be threaded through, and an empty SDK result falls
272
435
  // through to the CDP buffer instead of short-circuiting.
@@ -275,17 +438,6 @@ export function registerLogTools(server) {
275
438
  const sdkResult = await querySDKConsole({ count: maxResults, text }, device);
276
439
  if (sdkResult.success && sdkResult.data.length > 0) {
277
440
  const entries = sdkResult.data;
278
- if (format === "tonl") {
279
- const tonlLines = entries.map((e) => {
280
- const time = new Date(e.timestamp).toLocaleTimeString();
281
- let msg = e.message;
282
- if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
283
- msg = msg.slice(0, maxMessageLength) + "...";
284
- }
285
- return `${time} [${e.level}] ${msg}`;
286
- });
287
- return { content: [{ type: "text", text: `Search results for "${text}" (${entries.length} matches, SDK):\n\n${tonlLines.join("\n")}` }] };
288
- }
289
441
  const lines = entries.map((e) => {
290
442
  const time = new Date(e.timestamp).toLocaleTimeString();
291
443
  let msg = e.message;
@@ -297,7 +449,7 @@ export function registerLogTools(server) {
297
449
  return { content: [{ type: "text", text: `Search results for "${text}" (${entries.length} matches, SDK):\n\n${lines.join("\n")}` }] };
298
450
  }
299
451
  }
300
- const { logs, count, formatted } = searchLogs(resolveLogBuffer(device), text, { maxResults, maxMessageLength, verbose });
452
+ const { count, formatted } = searchLogs(resolveLogBuffer(device), text, { maxResults, maxMessageLength, verbose });
301
453
  // Check connection health
302
454
  let connectionWarning = "";
303
455
  if (count === 0) {
@@ -311,18 +463,6 @@ export function registerLogTools(server) {
311
463
  ? "\n\n[CONNECTION] Disconnected. Showing cached data. New data is not being captured."
312
464
  : "";
313
465
  }
314
- // Use TONL format if requested
315
- if (format === "tonl") {
316
- const tonlOutput = formatLogsAsTonl(logs, { maxMessageLength: verbose ? 0 : maxMessageLength });
317
- return {
318
- content: [
319
- {
320
- type: "text",
321
- text: `Search results for "${text}" (${count} matches):\n\n${tonlOutput}${connectionWarning}`
322
- }
323
- ]
324
- };
325
- }
326
466
  return {
327
467
  content: [
328
468
  {
@@ -359,6 +499,16 @@ export function registerLogTools(server) {
359
499
  if (sdkResult.success && sdkResult.count)
360
500
  count += sdkResult.count;
361
501
  }
502
+ // Clear that device's native buffer too, otherwise a targeted
503
+ // clear leaves native (logcat/os_log) events behind and
504
+ // get_logs(source="native") still shows pre-repro entries. The
505
+ // native buffer deliberately keeps its watermark on clear (see
506
+ // the all-devices branch below) so cleared events are not
507
+ // re-ingested on the next fetch.
508
+ const deviceKey = deviceKeyOf(app);
509
+ if (deviceKey) {
510
+ count += getNativeLogBuffer(deviceKey).clear();
511
+ }
362
512
  return { content: [{ type: "text", text: `Cleared ${count} log entries from ${deviceName}.` }] };
363
513
  }
364
514
  // Clear all
@@ -374,7 +524,41 @@ export function registerLogTools(server) {
374
524
  total += sdkResult.count;
375
525
  }
376
526
  }
527
+ // Native buffers keep their watermark so cleared events are not
528
+ // re-ingested on the next fetch.
529
+ for (const buffer of nativeLogBuffers.values()) {
530
+ total += buffer.clear();
531
+ }
377
532
  return { content: [{ type: "text", text: `Cleared ${total} log entries from all devices.` }] };
378
533
  });
534
+ // Tool: Get full payload for one log event
535
+ registerToolWithTelemetry(server, "get_log_details", {
536
+ description: "Get the full payload of a single log event — complete stack trace, backtrace, or oversized message.\n" +
537
+ "PURPOSE: Expand one row from get_logs into its full text. A crash row collapses a 60-line backtrace; this returns all of it.\n" +
538
+ "WHEN TO USE: After get_logs shows an event you need to read in full (a crash, an exception, a large payload).\n" +
539
+ "WORKFLOW: get_logs -> copy the id (e.g. \"n7\") -> get_log_details(id=\"n7\").\n" +
540
+ "LIMITATIONS: Ids are valid until that device's buffer rolls over or clear_logs runs, not for the whole server session — call get_logs again to get fresh ones. Reads the buffer — it does not re-query the device.\n" +
541
+ "GOOD: get_log_details({ id: \"n7\" })\n" +
542
+ "BAD: Guessing ids — always take them from get_logs.\n" +
543
+ "SEE ALSO: call get_usage_guide(topic=\"logs\") for the full console-debugging playbook.",
544
+ inputSchema: {
545
+ id: z.string().describe("Event id from get_logs (e.g. \"n7\")"),
546
+ maxLength: z.coerce
547
+ .number()
548
+ .optional()
549
+ .default(4000)
550
+ .describe("Max characters of payload (default: 4000, 0 for unlimited)"),
551
+ verbose: z.boolean().optional().default(false).describe("Disable truncation entirely")
552
+ }
553
+ }, async ({ id, maxLength, verbose }) => {
554
+ const event = findLogEvent(id);
555
+ if (!event) {
556
+ throw new UserInputError(`No log event with id "${id}". Ids come from get_logs — "n7" for native events, "j12" for console entries — ` +
557
+ `and are valid until that device's buffer rolls over or clear_logs runs. Call get_logs again to refresh them.`);
558
+ }
559
+ return {
560
+ content: [{ type: "text", text: formatEventDetails(event, { maxLength, verbose }) }]
561
+ };
562
+ });
379
563
  }
380
564
  //# sourceMappingURL=logTools.js.map