mobile-debug-mcp 0.32.4 → 0.34.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 (170) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.cursor-plugin/marketplace.json +16 -0
  4. package/.github/copilot-instructions.md +7 -0
  5. package/.github/workflows/ci.yml +21 -0
  6. package/AGENTS.md +21 -10
  7. package/README.md +69 -4
  8. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  9. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  10. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  11. package/agent-plugin/.mcp.json +14 -0
  12. package/agent-plugin/README.md +9 -3
  13. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  14. package/agent-plugin/mcp.json +1 -1
  15. package/agent-plugin/plugin.json +1 -1
  16. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
  17. package/dist/debugger/android.js +78 -0
  18. package/dist/debugger/guard.js +30 -0
  19. package/dist/debugger/sessions.js +262 -0
  20. package/dist/debugger/worker.js +165 -0
  21. package/dist/interact/android.js +10 -4
  22. package/dist/interact/conditional-action.js +68 -0
  23. package/dist/interact/index.js +70 -14
  24. package/dist/interact/ios.js +14 -8
  25. package/dist/manage/android.js +4 -3
  26. package/dist/manage/ios.js +3 -2
  27. package/dist/observe/android.js +25 -4
  28. package/dist/observe/evidence.js +280 -0
  29. package/dist/observe/index.js +33 -6
  30. package/dist/observe/ios.js +25 -6
  31. package/dist/observe/readiness-service.js +124 -0
  32. package/dist/observe/readiness.js +88 -0
  33. package/dist/observe/recording.js +165 -0
  34. package/dist/observe/snapshot-metadata.js +10 -2
  35. package/dist/observe/system-trace-android.js +135 -0
  36. package/dist/observe/system-trace.js +285 -0
  37. package/dist/server/common.js +9 -0
  38. package/dist/server/condition-schema.js +32 -0
  39. package/dist/server/debugger-handlers.js +38 -0
  40. package/dist/server/debugger-schema.js +42 -0
  41. package/dist/server/journey-schema.js +4 -2
  42. package/dist/server/readiness-schema.js +12 -0
  43. package/dist/server/system-trace-schema.js +17 -0
  44. package/dist/server/tool-definitions.js +51 -0
  45. package/dist/server/tool-handlers.js +294 -105
  46. package/dist/server-core.js +3 -3
  47. package/dist/server.js +34 -0
  48. package/dist/system/index.js +5 -4
  49. package/dist/utils/exec.js +2 -0
  50. package/dist/utils/ios/utils.js +4 -4
  51. package/dist/utils/java.js +14 -12
  52. package/dist/utils/operation-budget.js +26 -2
  53. package/dist/utils/resolve-device.js +9 -0
  54. package/docs/CHANGELOG.md +15 -0
  55. package/docs/specs/020-conditional-actions/intent.md +62 -0
  56. package/docs/specs/020-conditional-actions/plan.md +214 -0
  57. package/docs/specs/020-conditional-actions/spec.md +301 -0
  58. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  59. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  60. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  61. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  62. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  63. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  64. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  65. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  66. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  67. package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
  68. package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
  69. package/docs/specs/023-android-system-traces/intent.md +40 -0
  70. package/docs/specs/023-android-system-traces/plan.md +117 -0
  71. package/docs/specs/023-android-system-traces/spec.md +120 -0
  72. package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
  73. package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
  74. package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
  75. package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
  76. package/docs/specs/process.md +213 -0
  77. package/docs/tools/TOOLS.md +5 -0
  78. package/docs/tools/debugger.md +104 -0
  79. package/docs/tools/evidence.md +82 -0
  80. package/docs/tools/interact.md +125 -2
  81. package/docs/tools/observe.md +70 -0
  82. package/docs/tools/system-traces.md +126 -0
  83. package/package.json +1 -1
  84. package/scripts/agent-plugin.mjs +68 -8
  85. package/src/debugger/android.ts +65 -0
  86. package/src/debugger/guard.ts +26 -0
  87. package/src/debugger/sessions.ts +168 -0
  88. package/src/debugger/worker.ts +122 -0
  89. package/src/interact/android.ts +10 -4
  90. package/src/interact/conditional-action.ts +75 -0
  91. package/src/interact/index.ts +57 -28
  92. package/src/interact/ios.ts +14 -8
  93. package/src/manage/android.ts +4 -3
  94. package/src/manage/ios.ts +3 -2
  95. package/src/observe/android.ts +33 -6
  96. package/src/observe/evidence.ts +262 -0
  97. package/src/observe/index.ts +32 -8
  98. package/src/observe/ios.ts +32 -8
  99. package/src/observe/readiness-service.ts +94 -0
  100. package/src/observe/readiness.ts +84 -0
  101. package/src/observe/snapshot-metadata.ts +12 -1
  102. package/src/observe/system-trace-android.ts +133 -0
  103. package/src/observe/system-trace.ts +218 -0
  104. package/src/server/common.ts +7 -0
  105. package/src/server/condition-schema.ts +32 -0
  106. package/src/server/debugger-handlers.ts +29 -0
  107. package/src/server/debugger-schema.ts +37 -0
  108. package/src/server/journey-schema.ts +4 -2
  109. package/src/server/readiness-schema.ts +12 -0
  110. package/src/server/system-trace-schema.ts +17 -0
  111. package/src/server/tool-definitions.ts +49 -0
  112. package/src/server/tool-handlers.ts +182 -21
  113. package/src/server-core.ts +3 -3
  114. package/src/server.ts +24 -0
  115. package/src/system/index.ts +7 -5
  116. package/src/types.ts +30 -1
  117. package/src/utils/exec.ts +2 -0
  118. package/src/utils/ios/utils.ts +4 -4
  119. package/src/utils/java.ts +13 -12
  120. package/src/utils/operation-budget.ts +24 -2
  121. package/src/utils/resolve-device.ts +10 -0
  122. package/test/device/automated/debugger/runtime.android.test.ts +203 -0
  123. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  124. package/test/device/automated/observe/system-trace.smoke.ts +104 -0
  125. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  126. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  127. package/test/device/manual/interact/readiness.manual.ts +99 -0
  128. package/test/fixtures/debugger-app/MainActivity.java +46 -0
  129. package/test/fixtures/debugger-app/NoLocals.java +7 -0
  130. package/test/fixtures/debugger-app/README.md +13 -0
  131. package/test/fixtures/debugger-app/build.ts +29 -0
  132. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  133. package/test/fixtures/readiness-app/README.md +12 -0
  134. package/test/fixtures/readiness-app/build.ts +37 -0
  135. package/test/fixtures/readiness-app/main.m +27 -0
  136. package/test/helpers/perfetto.ts +59 -0
  137. package/test/unit/debugger/guards.test.ts +46 -0
  138. package/test/unit/debugger/native.test.ts +33 -0
  139. package/test/unit/debugger/sessions.test.ts +83 -0
  140. package/test/unit/observe/android_tree_acquisition.test.ts +2 -0
  141. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  142. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  143. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  144. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  145. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  146. package/test/unit/observe/readiness.test.ts +41 -0
  147. package/test/unit/observe/readiness_native.test.ts +68 -0
  148. package/test/unit/observe/system-trace-android.test.ts +52 -0
  149. package/test/unit/observe/system-trace.test.ts +166 -0
  150. package/test/unit/server/conditional_actions.test.ts +299 -0
  151. package/test/unit/server/conditional_schema.test.ts +59 -0
  152. package/test/unit/server/correlated-evidence.test.ts +194 -0
  153. package/test/unit/server/readiness.test.ts +170 -0
  154. package/test/unit/server/selector_tap_target.test.ts +79 -0
  155. package/test/unit/server/system-trace.test.ts +64 -0
  156. package/test/unit/server/tap_selector.test.ts +2 -0
  157. package/worker/DebuggerWorker.java +160 -0
  158. package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
  159. /package/{skills → .agents/skills}/README.md +0 -0
  160. /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
  161. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  162. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  163. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  164. /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
  165. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  166. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  167. /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
  168. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  169. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  170. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -1,3 +1,7 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js'
2
+ import { currentReadinessWait, observeReadinessWait, isDeviceUnavailable } from '../observe/readiness.js'
3
+ import { readinessDelay } from '../observe/readiness-service.js'
4
+ import { isConditionBlocked } from './conditional-action.js'
1
5
  import { remainingBudget } from '../utils/operation-budget.js'
2
6
  import { createHash } from 'crypto'
3
7
  import { AndroidInteract } from './android.js';
@@ -605,6 +609,7 @@ export class ToolsInteract {
605
609
  }
606
610
 
607
611
  private static async _captureFingerprint(platform: 'android' | 'ios', deviceId?: string): Promise<string | null> {
612
+ if (isConditionBlocked()) return null
608
613
  try {
609
614
  const fingerprint = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
610
615
  return fingerprint?.fingerprint ?? null
@@ -2345,13 +2350,13 @@ export class ToolsInteract {
2345
2350
  break
2346
2351
  }
2347
2352
  }
2348
- } catch (e) { console.error('Error scoring element:', e) }
2353
+ } catch (e) { checkDebuggerGuard(); console.error('Error scoring element:', e) }
2349
2354
  }
2350
2355
  if (iterationImprovedBest) {
2351
2356
  bestIterationCandidates = iterationCandidates.slice()
2352
2357
  }
2353
2358
  }
2354
- } catch (e) { console.error('Error fetching UI tree:', e) }
2359
+ } catch (e) { checkDebuggerGuard(); console.error('Error fetching UI tree:', e) }
2355
2360
  if (shouldStop || Date.now() > deadline) break
2356
2361
  await new Promise(r => setTimeout(r, 100))
2357
2362
  }
@@ -2439,7 +2444,7 @@ export class ToolsInteract {
2439
2444
  }
2440
2445
  }
2441
2446
  }
2442
- } catch (e) { console.error('Error resolving ancestor:', e) }
2447
+ } catch (e) { checkDebuggerGuard(); console.error('Error resolving ancestor:', e) }
2443
2448
 
2444
2449
  if (!best) return { found: false, error: 'Element not found' }
2445
2450
 
@@ -2491,7 +2496,8 @@ export class ToolsInteract {
2491
2496
  return { found: true, element: outEl, score: scoreVal, confidence: scoreVal, resolution }
2492
2497
  }
2493
2498
 
2494
- static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId, singleObservation = false, singleAttempt = false, rejectAmbiguous = false }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string, singleObservation?: boolean, singleAttempt?: boolean, rejectAmbiguous?: boolean }) {
2499
+ static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId, singleObservation = false, singleAttempt = false, rejectAmbiguous = false, resolveTapTarget = false }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string, singleObservation?: boolean, singleAttempt?: boolean, rejectAmbiguous?: boolean, resolveTapTarget?: boolean }) {
2500
+ const readinessTracker = currentReadinessWait()
2495
2501
  const overallStart = Date.now()
2496
2502
  const requestedIndex = typeof match?.index === 'number' ? match.index : null
2497
2503
  const requested = {
@@ -2559,7 +2565,11 @@ export class ToolsInteract {
2559
2565
  remainingBudget()
2560
2566
  totalPollCount++
2561
2567
  try {
2568
+ const acquisitionStarted = Date.now()
2562
2569
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
2570
+ observeReadinessWait(tree, acquisitionStarted)
2571
+ // A failed acquisition cannot establish that the requested tap target is absent.
2572
+ if (resolveTapTarget && tree?.error && /ACTION_TIMEOUT|timed out/i.test(String(tree.error))) throw new Error('ACTION_TIMEOUT')
2563
2573
  const elements = (tree && Array.isArray(tree.elements)) ? tree.elements as any[] : []
2564
2574
 
2565
2575
  const matches: { el: any, idx: number }[] = []
@@ -2633,7 +2643,8 @@ export class ToolsInteract {
2633
2643
  conditionMet = visibleFlag
2634
2644
  } else conditionMet = false
2635
2645
  } else if (condition === 'clickable') {
2636
- matchedElement = chosen ? (ToolsInteract._resolveActionableAncestor(elements, chosen as { el: UiElement, idx: number }) || chosen) : null
2646
+ matchedElement = chosen ? (resolveTapTarget && (!ToolsInteract._isVisibleElement(chosen.el) || chosen.el.enabled === false)
2647
+ ? chosen : ToolsInteract._resolveActionableAncestor(elements, chosen as { el: UiElement, idx: number }) || chosen) : null
2637
2648
  if (matchedElement) {
2638
2649
  const b = matchedElement.el.bounds
2639
2650
  const visibleFlag = !!matchedElement.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1])
@@ -2643,6 +2654,13 @@ export class ToolsInteract {
2643
2654
  } else conditionMet = false
2644
2655
  }
2645
2656
 
2657
+ // Waiting for a label to exist/be visible is distinct from resolving its
2658
+ // executable control. Reuse the clickable-wait ancestor policy without
2659
+ // promoting hidden or disabled labels into an unrelated enabled target.
2660
+ if (resolveTapTarget && conditionMet && matchedElement &&
2661
+ ToolsInteract._isVisibleElement(matchedElement.el) && matchedElement.el.enabled !== false) {
2662
+ matchedElement = ToolsInteract._resolveActionableAncestor(elements, matchedElement) ?? matchedElement
2663
+ }
2646
2664
  const resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
2647
2665
  const resolvedDeviceId = tree?.device?.id || deviceId
2648
2666
  lastMatchedCount = matchedCount
@@ -2656,7 +2674,7 @@ export class ToolsInteract {
2656
2674
  if (conditionMet && snapshotFresh) {
2657
2675
  if (matchedAt === null) matchedAt = now
2658
2676
  stableMatchCount++
2659
- if (stableMatchCount >= stableObservationCount) {
2677
+ if (stableMatchCount >= stableObservationCount && (!readinessTracker || readinessTracker.summary.state === 'settled')) {
2660
2678
  const latency_ms = now - overallStart
2661
2679
  const outEl = lastMatchedElement
2662
2680
 
@@ -2680,20 +2698,23 @@ export class ToolsInteract {
2680
2698
  matchedAt = null
2681
2699
  }
2682
2700
 
2683
- } catch (e) {
2701
+ } catch (e) { checkDebuggerGuard();
2702
+ // Deadline expiry does not invalidate the last successfully observed state.
2684
2703
  if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
2704
+ if (readinessTracker && !String(e).includes('READINESS_')) readinessTracker.unavailable()
2705
+ if (readinessTracker && isDeviceUnavailable(e)) throw e
2685
2706
  // Non-fatal per-poll error; record and continue
2686
- console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e))
2707
+ if (!readinessTracker) console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e))
2687
2708
  }
2688
2709
 
2689
2710
  if (singleAttempt) break
2690
2711
  // Sleep until next poll
2691
- await new Promise(r => setTimeout(r, effectivePoll || 50))
2712
+ await (readinessTracker ? readinessDelay(effectivePoll || 50) : new Promise(r => setTimeout(r, effectivePoll || 50)))
2692
2713
  }
2693
2714
 
2694
2715
  // Attempt timed out; if more attempts allowed, backoff then retry
2695
2716
  if (attempts < maxAttempts) {
2696
- if (backoff > 0) await new Promise(r => setTimeout(r, backoff))
2717
+ if (backoff > 0) await (readinessTracker ? readinessDelay(backoff) : new Promise(r => setTimeout(r, backoff)))
2697
2718
  continue
2698
2719
  }
2699
2720
 
@@ -2710,24 +2731,25 @@ export class ToolsInteract {
2710
2731
  : ` observed ${lastMatchedCount} match(es)`
2711
2732
  return {
2712
2733
  status: 'timeout',
2713
- error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout;${matchNote}` },
2734
+ error: { code: readinessTracker ? 'TIMEOUT' : 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout;${matchNote}` },
2714
2735
  metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts },
2715
2736
  requested,
2716
2737
  observed
2717
2738
  }
2718
2739
  }
2719
2740
 
2720
- } catch (err) {
2721
- if (err instanceof Error && err.message === 'ACTION_TIMEOUT') throw err
2741
+ } catch (err) { checkDebuggerGuard();
2742
+ if (!readinessTracker && err instanceof Error && err.message === 'ACTION_TIMEOUT') throw err
2743
+ if (readinessTracker && isDeviceUnavailable(err)) throw err
2722
2744
  const elapsed = Date.now() - overallStart
2723
2745
  return {
2724
2746
  status: 'timeout',
2725
- error: { code: 'INTERNAL_ERROR', message: err instanceof Error ? err.message : String(err) },
2747
+ error: { code: readinessTracker && String(err).includes('ACTION_TIMEOUT') ? 'TIMEOUT' : 'INTERNAL_ERROR', message: readinessTracker ? 'Readiness predicate wait did not complete' : err instanceof Error ? err.message : String(err) },
2726
2748
  metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts },
2727
2749
  requested,
2728
2750
  observed: {
2729
2751
  matched_count: lastMatchedCount,
2730
- condition_satisfied: false,
2752
+ condition_satisfied: readinessTracker ? lastConditionSatisfied : false,
2731
2753
  selected_index: lastMatchedElement?.index ?? null,
2732
2754
  last_matched_element: lastMatchedElement
2733
2755
  }
@@ -2784,9 +2806,9 @@ export class ToolsInteract {
2784
2806
  }
2785
2807
  lastFingerprint = confirmFp
2786
2808
  continue
2787
- } catch (e) { console.error('Error confirming fingerprint:', e); continue }
2809
+ } catch (e) { checkDebuggerGuard(); console.error('Error confirming fingerprint:', e); continue }
2788
2810
  }
2789
- } catch (e) { console.error('Error getting screen fingerprint:', e) }
2811
+ } catch (e) { checkDebuggerGuard(); console.error('Error getting screen fingerprint:', e) }
2790
2812
 
2791
2813
  await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
2792
2814
  }
@@ -2821,6 +2843,7 @@ export class ToolsInteract {
2821
2843
  scope?: 'screen' | 'subtree',
2822
2844
  target?: string | null
2823
2845
  }): Promise<WaitForUIChangeResponse> {
2846
+ const readinessTracker = currentReadinessWait()
2824
2847
  const start = Date.now()
2825
2848
  const pollIntervalMs = 300
2826
2849
  const stabilityWindow = Math.max(0, typeof stability_window_ms === 'number' ? stability_window_ms : 300)
@@ -2844,7 +2867,10 @@ export class ToolsInteract {
2844
2867
 
2845
2868
  while (Date.now() - start < timeout_ms) {
2846
2869
  try {
2870
+ if (readinessTracker) remainingBudget()
2871
+ const acquisitionStarted = Date.now()
2847
2872
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
2873
+ observeReadinessWait(tree, acquisitionStarted)
2848
2874
  const scopedTree = ToolsInteract._resolveUiChangeScope(tree, scope, target)
2849
2875
  if (scopedTree.error) {
2850
2876
  lastScopeResolution = scopedTree.resolution
@@ -2892,7 +2918,7 @@ export class ToolsInteract {
2892
2918
  }
2893
2919
 
2894
2920
  const stableForMs = candidateSinceMs === null ? 0 : Date.now() - candidateSinceMs
2895
- if (stabilityWindow === 0 || stableForMs >= stabilityWindow) {
2921
+ if ((stabilityWindow === 0 || stableForMs >= stabilityWindow) && (!readinessTracker || readinessTracker.summary.state === 'settled')) {
2896
2922
  return {
2897
2923
  success: true,
2898
2924
  observed_change: candidateObservedChange ?? observedChange,
@@ -2915,11 +2941,14 @@ export class ToolsInteract {
2915
2941
  candidateSinceMs = null
2916
2942
  }
2917
2943
  }
2918
- } catch {
2919
- // Keep polling until timeout; the observable surface should be best-effort.
2944
+ } catch (error) { checkDebuggerGuard();
2945
+ if (readinessTracker && !String(error).includes('READINESS_')) readinessTracker.unavailable()
2946
+ if (readinessTracker && isDeviceUnavailable(error)) throw error
2947
+ if (readinessTracker && String(error).includes('ACTION_TIMEOUT')) { readinessTracker.deadline(); break }
2948
+ // Default waits retain best-effort polling.
2920
2949
  }
2921
2950
 
2922
- await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
2951
+ await (readinessTracker ? readinessDelay(pollIntervalMs) : new Promise(resolve => setTimeout(resolve, pollIntervalMs)))
2923
2952
  }
2924
2953
 
2925
2954
  return {
@@ -3505,7 +3534,7 @@ export class ToolsInteract {
3505
3534
  const msgs = ToolsInteract._logsToMessages(logsArr)
3506
3535
  baselineLastLine = msgs.length ? msgs[msgs.length - 1] : null
3507
3536
  }
3508
- } catch (err) {
3537
+ } catch (err) { checkDebuggerGuard();
3509
3538
  try { console.warn('waitForUI: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
3510
3539
  }
3511
3540
 
@@ -3555,8 +3584,8 @@ export class ToolsInteract {
3555
3584
  stableDuration = 0
3556
3585
  lastObservedState = false
3557
3586
  }
3558
- } catch (err) { console.error('waitForUI(ui) find error:', err) }
3559
- } catch (err) { console.error('waitForUI(ui) outer error:', err) }
3587
+ } catch (err) { checkDebuggerGuard(); console.error('waitForUI(ui) find error:', err) }
3588
+ } catch (err) { checkDebuggerGuard(); console.error('waitForUI(ui) outer error:', err) }
3560
3589
  } else if (type === 'log') {
3561
3590
  try {
3562
3591
  // Logs: presence semantics only (match 'present'). Stability not applicable (immediate)
@@ -3586,7 +3615,7 @@ export class ToolsInteract {
3586
3615
  return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true }
3587
3616
  }
3588
3617
  }
3589
- } catch (err) { console.error('waitForUI(log) error:', err) }
3618
+ } catch (err) { checkDebuggerGuard(); console.error('waitForUI(log) error:', err) }
3590
3619
  } else if (type === 'screen') {
3591
3620
  try {
3592
3621
  const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
@@ -3608,7 +3637,7 @@ export class ToolsInteract {
3608
3637
  lastObservedState = false
3609
3638
  }
3610
3639
  }
3611
- } catch (err) { console.error('waitForUI(screen) error:', err) }
3640
+ } catch (err) { checkDebuggerGuard(); console.error('waitForUI(screen) error:', err) }
3612
3641
  } else if (type === 'idle') {
3613
3642
  try {
3614
3643
  const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
@@ -3627,7 +3656,7 @@ export class ToolsInteract {
3627
3656
  return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: idleMs, matchSource: 'idle-stable', timestamp: now2, type: 'idle', observed_state: lastObservedState ?? null }
3628
3657
  }
3629
3658
  }
3630
- } catch (err) { console.error('waitForUI(idle) error:', err) }
3659
+ } catch (err) { checkDebuggerGuard(); console.error('waitForUI(idle) error:', err) }
3631
3660
  }
3632
3661
 
3633
3662
  // Respect poll interval and avoid tight loop
@@ -3643,7 +3672,7 @@ export class ToolsInteract {
3643
3672
  // time ensures the latest exported ToolsObserve object is used.
3644
3673
  const Obs = await import('../observe/index.js')
3645
3674
  snapshot = await (Obs as any).ToolsObserve.captureDebugSnapshotHandler({ reason: `wait_for_ui timeout for ${type}`, includeLogs: true, platform, deviceId })
3646
- } catch (err) {
3675
+ } catch (err) { checkDebuggerGuard();
3647
3676
  snapshot = { error: err instanceof Error ? err.message : String(err) }
3648
3677
  }
3649
3678
  }
@@ -1,3 +1,5 @@
1
+ import { evidenceDispatch } from '../observe/evidence.js'
2
+ import { beforeInputDispatch } from './conditional-action.js'
1
3
  import { remainingBudget, markDispatchStarted } from '../utils/operation-budget.js'
2
4
  import { spawn } from "child_process"
3
5
  import { TapResponse, SwipeResponse } from "../types.js"
@@ -31,9 +33,11 @@ export class iOSInteract {
31
33
  args.push('--udid', targetUdid);
32
34
  }
33
35
 
34
- await new Promise<void>((resolve, reject) => {
35
- markDispatchStarted()
36
- const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
36
+ await beforeInputDispatch('ios', device.id)
37
+ const timeout = remainingBudget() || undefined
38
+ markDispatchStarted()
39
+ await evidenceDispatch({ platform: 'ios', id: device.id }, () => new Promise<void>((resolve, reject) => {
40
+ const proc = spawn(getIdbCmd(), args, { timeout });
37
41
  let stderr = '';
38
42
  proc.stderr.on('data', d => stderr += d.toString());
39
43
  proc.on('close', code => {
@@ -41,7 +45,7 @@ export class iOSInteract {
41
45
  else reject(new Error(`idb ui tap failed: ${stderr}`));
42
46
  });
43
47
  proc.on('error', err => reject(err));
44
- });
48
+ }));
45
49
 
46
50
  return { device, success: true, x, y };
47
51
  } catch (e) {
@@ -73,9 +77,11 @@ export class iOSInteract {
73
77
  args.push('--udid', targetUdid);
74
78
  }
75
79
 
76
- await new Promise<void>((resolve, reject) => {
77
- markDispatchStarted()
78
- const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
80
+ await beforeInputDispatch('ios', device.id)
81
+ const timeout = remainingBudget() || undefined
82
+ markDispatchStarted()
83
+ await evidenceDispatch({ platform: 'ios', id: device.id }, () => new Promise<void>((resolve, reject) => {
84
+ const proc = spawn(getIdbCmd(), args, { timeout });
79
85
  let stderr = '';
80
86
  proc.stderr.on('data', d => stderr += d.toString());
81
87
  proc.on('close', code => {
@@ -83,7 +89,7 @@ export class iOSInteract {
83
89
  else reject(new Error(`idb ui swipe failed: ${stderr}`));
84
90
  });
85
91
  proc.on('error', err => reject(err));
86
- });
92
+ }));
87
93
 
88
94
  return { device, success: true, start: [x1, y1], end: [x2, y2], duration };
89
95
  } catch (e) {
@@ -1,3 +1,4 @@
1
+ import { evidenceDispatch } from '../observe/evidence.js'
1
2
  import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js'
2
3
  import { promises as fs } from 'fs'
3
4
  import { spawn } from 'child_process'
@@ -156,7 +157,7 @@ export class AndroidManage {
156
157
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
157
158
  try {
158
159
  markDispatchStarted()
159
- const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs })
160
+ const output = await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs }))
160
161
  const current = await new AndroidObserve().getCurrentScreen(deviceId).catch(() => null)
161
162
  return {
162
163
  device: deviceInfo,
@@ -182,7 +183,7 @@ export class AndroidManage {
182
183
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
183
184
  try {
184
185
  markDispatchStarted()
185
- await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
186
+ await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'am', 'force-stop', appId], deviceId))
186
187
  return { device: deviceInfo, appTerminated: true }
187
188
  } catch (e: unknown) {
188
189
  const diagnostics = hasFiniteBudget() ? undefined : execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId)
@@ -215,7 +216,7 @@ export class AndroidManage {
215
216
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
216
217
  try {
217
218
  markDispatchStarted()
218
- const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
219
+ const output = await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'pm', 'clear', appId], deviceId))
219
220
  return { device: deviceInfo, dataCleared: output === 'Success' }
220
221
  } catch (e: unknown) {
221
222
  const diag = execAdbWithDiagnostics(['shell', 'pm', 'clear', appId], deviceId)
package/src/manage/ios.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { evidenceDispatch } from '../observe/evidence.js'
1
2
  import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js'
2
3
  import { promises as fs } from "fs"
3
4
  import { spawn, spawnSync } from "child_process"
@@ -316,7 +317,7 @@ export class iOSManage {
316
317
  validateBundleId(bundleId)
317
318
  try {
318
319
  markDispatchStarted()
319
- const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
320
+ const result = await evidenceDispatch({ platform: 'ios', id: deviceId }, () => execCommand(['simctl', 'launch', deviceId, bundleId], deviceId))
320
321
  const device = await getIOSDeviceMetadata(deviceId)
321
322
  const fingerprint = observe ? await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null) : null
322
323
  const pidMatch = result.output.match(/:\s*(\d+)\s*$/)
@@ -343,7 +344,7 @@ export class iOSManage {
343
344
  validateBundleId(bundleId)
344
345
  try {
345
346
  markDispatchStarted()
346
- await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
347
+ await evidenceDispatch({ platform: 'ios', id: deviceId }, () => execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId))
347
348
  const device = await getIOSDeviceMetadata(deviceId)
348
349
  return { device, appTerminated: true }
349
350
  } catch (e: unknown) {
@@ -1,3 +1,4 @@
1
+ import { captureEvidence } from './evidence.js'
1
2
  import { spawn } from "child_process"
2
3
  import { XMLParser } from "fast-xml-parser"
3
4
  import { GetLogsResponse, CaptureAndroidScreenResponse, GetUITreeResponse, GetCurrentScreenResponse, UIElement, DeviceInfo } from "../types.js"
@@ -11,14 +12,20 @@ import { deriveSnapshotMetadata } from "./snapshot-metadata.js"
11
12
  import { remainingBudget } from '../utils/operation-budget.js'
12
13
  import { createObservationMeasurement } from './performance-metrics.js'
13
14
 
14
- const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
15
+ const activeLogStreams: Map<string, { proc: any, file: string, device?: { platform: 'android' | 'ios'; id: string } }> = new Map()
15
16
 
16
17
  export class AndroidObserve {
18
+ getLogStreamDevice(sessionId: string = 'default') { return activeLogStreams.get(sessionId)?.device ?? null }
19
+
17
20
  async getDeviceMetadata(appId: string, deviceId?: string): Promise<DeviceInfo> {
18
21
  return getAndroidDeviceMetadata(appId, deviceId);
19
22
  }
20
23
 
21
24
  async getUITree(deviceId?: string): Promise<GetUITreeResponse> {
25
+ return captureEvidence('ui_tree', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetUITree(deviceId))
26
+ }
27
+
28
+ private async collectGetUITree(deviceId?: string): Promise<GetUITreeResponse> {
22
29
  const measurement = createObservationMeasurement('android')
23
30
  let metadata
24
31
  try {
@@ -122,6 +129,10 @@ export class AndroidObserve {
122
129
  }
123
130
 
124
131
  async getLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
132
+ return captureEvidence('logs', { platform: 'android', id: filters.deviceId ?? '' }, () => this.collectGetLogs(filters))
133
+ }
134
+
135
+ private async collectGetLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
125
136
  const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters
126
137
  const metadata = await getAndroidDeviceMetadata(appId || "", deviceId)
127
138
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
@@ -221,6 +232,10 @@ export class AndroidObserve {
221
232
  }
222
233
 
223
234
  async captureScreen(deviceId?: string): Promise<CaptureAndroidScreenResponse> {
235
+ return captureEvidence('screenshot', { platform: 'android', id: deviceId ?? '' }, () => this.collectCaptureScreen(deviceId))
236
+ }
237
+
238
+ private async collectCaptureScreen(deviceId?: string): Promise<CaptureAndroidScreenResponse> {
224
239
  const metadata = await getAndroidDeviceMetadata("", deviceId)
225
240
  const deviceInfo: DeviceInfo = getDeviceInfo(deviceId || 'default', metadata)
226
241
 
@@ -349,6 +364,10 @@ export class AndroidObserve {
349
364
  }
350
365
 
351
366
  async getCurrentScreen(deviceId?: string): Promise<GetCurrentScreenResponse> {
367
+ return captureEvidence('activity', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetCurrentScreen(deviceId))
368
+ }
369
+
370
+ private async collectGetCurrentScreen(deviceId?: string): Promise<GetCurrentScreenResponse> {
352
371
  const metadata = await getAndroidDeviceMetadata("", deviceId)
353
372
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
354
373
 
@@ -410,13 +429,17 @@ export class AndroidObserve {
410
429
  }
411
430
  }
412
431
 
413
- async getScreenFingerprint(deviceId?: string): Promise<{ fingerprint: string | null; activity?: string; error?: string }> {
432
+ async getScreenFingerprint(deviceId?: string): Promise<{ fingerprint: string | null; activity?: string; error?: string; captured_at_ms?: number }> {
433
+ return captureEvidence('fingerprint', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetScreenFingerprint(deviceId))
434
+ }
435
+
436
+ private async collectGetScreenFingerprint(deviceId?: string): Promise<{ fingerprint: string | null; activity?: string; error?: string; captured_at_ms?: number }> {
414
437
  try {
415
438
  const tree = await this.getUITree(deviceId)
416
439
  if (!tree || (tree as any).error) return { fingerprint: null, error: (tree as any).error }
417
440
 
418
441
  const current = await this.getCurrentScreen(deviceId).catch(() => null)
419
- return computeScreenFingerprint(tree, current, 'android', 50)
442
+ return { ...computeScreenFingerprint(tree, current, 'android', 50), captured_at_ms: tree.captured_at_ms }
420
443
  } catch (e) {
421
444
  return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
422
445
  }
@@ -436,7 +459,7 @@ export class AndroidObserve {
436
459
  activeLogStreams.delete(sessionId)
437
460
  }
438
461
 
439
- const args = ['logcat', `--pid=${pid}`, filter]
462
+ const args = [...(deviceId ? ['-s', deviceId] : []), 'logcat', `--pid=${pid}`, filter]
440
463
  const proc = spawn(getAdbCmd(), args)
441
464
 
442
465
  const tmpDir = process.env.TMPDIR || '/tmp'
@@ -463,10 +486,10 @@ export class AndroidObserve {
463
486
 
464
487
  proc.on('close', () => {
465
488
  stream.end()
466
- activeLogStreams.delete(sessionId)
489
+ if (activeLogStreams.get(sessionId)?.proc === proc) activeLogStreams.delete(sessionId)
467
490
  })
468
491
 
469
- activeLogStreams.set(sessionId, { proc, file })
492
+ activeLogStreams.set(sessionId, { proc, file, device: { platform: 'android', id: deviceId ?? '' } })
470
493
  return { success: true, stream_started: true }
471
494
  } catch (e) {
472
495
  return { success: false, error: e instanceof Error ? e.message : String(e) }
@@ -482,6 +505,10 @@ export class AndroidObserve {
482
505
  }
483
506
 
484
507
  async readLogStream(sessionId: string = 'default', limit: number = 100, since?: string) {
508
+ return captureEvidence('logs', this.getLogStreamDevice(sessionId), () => this.collectReadLogStream(sessionId, limit, since))
509
+ }
510
+
511
+ private async collectReadLogStream(sessionId: string = 'default', limit: number = 100, since?: string) {
485
512
  const entry = activeLogStreams.get(sessionId)
486
513
  let file: string | undefined
487
514
  if (entry && entry.file) file = entry.file