mobile-debug-mcp 0.30.2 → 0.32.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 (87) hide show
  1. package/README.md +50 -5
  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 +20 -11
  7. package/dist/observe/android.js +6 -1
  8. package/dist/observe/index.js +22 -5
  9. package/dist/observe/ios.js +7 -2
  10. package/dist/observe/snapshot-metadata.js +94 -15
  11. package/dist/server/common.js +14 -7
  12. package/dist/server/journey-schema.js +60 -0
  13. package/dist/server/tool-definitions.js +59 -6
  14. package/dist/server/tool-handlers.js +358 -55
  15. package/dist/server-core.js +1 -1
  16. package/dist/system/index.js +485 -48
  17. package/dist/system/ios.js +15 -7
  18. package/dist/utils/android/utils.js +4 -4
  19. package/dist/utils/cli/idb/check-idb.js +10 -3
  20. package/dist/utils/cli/idb/idb-helper.js +47 -13
  21. package/dist/utils/exec.js +4 -2
  22. package/dist/utils/ios/utils.js +10 -11
  23. package/dist/utils/operation-budget.js +25 -0
  24. package/dist/utils/resolve-device.js +12 -1
  25. package/dist/utils/timing.js +19 -0
  26. package/dist/utils/ui/index.js +2 -0
  27. package/docs/CHANGELOG.md +6 -0
  28. package/docs/ROADMAP.md +4 -4
  29. package/docs/benchmarks/interaction-latency/README.md +26 -0
  30. package/docs/benchmarks/interaction-latency/final.json +1810 -0
  31. package/docs/benchmarks/interaction-latency/final.md +17 -0
  32. package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
  33. package/docs/specs/baseline-spec-v0.md +1 -1
  34. package/docs/specs-deltas/016-environment-tools.md +533 -0
  35. package/docs/specs-deltas/018-interaction-latency-and-batched-journeys.md +301 -0
  36. package/docs/tools/TOOLS.md +2 -0
  37. package/docs/tools/interact.md +30 -0
  38. package/docs/tools/manage.md +4 -2
  39. package/docs/tools/system.md +9 -2
  40. package/package.json +1 -1
  41. package/src/interact/android.ts +13 -8
  42. package/src/interact/index.ts +32 -23
  43. package/src/interact/ios.ts +5 -2
  44. package/src/manage/android.ts +19 -10
  45. package/src/manage/ios.ts +19 -11
  46. package/src/observe/android.ts +5 -1
  47. package/src/observe/index.ts +21 -5
  48. package/src/observe/ios.ts +6 -2
  49. package/src/observe/snapshot-metadata.ts +94 -15
  50. package/src/server/common.ts +13 -6
  51. package/src/server/journey-schema.ts +63 -0
  52. package/src/server/tool-definitions.ts +60 -7
  53. package/src/server/tool-handlers.ts +324 -53
  54. package/src/server-core.ts +1 -1
  55. package/src/system/index.ts +572 -50
  56. package/src/system/ios.ts +14 -7
  57. package/src/types.ts +31 -2
  58. package/src/utils/android/utils.ts +3 -3
  59. package/src/utils/cli/idb/check-idb.ts +11 -2
  60. package/src/utils/cli/idb/idb-helper.ts +49 -12
  61. package/src/utils/exec.ts +4 -2
  62. package/src/utils/ios/utils.ts +9 -10
  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/fixtures/latency-journey-app/AndroidManifest.xml +6 -0
  70. package/test/fixtures/latency-journey-app/README.md +10 -0
  71. package/test/fixtures/latency-journey-app/build.ts +26 -0
  72. package/test/fixtures/latency-journey-app/src/MainActivity.java +48 -0
  73. package/test/unit/interact/tap_element.test.ts +1 -1
  74. package/test/unit/manage/restart_budget.test.ts +37 -0
  75. package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
  76. package/test/unit/observe/snapshot_metadata.test.ts +20 -1
  77. package/test/unit/observe/state_delta.test.ts +15 -0
  78. package/test/unit/server/benchmark_report.test.ts +4 -0
  79. package/test/unit/server/contract.test.ts +1 -1
  80. package/test/unit/server/interaction_modes.test.ts +60 -0
  81. package/test/unit/server/operation_budget.test.ts +32 -0
  82. package/test/unit/server/response_shapes.test.ts +3 -3
  83. package/test/unit/server/run_journey.test.ts +90 -0
  84. package/test/unit/server/tap_selector.test.ts +68 -0
  85. package/test/unit/system/get_system_status.test.ts +3 -0
  86. package/test/unit/system/system_status.test.ts +40 -0
  87. package/test/unit/utils/idb_health.test.ts +74 -0
package/README.md CHANGED
@@ -16,11 +16,36 @@ A minimal, secure MCP server for AI-assisted mobile development. Build, install,
16
16
  - Xcode command-line tools for iOS support
17
17
  - [idb](https://github.com/facebook/idb) for iOS device support
18
18
 
19
+ ## Environment Setup
20
+
21
+ The server discovers Android and iOS tools automatically from standard locations by default. Manual environment setup is still supported, but it should be treated as the fallback for non-standard installs, overrides, or reproducible pinned toolchains.
22
+
23
+ Use explicit paths when:
24
+ - you have multiple SDKs installed
25
+ - the tools live outside standard locations
26
+ - you want deterministic setup across machines
27
+ - automatic discovery does not find the expected binary
28
+
29
+ Leave the variables unset when:
30
+ - the tools are already on `PATH`
31
+ - the standard Android and Xcode locations are enough
32
+
33
+ Common environment variables:
34
+ - `ADB_PATH`: explicit path to `adb`
35
+ - `ANDROID_SDK_ROOT` or `ANDROID_HOME`: Android SDK root
36
+ - `XCRUN_PATH`: explicit path to `xcrun`
37
+ - `MCP_IDB_PATH` or `IDB_PATH`: explicit path to `idb`
38
+ - `GRADLE_JAVA_HOME` or `JAVA_HOME`: Java home for Gradle-backed operations
39
+
40
+ For normal use, call `get_system_status` first. It reports the detected host, Android, and iOS toolchain state so the client can decide whether automatic discovery is sufficient or whether explicit overrides are needed.
41
+
19
42
  ## Configuration
20
43
 
21
44
  <details>
22
45
 
23
- <summary>Android Studio</summary>
46
+ <summary>Android setup</summary>
47
+
48
+ Recommended when you want Android only, or when you want to make the Android toolchain explicit.
24
49
 
25
50
  ```json
26
51
  {
@@ -28,17 +53,25 @@ A minimal, secure MCP server for AI-assisted mobile development. Build, install,
28
53
  "mobile-debug": {
29
54
  "command": "npx",
30
55
  "args": ["--yes","mobile-debug-mcp","server"],
31
- "env": { "ADB_PATH": "/path/to/adb", "XCRUN_PATH": "/usr/bin/xcrun", "IDB_PATH": "/path/to/idb" }
56
+ "env": {
57
+ "ADB_PATH": "/path/to/adb",
58
+ "ANDROID_SDK_ROOT": "/path/to/android/sdk",
59
+ "GRADLE_JAVA_HOME": "/path/to/jdk"
60
+ }
32
61
  }
33
62
  }
34
63
  }
35
64
  ```
36
65
 
66
+ For Android-only setups, `XCRUN_PATH` and `IDB_PATH` are not required unless you want to pin them explicitly.
67
+
37
68
  </details>
38
69
 
39
70
  <details>
40
71
 
41
- <summary>Copilot</summary>
72
+ <summary>iOS setup</summary>
73
+
74
+ Recommended when you want iOS simulator or device support, or when `idb` lives in a non-standard location.
42
75
 
43
76
  ```json
44
77
  {
@@ -46,12 +79,18 @@ A minimal, secure MCP server for AI-assisted mobile development. Build, install,
46
79
  "mobile-debug": {
47
80
  "command": "npx",
48
81
  "args": ["--yes","mobile-debug-mcp","server"],
49
- "env": { "ADB_PATH": "/path/to/adb", "XCRUN_PATH": "/usr/bin/xcrun", "IDB_PATH": "/path/to/idb" }
82
+ "env": {
83
+ "XCRUN_PATH": "/usr/bin/xcrun",
84
+ "MCP_IDB_PATH": "/path/to/idb",
85
+ "IDB_PATH": "/path/to/idb"
86
+ }
50
87
  }
51
88
  }
52
89
  }
53
90
  ```
54
91
 
92
+ For iOS-only setups, `ADB_PATH` and `ANDROID_SDK_ROOT` are not required unless you want to pin them explicitly.
93
+
55
94
  </details>
56
95
 
57
96
  <details>
@@ -69,7 +108,13 @@ args:
69
108
  environment variables:
70
109
  * ADB_PATH: /path/to/adb
71
110
  * XCRUN_PATH: /usr/bin/xcrun
72
- * IDC_PATH: /path/to/idb"
111
+ * IDB_PATH: /path/to/idb
112
+ * MCP_IDB_PATH: /path/to/idb
113
+ * ANDROID_SDK_ROOT: /path/to/android/sdk
114
+ * GRADLE_JAVA_HOME: /path/to/jdk
115
+ * JAVA_HOME: /path/to/jdk
116
+
117
+ If you are unsure whether the environment is configured correctly, run `get_system_status` first. It reports the detected host, Android, and iOS toolchain state in a structured form.
73
118
 
74
119
  </details>
75
120
 
@@ -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";
@@ -257,7 +258,7 @@ export class iOSManage {
257
258
  // Gather diagnostics for simctl failure
258
259
  const diag = execCommandWithDiagnostics(['simctl', 'install', deviceId, toInstall], deviceId);
259
260
  try {
260
- const child = spawn(getIdbCmd(), ['--version']);
261
+ const child = spawn(getIdbCmd(), ['list-targets', '--json']);
261
262
  const idbExists = await new Promise((resolve) => {
262
263
  child.on('error', () => resolve(false));
263
264
  child.on('close', (code) => resolve(code === 0));
@@ -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,
@@ -7,6 +7,7 @@ import path from "path";
7
7
  import { computeScreenFingerprint } from "../utils/ui/index.js";
8
8
  import { parsePngSize } from "../utils/image.js";
9
9
  import { deriveSnapshotMetadata } from "./snapshot-metadata.js";
10
+ import { remainingBudget } from '../utils/operation-budget.js';
10
11
  const activeLogStreams = new Map();
11
12
  export class AndroidObserve {
12
13
  async getDeviceMetadata(appId, deviceId) {
@@ -26,10 +27,12 @@ export class AndroidObserve {
26
27
  let attempts = 0;
27
28
  const maxAttempts = 3;
28
29
  while (attempts < maxAttempts) {
30
+ remainingBudget();
29
31
  attempts++;
30
32
  try {
31
33
  // Stabilization delay
32
- await delay(300 + (attempts * 100)); // 300ms, 400ms, 500ms...
34
+ await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))); // 300ms, 400ms, 500ms...
35
+ remainingBudget();
33
36
  // Dump UI hierarchy
34
37
  await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
35
38
  // Read the file
@@ -40,6 +43,8 @@ export class AndroidObserve {
40
43
  }
41
44
  }
42
45
  catch (e) {
46
+ if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
47
+ throw e;
43
48
  console.error(`Attempt ${attempts} failed: ${e}`);
44
49
  }
45
50
  if (attempts === maxAttempts) {
@@ -1,7 +1,8 @@
1
+ import { measure } from '../utils/timing.js';
1
2
  import { resolveTargetDevice } from '../utils/resolve-device.js';
2
3
  import { AndroidObserve } from './android.js';
3
4
  import { iOSObserve } from './ios.js';
4
- import { deriveSnapshotMetadata } from './snapshot-metadata.js';
5
+ import { deriveSnapshotMetadata, getLatestStateDelta, getStateDelta } from './snapshot-metadata.js';
5
6
  export { AndroidObserve } from './android.js';
6
7
  export { iOSObserve } from './ios.js';
7
8
  function normalizeHint(value) {
@@ -110,6 +111,9 @@ function deriveSnapshotSemantic(raw) {
110
111
  };
111
112
  }
112
113
  export class ToolsObserve {
114
+ static getLatestStateDelta(platform, deviceId) {
115
+ return getLatestStateDelta(`${platform}:${deviceId}`);
116
+ }
113
117
  // Resolve a target device and return the appropriate observe instance and resolved info.
114
118
  static async resolveObserve(platform, deviceId, appId) {
115
119
  if (platform === 'android') {
@@ -130,9 +134,22 @@ export class ToolsObserve {
130
134
  return { observe: new iOSObserve(), resolved };
131
135
  }
132
136
  }
133
- static async getUITreeHandler({ platform, deviceId }) {
137
+ static async getUITreeHandler({ platform, deviceId, responseMode = 'debug', sinceSnapshotRevision }) {
134
138
  const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId);
135
- return await observe.getUITree(resolved.id);
139
+ const tree = await observe.getUITree(resolved.id);
140
+ if (responseMode !== 'compact')
141
+ return tree;
142
+ const revision = tree?.snapshot_revision ?? null;
143
+ const baseRevision = sinceSnapshotRevision;
144
+ if (!Number.isInteger(baseRevision) || revision === null) {
145
+ return { delta_available: false, snapshot_revision: revision };
146
+ }
147
+ const delta = getStateDelta(`${resolved.platform}:${resolved.id}`, baseRevision, revision);
148
+ return delta ? { delta_available: true, state_delta: delta, snapshot_revision: revision } : {
149
+ delta_available: false,
150
+ snapshot_revision: revision,
151
+ reason: 'BASE_REVISION_UNAVAILABLE'
152
+ };
136
153
  }
137
154
  static async getCurrentScreenHandler({ deviceId }) {
138
155
  const { observe, resolved } = await ToolsObserve.resolveObserve('android', deviceId);
@@ -199,7 +216,7 @@ export class ToolsObserve {
199
216
  // Both observes implement getScreenFingerprint
200
217
  return await observe.getScreenFingerprint(resolved.id);
201
218
  }
202
- static async captureDebugSnapshotHandler({ reason, includeLogs = true, logLines = 200, platform, appId, deviceId, sessionId } = {}) {
219
+ static async captureDebugSnapshotHandler({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId } = {}) {
203
220
  const timestamp = Date.now();
204
221
  const raw = {
205
222
  timestamp,
@@ -215,7 +232,7 @@ export class ToolsObserve {
215
232
  // Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
216
233
  const sid = sessionId || 'default';
217
234
  const tasks = {
218
- screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
235
+ screenshot: includeScreenshot ? measure('screenshot_ms', () => ToolsObserve.captureScreenshotHandler({ platform, deviceId })) : Promise.resolve(null),
219
236
  currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
220
237
  fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
221
238
  uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "child_process";
2
+ import { remainingBudget } from '../utils/operation-budget.js';
2
3
  import { promises as fs } from "fs";
3
4
  import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js";
4
5
  import { createWriteStream, promises as fsPromises } from 'fs';
@@ -501,15 +502,17 @@ export class iOSObserve {
501
502
  let attempts = 0;
502
503
  const maxAttempts = 3;
503
504
  while (attempts < maxAttempts) {
505
+ remainingBudget();
504
506
  attempts++;
505
507
  try {
506
- await delay(300 + (attempts * 100));
508
+ await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100)));
509
+ remainingBudget();
507
510
  const args = ['ui', 'describe-all', '--json'];
508
511
  if (targetUdid) {
509
512
  args.push('--udid', targetUdid);
510
513
  }
511
514
  const output = await new Promise((resolve, reject) => {
512
- const child = spawn(getIdbCmd(), args);
515
+ const child = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
513
516
  let stdout = '';
514
517
  let stderr = '';
515
518
  child.stdout.on('data', (data) => stdout += data.toString());
@@ -530,6 +533,8 @@ export class iOSObserve {
530
533
  }
531
534
  }
532
535
  catch (e) {
536
+ if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
537
+ throw e;
533
538
  console.error(`Attempt ${attempts} failed: ${e}`);
534
539
  }
535
540
  if (attempts === maxAttempts) {