mobile-debug-mcp 0.36.0 → 0.37.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 (171) hide show
  1. package/README.md +10 -0
  2. package/agent-plugin/.antigravity-plugin/plugin.json +1 -1
  3. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  4. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  5. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  6. package/agent-plugin/.mcp.json +1 -1
  7. package/agent-plugin/cursor/.cursor-plugin/plugin.json +2 -2
  8. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +10 -2
  9. package/agent-plugin/mcp.json +1 -1
  10. package/agent-plugin/mcp_config.json +1 -1
  11. package/agent-plugin/plugin.json +1 -1
  12. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +10 -2
  13. package/dist/contracts/device-readiness.js +1 -0
  14. package/dist/interact/waits/ui-change-wait.js +9 -2
  15. package/dist/interact/waits/ui-wait.js +7 -3
  16. package/dist/manage/index.js +3 -0
  17. package/dist/observe/device-readiness.js +116 -0
  18. package/dist/observe/evidence.js +31 -0
  19. package/dist/observe/ios-trace/sessions.js +5 -3
  20. package/dist/observe/readiness-service.js +21 -9
  21. package/dist/observe/readiness.js +30 -0
  22. package/dist/observe/screenshot-encoding.js +4 -0
  23. package/dist/observe/snapshot-collection.js +7 -2
  24. package/dist/server/definitions/manage.js +3 -2
  25. package/dist/server/handlers/action-execution.js +23 -14
  26. package/dist/server/handlers/compact-interaction.js +106 -0
  27. package/dist/server/handlers/compact-state.js +47 -0
  28. package/dist/server/handlers/input.js +2 -0
  29. package/dist/server/handlers/interact.js +2 -0
  30. package/dist/server/handlers/interaction-diagnostics.js +62 -0
  31. package/dist/server/handlers/journey.js +18 -20
  32. package/dist/server/handlers/observe.js +2 -1
  33. package/dist/server/handlers/tool-handlers.js +9 -5
  34. package/dist/server/schemas/interaction-diagnostics.js +9 -0
  35. package/dist/server/tool-definitions.js +9 -4
  36. package/dist/server-core.js +5 -2
  37. package/dist/server.js +1 -1
  38. package/dist/system/android.js +19 -15
  39. package/dist/system/index.js +2 -1
  40. package/dist/utils/android/utils.js +7 -16
  41. package/dist/utils/app-installation.js +77 -0
  42. package/dist/utils/benchmark-metrics.js +43 -0
  43. package/dist/utils/exec.js +4 -0
  44. package/dist/utils/ios/utils.js +4 -11
  45. package/dist/utils/resolve-device.js +3 -2
  46. package/dist/utils/timing.js +3 -3
  47. package/docs/CHANGELOG.md +25 -0
  48. package/docs/benchmarks/README.md +1 -0
  49. package/docs/benchmarks/end-to-end-latency/README.md +98 -0
  50. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
  51. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
  52. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
  53. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
  54. package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
  55. package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
  56. package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
  57. package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
  58. package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
  59. package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
  60. package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
  61. package/docs/specs/031-ios-system-traces/plan.md +28 -0
  62. package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
  63. package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
  64. package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
  65. package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
  66. package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
  67. package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
  68. package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
  69. package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
  70. package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
  71. package/docs/specs/034-app-installation-status/intent.md +41 -0
  72. package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
  73. package/docs/specs/034-app-installation-status/plan.md +73 -0
  74. package/docs/specs/034-app-installation-status/spec.md +114 -0
  75. package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
  76. package/docs/specs/035-compact-interaction-results/intent.md +38 -0
  77. package/docs/specs/035-compact-interaction-results/plan.md +43 -0
  78. package/docs/specs/035-compact-interaction-results/spec.md +118 -0
  79. package/docs/specs/035-compact-interaction-results/verification.md +111 -0
  80. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
  81. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
  82. package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
  83. package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
  84. package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
  85. package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
  86. package/docs/tools/TOOLS.md +1 -0
  87. package/docs/tools/compact-interactions.md +56 -0
  88. package/docs/tools/correlated-evidence.md +6 -0
  89. package/docs/tools/interact.md +17 -8
  90. package/docs/tools/manage.md +10 -2
  91. package/docs/tools/observe.md +42 -2
  92. package/docs/tools/system.md +6 -0
  93. package/package.json +1 -1
  94. package/src/contracts/device-readiness.ts +8 -0
  95. package/src/interact/waits/ui-change-wait.ts +7 -2
  96. package/src/interact/waits/ui-wait.ts +6 -3
  97. package/src/manage/index.ts +2 -0
  98. package/src/observe/device-readiness.ts +96 -0
  99. package/src/observe/evidence.ts +30 -1
  100. package/src/observe/ios-trace/sessions.ts +4 -2
  101. package/src/observe/readiness-service.ts +21 -9
  102. package/src/observe/readiness.ts +33 -0
  103. package/src/observe/screenshot-encoding.ts +4 -0
  104. package/src/observe/snapshot-collection.ts +7 -2
  105. package/src/server/common.ts +2 -2
  106. package/src/server/definitions/manage.ts +3 -2
  107. package/src/server/handlers/action-execution.ts +22 -14
  108. package/src/server/handlers/compact-interaction.ts +91 -0
  109. package/src/server/handlers/compact-state.ts +43 -0
  110. package/src/server/handlers/input.ts +2 -0
  111. package/src/server/handlers/interact.ts +2 -0
  112. package/src/server/handlers/interaction-diagnostics.ts +50 -0
  113. package/src/server/handlers/journey.ts +15 -17
  114. package/src/server/handlers/observe.ts +2 -1
  115. package/src/server/handlers/payload.ts +2 -1
  116. package/src/server/handlers/tool-handlers.ts +9 -5
  117. package/src/server/schemas/interaction-diagnostics.ts +10 -0
  118. package/src/server/tool-definitions.ts +8 -4
  119. package/src/server-core.ts +5 -2
  120. package/src/server.ts +1 -1
  121. package/src/system/android.ts +18 -11
  122. package/src/system/index.ts +2 -1
  123. package/src/types.ts +2 -0
  124. package/src/utils/android/utils.ts +8 -10
  125. package/src/utils/app-installation.ts +73 -0
  126. package/src/utils/benchmark-metrics.ts +42 -0
  127. package/src/utils/exec.ts +5 -0
  128. package/src/utils/ios/utils.ts +4 -10
  129. package/src/utils/resolve-device.ts +4 -2
  130. package/src/utils/timing.ts +3 -2
  131. package/test/device/automated/debugger/runtime.android.test.ts +5 -0
  132. package/test/device/automated/debugger/runtime.ios.test.ts +7 -1
  133. package/test/device/automated/interact/compact-results.smoke.ts +58 -0
  134. package/test/device/automated/latency/end-to-end-environment.ts +61 -0
  135. package/test/device/automated/latency/end-to-end-report.ts +90 -0
  136. package/test/device/automated/latency/end-to-end-session.ts +98 -0
  137. package/test/device/automated/latency/end-to-end.ts +149 -0
  138. package/test/device/automated/latency/owned-transport.ts +81 -0
  139. package/test/device/automated/latency/snapshot-pair.ts +143 -0
  140. package/test/device/automated/latency-ios/benchmark.ts +126 -0
  141. package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
  142. package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
  143. package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
  144. package/test/device/automated/observe/ios-system-trace.smoke.ts +2 -2
  145. package/test/device/automated/observe/system-trace.smoke.ts +2 -2
  146. package/test/fixtures/contracts/tool-definitions.json +51 -15
  147. package/test/fixtures/latency-journey-app/build.ts +3 -3
  148. package/test/unit/debugger/handle-guards.test.ts +2 -1
  149. package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
  150. package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
  151. package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
  152. package/test/unit/observe/device-readiness.test.ts +97 -0
  153. package/test/unit/observe/ios-trace-sessions.test.ts +23 -0
  154. package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
  155. package/test/unit/server/compact-interaction.test.ts +87 -0
  156. package/test/unit/server/compact-journey-evidence.test.ts +40 -0
  157. package/test/unit/server/compact-review-regressions.test.ts +78 -0
  158. package/test/unit/server/conditional_actions.test.ts +7 -3
  159. package/test/unit/server/correlated-evidence.test.ts +18 -6
  160. package/test/unit/server/end-to-end-report.test.ts +46 -0
  161. package/test/unit/server/input-device-readiness.test.ts +55 -0
  162. package/test/unit/server/interaction-diagnostics.test.ts +45 -0
  163. package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
  164. package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
  165. package/test/unit/server/readiness.test.ts +24 -1
  166. package/test/unit/server/run_journey.test.ts +2 -1
  167. package/test/unit/server/selector_tap_target.test.ts +2 -1
  168. package/test/unit/server/system-trace.test.ts +6 -4
  169. package/test/unit/utils/app-installation.test.ts +78 -0
  170. package/test/unit/utils/benchmark-metrics.test.ts +36 -0
  171. package/test/unit/utils/resolve_device.test.ts +11 -0
package/README.md CHANGED
@@ -10,6 +10,11 @@ Android, iOS, and Kotlin Multiplatform projects are supported. Flutter and React
10
10
  Native have not been tested. Platform and device limitations are described in the
11
11
  [tool reference](docs/tools/TOOLS.md).
12
12
 
13
+ Use `responseMode: "compact"` for bounded interaction and journey summaries.
14
+ Retrieve retained details without replaying input with
15
+ [`get_interaction_diagnostics`](docs/tools/compact-interactions.md); debug output
16
+ remains available.
17
+
13
18
  ## What you can use it for
14
19
 
15
20
  Mobile Debug Tools gives a coding agent access to a running app, so development
@@ -297,3 +302,8 @@ Android debugger errors include bounded, sanitized failure classifications and r
297
302
  ## License
298
303
 
299
304
  [Apache-2.0](LICENSE)
305
+
306
+ Launch and interaction results distinguish dispatch from timestamped
307
+ [device readiness evidence](docs/tools/observe.md#device-readiness-evidence).
308
+ Readiness waits account for observed sleeping/locked device blockers. Unknown or
309
+ unsupported signals are explicit, and no automatic unlock is performed.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://antigravity.google/schemas/v1/plugin.json",
3
3
  "name": "mobile-debug-tools",
4
- "version": "0.0.6",
4
+ "version": "0.0.7",
5
5
  "description": "Mobile app debugging tools and usage guidance for Android and iOS agents.",
6
6
  "author": {
7
7
  "name": "mobile-debug-tools contributors"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-tools",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Mobile app debugging tools and usage guidance for Android and iOS agents.",
5
5
  "author": {
6
6
  "name": "mobile-debug-tools contributors"
@@ -12,7 +12,7 @@
12
12
  "command": "npx",
13
13
  "args": [
14
14
  "--yes",
15
- "mobile-debug-mcp@0.36.0",
15
+ "mobile-debug-mcp@0.37.0",
16
16
  "server"
17
17
  ]
18
18
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-tools",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Mobile app debugging tools and usage guidance for Android and iOS agents.",
5
5
  "repository": "https://github.com/clivejefferies/mobile-debug-tools",
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-tools",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Mobile app debugging tools and usage guidance for Android and iOS agents.",
5
5
  "author": {
6
6
  "name": "mobile-debug-tools contributors"
@@ -14,7 +14,7 @@
14
14
  "command": "npx",
15
15
  "args": [
16
16
  "--yes",
17
- "mobile-debug-mcp@0.36.0",
17
+ "mobile-debug-mcp@0.37.0",
18
18
  "server"
19
19
  ],
20
20
  "cwd": "${CURSOR_PLUGIN_ROOT}"
@@ -5,7 +5,7 @@
5
5
  "command": "npx",
6
6
  "args": [
7
7
  "--yes",
8
- "mobile-debug-mcp@0.36.0",
8
+ "mobile-debug-mcp@0.37.0",
9
9
  "server"
10
10
  ],
11
11
  "cwd": "${PLUGIN_DATA}"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-tools",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Mobile app debugging tools and usage guidance for Android and iOS agents.",
5
5
  "author": {
6
6
  "name": "mobile-debug-tools contributors"
@@ -14,7 +14,7 @@
14
14
  "command": "npx",
15
15
  "args": [
16
16
  "--yes",
17
- "mobile-debug-mcp@0.36.0",
17
+ "mobile-debug-mcp@0.37.0",
18
18
  "server"
19
19
  ],
20
20
  "cwd": "${CURSOR_PLUGIN_ROOT}"
@@ -57,7 +57,14 @@ required. A 2-second timeout cannot reliably accommodate a 3-second tree read.
57
57
  Adjust to measured device latency and supported tool limits. Report an
58
58
  observation timeout separately from evidence that a control is absent.
59
59
 
60
- Use compact responses for routine actions. If action dispatch is reliable but
60
+ Use compact responses for routine actions. Compact interaction/journey results retain
61
+ outcomes, verification, readiness blockers and observed_state (or explicit unknown).
62
+ For original errors, condition trees, state-delta elements and evidence timestamps,
63
+ call get_interaction_diagnostics with diagnostics.session_id/result_id as
64
+ sessionId/resultId. This reads retained data without replaying input. Results expire
65
+ after ten minutes or earlier eviction (100 results, 16 MiB total, 1 MiB per result).
66
+ Check diagnostics.available; unavailable details never justify automatic replay.
67
+ Debug mode can request additional captures at execution time. If action dispatch is reliable but
61
68
  verification is slow or unavailable, dispatch with verification disabled and
62
69
  verify at meaningful checkpoints with one focused accessibility assertion or
63
70
  screenshot. Do not assume `light` verification is faster: use it only when it
@@ -103,7 +110,8 @@ actions are tap, element tap, swipe, Android typing/back, and journey tap steps.
103
110
  The server takes a fresh normalized hierarchy immediately before input.
104
111
  `PRECONDITION_FAILED` or `PRECONDITION_UNAVAILABLE` means input was blocked:
105
112
  inspect `condition` evidence and decide again; do not blindly retry or replace
106
- the expectation. Compact failures may contain the full UI tree and private text.
113
+ the expectation. Compact failures summarize condition evidence; their diagnostics lookup may contain
114
+ the full UI tree and private text. Compact output is not privacy redaction.
107
115
  Expectations expire with retained history or server restart. The extra hierarchy
108
116
  read can take seconds; it is not atomic with input delivery and does not prove
109
117
  the action's outcome.
@@ -4,7 +4,7 @@
4
4
  "mobile-debug-mcp": {
5
5
  "type": "stdio",
6
6
  "command": "npx",
7
- "args": ["--yes", "mobile-debug-mcp@0.36.0", "server"],
7
+ "args": ["--yes", "mobile-debug-mcp@0.37.0", "server"],
8
8
  "cwd": "${PLUGIN_DATA}"
9
9
  }
10
10
  }
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "--yes",
7
- "mobile-debug-mcp@0.36.0",
7
+ "mobile-debug-mcp@0.37.0",
8
8
  "server"
9
9
  ],
10
10
  "cwd": "${PLUGIN_DATA}"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "mobile-debug-tools",
4
- "version": "0.0.6",
4
+ "version": "0.0.7",
5
5
  "description": "Mobile app debugging tools and usage guidance for Android and iOS agents.",
6
6
  "repository": "https://github.com/clivejefferies/mobile-debug-tools",
7
7
  "license": "Apache-2.0",
@@ -57,7 +57,14 @@ required. A 2-second timeout cannot reliably accommodate a 3-second tree read.
57
57
  Adjust to measured device latency and supported tool limits. Report an
58
58
  observation timeout separately from evidence that a control is absent.
59
59
 
60
- Use compact responses for routine actions. If action dispatch is reliable but
60
+ Use compact responses for routine actions. Compact interaction/journey results retain
61
+ outcomes, verification, readiness blockers and observed_state (or explicit unknown).
62
+ For original errors, condition trees, state-delta elements and evidence timestamps,
63
+ call get_interaction_diagnostics with diagnostics.session_id/result_id as
64
+ sessionId/resultId. This reads retained data without replaying input. Results expire
65
+ after ten minutes or earlier eviction (100 results, 16 MiB total, 1 MiB per result).
66
+ Check diagnostics.available; unavailable details never justify automatic replay.
67
+ Debug mode can request additional captures at execution time. If action dispatch is reliable but
61
68
  verification is slow or unavailable, dispatch with verification disabled and
62
69
  verify at meaningful checkpoints with one focused accessibility assertion or
63
70
  screenshot. Do not assume `light` verification is faster: use it only when it
@@ -103,7 +110,8 @@ actions are tap, element tap, swipe, Android typing/back, and journey tap steps.
103
110
  The server takes a fresh normalized hierarchy immediately before input.
104
111
  `PRECONDITION_FAILED` or `PRECONDITION_UNAVAILABLE` means input was blocked:
105
112
  inspect `condition` evidence and decide again; do not blindly retry or replace
106
- the expectation. Compact failures may contain the full UI tree and private text.
113
+ the expectation. Compact failures summarize condition evidence; their diagnostics lookup may contain
114
+ the full UI tree and private text. Compact output is not privacy redaction.
107
115
  Expectations expire with retained history or server restart. The extra hierarchy
108
116
  read can take seconds; it is not atomic with input delivery and does not prove
109
117
  the action's outcome.
@@ -0,0 +1 @@
1
+ export {};
@@ -1,7 +1,7 @@
1
1
  import { systemClock } from '../../runtime/clock.js';
2
2
  import { remainingBudget } from '../../utils/operation-budget.js';
3
3
  import { checkDebuggerGuard } from '../../debugger/guard.js';
4
- import { observeReadinessWait, isDeviceUnavailable } from '../../observe/readiness.js';
4
+ import { observeReadinessWait, observeReadinessDeviceWait, isDeviceUnavailable } from '../../observe/readiness.js';
5
5
  import { readinessDelay } from '../../observe/readiness-service.js';
6
6
  import { _buildUiChangeSignatures, _summarizeUiChangeDelta, _matchesUiChange, _uiChangeSignaturesEqual } from './ui-change-helpers.js';
7
7
  import { createChangeWait, changeResult } from './ui-change-state.js';
@@ -26,12 +26,19 @@ function changed(s, signatures) {
26
26
  s.since = s.clock.now();
27
27
  }
28
28
  const stable = s.since === null ? 0 : s.clock.now() - s.since;
29
- return (s.stability === 0 || stable >= s.stability) && (!s.tracker || s.tracker.summary.state === 'settled');
29
+ return (s.stability === 0 || stable >= s.stability) && (!s.tracker || s.tracker.ready);
30
30
  }
31
31
  async function sample(s, deps) {
32
32
  if (s.tracker)
33
33
  remainingBudget();
34
34
  const started = s.clock.now(), a = s.input;
35
+ await observeReadinessDeviceWait();
36
+ if (s.tracker?.deviceReadiness.input_ready === false) {
37
+ s.candidate = null;
38
+ s.change = null;
39
+ s.since = null;
40
+ return null;
41
+ }
35
42
  const tree = await deps.observe({ platform: a.platform, deviceId: a.deviceId });
36
43
  observeReadinessWait(tree, started);
37
44
  const scoped = deps.scope(tree, a.scope, a.target);
@@ -1,12 +1,13 @@
1
1
  import { systemClock } from '../../runtime/clock.js';
2
+ import { benchmarkStage } from '../../utils/benchmark-metrics.js';
2
3
  import { remainingBudget } from '../../utils/operation-budget.js';
3
4
  import { checkDebuggerGuard } from '../../debugger/guard.js';
4
- import { observeReadinessWait, isDeviceUnavailable } from '../../observe/readiness.js';
5
+ import { observeReadinessWait, observeReadinessDeviceWait, isDeviceUnavailable } from '../../observe/readiness.js';
5
6
  import { readinessDelay } from '../../observe/readiness-service.js';
6
7
  import { matchingElements, evaluateCondition } from './ui-wait-condition.js';
7
8
  import { createUIWait, validateUIWait, waitObserved, waitFailure } from './ui-wait-state.js';
8
9
  async function delay(s, ms) {
9
- await (s.tracker && s.clock === systemClock ? readinessDelay(ms) : s.clock.sleep(ms));
10
+ await benchmarkStage('poll_sleep_ms', () => s.tracker && s.clock === systemClock ? readinessDelay(ms) : s.clock.sleep(ms));
10
11
  }
11
12
  function acceptObservation(s, tree, now) {
12
13
  const age = typeof tree?.captured_at_ms === 'number' ? now - tree.captured_at_ms : null;
@@ -15,7 +16,7 @@ function acceptObservation(s, tree, now) {
15
16
  return null;
16
17
  }
17
18
  s.stable++;
18
- if (s.stable < (s.args.singleObservation ? 1 : 2) || (s.tracker && s.tracker.summary.state !== 'settled'))
19
+ if (s.stable < (s.args.singleObservation ? 1 : 2) || (s.tracker && !s.tracker.ready))
19
20
  return null;
20
21
  return { status: 'success', ...(s.args.singleObservation ? { _tree: tree } : {}), matched: s.count,
21
22
  element: s.element, metrics: { latency_ms: now - s.start, poll_count: s.polls, attempts: s.attempts }, requested: s.requested, observed: waitObserved(s, true) };
@@ -50,6 +51,9 @@ async function poll(s, deps) {
50
51
  s.polls++;
51
52
  try {
52
53
  const started = s.clock.now(), a = s.args;
54
+ await observeReadinessDeviceWait();
55
+ if (s.tracker?.deviceReadiness.input_ready === false)
56
+ return null;
53
57
  const tree = await deps.observe({ platform: a.platform, deviceId: a.deviceId });
54
58
  observeReadinessWait(tree, started);
55
59
  if (a.resolveTapTarget && tree?.error && /ACTION_TIMEOUT|timed out/i.test(String(tree.error)))
@@ -1,3 +1,4 @@
1
+ import { validAppId } from '../utils/app-installation.js';
1
2
  import { promises as fs } from 'fs';
2
3
  import path from 'path';
3
4
  import { resolveTargetDevice, listDevices } from '../utils/resolve-device.js';
@@ -313,6 +314,8 @@ export class ToolsManage {
313
314
  }
314
315
  }
315
316
  static async listDevicesHandler({ platform, appId }) {
317
+ if (appId !== undefined && !validAppId(appId))
318
+ throw new Error('Invalid appId');
316
319
  const devices = await listDevices(platform, appId);
317
320
  return { devices };
318
321
  }
@@ -0,0 +1,116 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { pausedReadiness } from '../debugger/guard.js';
3
+ import { execCmd } from '../utils/exec.js';
4
+ import { getAdbCmd } from '../utils/android/commands.js';
5
+ import { getXcrunCmd } from '../utils/ios/utils.js';
6
+ import { remainingBudget } from '../utils/operation-budget.js';
7
+ import { benchmarkStage } from '../utils/benchmark-metrics.js';
8
+ export function unknownDeviceReadiness(code) {
9
+ return { wakefulness: 'unknown', lock_state: 'unknown', input_ready: null, blockers: [], evidence: [], limitations: code ? [code] : [] };
10
+ }
11
+ function values(text, pattern) { return [...text.matchAll(pattern)].map(match => match[1]); }
12
+ function unique(items) { return items.length > 0 && new Set(items).size === 1 ? items[0] : null; }
13
+ export function parseAndroidPower(text) {
14
+ // Later vendor/debug sections can repeat mWakefulness as a numeric enum.
15
+ // Classify only the named current-state section when its boundary is present.
16
+ const main = text.split('Power Manager State:')[1];
17
+ if (main?.includes('Settings and Configuration:'))
18
+ text = main.split('Settings and Configuration:')[0];
19
+ const wake = unique(values(text, /^\s*mWakefulness=(\w+)\s*$/gm));
20
+ const changing = unique(values(text, /^\s*mWakefulnessChanging=(true|false)\s*$/gm));
21
+ if (!wake || !changing)
22
+ return 'unknown';
23
+ if (changing === 'true')
24
+ return 'transitioning';
25
+ return wake === 'Awake' ? 'awake' : ['Asleep', 'Dozing'].includes(wake) ? 'asleep' : 'unknown';
26
+ }
27
+ export function parseAndroidLock(text) {
28
+ const section = text.split(/\bKeyguardServiceDelegate\s*\n/)[1];
29
+ if (!section)
30
+ return 'unknown';
31
+ const showing = unique(values(section, /^\s*(?:showing|mIsShowing)=(true|false)\s*$/gm));
32
+ return showing === 'true' ? 'locked' : showing === 'false' ? 'unlocked' : 'unknown';
33
+ }
34
+ export function finishDeviceReadiness(state) {
35
+ if (state.wakefulness === 'asleep')
36
+ state.blockers.push('DEVICE_ASLEEP');
37
+ if (state.wakefulness === 'transitioning')
38
+ state.blockers.push('DEVICE_TRANSITIONING');
39
+ if (state.lock_state === 'locked')
40
+ state.blockers.push('DEVICE_LOCKED');
41
+ state.blockers = [...new Set(state.blockers)];
42
+ state.input_ready = state.blockers.length ? false : null;
43
+ return state;
44
+ }
45
+ export function parseIosLock(text) {
46
+ try {
47
+ const data = JSON.parse(text);
48
+ if (data.error?.domain === 'com.apple.dt.CoreDeviceError' && data.error.code === 1001 &&
49
+ data.error.userInfo?.CapabilityFeatureIdentifier?.string === 'com.apple.coredevice.feature.getlockstate')
50
+ return 'unsupported';
51
+ // Successful response semantics require runtime validation before classification.
52
+ }
53
+ catch { /* Non-JSON output supplies no state evidence. */ }
54
+ return 'unknown';
55
+ }
56
+ const probes = new AsyncLocalStorage();
57
+ export function withDeviceReadinessProbe(probe, operation) { return probes.run(probe, operation); }
58
+ const nativeProbe = (command, args, timeout) => execCmd(command, args, { timeout });
59
+ export async function observeDeviceReadiness(device, probe = probes.getStore() ?? nativeProbe) {
60
+ return benchmarkStage('device_readiness_ms', () => observe(device, probe));
61
+ }
62
+ async function observe(device, probe) {
63
+ const state = unknownDeviceReadiness();
64
+ if (!device?.id || !['android', 'ios'].includes(device.platform) || ['booted', 'default'].includes(device.id))
65
+ return unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE');
66
+ const paused = pausedReadiness(device.id, device.platform);
67
+ if (paused) {
68
+ state.blockers.push('DEBUGGER_PAUSED');
69
+ state.evidence.push({ code: 'DEBUGGER_PAUSED', source: 'debugger', captured_at_ms: paused.captured_at_ms });
70
+ return finishDeviceReadiness(state);
71
+ }
72
+ if (device.platform === 'android') {
73
+ await androidProbe(state, device.id, 'power', probe);
74
+ await androidProbe(state, device.id, 'window', probe);
75
+ }
76
+ else {
77
+ state.limitations.push('DEVICE_WAKEFULNESS_UNVERIFIED');
78
+ try {
79
+ const timeout = remainingBudget(1000);
80
+ // CoreDevice requires a CLI timeout of at least five seconds; the parent
81
+ // subprocess deadline still caps this read at one second.
82
+ const result = await probe(getXcrunCmd(), ['devicectl', 'device', 'info', 'lockState', '--device', device.id, '--timeout', '5', '--json-output', '-'], timeout);
83
+ remainingBudget();
84
+ state.lock_state = parseIosLock(result.stdout);
85
+ if (state.lock_state === 'unsupported')
86
+ state.evidence.push({ code: 'DEVICE_LOCK_SIGNAL_UNSUPPORTED', source: 'coredevice', captured_at_ms: Date.now() });
87
+ else
88
+ state.limitations.push('DEVICE_LOCK_OBSERVATION_UNAVAILABLE');
89
+ }
90
+ catch {
91
+ state.limitations.push('DEVICE_LOCK_OBSERVATION_FAILED');
92
+ }
93
+ }
94
+ return finishDeviceReadiness(state);
95
+ }
96
+ async function androidProbe(state, id, kind, probe) {
97
+ try {
98
+ const timeout = remainingBudget(1000);
99
+ const result = await probe(getAdbCmd(), ['-s', id, 'shell', 'dumpsys', kind, ...(kind === 'window' ? ['policy'] : [])], timeout);
100
+ remainingBudget();
101
+ if (result.exitCode !== 0)
102
+ throw new Error('PROBE_FAILED');
103
+ const value = kind === 'power' ? parseAndroidPower(result.stdout) : parseAndroidLock(result.stdout);
104
+ if (kind === 'power')
105
+ state.wakefulness = value;
106
+ else
107
+ state.lock_state = value;
108
+ if (value === 'unknown')
109
+ state.limitations.push(kind === 'power' ? 'DEVICE_WAKEFULNESS_UNVERIFIED' : 'DEVICE_LOCK_STATE_UNVERIFIED');
110
+ else
111
+ state.evidence.push({ code: `DEVICE_${value.toUpperCase()}`, source: `android_${kind}`, captured_at_ms: Date.now() });
112
+ }
113
+ catch {
114
+ state.limitations.push(kind === 'power' ? 'DEVICE_POWER_OBSERVATION_FAILED' : 'DEVICE_LOCK_OBSERVATION_FAILED');
115
+ }
116
+ }
@@ -217,11 +217,21 @@ export function refreshJourneyEvidence(steps) {
217
217
  const child = current.children.find(c => c.action?.id === previous?.action_id);
218
218
  if (child && step.result)
219
219
  step.result.evidence = assemble(child);
220
+ else if (previous?.observations && step.result) {
221
+ const ids = new Set(previous.observations.map(o => o.evidence_id));
222
+ step.result.evidence = assemble({ ...current, observations: current.observations.filter(o => ids.has(o.value.evidence_id)) });
223
+ }
220
224
  }
221
225
  }
222
226
  export function completeJourneyVerification(actionId) {
223
227
  const child = context.getStore()?.children.find(c => c.action?.id === actionId);
224
228
  if (child?.action) {
229
+ const parent = context.getStore();
230
+ const completed = child.action.dispatchEnd?.mono;
231
+ if (completed !== undefined) {
232
+ const after = parent.observations.filter(o => o.start.mono >= completed && sameDevice(o.value.device, child.action.device));
233
+ child.observations.push(...after);
234
+ }
225
235
  child.verification = stamp();
226
236
  child.action.verification = child.verification;
227
237
  }
@@ -317,3 +327,24 @@ function recordCaptureTime(acquisition, result, start) {
317
327
  acquisition.reused = source < start.wall;
318
328
  }
319
329
  }
330
+ export class JourneyStepError extends Error {
331
+ evidence;
332
+ constructor(evidence, cause) {
333
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
334
+ this.evidence = evidence;
335
+ }
336
+ }
337
+ /** Retain per-step evidence without changing the parent journey's acquisition flow. */
338
+ export async function withJourneyStepEvidence(operation) {
339
+ const current = context.getStore();
340
+ const start = current?.observations.length ?? 0;
341
+ try {
342
+ const result = await operation();
343
+ return { ...result, ...(current ? { evidence: assemble({ ...current, observations: current.observations.slice(start) }) } : {}) };
344
+ }
345
+ catch (error) {
346
+ if (current)
347
+ throw new JourneyStepError(assemble({ ...current, observations: current.observations.slice(start) }), error);
348
+ throw error;
349
+ }
350
+ }
@@ -163,11 +163,13 @@ export class IosTraceSessions {
163
163
  catch (error) {
164
164
  this.fail(s, reasonFor(error, s.recording?.recorder.alive() ? 'TRACE_STOP_FAILED' : 'TRACE_ARTIFACT_FAILED'));
165
165
  s.result.cleanup.status = 'failed';
166
- if (s.result.reason === 'STORAGE_LIMIT' && !s.recording?.recorder.alive())
167
- await this.discardOversized(s);
166
+ // Stop timeouts can throw after SIGKILL has already closed the recorder.
167
+ // Discarding is safe only after exit; keep the failure but release owned files.
168
+ if ((s.discard || s.result.reason === 'STORAGE_LIMIT') && !s.recording?.recorder.alive())
169
+ await this.discardStoppedFiles(s);
168
170
  }
169
171
  }
170
- async discardOversized(s) {
172
+ async discardStoppedFiles(s) {
171
173
  try {
172
174
  if (s.directory)
173
175
  await fs.rm(s.directory, { recursive: true, force: true });
@@ -1,3 +1,4 @@
1
+ import { unknownDeviceReadiness, observeDeviceReadiness } from './device-readiness.js';
1
2
  import { pausedReadiness } from '../debugger/guard.js';
2
3
  import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js';
3
4
  import { remainingBudget, runWithBudget } from '../utils/operation-budget.js';
@@ -28,10 +29,14 @@ export async function readinessDelay(ms) {
28
29
  await new Promise(resolve => setTimeout(resolve, Math.min(ms, remainingBudget(ms))));
29
30
  remainingBudget();
30
31
  }
32
+ function observationComplete(tracker, wait, count) {
33
+ return tracker.ready || (!wait && (tracker.summary.state === 'loading' || count === 2));
34
+ }
31
35
  export async function getAppReadiness(args, wait = false) {
32
36
  const started = performance.now();
33
37
  let device = null;
34
38
  let tracker = new ReadinessTracker(args.platform ?? 'android', args.deviceId ?? '');
39
+ tracker.deviceReadiness = unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE');
35
40
  let failureCode;
36
41
  let debugPause = null;
37
42
  await runWithBudget(args.timeoutMs ?? (wait ? 30000 : 15000), async () => {
@@ -45,9 +50,17 @@ export async function getAppReadiness(args, wait = false) {
45
50
  remainingBudget();
46
51
  debugPause = pausedReadiness(device.id, device.platform);
47
52
  if (debugPause) {
53
+ tracker.deviceReadiness = await observeDeviceReadiness(device);
48
54
  failureCode = wait ? 'DEBUGGER_PAUSED' : undefined;
49
55
  return;
50
56
  }
57
+ await tracker.observeDevice();
58
+ if (tracker.deviceReadiness.input_ready === false) {
59
+ if (!wait)
60
+ return;
61
+ await readinessDelay(args.pollIntervalMs ?? 500);
62
+ continue;
63
+ }
51
64
  const captureStarted = Date.now();
52
65
  try {
53
66
  const tree = await ToolsObserve.getUITreeHandler({ platform: device.platform, deviceId: device.id });
@@ -68,9 +81,7 @@ export async function getAppReadiness(args, wait = false) {
68
81
  }
69
82
  }
70
83
  count++;
71
- if (tracker.summary.state === 'settled' || (!wait && tracker.summary.state === 'loading'))
72
- return;
73
- if (!wait && count === 2)
84
+ if (observationComplete(tracker, wait, count))
74
85
  return;
75
86
  await readinessDelay(wait ? args.pollIntervalMs ?? 500 : args.stabilityMs ?? 500);
76
87
  }
@@ -86,17 +97,18 @@ export async function getAppReadiness(args, wait = false) {
86
97
  }
87
98
  });
88
99
  if (debugPause)
89
- return { success: !wait, device, readiness: debugPause, elapsed_ms: performance.now() - started, ...(wait ? { failure_code: 'DEBUGGER_PAUSED' } : {}) };
100
+ return { success: !wait, device, readiness: debugPause, device_readiness: tracker.deviceReadiness, elapsed_ms: performance.now() - started, ...(wait ? { failure_code: 'DEBUGGER_PAUSED' } : {}) };
90
101
  const state = tracker.summary.state;
91
- const success = !failureCode && (wait ? state === 'settled' : state !== 'unknown');
102
+ const success = !failureCode && (wait ? tracker.ready : state !== 'unknown' || tracker.deviceReadiness.input_ready === false);
92
103
  return {
93
- success, device, readiness: tracker.summary, elapsed_ms: performance.now() - started,
104
+ success, device, readiness: tracker.summary, device_readiness: tracker.deviceReadiness, elapsed_ms: performance.now() - started,
94
105
  ...(!success ? { failure_code: failureCode ?? (wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE') } : {})
95
106
  };
96
107
  }
97
108
  export async function withReadyWait(target, timeoutMs, operation, failure) {
98
109
  const started = performance.now();
99
110
  let tracker = new ReadinessTracker(target.platform ?? 'android', target.deviceId ?? '');
111
+ tracker.deviceReadiness = unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE');
100
112
  let resolved = false;
101
113
  return runWithBudget(timeoutMs, async () => {
102
114
  try {
@@ -110,9 +122,9 @@ export async function withReadyWait(target, timeoutMs, operation, failure) {
110
122
  }
111
123
  catch {
112
124
  const alreadyFailed = result && (('status' in result && result.status !== 'success') || ('success' in result && result.success === false));
113
- return { ...(alreadyFailed ? result : failure('TIMEOUT', performance.now() - started, tracker.summary.sample_count)), readiness: tracker.deadline() };
125
+ return { ...(alreadyFailed ? result : failure('TIMEOUT', performance.now() - started, tracker.summary.sample_count)), readiness: tracker.deadline(), device_readiness: tracker.deviceReadiness };
114
126
  }
115
- return { ...result, readiness: tracker.summary };
127
+ return { ...result, readiness: tracker.summary, device_readiness: tracker.deviceReadiness };
116
128
  }
117
129
  catch (error) {
118
130
  const expired = budgetExpired(error);
@@ -120,7 +132,7 @@ export async function withReadyWait(target, timeoutMs, operation, failure) {
120
132
  tracker.deadline();
121
133
  else
122
134
  tracker.unavailable();
123
- return { ...failure(!expired && (!resolved || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : 'TIMEOUT', performance.now() - started, tracker.summary.sample_count), readiness: tracker.summary };
135
+ return { ...failure(!expired && (!resolved || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : 'TIMEOUT', performance.now() - started, tracker.summary.sample_count), readiness: tracker.summary, device_readiness: tracker.deviceReadiness };
124
136
  }
125
137
  });
126
138
  }
@@ -1,3 +1,4 @@
1
+ import { unknownDeviceReadiness, observeDeviceReadiness, finishDeviceReadiness } from './device-readiness.js';
1
2
  import { pausedReadiness } from '../debugger/guard.js';
2
3
  import { AsyncLocalStorage } from 'node:async_hooks';
3
4
  import { remainingBudget } from '../utils/operation-budget.js';
@@ -7,10 +8,38 @@ const capabilities = { loading: true, transitioning: true, settled: true, unresp
7
8
  export function isDeviceUnavailable(error) {
8
9
  return /device.*(?:not found|offline|disconnected)|no devices found|READINESS_DEVICE_UNAVAILABLE/i.test(String(error));
9
10
  }
11
+ function retainBlockedEvidence(previous, next) {
12
+ const retained = [];
13
+ if (['unknown', 'unsupported'].includes(next.wakefulness) && ['asleep', 'transitioning'].includes(previous.wakefulness)) {
14
+ next.wakefulness = previous.wakefulness;
15
+ retained.push('DEVICE_ASLEEP', 'DEVICE_TRANSITIONING');
16
+ }
17
+ if (['unknown', 'unsupported'].includes(next.lock_state) && previous.lock_state === 'locked') {
18
+ next.lock_state = previous.lock_state;
19
+ retained.push('DEVICE_LOCKED');
20
+ }
21
+ if (retained.length) {
22
+ next.evidence = [...previous.evidence.filter(item => retained.includes(item.code)), ...next.evidence].slice(-8);
23
+ next.limitations = [...new Set([...next.limitations, 'DEVICE_BLOCKER_EVIDENCE_RETAINED'])];
24
+ }
25
+ return finishDeviceReadiness(next);
26
+ }
10
27
  export class ReadinessTracker {
11
28
  platform;
12
29
  deviceId;
13
30
  stabilityMs;
31
+ deviceReadiness = unknownDeviceReadiness();
32
+ get ready() { return this.summary.state === 'settled' && this.deviceReadiness.input_ready !== false; }
33
+ async observeDevice() {
34
+ const next = await observeDeviceReadiness({ platform: this.platform, id: this.deviceId });
35
+ this.deviceReadiness = retainBlockedEvidence(this.deviceReadiness, next);
36
+ if (this.deviceReadiness.input_ready === false) {
37
+ this.signature = null;
38
+ this.stableSince = 0;
39
+ this.summary = { ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
40
+ limitations: [...new Set([...this.summary.limitations, 'DEVICE_BLOCKED'])] };
41
+ }
42
+ }
14
43
  signature = null;
15
44
  stableSince = 0;
16
45
  summary;
@@ -105,3 +134,4 @@ function readinessState(loading, previous, unchanged, stableFor, stabilityMs) {
105
134
  function readinessCode(nativeLoading, textLoading, state) {
106
135
  return nativeLoading ? 'NATIVE_LOADING_INDICATOR' : textLoading ? 'LOADING_LABEL' : state === 'transitioning' ? 'SNAPSHOT_CHANGED' : state === 'settled' ? 'SNAPSHOT_STABLE' : 'INSUFFICIENT_SAMPLES';
107
136
  }
137
+ export async function observeReadinessDeviceWait() { await waits.getStore()?.observeDevice(); }
@@ -1,3 +1,4 @@
1
+ import { benchmarkStage } from '../utils/benchmark-metrics.js';
1
2
  async function convert(buffer, platform) {
2
3
  const { default: sharp } = await import('sharp');
3
4
  const image = sharp(buffer);
@@ -22,6 +23,9 @@ async function convert(buffer, platform) {
22
23
  return { hasAlpha, webp, jpeg };
23
24
  }
24
25
  export async function encodeScreenshot(buffer, platform) {
26
+ return benchmarkStage('screenshot_encoding_ms', () => encode(buffer, platform));
27
+ }
28
+ async function encode(buffer, platform) {
25
29
  const png = buffer.toString('base64');
26
30
  try {
27
31
  const { hasAlpha, webp, jpeg } = await convert(buffer, platform);
@@ -3,6 +3,7 @@ import { measure } from '../utils/timing.js';
3
3
  import { deriveSnapshotMetadata } from './snapshot-metadata.js';
4
4
  import { deriveSnapshotSemantic } from './snapshot-semantic.js';
5
5
  import { snapshotLog, streamLog } from './snapshot-logs.js';
6
+ import { collectGetScreenFingerprint } from './android/fingerprint.js';
6
7
  async function settle(tasks) {
7
8
  const results = await Promise.allSettled(Object.values(tasks));
8
9
  return Object.fromEntries(Object.keys(tasks).map((key, index) => [key, results[index]]));
@@ -15,11 +16,15 @@ function apply(result, success, failure) {
15
16
  }
16
17
  async function collect(raw, input, deps) {
17
18
  const { platform, deviceId, includeScreenshot = true, includeLogs = true, sessionId, logLines = 200 } = input;
19
+ const androidTree = platform === 'android' ? deps.getUITreeHandler({ platform, deviceId }) : undefined;
18
20
  const results = await settle({
19
21
  screenshot: includeScreenshot ? measure('screenshot_ms', () => deps.captureScreenshotHandler({ platform, deviceId })) : Promise.resolve(null),
20
22
  currentScreen: !platform || platform === 'android' ? deps.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
21
- fingerprint: deps.getScreenFingerprintHandler({ platform, deviceId }),
22
- uiTree: deps.getUITreeHandler({ platform, deviceId }),
23
+ fingerprint: androidTree ? collectGetScreenFingerprint({
24
+ getUITree: () => androidTree,
25
+ getCurrentScreen: id => deps.getCurrentScreenHandler({ deviceId: id })
26
+ }, deviceId) : deps.getScreenFingerprintHandler({ platform, deviceId }),
27
+ uiTree: androidTree ?? deps.getUITreeHandler({ platform, deviceId }),
23
28
  readLogStream: includeLogs ? deps.readLogStreamHandler({ platform, deviceId, sessionId: sessionId || 'default', limit: logLines }) : Promise.resolve({ entries: [] })
24
29
  });
25
30
  let entries = [];