mobile-debug-mcp 0.32.2 → 0.33.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 (127) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/{skills → .agents/skills}/mcp-builder/SKILL.md +1 -2
  3. package/{skills → .agents/skills}/spec-review/SKILL.md +1 -1
  4. package/{skills → .agents/skills}/test-authoring/SKILL.md +1 -1
  5. package/.claude-plugin/marketplace.json +14 -0
  6. package/.cursor-plugin/marketplace.json +16 -0
  7. package/.github/copilot-instructions.md +7 -0
  8. package/AGENTS.md +21 -10
  9. package/README.md +67 -5
  10. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  11. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  12. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  13. package/agent-plugin/.mcp.json +14 -0
  14. package/agent-plugin/LICENSE +201 -0
  15. package/agent-plugin/README.md +29 -0
  16. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  17. package/agent-plugin/mcp.json +11 -0
  18. package/agent-plugin/plugin.json +29 -0
  19. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +124 -0
  20. package/dist/interact/android.js +10 -4
  21. package/dist/interact/conditional-action.js +68 -0
  22. package/dist/interact/index.js +54 -14
  23. package/dist/interact/ios.js +14 -8
  24. package/dist/manage/android.js +4 -3
  25. package/dist/manage/ios.js +3 -2
  26. package/dist/observe/android.js +27 -22
  27. package/dist/observe/evidence.js +273 -0
  28. package/dist/observe/index.js +33 -6
  29. package/dist/observe/ios.js +25 -6
  30. package/dist/observe/readiness-service.js +115 -0
  31. package/dist/observe/readiness.js +81 -0
  32. package/dist/observe/recording.js +165 -0
  33. package/dist/observe/snapshot-metadata.js +10 -2
  34. package/dist/server/common.js +9 -0
  35. package/dist/server/condition-schema.js +32 -0
  36. package/dist/server/journey-schema.js +3 -2
  37. package/dist/server/readiness-schema.js +12 -0
  38. package/dist/server/tool-definitions.js +46 -0
  39. package/dist/server/tool-handlers.js +99 -17
  40. package/dist/server-core.js +1 -1
  41. package/dist/utils/ios/utils.js +4 -4
  42. package/dist/utils/operation-budget.js +24 -2
  43. package/dist/utils/resolve-device.js +7 -0
  44. package/docs/CHANGELOG.md +16 -2
  45. package/docs/benchmarks/ui-tree-performance/README.md +4 -3
  46. package/docs/specs/020-conditional-actions/intent.md +62 -0
  47. package/docs/specs/020-conditional-actions/plan.md +214 -0
  48. package/docs/specs/020-conditional-actions/spec.md +301 -0
  49. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  50. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  51. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  52. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  53. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  54. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  55. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  56. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  57. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  58. package/docs/specs/process.md +213 -0
  59. package/docs/tools/TOOLS.md +3 -0
  60. package/docs/tools/evidence.md +79 -0
  61. package/docs/tools/interact.md +125 -2
  62. package/docs/tools/observe.md +64 -2
  63. package/package.json +4 -2
  64. package/scripts/agent-plugin.mjs +98 -0
  65. package/src/interact/android.ts +10 -4
  66. package/src/interact/conditional-action.ts +75 -0
  67. package/src/interact/index.ts +42 -14
  68. package/src/interact/ios.ts +14 -8
  69. package/src/manage/android.ts +4 -3
  70. package/src/manage/ios.ts +3 -2
  71. package/src/observe/android.ts +35 -21
  72. package/src/observe/evidence.ts +255 -0
  73. package/src/observe/index.ts +32 -8
  74. package/src/observe/ios.ts +32 -8
  75. package/src/observe/readiness-service.ts +89 -0
  76. package/src/observe/readiness.ts +78 -0
  77. package/src/observe/snapshot-metadata.ts +12 -1
  78. package/src/server/common.ts +7 -0
  79. package/src/server/condition-schema.ts +32 -0
  80. package/src/server/journey-schema.ts +3 -2
  81. package/src/server/readiness-schema.ts +12 -0
  82. package/src/server/tool-definitions.ts +44 -0
  83. package/src/server/tool-handlers.ts +93 -17
  84. package/src/server-core.ts +1 -1
  85. package/src/types.ts +30 -1
  86. package/src/utils/ios/utils.ts +4 -4
  87. package/src/utils/operation-budget.ts +22 -2
  88. package/src/utils/resolve-device.ts +8 -0
  89. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  90. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  91. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  92. package/test/device/manual/interact/readiness.manual.ts +99 -0
  93. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  94. package/test/fixtures/readiness-app/README.md +12 -0
  95. package/test/fixtures/readiness-app/build.ts +37 -0
  96. package/test/fixtures/readiness-app/main.m +27 -0
  97. package/test/unit/observe/android_tree_acquisition.test.ts +6 -0
  98. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  99. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  100. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  101. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  102. package/test/unit/observe/ios-getlogs.test.ts +3 -3
  103. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  104. package/test/unit/observe/readiness.test.ts +39 -0
  105. package/test/unit/observe/readiness_native.test.ts +68 -0
  106. package/test/unit/server/conditional_actions.test.ts +299 -0
  107. package/test/unit/server/conditional_schema.test.ts +59 -0
  108. package/test/unit/server/correlated-evidence.test.ts +194 -0
  109. package/test/unit/server/readiness.test.ts +170 -0
  110. package/test/unit/server/selector_tap_target.test.ts +79 -0
  111. package/test/unit/server/tap_selector.test.ts +2 -0
  112. package/skills/mobile-debug-tools-usage/SKILL.md +0 -74
  113. package/src/observe/android-tree-bridge.ts +0 -178
  114. package/test/device/automated/ui-tree-performance/bridge-probe.ts +0 -197
  115. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +0 -6
  116. package/test/fixtures/ui-tree-bridge/build.ts +0 -34
  117. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +0 -126
  118. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +0 -95
  119. /package/{skills → .agents/skills}/README.md +0 -0
  120. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  121. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  122. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  123. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  124. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  125. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  126. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  127. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -1,3 +1,5 @@
1
+ import { evidenceDispatch } from '../observe/evidence.js'
2
+ import { beforeInputDispatch } from './conditional-action.js'
1
3
  import { markDispatchStarted } from '../utils/operation-budget.js'
2
4
  import { TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
3
5
  import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
@@ -13,8 +15,9 @@ export class AndroidInteract {
13
15
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
14
16
 
15
17
  try {
18
+ await beforeInputDispatch('android', deviceInfo.id)
16
19
  markDispatchStarted()
17
- await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs })
20
+ await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs }))
18
21
  return { device: deviceInfo, success: true, x, y }
19
22
  } catch (e) {
20
23
  return { device: deviceInfo, success: false, x, y, error: e instanceof Error ? e.message : String(e) }
@@ -26,8 +29,9 @@ export class AndroidInteract {
26
29
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
27
30
 
28
31
  try {
32
+ await beforeInputDispatch('android', deviceInfo.id)
29
33
  markDispatchStarted()
30
- await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs })
34
+ await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs }))
31
35
  return { device: deviceInfo, success: true, start: [x1, y1], end: [x2, y2], duration }
32
36
  } catch (e) {
33
37
  return { device: deviceInfo, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) }
@@ -42,8 +46,9 @@ export class AndroidInteract {
42
46
  // Encode spaces as %s to ensure proper input handling by adb shell input text
43
47
  const encodedText = text.replace(/\s/g, '%s')
44
48
  // Note: 'input text' might fail with some characters or if keyboard isn't ready, but it's the standard ADB way.
49
+ await beforeInputDispatch('android', deviceInfo.id)
45
50
  markDispatchStarted()
46
- await execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs })
51
+ await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs }))
47
52
  return { device: deviceInfo, success: true, text }
48
53
  } catch (e) {
49
54
  return { device: deviceInfo, success: false, text, error: e instanceof Error ? e.message : String(e) }
@@ -55,8 +60,9 @@ export class AndroidInteract {
55
60
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
56
61
 
57
62
  try {
63
+ await beforeInputDispatch('android', deviceInfo.id)
58
64
  markDispatchStarted()
59
- await execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs })
65
+ await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs }))
60
66
  return { device: deviceInfo, success: true }
61
67
  } catch (e) {
62
68
  return { device: deviceInfo, success: false, error: e instanceof Error ? e.message : String(e) }
@@ -0,0 +1,75 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import type { ActionConditionResult } from '../types.js'
3
+ import type { ExpectedState } from '../server/condition-schema.js'
4
+ import { ToolsObserve } from '../observe/index.js'
5
+ import { getSnapshotSessionId, hasSnapshotBaseline } from '../observe/snapshot-metadata.js'
6
+ import { runWithSeparateBudget } from '../utils/operation-budget.js'
7
+ import { ToolsNetwork } from '../network/index.js'
8
+
9
+ export interface ConditionalAction {
10
+ platform: 'android' | 'ios'
11
+ deviceId: string
12
+ expected: ExpectedState
13
+ result: ActionConditionResult
14
+ }
15
+
16
+ const context = new AsyncLocalStorage<ConditionalAction>()
17
+
18
+ export function createConditionalAction(platform: 'android' | 'ios', deviceId: string, expected: ExpectedState): ConditionalAction {
19
+ return { platform, deviceId, expected, result: {
20
+ status: 'not_checked',
21
+ expected: { snapshot_session_id: expected.snapshotSessionId, snapshot_revision: expected.snapshotRevision },
22
+ observed: null, elapsed_ms: 0, reason: 'ACTION_NOT_REACHED'
23
+ } }
24
+ }
25
+
26
+ export function withConditionalAction<T>(condition: ConditionalAction, operation: () => Promise<T>) {
27
+ return context.run(condition, operation)
28
+ }
29
+
30
+ export function isConditionBlocked() {
31
+ const status = context.getStore()?.result.status
32
+ return status === 'mismatch' || status === 'unavailable'
33
+ }
34
+
35
+ // Guarded calls reset the observation window only when input is about to spawn.
36
+ export function notifyActionStart() {
37
+ if (!context.getStore()) ToolsNetwork.notifyActionStart()
38
+ }
39
+
40
+ export function conditionFailureCode(condition: ConditionalAction) {
41
+ return condition.result.status === 'mismatch' ? 'PRECONDITION_FAILED' as const : 'PRECONDITION_UNAVAILABLE' as const
42
+ }
43
+
44
+ export async function beforeInputDispatch(platform: 'android' | 'ios', deviceId: string) {
45
+ const condition = context.getStore()
46
+ if (!condition) return
47
+ const started = performance.now()
48
+ const result = condition.result
49
+ result.status = 'unavailable'
50
+ delete result.reason
51
+ try {
52
+ if (platform !== condition.platform || deviceId !== condition.deviceId) result.reason = 'DEVICE_MISMATCH'
53
+ else if (condition.expected.snapshotSessionId !== getSnapshotSessionId()) result.reason = 'SESSION_MISMATCH'
54
+ else if (!hasSnapshotBaseline(`${platform}:${deviceId}`, condition.expected.snapshotRevision)) result.reason = 'BASELINE_UNAVAILABLE'
55
+ else {
56
+ const tree = await runWithSeparateBudget(condition.expected.timeoutMs ?? 15000, () =>
57
+ ToolsObserve.getUITreeHandler({ platform, deviceId })) as any
58
+ if (tree?.error) result.reason = 'OBSERVATION_FAILED'
59
+ else if (!tree || !Array.isArray(tree.elements) || !Number.isSafeInteger(tree.snapshot_revision) || tree.snapshot_revision < 1 ||
60
+ tree.snapshot_session_id !== getSnapshotSessionId() || !Number.isFinite(tree.captured_at_ms) || tree.captured_at_ms <= 0 ||
61
+ !tree.resolution || !Number.isFinite(tree.resolution.width) || !Number.isFinite(tree.resolution.height) ||
62
+ !(tree.resolution.width > 0) || !(tree.resolution.height > 0)) result.reason = 'OBSERVATION_INVALID'
63
+ else if (tree.device?.platform !== platform || tree.device?.id !== deviceId) result.reason = 'DEVICE_MISMATCH'
64
+ else {
65
+ result.observed = tree
66
+ result.status = tree.snapshot_revision === condition.expected.snapshotRevision ? 'matched' : 'mismatch'
67
+ }
68
+ }
69
+ } catch (error) {
70
+ result.reason = /TIMEOUT|timed out/i.test(String(error)) ? 'CONDITION_TIMEOUT' : 'OBSERVATION_FAILED'
71
+ }
72
+ result.elapsed_ms = performance.now() - started
73
+ if (result.status !== 'matched') throw new Error(conditionFailureCode(condition))
74
+ ToolsNetwork.notifyActionStart()
75
+ }
@@ -1,3 +1,6 @@
1
+ import { currentReadinessWait, observeReadinessWait, isDeviceUnavailable } from '../observe/readiness.js'
2
+ import { readinessDelay } from '../observe/readiness-service.js'
3
+ import { isConditionBlocked } from './conditional-action.js'
1
4
  import { remainingBudget } from '../utils/operation-budget.js'
2
5
  import { createHash } from 'crypto'
3
6
  import { AndroidInteract } from './android.js';
@@ -605,6 +608,7 @@ export class ToolsInteract {
605
608
  }
606
609
 
607
610
  private static async _captureFingerprint(platform: 'android' | 'ios', deviceId?: string): Promise<string | null> {
611
+ if (isConditionBlocked()) return null
608
612
  try {
609
613
  const fingerprint = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
610
614
  return fingerprint?.fingerprint ?? null
@@ -2491,7 +2495,8 @@ export class ToolsInteract {
2491
2495
  return { found: true, element: outEl, score: scoreVal, confidence: scoreVal, resolution }
2492
2496
  }
2493
2497
 
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 }) {
2498
+ 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 }) {
2499
+ const readinessTracker = currentReadinessWait()
2495
2500
  const overallStart = Date.now()
2496
2501
  const requestedIndex = typeof match?.index === 'number' ? match.index : null
2497
2502
  const requested = {
@@ -2559,7 +2564,11 @@ export class ToolsInteract {
2559
2564
  remainingBudget()
2560
2565
  totalPollCount++
2561
2566
  try {
2567
+ const acquisitionStarted = Date.now()
2562
2568
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
2569
+ observeReadinessWait(tree, acquisitionStarted)
2570
+ // A failed acquisition cannot establish that the requested tap target is absent.
2571
+ if (resolveTapTarget && tree?.error && /ACTION_TIMEOUT|timed out/i.test(String(tree.error))) throw new Error('ACTION_TIMEOUT')
2563
2572
  const elements = (tree && Array.isArray(tree.elements)) ? tree.elements as any[] : []
2564
2573
 
2565
2574
  const matches: { el: any, idx: number }[] = []
@@ -2633,7 +2642,8 @@ export class ToolsInteract {
2633
2642
  conditionMet = visibleFlag
2634
2643
  } else conditionMet = false
2635
2644
  } else if (condition === 'clickable') {
2636
- matchedElement = chosen ? (ToolsInteract._resolveActionableAncestor(elements, chosen as { el: UiElement, idx: number }) || chosen) : null
2645
+ matchedElement = chosen ? (resolveTapTarget && (!ToolsInteract._isVisibleElement(chosen.el) || chosen.el.enabled === false)
2646
+ ? chosen : ToolsInteract._resolveActionableAncestor(elements, chosen as { el: UiElement, idx: number }) || chosen) : null
2637
2647
  if (matchedElement) {
2638
2648
  const b = matchedElement.el.bounds
2639
2649
  const visibleFlag = !!matchedElement.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1])
@@ -2643,6 +2653,13 @@ export class ToolsInteract {
2643
2653
  } else conditionMet = false
2644
2654
  }
2645
2655
 
2656
+ // Waiting for a label to exist/be visible is distinct from resolving its
2657
+ // executable control. Reuse the clickable-wait ancestor policy without
2658
+ // promoting hidden or disabled labels into an unrelated enabled target.
2659
+ if (resolveTapTarget && conditionMet && matchedElement &&
2660
+ ToolsInteract._isVisibleElement(matchedElement.el) && matchedElement.el.enabled !== false) {
2661
+ matchedElement = ToolsInteract._resolveActionableAncestor(elements, matchedElement) ?? matchedElement
2662
+ }
2646
2663
  const resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
2647
2664
  const resolvedDeviceId = tree?.device?.id || deviceId
2648
2665
  lastMatchedCount = matchedCount
@@ -2656,7 +2673,7 @@ export class ToolsInteract {
2656
2673
  if (conditionMet && snapshotFresh) {
2657
2674
  if (matchedAt === null) matchedAt = now
2658
2675
  stableMatchCount++
2659
- if (stableMatchCount >= stableObservationCount) {
2676
+ if (stableMatchCount >= stableObservationCount && (!readinessTracker || readinessTracker.summary.state === 'settled')) {
2660
2677
  const latency_ms = now - overallStart
2661
2678
  const outEl = lastMatchedElement
2662
2679
 
@@ -2681,19 +2698,22 @@ export class ToolsInteract {
2681
2698
  }
2682
2699
 
2683
2700
  } catch (e) {
2701
+ // Deadline expiry does not invalidate the last successfully observed state.
2684
2702
  if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
2703
+ if (readinessTracker && !String(e).includes('READINESS_')) readinessTracker.unavailable()
2704
+ if (readinessTracker && isDeviceUnavailable(e)) throw e
2685
2705
  // Non-fatal per-poll error; record and continue
2686
- console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e))
2706
+ if (!readinessTracker) console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e))
2687
2707
  }
2688
2708
 
2689
2709
  if (singleAttempt) break
2690
2710
  // Sleep until next poll
2691
- await new Promise(r => setTimeout(r, effectivePoll || 50))
2711
+ await (readinessTracker ? readinessDelay(effectivePoll || 50) : new Promise(r => setTimeout(r, effectivePoll || 50)))
2692
2712
  }
2693
2713
 
2694
2714
  // Attempt timed out; if more attempts allowed, backoff then retry
2695
2715
  if (attempts < maxAttempts) {
2696
- if (backoff > 0) await new Promise(r => setTimeout(r, backoff))
2716
+ if (backoff > 0) await (readinessTracker ? readinessDelay(backoff) : new Promise(r => setTimeout(r, backoff)))
2697
2717
  continue
2698
2718
  }
2699
2719
 
@@ -2710,7 +2730,7 @@ export class ToolsInteract {
2710
2730
  : ` observed ${lastMatchedCount} match(es)`
2711
2731
  return {
2712
2732
  status: 'timeout',
2713
- error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout;${matchNote}` },
2733
+ error: { code: readinessTracker ? 'TIMEOUT' : 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout;${matchNote}` },
2714
2734
  metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts },
2715
2735
  requested,
2716
2736
  observed
@@ -2718,16 +2738,17 @@ export class ToolsInteract {
2718
2738
  }
2719
2739
 
2720
2740
  } catch (err) {
2721
- if (err instanceof Error && err.message === 'ACTION_TIMEOUT') throw err
2741
+ if (!readinessTracker && err instanceof Error && err.message === 'ACTION_TIMEOUT') throw err
2742
+ if (readinessTracker && isDeviceUnavailable(err)) throw err
2722
2743
  const elapsed = Date.now() - overallStart
2723
2744
  return {
2724
2745
  status: 'timeout',
2725
- error: { code: 'INTERNAL_ERROR', message: err instanceof Error ? err.message : String(err) },
2746
+ 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
2747
  metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts },
2727
2748
  requested,
2728
2749
  observed: {
2729
2750
  matched_count: lastMatchedCount,
2730
- condition_satisfied: false,
2751
+ condition_satisfied: readinessTracker ? lastConditionSatisfied : false,
2731
2752
  selected_index: lastMatchedElement?.index ?? null,
2732
2753
  last_matched_element: lastMatchedElement
2733
2754
  }
@@ -2821,6 +2842,7 @@ export class ToolsInteract {
2821
2842
  scope?: 'screen' | 'subtree',
2822
2843
  target?: string | null
2823
2844
  }): Promise<WaitForUIChangeResponse> {
2845
+ const readinessTracker = currentReadinessWait()
2824
2846
  const start = Date.now()
2825
2847
  const pollIntervalMs = 300
2826
2848
  const stabilityWindow = Math.max(0, typeof stability_window_ms === 'number' ? stability_window_ms : 300)
@@ -2844,7 +2866,10 @@ export class ToolsInteract {
2844
2866
 
2845
2867
  while (Date.now() - start < timeout_ms) {
2846
2868
  try {
2869
+ if (readinessTracker) remainingBudget()
2870
+ const acquisitionStarted = Date.now()
2847
2871
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
2872
+ observeReadinessWait(tree, acquisitionStarted)
2848
2873
  const scopedTree = ToolsInteract._resolveUiChangeScope(tree, scope, target)
2849
2874
  if (scopedTree.error) {
2850
2875
  lastScopeResolution = scopedTree.resolution
@@ -2892,7 +2917,7 @@ export class ToolsInteract {
2892
2917
  }
2893
2918
 
2894
2919
  const stableForMs = candidateSinceMs === null ? 0 : Date.now() - candidateSinceMs
2895
- if (stabilityWindow === 0 || stableForMs >= stabilityWindow) {
2920
+ if ((stabilityWindow === 0 || stableForMs >= stabilityWindow) && (!readinessTracker || readinessTracker.summary.state === 'settled')) {
2896
2921
  return {
2897
2922
  success: true,
2898
2923
  observed_change: candidateObservedChange ?? observedChange,
@@ -2915,11 +2940,14 @@ export class ToolsInteract {
2915
2940
  candidateSinceMs = null
2916
2941
  }
2917
2942
  }
2918
- } catch {
2919
- // Keep polling until timeout; the observable surface should be best-effort.
2943
+ } catch (error) {
2944
+ if (readinessTracker && !String(error).includes('READINESS_')) readinessTracker.unavailable()
2945
+ if (readinessTracker && isDeviceUnavailable(error)) throw error
2946
+ if (readinessTracker && String(error).includes('ACTION_TIMEOUT')) { readinessTracker.deadline(); break }
2947
+ // Default waits retain best-effort polling.
2920
2948
  }
2921
2949
 
2922
- await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
2950
+ await (readinessTracker ? readinessDelay(pollIntervalMs) : new Promise(resolve => setTimeout(resolve, pollIntervalMs)))
2923
2951
  }
2924
2952
 
2925
2953
  return {
@@ -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"
@@ -10,16 +11,21 @@ import { parsePngSize } from "../utils/image.js"
10
11
  import { deriveSnapshotMetadata } from "./snapshot-metadata.js"
11
12
  import { remainingBudget } from '../utils/operation-budget.js'
12
13
  import { createObservationMeasurement } from './performance-metrics.js'
13
- import { androidTreeBridgeEnabled, readAndroidTreeFromBridge } from './android-tree-bridge.js'
14
14
 
15
- 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()
16
16
 
17
17
  export class AndroidObserve {
18
+ getLogStreamDevice(sessionId: string = 'default') { return activeLogStreams.get(sessionId)?.device ?? null }
19
+
18
20
  async getDeviceMetadata(appId: string, deviceId?: string): Promise<DeviceInfo> {
19
21
  return getAndroidDeviceMetadata(appId, deviceId);
20
22
  }
21
23
 
22
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> {
23
29
  const measurement = createObservationMeasurement('android')
24
30
  let metadata
25
31
  try {
@@ -54,19 +60,7 @@ export class AndroidObserve {
54
60
  }
55
61
  remainingBudget()
56
62
 
57
- let bridgeContent: string | null = null
58
- if (androidTreeBridgeEnabled()) {
59
- try {
60
- bridgeContent = await measurement.physicalRead(async () => {
61
- const content = await readAndroidTreeFromBridge(deviceInfo.id)
62
- if (!content) throw new Error('Android UI tree bridge unavailable')
63
- return content
64
- })
65
- } catch (error) {
66
- if (error instanceof Error && (error.message === 'ACTION_TIMEOUT' || error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))) throw error
67
- }
68
- }
69
- xmlContent = bridgeContent ?? await measurement.physicalRead(async () => {
63
+ xmlContent = await measurement.physicalRead(async () => {
70
64
  await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId)
71
65
  const content = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId)
72
66
  if (!content || !content.trim() || content.includes('ERROR:')) throw new Error('Empty or invalid UI dump')
@@ -74,7 +68,7 @@ export class AndroidObserve {
74
68
  })
75
69
  break
76
70
  } catch (e) {
77
- if (e instanceof Error && (e.message === 'ACTION_TIMEOUT' || e.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))) throw e
71
+ if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
78
72
  console.error(`Attempt ${attempts} failed: ${e}`);
79
73
  }
80
74
 
@@ -135,6 +129,10 @@ export class AndroidObserve {
135
129
  }
136
130
 
137
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> {
138
136
  const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters
139
137
  const metadata = await getAndroidDeviceMetadata(appId || "", deviceId)
140
138
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
@@ -234,6 +232,10 @@ export class AndroidObserve {
234
232
  }
235
233
 
236
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> {
237
239
  const metadata = await getAndroidDeviceMetadata("", deviceId)
238
240
  const deviceInfo: DeviceInfo = getDeviceInfo(deviceId || 'default', metadata)
239
241
 
@@ -362,6 +364,10 @@ export class AndroidObserve {
362
364
  }
363
365
 
364
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> {
365
371
  const metadata = await getAndroidDeviceMetadata("", deviceId)
366
372
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
367
373
 
@@ -423,13 +429,17 @@ export class AndroidObserve {
423
429
  }
424
430
  }
425
431
 
426
- 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 }> {
427
437
  try {
428
438
  const tree = await this.getUITree(deviceId)
429
439
  if (!tree || (tree as any).error) return { fingerprint: null, error: (tree as any).error }
430
440
 
431
441
  const current = await this.getCurrentScreen(deviceId).catch(() => null)
432
- return computeScreenFingerprint(tree, current, 'android', 50)
442
+ return { ...computeScreenFingerprint(tree, current, 'android', 50), captured_at_ms: tree.captured_at_ms }
433
443
  } catch (e) {
434
444
  return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
435
445
  }
@@ -449,7 +459,7 @@ export class AndroidObserve {
449
459
  activeLogStreams.delete(sessionId)
450
460
  }
451
461
 
452
- const args = ['logcat', `--pid=${pid}`, filter]
462
+ const args = [...(deviceId ? ['-s', deviceId] : []), 'logcat', `--pid=${pid}`, filter]
453
463
  const proc = spawn(getAdbCmd(), args)
454
464
 
455
465
  const tmpDir = process.env.TMPDIR || '/tmp'
@@ -476,10 +486,10 @@ export class AndroidObserve {
476
486
 
477
487
  proc.on('close', () => {
478
488
  stream.end()
479
- activeLogStreams.delete(sessionId)
489
+ if (activeLogStreams.get(sessionId)?.proc === proc) activeLogStreams.delete(sessionId)
480
490
  })
481
491
 
482
- activeLogStreams.set(sessionId, { proc, file })
492
+ activeLogStreams.set(sessionId, { proc, file, device: { platform: 'android', id: deviceId ?? '' } })
483
493
  return { success: true, stream_started: true }
484
494
  } catch (e) {
485
495
  return { success: false, error: e instanceof Error ? e.message : String(e) }
@@ -495,6 +505,10 @@ export class AndroidObserve {
495
505
  }
496
506
 
497
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) {
498
512
  const entry = activeLogStreams.get(sessionId)
499
513
  let file: string | undefined
500
514
  if (entry && entry.file) file = entry.file