mobile-debug-mcp 0.31.0 → 0.32.1

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 (109) hide show
  1. package/README.md +1 -0
  2. package/dist/interact/android.js +13 -8
  3. package/dist/interact/index.js +33 -22
  4. package/dist/interact/ios.js +5 -2
  5. package/dist/manage/android.js +20 -10
  6. package/dist/manage/ios.js +19 -10
  7. package/dist/observe/android-tree-bridge.js +192 -0
  8. package/dist/observe/android.js +53 -14
  9. package/dist/observe/index.js +22 -5
  10. package/dist/observe/ios.js +50 -29
  11. package/dist/observe/performance-metrics.js +124 -0
  12. package/dist/observe/snapshot-metadata.js +94 -15
  13. package/dist/server/common.js +14 -7
  14. package/dist/server/journey-schema.js +60 -0
  15. package/dist/server/tool-definitions.js +59 -6
  16. package/dist/server/tool-handlers.js +381 -56
  17. package/dist/server-core.js +1 -1
  18. package/dist/utils/exec.js +18 -5
  19. package/dist/utils/ios/utils.js +5 -2
  20. package/dist/utils/operation-budget.js +25 -0
  21. package/dist/utils/resolve-device.js +12 -1
  22. package/dist/utils/timing.js +19 -0
  23. package/dist/utils/ui/index.js +2 -0
  24. package/docs/CHANGELOG.md +6 -0
  25. package/docs/ROADMAP.md +9 -0
  26. package/docs/benchmarks/interaction-latency/README.md +26 -0
  27. package/docs/benchmarks/interaction-latency/final.json +1810 -0
  28. package/docs/benchmarks/interaction-latency/final.md +17 -0
  29. package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
  30. package/docs/benchmarks/ui-tree-performance/README.md +51 -0
  31. package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
  32. package/docs/benchmarks/ui-tree-performance/bridge-probe.json +28 -0
  33. package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
  34. package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
  35. package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
  36. package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
  37. package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
  38. package/docs/specs/018-interaction-latency-and-batched-journeys/spec.md +301 -0
  39. package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
  40. package/docs/tools/interact.md +30 -0
  41. package/docs/tools/observe.md +1 -0
  42. package/package.json +1 -1
  43. package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
  44. package/src/interact/android.ts +13 -8
  45. package/src/interact/index.ts +32 -23
  46. package/src/interact/ios.ts +5 -2
  47. package/src/manage/android.ts +19 -10
  48. package/src/manage/ios.ts +18 -10
  49. package/src/observe/android-tree-bridge.ts +170 -0
  50. package/src/observe/android.ts +48 -17
  51. package/src/observe/index.ts +21 -5
  52. package/src/observe/ios.ts +49 -36
  53. package/src/observe/performance-metrics.ts +125 -0
  54. package/src/observe/snapshot-metadata.ts +94 -15
  55. package/src/server/common.ts +13 -6
  56. package/src/server/journey-schema.ts +63 -0
  57. package/src/server/tool-definitions.ts +60 -7
  58. package/src/server/tool-handlers.ts +349 -54
  59. package/src/server-core.ts +1 -1
  60. package/src/types.ts +31 -2
  61. package/src/utils/exec.ts +13 -5
  62. package/src/utils/ios/utils.ts +5 -2
  63. package/src/utils/operation-budget.ts +22 -0
  64. package/src/utils/resolve-device.ts +13 -1
  65. package/src/utils/timing.ts +15 -0
  66. package/src/utils/ui/index.ts +2 -0
  67. package/test/device/automated/latency/benchmark.ts +136 -0
  68. package/test/device/automated/latency/report.ts +6 -0
  69. package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
  70. package/test/device/automated/ui-tree-performance/bridge-probe.ts +183 -0
  71. package/test/fixtures/latency-journey-app/AndroidManifest.xml +7 -0
  72. package/test/fixtures/latency-journey-app/README.md +28 -0
  73. package/test/fixtures/latency-journey-app/build.ts +26 -0
  74. package/test/fixtures/latency-journey-app/src/MainActivity.java +104 -0
  75. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
  76. package/test/fixtures/ui-tree-bridge/build.ts +34 -0
  77. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +119 -0
  78. package/test/unit/manage/restart_budget.test.ts +37 -0
  79. package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
  80. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +76 -0
  81. package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
  82. package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
  83. package/test/unit/observe/performance_metrics.test.ts +40 -0
  84. package/test/unit/observe/snapshot_metadata.test.ts +20 -1
  85. package/test/unit/observe/state_delta.test.ts +15 -0
  86. package/test/unit/server/benchmark_report.test.ts +4 -0
  87. package/test/unit/server/contract.test.ts +1 -1
  88. package/test/unit/server/interaction_modes.test.ts +60 -0
  89. package/test/unit/server/operation_budget.test.ts +45 -0
  90. package/test/unit/server/response_shapes.test.ts +3 -3
  91. package/test/unit/server/run_journey.test.ts +120 -0
  92. package/test/unit/server/tap_selector.test.ts +68 -0
  93. /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
  94. /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
  95. /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
  96. /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
  97. /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
  98. /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
  99. /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
  100. /package/docs/{specs-deltas/008-adjustable-control-support-and-semantic-value-manipulation.md → specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md} +0 -0
  101. /package/docs/{specs-deltas/009-semantic-control-modeling-for-custom-and-composite-controls.md → specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md} +0 -0
  102. /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
  103. /package/docs/{specs-deltas/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md → specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md} +0 -0
  104. /package/docs/{specs-deltas/011.1-recovery-contract-types-and-runtime-wiring-spec.md → specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md} +0 -0
  105. /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
  106. /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
  107. /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
  108. /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
  109. /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
package/README.md CHANGED
@@ -1,4 +1,5 @@
1
1
  # Mobile Debug Tools
2
+ [![npm version](https://img.shields.io/npm/v/mobile-debug-mcp.svg)](https://www.npmjs.com/package/mobile-debug-mcp)
2
3
 
3
4
  A minimal, secure MCP server for AI-assisted mobile development. Build, install, interact and inspect Android/iOS apps from an MCP-compatible client.
4
5
 
@@ -1,49 +1,54 @@
1
+ import { markDispatchStarted } from '../utils/operation-budget.js';
1
2
  import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js";
2
3
  import { AndroidObserve } from "../observe/index.js";
3
4
  import { scrollToElementShared } from "../utils/ui/index.js";
4
5
  export class AndroidInteract {
5
6
  observe = new AndroidObserve();
6
- async tap(x, y, deviceId) {
7
+ async tap(x, y, deviceId, timeoutMs) {
7
8
  const metadata = await getAndroidDeviceMetadata("", deviceId);
8
9
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
9
10
  try {
10
- await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId);
11
+ markDispatchStarted();
12
+ await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs });
11
13
  return { device: deviceInfo, success: true, x, y };
12
14
  }
13
15
  catch (e) {
14
16
  return { device: deviceInfo, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
15
17
  }
16
18
  }
17
- async swipe(x1, y1, x2, y2, duration, deviceId) {
19
+ async swipe(x1, y1, x2, y2, duration, deviceId, timeoutMs) {
18
20
  const metadata = await getAndroidDeviceMetadata("", deviceId);
19
21
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
20
22
  try {
21
- await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId);
23
+ markDispatchStarted();
24
+ await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs });
22
25
  return { device: deviceInfo, success: true, start: [x1, y1], end: [x2, y2], duration };
23
26
  }
24
27
  catch (e) {
25
28
  return { device: deviceInfo, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) };
26
29
  }
27
30
  }
28
- async typeText(text, deviceId) {
31
+ async typeText(text, deviceId, timeoutMs) {
29
32
  const metadata = await getAndroidDeviceMetadata("", deviceId);
30
33
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
31
34
  try {
32
35
  // Encode spaces as %s to ensure proper input handling by adb shell input text
33
36
  const encodedText = text.replace(/\s/g, '%s');
34
37
  // Note: 'input text' might fail with some characters or if keyboard isn't ready, but it's the standard ADB way.
35
- await execAdb(['shell', 'input', 'text', encodedText], deviceId);
38
+ markDispatchStarted();
39
+ await execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs });
36
40
  return { device: deviceInfo, success: true, text };
37
41
  }
38
42
  catch (e) {
39
43
  return { device: deviceInfo, success: false, text, error: e instanceof Error ? e.message : String(e) };
40
44
  }
41
45
  }
42
- async pressBack(deviceId) {
46
+ async pressBack(deviceId, timeoutMs) {
43
47
  const metadata = await getAndroidDeviceMetadata("", deviceId);
44
48
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
45
49
  try {
46
- await execAdb(['shell', 'input', 'keyevent', '4'], deviceId);
50
+ markDispatchStarted();
51
+ await execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs });
47
52
  return { device: deviceInfo, success: true };
48
53
  }
49
54
  catch (e) {
@@ -1,3 +1,4 @@
1
+ import { remainingBudget } from '../utils/operation-budget.js';
1
2
  import { createHash } from 'crypto';
2
3
  import { AndroidInteract } from './android.js';
3
4
  import { iOSInteract } from './ios.js';
@@ -950,19 +951,19 @@ export class ToolsInteract {
950
951
  const interact = effectivePlatform === 'android' ? new AndroidInteract() : new iOSInteract();
951
952
  return { interact: interact, resolved, platform: effectivePlatform };
952
953
  }
953
- static async tapHandler({ platform, x, y, deviceId }) {
954
+ static async tapHandler({ platform, x, y, deviceId, timeoutMs }) {
954
955
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
955
- return await interact.tap(x, y, resolved.id);
956
+ return await interact.tap(x, y, resolved.id, timeoutMs);
956
957
  }
957
- static async tapElementHandler({ elementId }) {
958
+ static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }) {
958
959
  const actionType = 'tap_element';
959
960
  const selector = { elementId };
960
961
  const resolved = ToolsInteract._resolvedUiElements.get(elementId);
961
- if (!resolved) {
962
+ if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId)) {
962
963
  return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null);
963
964
  }
964
- const fingerprintBefore = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId);
965
- const tree = await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId });
965
+ const fingerprintBefore = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null;
966
+ const tree = freshTree ?? await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId });
966
967
  const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : resolved.platform;
967
968
  const treeDeviceId = tree?.device?.id || resolved.deviceId;
968
969
  const elements = Array.isArray(tree?.elements) ? tree.elements : [];
@@ -981,12 +982,12 @@ export class ToolsInteract {
981
982
  }
982
983
  const x = Math.floor((bounds[0] + bounds[2]) / 2);
983
984
  const y = Math.floor((bounds[1] + bounds[3]) / 2);
984
- const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId });
985
+ const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId, timeoutMs });
985
986
  if (!tapResult.success) {
986
- const fingerprintAfterFailure = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId);
987
- return ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure);
987
+ const fingerprintAfterFailure = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null;
988
+ return { ...ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure), error: tapResult.error };
988
989
  }
989
- const fingerprintAfter = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId);
990
+ const fingerprintAfter = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null;
990
991
  return buildActionExecutionResult({
991
992
  actionType,
992
993
  device: tree?.device,
@@ -1756,17 +1757,17 @@ export class ToolsInteract {
1756
1757
  const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
1757
1758
  return buildFailure('CONTROL_CONVERGENCE_FAILED', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter);
1758
1759
  }
1759
- static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }) {
1760
+ static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId, timeoutMs }) {
1760
1761
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
1761
- return await interact.swipe(x1, y1, x2, y2, duration, resolved.id);
1762
+ return await interact.swipe(x1, y1, x2, y2, duration, resolved.id, timeoutMs);
1762
1763
  }
1763
- static async typeTextHandler({ text, deviceId }) {
1764
+ static async typeTextHandler({ text, deviceId, timeoutMs }) {
1764
1765
  const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
1765
- return await new AndroidInteract().typeText(text, resolved.id);
1766
+ return await new AndroidInteract().typeText(text, resolved.id, timeoutMs);
1766
1767
  }
1767
- static async pressBackHandler({ deviceId }) {
1768
+ static async pressBackHandler({ deviceId, timeoutMs }) {
1768
1769
  const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
1769
- return await new AndroidInteract().pressBack(resolved.id);
1770
+ return await new AndroidInteract().pressBack(resolved.id, timeoutMs);
1770
1771
  }
1771
1772
  static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }) {
1772
1773
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
@@ -2067,7 +2068,7 @@ export class ToolsInteract {
2067
2068
  };
2068
2069
  return { found: true, element: outEl, score: scoreVal, confidence: scoreVal, resolution };
2069
2070
  }
2070
- static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }) {
2071
+ 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 }) {
2071
2072
  const overallStart = Date.now();
2072
2073
  const requestedIndex = typeof match?.index === 'number' ? match.index : null;
2073
2074
  const requested = {
@@ -2109,7 +2110,7 @@ export class ToolsInteract {
2109
2110
  let lastConditionSatisfied = false;
2110
2111
  let matchedAt = null;
2111
2112
  let stableMatchCount = 0;
2112
- const stableObservationCount = 2;
2113
+ const stableObservationCount = singleObservation ? 1 : 2;
2113
2114
  const snapshotStaleThresholdMs = 500;
2114
2115
  // Precompute normalized selector values and helpers (constant across polls)
2115
2116
  const normalize = ToolsInteract._normalize;
@@ -2122,7 +2123,8 @@ export class ToolsInteract {
2122
2123
  attempts++;
2123
2124
  const attemptStart = Date.now();
2124
2125
  const deadline = attemptStart + (timeout_ms || 0);
2125
- while (Date.now() <= deadline) {
2126
+ while (Date.now() <= deadline || (singleAttempt && totalPollCount === 0)) {
2127
+ remainingBudget();
2126
2128
  totalPollCount++;
2127
2129
  try {
2128
2130
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
@@ -2172,6 +2174,8 @@ export class ToolsInteract {
2172
2174
  }
2173
2175
  // Evaluate condition
2174
2176
  const matchedCount = matches.length;
2177
+ if (rejectAmbiguous && matchedCount > 1 && match?.index === undefined)
2178
+ return { status: 'timeout', error: { code: 'AMBIGUOUS_TARGET' } };
2175
2179
  const pickIndex = (typeof match?.index === 'number') ? match.index : undefined;
2176
2180
  let chosen = null;
2177
2181
  if (matches.length > 0) {
@@ -2237,6 +2241,7 @@ export class ToolsInteract {
2237
2241
  const outEl = lastMatchedElement;
2238
2242
  return {
2239
2243
  status: 'success',
2244
+ ...(singleObservation ? { _tree: tree } : {}),
2240
2245
  matched: matchedCount,
2241
2246
  element: outEl,
2242
2247
  metrics: { latency_ms, poll_count: totalPollCount, attempts },
@@ -2256,9 +2261,13 @@ export class ToolsInteract {
2256
2261
  }
2257
2262
  }
2258
2263
  catch (e) {
2264
+ if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
2265
+ throw e;
2259
2266
  // Non-fatal per-poll error; record and continue
2260
2267
  console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e));
2261
2268
  }
2269
+ if (singleAttempt)
2270
+ break;
2262
2271
  // Sleep until next poll
2263
2272
  await new Promise(r => setTimeout(r, effectivePoll || 50));
2264
2273
  }
@@ -2289,6 +2298,8 @@ export class ToolsInteract {
2289
2298
  }
2290
2299
  }
2291
2300
  catch (err) {
2301
+ if (err instanceof Error && err.message === 'ACTION_TIMEOUT')
2302
+ throw err;
2292
2303
  const elapsed = Date.now() - overallStart;
2293
2304
  return {
2294
2305
  status: 'timeout',
@@ -2665,15 +2676,15 @@ export class ToolsInteract {
2665
2676
  })
2666
2677
  };
2667
2678
  }
2668
- static async expectStateHandler({ selector, element_id, property, expected, platform, deviceId, stabilization_window_ms = 1000, stable_observation_count = 2, snapshot_stale_threshold_ms = 500, poll_interval_ms = 150 }) {
2679
+ static async expectStateHandler({ selector, element_id, property, expected, platform, deviceId, stabilization_window_ms = 1000, stable_observation_count = 2, snapshot_stale_threshold_ms = 500, poll_interval_ms = 150, exact_budget = false }) {
2669
2680
  const compareBoolean = (value) => typeof value === 'boolean' ? value : null;
2670
2681
  const compareString = (value) => typeof value === 'string' ? value : null;
2671
2682
  const compareNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
2672
2683
  const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
2673
2684
  const start = Date.now();
2674
- const deadline = start + Math.max(500, stabilization_window_ms);
2685
+ const deadline = start + (exact_budget ? stabilization_window_ms : Math.max(500, stabilization_window_ms));
2675
2686
  const stableTarget = Math.max(1, Math.floor(stable_observation_count || 2));
2676
- const pollDelay = Math.max(100, Math.min(poll_interval_ms || 150, 200));
2687
+ const pollDelay = exact_budget ? Math.max(50, Math.min(poll_interval_ms || 150, 1000)) : Math.max(100, Math.min(poll_interval_ms || 150, 200));
2677
2688
  const staleThreshold = Math.max(300, Math.min(snapshot_stale_threshold_ms || 500, 800));
2678
2689
  let attempts = 0;
2679
2690
  let stableCount = 0;
@@ -1,3 +1,4 @@
1
+ import { remainingBudget, markDispatchStarted } from '../utils/operation-budget.js';
1
2
  import { spawn } from "child_process";
2
3
  import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js";
3
4
  import { iOSObserve } from "../observe/index.js";
@@ -24,7 +25,8 @@ export class iOSInteract {
24
25
  args.push('--udid', targetUdid);
25
26
  }
26
27
  await new Promise((resolve, reject) => {
27
- const proc = spawn(getIdbCmd(), args);
28
+ markDispatchStarted();
29
+ const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
28
30
  let stderr = '';
29
31
  proc.stderr.on('data', d => stderr += d.toString());
30
32
  proc.on('close', code => {
@@ -63,7 +65,8 @@ export class iOSInteract {
63
65
  args.push('--udid', targetUdid);
64
66
  }
65
67
  await new Promise((resolve, reject) => {
66
- const proc = spawn(getIdbCmd(), args);
68
+ markDispatchStarted();
69
+ const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
67
70
  let stderr = '';
68
71
  proc.stderr.on('data', d => stderr += d.toString());
69
72
  proc.on('close', code => {
@@ -1,3 +1,4 @@
1
+ import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js';
1
2
  import { promises as fs } from 'fs';
2
3
  import { spawn } from 'child_process';
3
4
  import path from 'path';
@@ -145,16 +146,18 @@ export class AndroidManage {
145
146
  return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: { installDiag, pushDiag, pmDiag } };
146
147
  }
147
148
  }
148
- async startApp(appId, deviceId) {
149
+ async startApp(appId, deviceId, timeoutMs) {
150
+ const started = performance.now();
149
151
  const metadata = await getAndroidDeviceMetadata(appId, deviceId);
150
152
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
151
153
  try {
152
- const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
154
+ markDispatchStarted();
155
+ const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs });
153
156
  const current = await new AndroidObserve().getCurrentScreen(deviceId).catch(() => null);
154
157
  return {
155
158
  device: deviceInfo,
156
159
  appStarted: true,
157
- launchTimeMs: 1000,
160
+ launchTimeMs: performance.now() - started,
158
161
  output,
159
162
  observedApp: {
160
163
  appId,
@@ -166,25 +169,31 @@ export class AndroidManage {
166
169
  };
167
170
  }
168
171
  catch (e) {
169
- const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
170
- return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
172
+ const diagnostics = timeoutMs === undefined ? execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId) : undefined;
173
+ return { device: deviceInfo, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) };
171
174
  }
172
175
  }
173
176
  async terminateApp(appId, deviceId) {
174
177
  const metadata = await getAndroidDeviceMetadata(appId, deviceId);
175
178
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
176
179
  try {
180
+ markDispatchStarted();
177
181
  await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
178
182
  return { device: deviceInfo, appTerminated: true };
179
183
  }
180
184
  catch (e) {
181
- const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId);
182
- return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
185
+ const diagnostics = hasFiniteBudget() ? undefined : execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId);
186
+ return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) };
183
187
  }
184
188
  }
185
- async restartApp(appId, deviceId) {
186
- const terminateResult = await this.terminateApp(appId, deviceId);
187
- const startResult = await this.startApp(appId, deviceId);
189
+ async restartApp(appId, deviceId, bounded = false, timeoutMs = 15000) {
190
+ const started = performance.now();
191
+ const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(appId, deviceId)) : await this.terminateApp(appId, deviceId);
192
+ const remaining = timeoutMs - (performance.now() - started);
193
+ if (bounded && remaining <= 0)
194
+ throw new Error('ACTION_TIMEOUT');
195
+ const launchBudget = remainingBudget(remaining);
196
+ const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(appId, deviceId, remainingBudget(launchBudget))) : await this.startApp(appId, deviceId);
188
197
  return {
189
198
  device: startResult.device,
190
199
  appRestarted: startResult.appStarted,
@@ -201,6 +210,7 @@ export class AndroidManage {
201
210
  const metadata = await getAndroidDeviceMetadata(appId, deviceId);
202
211
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
203
212
  try {
213
+ markDispatchStarted();
204
214
  const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
205
215
  return { device: deviceInfo, dataCleared: output === 'Success' };
206
216
  }
@@ -1,3 +1,4 @@
1
+ import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js';
1
2
  import { promises as fs } from "fs";
2
3
  import { spawn, spawnSync } from "child_process";
3
4
  import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "../utils/ios/utils.js";
@@ -287,17 +288,19 @@ export class iOSManage {
287
288
  return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
288
289
  }
289
290
  }
290
- async startApp(bundleId, deviceId = "booted") {
291
+ async startApp(bundleId, deviceId = "booted", observe = true) {
292
+ const started = performance.now();
291
293
  validateBundleId(bundleId);
292
294
  try {
295
+ markDispatchStarted();
293
296
  const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
294
297
  const device = await getIOSDeviceMetadata(deviceId);
295
- const fingerprint = await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null);
298
+ const fingerprint = observe ? await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null) : null;
296
299
  const pidMatch = result.output.match(/:\s*(\d+)\s*$/);
297
300
  return {
298
301
  device,
299
302
  appStarted: !!result.output,
300
- launchTimeMs: 1000,
303
+ launchTimeMs: performance.now() - started,
301
304
  output: result.output,
302
305
  observedApp: {
303
306
  appId: bundleId,
@@ -308,27 +311,33 @@ export class iOSManage {
308
311
  };
309
312
  }
310
313
  catch (e) {
311
- const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
312
314
  const device = await getIOSDeviceMetadata(deviceId);
313
- return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
315
+ const diagnostics = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
316
+ return { device, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) };
314
317
  }
315
318
  }
316
319
  async terminateApp(bundleId, deviceId = "booted") {
317
320
  validateBundleId(bundleId);
318
321
  try {
322
+ markDispatchStarted();
319
323
  await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
320
324
  const device = await getIOSDeviceMetadata(deviceId);
321
325
  return { device, appTerminated: true };
322
326
  }
323
327
  catch (e) {
324
- const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId);
328
+ const diag = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId);
325
329
  const device = await getIOSDeviceMetadata(deviceId);
326
- return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
330
+ return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diag ? { diagnostics: diag } : {}) };
327
331
  }
328
332
  }
329
- async restartApp(bundleId, deviceId = "booted") {
330
- const terminateResult = await this.terminateApp(bundleId, deviceId);
331
- const startResult = await this.startApp(bundleId, deviceId);
333
+ async restartApp(bundleId, deviceId = "booted", observe = true, bounded = false, timeoutMs = 15000) {
334
+ const started = performance.now();
335
+ const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(bundleId, deviceId)) : await this.terminateApp(bundleId, deviceId);
336
+ const remaining = timeoutMs - (performance.now() - started);
337
+ if (bounded && remaining <= 0)
338
+ throw new Error('ACTION_TIMEOUT');
339
+ const launchBudget = remainingBudget(remaining);
340
+ const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(bundleId, deviceId, observe)) : await this.startApp(bundleId, deviceId, observe);
332
341
  return {
333
342
  device: startResult.device,
334
343
  appRestarted: startResult.appStarted,
@@ -0,0 +1,192 @@
1
+ import { execFile, spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { connect } from 'node:net';
4
+ import path from 'node:path';
5
+ import { execAdb, getAdbCmd } from '../utils/android/utils.js';
6
+ import { remainingBudget } from '../utils/operation-budget.js';
7
+ const packageName = 'dev.mobiledebugmcp.treebridge';
8
+ const component = `${packageName}/.TreeInstrumentation`;
9
+ const devicePort = 39019;
10
+ const active = new Map();
11
+ const starting = new Map();
12
+ const stopping = new Map();
13
+ export function androidTreeBridgeEnabled() {
14
+ return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK);
15
+ }
16
+ function configuredApk() {
17
+ const configured = process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK;
18
+ if (!configured)
19
+ return null;
20
+ const apk = path.resolve(configured);
21
+ if (!existsSync(apk))
22
+ throw new Error(`Android UI tree bridge APK does not exist: ${apk}`);
23
+ return apk;
24
+ }
25
+ function unbudgetedAdb(deviceId, args) {
26
+ return new Promise((resolve, reject) => {
27
+ execFile(getAdbCmd(), ['-s', deviceId, ...args], { timeout: 5000 }, (error) => error ? reject(error) : resolve());
28
+ });
29
+ }
30
+ function request(bridge, command, timeoutMs) {
31
+ return new Promise((resolve, reject) => {
32
+ let output = '';
33
+ const socket = connect(bridge.hostPort, '127.0.0.1', () => socket.write(`${command}\n`));
34
+ socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`Android UI tree bridge ${command} timed out`)));
35
+ socket.on('data', (chunk) => {
36
+ output += chunk.toString();
37
+ if (command === 'STOP' && output.startsWith('OK\n')) {
38
+ resolve(output);
39
+ socket.destroy();
40
+ }
41
+ });
42
+ socket.on('error', reject);
43
+ socket.on('end', () => resolve(output));
44
+ });
45
+ }
46
+ function waitForChildClose(bridge, timeoutMs) {
47
+ if (bridge.childClosed)
48
+ return Promise.resolve();
49
+ return new Promise((resolve, reject) => {
50
+ const onClose = () => { clearTimeout(timer); resolve(); };
51
+ const timer = setTimeout(() => {
52
+ bridge.child.off('close', onClose);
53
+ reject(new Error('Android UI tree bridge child did not exit'));
54
+ }, timeoutMs);
55
+ bridge.child.once('close', onClose);
56
+ });
57
+ }
58
+ async function stop(bridge) {
59
+ const pending = stopping.get(bridge.deviceId);
60
+ if (pending)
61
+ return pending;
62
+ bridge.closed = true;
63
+ const cleanup = (async () => {
64
+ await request(bridge, 'STOP', 1000).catch(() => '');
65
+ let forceStopError;
66
+ try {
67
+ await unbudgetedAdb(bridge.deviceId, ['shell', 'am', 'force-stop', packageName]);
68
+ }
69
+ catch (error) {
70
+ forceStopError = error;
71
+ }
72
+ try {
73
+ await waitForChildClose(bridge, 1000);
74
+ }
75
+ catch {
76
+ bridge.child.kill('SIGTERM');
77
+ try {
78
+ await waitForChildClose(bridge, 1000);
79
+ }
80
+ catch {
81
+ bridge.child.kill('SIGKILL');
82
+ await waitForChildClose(bridge, 2000).catch(() => { });
83
+ }
84
+ }
85
+ let forwardError;
86
+ try {
87
+ await unbudgetedAdb(bridge.deviceId, ['forward', '--remove', `tcp:${bridge.hostPort}`]);
88
+ }
89
+ catch (error) {
90
+ forwardError = error;
91
+ }
92
+ if (forceStopError || !bridge.childClosed || forwardError) {
93
+ const reason = forceStopError ?? (!bridge.childClosed ? 'instrumentation child did not close' : forwardError);
94
+ throw new Error(`BRIDGE_CLEANUP_UNCONFIRMED: ${reason instanceof Error ? reason.message : String(reason)}`);
95
+ }
96
+ if (active.get(bridge.deviceId) === bridge)
97
+ active.delete(bridge.deviceId);
98
+ })();
99
+ stopping.set(bridge.deviceId, cleanup);
100
+ try {
101
+ await cleanup;
102
+ }
103
+ finally {
104
+ stopping.delete(bridge.deviceId);
105
+ }
106
+ }
107
+ async function launch(deviceId, apk) {
108
+ await unbudgetedAdb(deviceId, ['shell', 'am', 'force-stop', packageName]);
109
+ try {
110
+ await execAdb(['install', '-r', apk], deviceId);
111
+ }
112
+ catch (error) {
113
+ if (!(error instanceof Error) || !error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE'))
114
+ throw error;
115
+ await execAdb(['uninstall', packageName], deviceId);
116
+ await execAdb(['install', '-r', apk], deviceId);
117
+ }
118
+ const hostPort = Number(await execAdb(['forward', 'tcp:0', `tcp:${devicePort}`], deviceId));
119
+ if (!Number.isInteger(hostPort) || hostPort <= 0)
120
+ throw new Error('Android UI tree bridge could not obtain a host port');
121
+ const child = spawn(getAdbCmd(), ['-s', deviceId, 'shell', 'am', 'instrument', '-w', component], { stdio: 'ignore' });
122
+ const bridge = { deviceId, hostPort, child, closed: false, childClosed: false };
123
+ child.once('close', () => { bridge.childClosed = true; });
124
+ child.once('error', () => { });
125
+ active.set(deviceId, bridge);
126
+ try {
127
+ const deadline = Date.now() + Math.min(remainingBudget(3000), 3000);
128
+ while (Date.now() < deadline) {
129
+ if (bridge.childClosed || child.exitCode !== null || child.signalCode !== null)
130
+ throw new Error('Android UI tree bridge exited during startup');
131
+ try {
132
+ if ((await request(bridge, 'PING', 250)).startsWith('OK\n')) {
133
+ return bridge;
134
+ }
135
+ }
136
+ catch { /* The instrumentation process may still be starting. */ }
137
+ await new Promise((resolve) => setTimeout(resolve, 100));
138
+ }
139
+ throw new Error('Android UI tree bridge did not become ready');
140
+ }
141
+ catch (error) {
142
+ await stop(bridge);
143
+ throw error;
144
+ }
145
+ }
146
+ async function ensure(deviceId, apk) {
147
+ const pending = starting.get(deviceId);
148
+ if (pending)
149
+ return pending;
150
+ const ready = active.get(deviceId);
151
+ if (ready && !ready.closed && !ready.childClosed && ready.child.exitCode === null && ready.child.signalCode === null)
152
+ return ready;
153
+ if (ready)
154
+ await stop(ready);
155
+ const promise = launch(deviceId, apk);
156
+ starting.set(deviceId, promise);
157
+ try {
158
+ return await promise;
159
+ }
160
+ finally {
161
+ starting.delete(deviceId);
162
+ }
163
+ }
164
+ /** Returns null only after the bridge has been stopped, so legacy acquisition is safe. */
165
+ export async function readAndroidTreeFromBridge(deviceId) {
166
+ const apk = configuredApk();
167
+ if (!apk)
168
+ return null;
169
+ let bridge;
170
+ try {
171
+ bridge = await ensure(deviceId, apk);
172
+ const output = await request(bridge, 'TREE', remainingBudget(10000));
173
+ if (!output.startsWith('OK\n') || !output.includes('<hierarchy'))
174
+ throw new Error('Android UI tree bridge returned invalid hierarchy output');
175
+ return output.slice(3);
176
+ }
177
+ catch (error) {
178
+ if (error instanceof Error && error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))
179
+ throw error;
180
+ if (bridge)
181
+ await stop(bridge);
182
+ if (error instanceof Error && error.message === 'ACTION_TIMEOUT')
183
+ throw error;
184
+ return null;
185
+ }
186
+ }
187
+ process.once('exit', () => {
188
+ for (const bridge of active.values()) {
189
+ spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'shell', 'am', 'force-stop', packageName], { timeout: 2000, stdio: 'ignore' });
190
+ spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'forward', '--remove', `tcp:${bridge.hostPort}`], { timeout: 2000, stdio: 'ignore' });
191
+ }
192
+ });