mobile-debug-mcp 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/.cursor-plugin/marketplace.json +1 -1
  2. package/.github/workflows/ci.yml +1 -1
  3. package/README.md +96 -24
  4. package/agent-plugin/.antigravity-plugin/README.md +48 -0
  5. package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
  6. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  7. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  8. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  9. package/agent-plugin/.mcp.json +1 -1
  10. package/agent-plugin/README.md +37 -8
  11. package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
  12. package/agent-plugin/cursor/LICENSE +201 -0
  13. package/agent-plugin/cursor/README.md +9 -0
  14. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +261 -0
  15. package/agent-plugin/mcp.json +1 -1
  16. package/agent-plugin/mcp_config.json +13 -0
  17. package/agent-plugin/plugin.json +1 -1
  18. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +46 -18
  19. package/dist/contracts/device-readiness.js +1 -0
  20. package/dist/debugger/guard.js +4 -4
  21. package/dist/debugger/ios-worker.js +62 -0
  22. package/dist/debugger/ios.js +57 -0
  23. package/dist/debugger/sessions.js +33 -16
  24. package/dist/debugger/worker.js +6 -4
  25. package/dist/interact/index.js +12 -2
  26. package/dist/interact/waits/ui-change-wait.js +9 -2
  27. package/dist/interact/waits/ui-wait.js +7 -3
  28. package/dist/manage/index.js +3 -0
  29. package/dist/manage/ios.js +2 -0
  30. package/dist/observe/device-readiness.js +116 -0
  31. package/dist/observe/evidence.js +31 -0
  32. package/dist/observe/ios-trace/artifact.js +63 -0
  33. package/dist/observe/ios-trace/capabilities.js +86 -0
  34. package/dist/observe/ios-trace/native.js +93 -0
  35. package/dist/observe/ios-trace/notification.js +35 -0
  36. package/dist/observe/ios-trace/process.js +98 -0
  37. package/dist/observe/ios-trace/sessions.js +246 -0
  38. package/dist/observe/readiness-service.js +22 -10
  39. package/dist/observe/readiness.js +34 -4
  40. package/dist/observe/screenshot-encoding.js +4 -0
  41. package/dist/observe/snapshot-collection.js +7 -2
  42. package/dist/observe/system-trace-error.js +7 -0
  43. package/dist/observe/system-trace-router.js +81 -0
  44. package/dist/observe/system-trace.js +4 -8
  45. package/dist/server/definitions/manage.js +3 -2
  46. package/dist/server/handlers/action-execution.js +23 -14
  47. package/dist/server/handlers/compact-interaction.js +106 -0
  48. package/dist/server/handlers/compact-state.js +47 -0
  49. package/dist/server/handlers/debugger-handlers.js +27 -8
  50. package/dist/server/handlers/diagnostic.js +12 -4
  51. package/dist/server/handlers/input.js +2 -0
  52. package/dist/server/handlers/interact.js +2 -0
  53. package/dist/server/handlers/interaction-diagnostics.js +62 -0
  54. package/dist/server/handlers/journey.js +18 -20
  55. package/dist/server/handlers/observe.js +2 -1
  56. package/dist/server/handlers/tool-handlers.js +28 -14
  57. package/dist/server/schemas/debugger-schema.js +10 -7
  58. package/dist/server/schemas/interaction-diagnostics.js +9 -0
  59. package/dist/server/schemas/system-trace-schema.js +14 -9
  60. package/dist/server/tool-definitions.js +9 -4
  61. package/dist/server-core.js +5 -2
  62. package/dist/server.js +5 -5
  63. package/dist/system/android.js +19 -15
  64. package/dist/system/index.js +2 -1
  65. package/dist/utils/android/utils.js +7 -16
  66. package/dist/utils/app-installation.js +77 -0
  67. package/dist/utils/benchmark-metrics.js +43 -0
  68. package/dist/utils/exec.js +4 -0
  69. package/dist/utils/ios/utils.js +7 -11
  70. package/dist/utils/resolve-device.js +3 -2
  71. package/dist/utils/timing.js +3 -3
  72. package/docs/CHANGELOG.md +48 -1
  73. package/docs/agent-plugin-installation-verification.md +39 -0
  74. package/docs/benchmarks/README.md +1 -0
  75. package/docs/benchmarks/end-to-end-latency/README.md +98 -0
  76. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
  77. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
  78. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
  79. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
  80. package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
  81. package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
  82. package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
  83. package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
  84. package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
  85. package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
  86. package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
  87. package/docs/code-structure.md +3 -1
  88. package/docs/specs/031-ios-system-traces/intent.md +38 -0
  89. package/docs/specs/031-ios-system-traces/investigation.md +92 -0
  90. package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
  91. package/docs/specs/031-ios-system-traces/plan.md +166 -0
  92. package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
  93. package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
  94. package/docs/specs/031-ios-system-traces/spec.md +135 -0
  95. package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
  96. package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
  97. package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
  98. package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
  99. package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
  100. package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
  101. package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
  102. package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
  103. package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
  104. package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
  105. package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
  106. package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
  107. package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
  108. package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
  109. package/docs/specs/034-app-installation-status/intent.md +41 -0
  110. package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
  111. package/docs/specs/034-app-installation-status/plan.md +73 -0
  112. package/docs/specs/034-app-installation-status/spec.md +114 -0
  113. package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
  114. package/docs/specs/035-compact-interaction-results/intent.md +38 -0
  115. package/docs/specs/035-compact-interaction-results/plan.md +43 -0
  116. package/docs/specs/035-compact-interaction-results/spec.md +118 -0
  117. package/docs/specs/035-compact-interaction-results/verification.md +111 -0
  118. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
  119. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
  120. package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
  121. package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
  122. package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
  123. package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
  124. package/docs/tools/TOOLS.md +3 -2
  125. package/docs/tools/compact-interactions.md +56 -0
  126. package/docs/tools/correlated-evidence.md +6 -0
  127. package/docs/tools/debugger.md +45 -5
  128. package/docs/tools/interact.md +17 -8
  129. package/docs/tools/manage.md +10 -2
  130. package/docs/tools/observe.md +42 -2
  131. package/docs/tools/system-traces.md +81 -14
  132. package/docs/tools/system.md +6 -0
  133. package/package.json +2 -1
  134. package/scripts/agent-plugin.mjs +55 -5
  135. package/src/contracts/device-readiness.ts +8 -0
  136. package/src/debugger/android.ts +1 -1
  137. package/src/debugger/guard.ts +5 -5
  138. package/src/debugger/ios-worker.ts +49 -0
  139. package/src/debugger/ios.ts +45 -0
  140. package/src/debugger/lldb/breakpoints.py +51 -0
  141. package/src/debugger/lldb/inspection.py +109 -0
  142. package/src/debugger/lldb/worker.py +203 -0
  143. package/src/debugger/sessions.ts +24 -12
  144. package/src/debugger/worker.ts +7 -4
  145. package/src/interact/index.ts +13 -2
  146. package/src/interact/waits/ui-change-wait.ts +7 -2
  147. package/src/interact/waits/ui-wait.ts +6 -3
  148. package/src/manage/index.ts +2 -0
  149. package/src/manage/ios.ts +2 -0
  150. package/src/observe/device-readiness.ts +96 -0
  151. package/src/observe/evidence.ts +30 -1
  152. package/src/observe/ios-trace/artifact.ts +55 -0
  153. package/src/observe/ios-trace/capabilities.ts +74 -0
  154. package/src/observe/ios-trace/native.ts +72 -0
  155. package/src/observe/ios-trace/notification.ts +30 -0
  156. package/src/observe/ios-trace/process.ts +82 -0
  157. package/src/observe/ios-trace/sessions.ts +206 -0
  158. package/src/observe/readiness-service.ts +22 -10
  159. package/src/observe/readiness.ts +37 -4
  160. package/src/observe/screenshot-encoding.ts +4 -0
  161. package/src/observe/snapshot-collection.ts +7 -2
  162. package/src/observe/system-trace-error.ts +3 -0
  163. package/src/observe/system-trace-router.ts +61 -0
  164. package/src/observe/system-trace.ts +4 -4
  165. package/src/server/common.ts +2 -2
  166. package/src/server/definitions/manage.ts +3 -2
  167. package/src/server/handlers/action-execution.ts +22 -14
  168. package/src/server/handlers/compact-interaction.ts +91 -0
  169. package/src/server/handlers/compact-state.ts +43 -0
  170. package/src/server/handlers/debugger-handlers.ts +28 -9
  171. package/src/server/handlers/diagnostic.ts +11 -4
  172. package/src/server/handlers/input.ts +2 -0
  173. package/src/server/handlers/interact.ts +2 -0
  174. package/src/server/handlers/interaction-diagnostics.ts +50 -0
  175. package/src/server/handlers/journey.ts +15 -17
  176. package/src/server/handlers/observe.ts +2 -1
  177. package/src/server/handlers/payload.ts +2 -1
  178. package/src/server/handlers/tool-handlers.ts +29 -14
  179. package/src/server/schemas/debugger-schema.ts +10 -7
  180. package/src/server/schemas/interaction-diagnostics.ts +10 -0
  181. package/src/server/schemas/system-trace-schema.ts +15 -9
  182. package/src/server/tool-definitions.ts +8 -4
  183. package/src/server-core.ts +5 -2
  184. package/src/server.ts +4 -4
  185. package/src/system/android.ts +18 -11
  186. package/src/system/index.ts +2 -1
  187. package/src/types.ts +2 -0
  188. package/src/utils/android/utils.ts +8 -10
  189. package/src/utils/app-installation.ts +73 -0
  190. package/src/utils/benchmark-metrics.ts +42 -0
  191. package/src/utils/exec.ts +5 -0
  192. package/src/utils/ios/utils.ts +7 -10
  193. package/src/utils/resolve-device.ts +4 -2
  194. package/src/utils/timing.ts +3 -2
  195. package/test/device/automated/debugger/runtime.android.test.ts +5 -0
  196. package/test/device/automated/debugger/runtime.ios.test.ts +227 -0
  197. package/test/device/automated/interact/compact-results.smoke.ts +58 -0
  198. package/test/device/automated/latency/end-to-end-environment.ts +61 -0
  199. package/test/device/automated/latency/end-to-end-report.ts +90 -0
  200. package/test/device/automated/latency/end-to-end-session.ts +98 -0
  201. package/test/device/automated/latency/end-to-end.ts +149 -0
  202. package/test/device/automated/latency/owned-transport.ts +81 -0
  203. package/test/device/automated/latency/snapshot-pair.ts +143 -0
  204. package/test/device/automated/latency-ios/benchmark.ts +126 -0
  205. package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
  206. package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
  207. package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
  208. package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
  209. package/test/device/automated/observe/system-trace.smoke.ts +2 -2
  210. package/test/fixtures/contracts/tool-definitions.json +243 -47
  211. package/test/fixtures/ios-debugger-app/Main.swift +95 -0
  212. package/test/fixtures/ios-debugger-app/README.md +27 -0
  213. package/test/fixtures/ios-debugger-app/build.ts +21 -0
  214. package/test/fixtures/latency-journey-app/build.ts +3 -3
  215. package/test/helpers/ios-trace-shutdown.ts +29 -0
  216. package/test/unit/debugger/handle-guards.test.ts +89 -0
  217. package/test/unit/debugger/ios-preparation.test.ts +57 -0
  218. package/test/unit/debugger/ios.test.ts +104 -0
  219. package/test/unit/debugger/lldb-worker.py +166 -0
  220. package/test/unit/debugger/lldb-worker.test.ts +10 -0
  221. package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
  222. package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
  223. package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
  224. package/test/unit/observe/device-readiness.test.ts +97 -0
  225. package/test/unit/observe/ios-trace-native.test.ts +90 -0
  226. package/test/unit/observe/ios-trace-sessions.test.ts +154 -0
  227. package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
  228. package/test/unit/observe/readiness.test.ts +1 -1
  229. package/test/unit/observe/system-trace-router.test.ts +49 -0
  230. package/test/unit/quality/agent-plugin-export.test.ts +38 -0
  231. package/test/unit/server/compact-interaction.test.ts +87 -0
  232. package/test/unit/server/compact-journey-evidence.test.ts +40 -0
  233. package/test/unit/server/compact-review-regressions.test.ts +78 -0
  234. package/test/unit/server/conditional_actions.test.ts +7 -3
  235. package/test/unit/server/correlated-evidence.test.ts +18 -6
  236. package/test/unit/server/end-to-end-report.test.ts +46 -0
  237. package/test/unit/server/input-device-readiness.test.ts +55 -0
  238. package/test/unit/server/interaction-diagnostics.test.ts +45 -0
  239. package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
  240. package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
  241. package/test/unit/server/readiness.test.ts +24 -1
  242. package/test/unit/server/run_journey.test.ts +2 -1
  243. package/test/unit/server/selector_tap_target.test.ts +2 -1
  244. package/test/unit/server/system-trace.test.ts +18 -7
  245. package/test/unit/utils/app-installation.test.ts +78 -0
  246. package/test/unit/utils/benchmark-metrics.test.ts +36 -0
  247. package/test/unit/utils/resolve_device.test.ts +11 -0
@@ -1,3 +1,5 @@
1
+ import type { DeviceReadiness } from '../contracts/device-readiness.js'
2
+ import { unknownDeviceReadiness, observeDeviceReadiness, finishDeviceReadiness } from './device-readiness.js'
1
3
  import type { GetUITreeResponse } from '../types.js'
2
4
  import { pausedReadiness } from '../debugger/guard.js'
3
5
  import { AsyncLocalStorage } from 'node:async_hooks'
@@ -12,7 +14,36 @@ export function isDeviceUnavailable(error: unknown) {
12
14
  return /device.*(?:not found|offline|disconnected)|no devices found|READINESS_DEVICE_UNAVAILABLE/i.test(String(error))
13
15
  }
14
16
 
17
+ function retainBlockedEvidence(previous: DeviceReadiness, next: DeviceReadiness) {
18
+ const retained: string[] = []
19
+ if (['unknown', 'unsupported'].includes(next.wakefulness) && ['asleep', 'transitioning'].includes(previous.wakefulness)) {
20
+ next.wakefulness = previous.wakefulness
21
+ retained.push('DEVICE_ASLEEP', 'DEVICE_TRANSITIONING')
22
+ }
23
+ if (['unknown', 'unsupported'].includes(next.lock_state) && previous.lock_state === 'locked') {
24
+ next.lock_state = previous.lock_state
25
+ retained.push('DEVICE_LOCKED')
26
+ }
27
+ if (retained.length) {
28
+ next.evidence = [...previous.evidence.filter(item => retained.includes(item.code)), ...next.evidence].slice(-8)
29
+ next.limitations = [...new Set([...next.limitations, 'DEVICE_BLOCKER_EVIDENCE_RETAINED'])]
30
+ }
31
+ return finishDeviceReadiness(next)
32
+ }
33
+
15
34
  export class ReadinessTracker {
35
+ deviceReadiness = unknownDeviceReadiness()
36
+ get ready() { return this.summary.state === 'settled' && this.deviceReadiness.input_ready !== false }
37
+ async observeDevice() {
38
+ const next = await observeDeviceReadiness({ platform: this.platform, id: this.deviceId })
39
+ this.deviceReadiness = retainBlockedEvidence(this.deviceReadiness, next)
40
+ if (this.deviceReadiness.input_ready === false) {
41
+ this.signature = null
42
+ this.stableSince = 0
43
+ this.summary = { ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
44
+ limitations: [...new Set([...this.summary.limitations, 'DEVICE_BLOCKED'])] }
45
+ }
46
+ }
16
47
  private signature: string | null = null
17
48
  private stableSince = 0
18
49
  summary: ReadinessSummary
@@ -21,7 +52,7 @@ export class ReadinessTracker {
21
52
  this.summary = {
22
53
  state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
23
54
  sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
24
- evidence: [], limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
55
+ evidence: [], limitations: [...baselineLimitations, ['android', 'ios'].includes(this.platform) ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities, debugger_paused: ['android', 'ios'].includes(this.platform) }
25
56
  }
26
57
 
27
58
  }
@@ -30,7 +61,7 @@ export class ReadinessTracker {
30
61
  this.signature = null
31
62
  this.summary = {
32
63
  ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
33
- sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', code],
64
+ sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, ['android', 'ios'].includes(this.platform) ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', code],
34
65
  evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: null, snapshot_revision: null }].slice(-8)
35
66
  }
36
67
  return this.summary
@@ -42,7 +73,7 @@ export class ReadinessTracker {
42
73
  }
43
74
 
44
75
  observe(sample: unknown, acquisitionStartedAtMs: number, now = performance.now()): ReadinessSummary {
45
- const paused = this.platform === 'android' ? pausedReadiness(this.deviceId) : null
76
+ const paused = pausedReadiness(this.deviceId, this.platform)
46
77
  if (paused) { this.summary = paused; return this.summary }
47
78
  const tree = sample as GetUITreeResponse | null | undefined
48
79
  if (tree?.error) return this.unavailable('OBSERVATION_FAILED')
@@ -64,7 +95,7 @@ export class ReadinessTracker {
64
95
  captured_at_ms: tree.captured_at_ms, snapshot_session_id: tree.snapshot_session_id, snapshot_revision: tree.snapshot_revision,
65
96
  sample_count: this.summary.sample_count + 1, stable_for_ms: stableFor,
66
97
  evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: tree.captured_at_ms, snapshot_revision: tree.snapshot_revision }].slice(-8),
67
- limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
98
+ limitations: [...baselineLimitations, ['android', 'ios'].includes(this.platform) ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities, debugger_paused: ['android', 'ios'].includes(this.platform) }
68
99
  }
69
100
  return this.summary
70
101
  }
@@ -103,3 +134,5 @@ function readinessState(loading: boolean, previous: string | null, unchanged: bo
103
134
  function readinessCode(nativeLoading: boolean, textLoading: boolean, state: ReadinessSummary['state']) {
104
135
  return nativeLoading ? 'NATIVE_LOADING_INDICATOR' : textLoading ? 'LOADING_LABEL' : state === 'transitioning' ? 'SNAPSHOT_CHANGED' : state === 'settled' ? 'SNAPSHOT_STABLE' : 'INSUFFICIENT_SAMPLES'
105
136
  }
137
+
138
+ export async function observeReadinessDeviceWait() { await waits.getStore()?.observeDevice() }
@@ -1,4 +1,5 @@
1
1
  import type { Metadata } from 'sharp'
2
+ import { benchmarkStage } from '../utils/benchmark-metrics.js'
2
3
  interface EncodedScreenshot { screenshot: string; screenshot_mime: string; screenshot_fallback?: string | null; screenshot_fallback_mime?: string }
3
4
  async function convert(buffer: Buffer, platform: 'android' | 'ios') {
4
5
  const { default: sharp } = await import('sharp')
@@ -16,6 +17,9 @@ async function convert(buffer: Buffer, platform: 'android' | 'ios') {
16
17
  return { hasAlpha, webp, jpeg }
17
18
  }
18
19
  export async function encodeScreenshot(buffer: Buffer, platform: 'android' | 'ios'): Promise<EncodedScreenshot> {
20
+ return benchmarkStage('screenshot_encoding_ms', () => encode(buffer, platform))
21
+ }
22
+ async function encode(buffer: Buffer, platform: 'android' | 'ios'): Promise<EncodedScreenshot> {
19
23
  const png = buffer.toString('base64')
20
24
  try {
21
25
  const { hasAlpha, webp, jpeg } = await convert(buffer, platform)
@@ -5,6 +5,7 @@ import { measure } from '../utils/timing.js'
5
5
  import { deriveSnapshotMetadata } from './snapshot-metadata.js'
6
6
  import { deriveSnapshotSemantic } from './snapshot-semantic.js'
7
7
  import { snapshotLog, streamLog, type SnapshotLogEntry } from './snapshot-logs.js'
8
+ import { collectGetScreenFingerprint } from './android/fingerprint.js'
8
9
  export interface SnapshotInput { reason?: string; includeLogs?: boolean; includeScreenshot?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string }
9
10
  type Dependencies = Pick<typeof ToolsObserve, 'captureScreenshotHandler' | 'getCurrentScreenHandler' | 'getScreenFingerprintHandler' | 'getUITreeHandler' | 'readLogStreamHandler' | 'getLogsHandler'>
10
11
  async function settle<T extends Record<string, Promise<unknown>>>(tasks: T) {
@@ -17,11 +18,15 @@ function apply<T>(result: PromiseSettledResult<T>, success: (value: T) => void,
17
18
  }
18
19
  async function collect(raw: CaptureDebugSnapshotRawResponse, input: SnapshotInput, deps: Dependencies) {
19
20
  const { platform, deviceId, includeScreenshot = true, includeLogs = true, sessionId, logLines = 200 } = input
21
+ const androidTree = platform === 'android' ? deps.getUITreeHandler({ platform, deviceId }) : undefined
20
22
  const results = await settle({
21
23
  screenshot: includeScreenshot ? measure('screenshot_ms', () => deps.captureScreenshotHandler({ platform, deviceId })) : Promise.resolve(null),
22
24
  currentScreen: !platform || platform === 'android' ? deps.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
23
- fingerprint: deps.getScreenFingerprintHandler({ platform, deviceId }),
24
- uiTree: deps.getUITreeHandler({ platform, deviceId }),
25
+ fingerprint: androidTree ? collectGetScreenFingerprint({
26
+ getUITree: () => androidTree,
27
+ getCurrentScreen: id => deps.getCurrentScreenHandler({ deviceId: id })
28
+ }, deviceId) : deps.getScreenFingerprintHandler({ platform, deviceId }),
29
+ uiTree: androidTree ?? deps.getUITreeHandler({ platform, deviceId }),
25
30
  readLogStream: includeLogs ? deps.readLogStreamHandler({ platform, deviceId, sessionId: sessionId || 'default', limit: logLines }) : Promise.resolve({ entries: [] })
26
31
  })
27
32
  let entries: unknown[] = []
@@ -0,0 +1,3 @@
1
+ export class TraceError extends Error {
2
+ constructor(public code: string) { super(code) }
3
+ }
@@ -0,0 +1,61 @@
1
+ import { TraceError } from './system-trace-error.js'
2
+ import type { SystemTraceSessions, TraceResult } from './system-trace.js'
3
+ import { IosTraceSessions, type IosTraceResult } from './ios-trace/sessions.js'
4
+ import { IosTraceNative } from './ios-trace/native.js'
5
+ import type { IosTemplate } from './ios-trace/capabilities.js'
6
+
7
+ interface Route { platform: 'android' | 'ios'; device: string; active: boolean; touched: number }
8
+ export class SystemTraceRouter {
9
+ private routes = new Map<string, Route>()
10
+ private pending = new Set<string>()
11
+ private closing = false
12
+ constructor(private android: SystemTraceSessions, private ios = new IosTraceSessions(), private native = new IosTraceNative()) {}
13
+ capabilities(device: string) { return this.android.capabilities(device) }
14
+ iosCapabilities(device: string, appId?: string, template?: IosTemplate) { return this.native.capabilities(device, appId, template) }
15
+ private backend(platform: string) { return platform === 'ios' ? this.ios : this.android }
16
+ private track(result: TraceResult | IosTraceResult) {
17
+ this.routes.set(result.trace_id, { platform: result.device.platform, device: result.device.id,
18
+ active: result.cleanup.status !== 'complete', touched: performance.now() })
19
+ const terminal = [...this.routes.entries()].filter(([, r]) => !r.active)
20
+ for (const [index, [id, r]] of terminal.entries()) if (performance.now() - r.touched > 600000 || index < terminal.length - 100) this.routes.delete(id)
21
+ return result
22
+ }
23
+ async start(device: string, duration?: number, ios?: { appId: string; template?: IosTemplate }) {
24
+ if (this.closing) throw new TraceError('TRACE_BUSY')
25
+ // Refresh automatically finalised reservations without trusting cached state.
26
+ await Promise.all([...this.routes.entries()].filter(([, r]) => r.active).map(async ([id, r]) => {
27
+ try { this.track(await this.backend(r.platform).control(id, 'status')) }
28
+ catch { /* uncertain cleanup continues to reserve capacity */ }
29
+ }))
30
+ const key = (ios ? 'ios:' : 'android:') + device
31
+ const active = [...this.routes.values()].filter(r => r.active)
32
+ if (this.closing || this.pending.has(key) || active.some(r => r.platform + ':' + r.device === key) || active.length + this.pending.size >= 4) throw new TraceError('TRACE_BUSY')
33
+ this.pending.add(key)
34
+ try {
35
+ if (!ios) return this.track(await this.android.start(device, duration))
36
+ const cap = await this.native.capabilities(device, ios.appId, ios.template)
37
+ if (!cap.supported || !cap.device) throw new TraceError('TRACE_UNSUPPORTED')
38
+ if (this.closing) throw new TraceError('TRACE_BUSY')
39
+ return this.track(await this.ios.start(cap.device, ios.appId, duration))
40
+ } finally { this.pending.delete(key) }
41
+ }
42
+ async control(id: string, operation: 'status' | 'stop' | 'cancel' | 'duration') {
43
+ const route = this.routes.get(id)
44
+ if (!route) throw new TraceError('TRACE_NOT_FOUND')
45
+ return this.track(await this.backend(route.platform).control(id, operation))
46
+ }
47
+ async link(id: string, device: { platform: string; id: string }, correlation: string) {
48
+ const route = this.routes.get(id)
49
+ if (!route || route.platform !== device.platform || route.device !== device.id) throw new TraceError('TRACE_NOT_FOUND')
50
+ return this.backend(route.platform).link(id, device, correlation)
51
+ }
52
+ async shutdown() {
53
+ this.closing = true
54
+ this.native.shutdown()
55
+ const results = await Promise.allSettled([this.android.shutdown(), this.ios.shutdown()])
56
+ if (results.some(result => result.status === 'rejected')) throw new TraceError('TRACE_STOP_FAILED')
57
+ const remaining: (TraceResult | IosTraceResult)[] = []
58
+ for (const result of results) if (result.status === 'fulfilled') remaining.push(...result.value)
59
+ return remaining
60
+ }
61
+ }
@@ -1,3 +1,4 @@
1
+ import { SystemTraceRouter } from './system-trace-router.js'
1
2
  import { randomUUID } from 'node:crypto'
2
3
  import { promises as fs } from 'node:fs'
3
4
  import os from 'node:os'
@@ -6,9 +7,8 @@ import { AndroidTraceNative, TRACE_MAX_BYTES, type TraceCapabilities, type Trace
6
7
  import { evidenceSessionId } from './evidence.js'
7
8
  import { runWithBudget, remainingBudget } from '../utils/operation-budget.js'
8
9
 
9
- export class TraceError extends Error {
10
- constructor(public code: string) { super(code) }
11
- }
10
+ export { TraceError } from './system-trace-error.js'
11
+ import { TraceError } from './system-trace-error.js'
12
12
  type State = 'starting' | 'recording' | 'stopping' | 'completed' | 'cancelled' | 'failed'
13
13
  type Completion = 'requested' | 'duration_limit' | 'cancelled' | 'error'
14
14
  export interface TraceResult {
@@ -224,4 +224,4 @@ export class SystemTraceSessions {
224
224
  })
225
225
  }
226
226
  }
227
- export const systemTraces = new SystemTraceSessions()
227
+ export const systemTraces = new SystemTraceRouter(new SystemTraceSessions())
@@ -13,9 +13,9 @@ export function wrapResponse<T>(data: T, includeSerializationTiming = true) {
13
13
  }
14
14
 
15
15
  export type ToolCallArgs = Record<string, unknown>
16
- export type ToolCallResult = Awaited<ReturnType<typeof wrapResponse>> | {
16
+ export type ToolCallResult = (Awaited<ReturnType<typeof wrapResponse>> | {
17
17
  content: Array<{ type: 'text' | 'image'; text?: string; data?: string; mimeType?: string }>
18
- }
18
+ }) & { isError?: boolean }
19
19
  export type ToolHandler = (args: ToolCallArgs) => Promise<ToolCallResult>
20
20
 
21
21
  export function getStringArg(args: ToolCallArgs, key: string): string | undefined {
@@ -185,11 +185,12 @@ Failure Handling:
185
185
  },
186
186
  {
187
187
  name: 'list_devices',
188
- description: 'List connected devices and their metadata (android + ios).',
188
+ description: 'List Android devices and iOS simulators. Supply appId for device-specific installation status; unknown checks do not imply absence.',
189
189
  inputSchema: {
190
190
  type: 'object',
191
191
  properties: {
192
- platform: { type: 'string', enum: ['android', 'ios'] }
192
+ platform: { type: 'string', enum: ['android', 'ios'] },
193
+ appId: { type: 'string', pattern: '^[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)*$', description: 'Optional package or bundle identifier to check on each device.' }
193
194
  }
194
195
  }
195
196
  }
@@ -1,3 +1,4 @@
1
+ import { observeDeviceReadiness, unknownDeviceReadiness } from '../../observe/device-readiness.js'
1
2
  import type { ToolPayload } from './payload.js'
2
3
  import { payloadOf } from './payload.js'
3
4
  import { correlatedHandler } from './correlation.js'
@@ -8,7 +9,7 @@ import { randomUUID } from 'node:crypto'
8
9
  import { runWithBudget } from '../../utils/operation-budget.js'
9
10
  import { validateExpectedState } from '../schemas/condition-schema.js'
10
11
  import { conditionFailureCode, createConditionalAction, withConditionalAction } from '../../interact/conditional-action.js'
11
- import { resolveTargetDevice, withResolvedDevice } from '../../utils/resolve-device.js'
12
+ import { resolveTargetDevice, withResolvedDevice, currentResolvedDevice } from '../../utils/resolve-device.js'
12
13
 
13
14
  import { ToolsObserve } from '../../observe/index.js'
14
15
  import { recordObservationEvent, withObservationContext } from '../../observe/performance-metrics.js'
@@ -24,6 +25,7 @@ import type { PlatformArg, VerificationMode } from './handler-types.js'
24
25
  export function compactAction(result: ToolPayload, verificationMode: VerificationMode, started = true, stateDelta?: unknown, totalMs = 0) {
25
26
  return {
26
27
  action_id: result.action_id,
28
+ ...(result.device_readiness ? { device_readiness: result.device_readiness } : {}),
27
29
  action_type: result.action_type,
28
30
  success: !!result.success,
29
31
  lifecycle_state: result.success ? (verificationMode === 'full' ? result.lifecycle_state : 'pending_verification') : 'failed',
@@ -51,14 +53,25 @@ function createAction(type: string, args: ToolCallArgs, timing: Record<string, n
51
53
  }
52
54
  }
53
55
  type Action = ReturnType<typeof createAction>
56
+ async function executeWithDeviceState(args: ToolCallArgs, execute: ToolHandler) {
57
+ const result = payloadOf(await execute(args))
58
+ result.device_readiness = await observeDeviceReadiness(result.device ?? currentResolvedDevice())
59
+ return result
60
+ }
61
+ function actionError(type: string, error: unknown) {
62
+ const response = wrapToolError(type, error)
63
+ const item = response.content.find(item => item.type === 'text')
64
+ if (item?.text) item.text = JSON.stringify({ ...JSON.parse(item.text), device_readiness: unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE') })
65
+ return response
66
+ }
54
67
  async function dispatchAction(s: Action, execute: ToolHandler) {
55
68
  const { type, args, controls, condition, budgetMs } = s
56
69
  const dispatchWithBudget = (timeoutMs: number) => runWithBudget(timeoutMs, async (budget) => {
57
70
  try {
58
- if (!condition) return payloadOf(await execute(args))
71
+ if (!condition) return await executeWithDeviceState(args, execute)
59
72
  return await withConditionalAction(condition, async () => {
60
73
  const device = await resolveTargetDevice({ platform: condition.platform, deviceId: condition.deviceId })
61
- return withResolvedDevice(device, async () => payloadOf(await execute(args)))
74
+ return withResolvedDevice(device, async () => executeWithDeviceState(args, execute))
62
75
  })
63
76
  }
64
77
  finally { s.dispatchStarted = budget.dispatchStarted }
@@ -69,8 +82,8 @@ async function dispatchAction(s: Action, execute: ToolHandler) {
69
82
  ? await dispatchWithBudget(Infinity) : await dispatchWithBudget(budgetMs)
70
83
  if (s.result.success) s.dispatchStarted = true
71
84
  } catch (error) {
72
- if (!condition && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined && !/TIMEOUT|timed out/i.test(String(error))) return wrapToolError(type, error)
73
- s.result = { success: false, failure_code: /TIMEOUT|timed out/i.test(String(error)) ? 'ACTION_TIMEOUT' : 'UNKNOWN', error: String(error) }
85
+ if (!condition && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined && !/TIMEOUT|timed out/i.test(String(error))) return actionError(type, error)
86
+ s.result = { device_readiness: unknownDeviceReadiness('DEVICE_OBSERVATION_UNAVAILABLE'), success: false, failure_code: /TIMEOUT|timed out/i.test(String(error)) ? 'ACTION_TIMEOUT' : 'UNKNOWN', error: String(error) }
74
87
  }
75
88
  }
76
89
  function rejectCondition(s: Action) {
@@ -78,6 +91,7 @@ function rejectCondition(s: Action) {
78
91
  s.blocked = !!condition && ['mismatch', 'unavailable'].includes(condition.result.status)
79
92
  if (s.blocked && condition) {
80
93
  s.dispatchStarted = false
94
+ const deviceReadiness = s.result.device_readiness
81
95
  s.result = buildActionExecutionResult({
82
96
  actionType: type, sourceModule: 'server', device: condition.result.observed?.device,
83
97
  selector: s.result?.target?.selector ?? null, resolved: s.result?.target?.resolved,
@@ -85,6 +99,7 @@ function rejectCondition(s: Action) {
85
99
  failure: { failureCode: conditionFailureCode(condition), retryable: false },
86
100
  traceSteps: [{ stage: 'verify', timestamp: Date.now(), result: 'failure', attempt_index: 0, metadata: { phase: 'precondition', dispatch_started: false } }]
87
101
  })
102
+ s.result.device_readiness = deviceReadiness
88
103
  }
89
104
  }
90
105
  function normalizeAction(s: Action) {
@@ -130,19 +145,12 @@ function actionResponse(s: Action) {
130
145
  const evidence = {
131
146
  ...compact,
132
147
  ...(condition ? {
133
- condition: controls.responseMode === 'compact' && condition.result.status === 'matched'
134
- ? {
135
- ...condition.result, observed: condition.result.observed ? {
136
- snapshot_session_id: condition.result.observed.snapshot_session_id,
137
- snapshot_revision: condition.result.observed.snapshot_revision,
138
- captured_at_ms: condition.result.observed.captured_at_ms
139
- } : null
140
- } : condition.result
148
+ condition: condition.result
141
149
  } : {}),
142
150
  ...(controls.verificationMode === 'light' ? { state_delta_available: !!s.delta } : {}),
143
151
  ...(s.result.success && controls.verificationMode === 'light' && !s.observationAvailable ? { verification_diagnostic: { code: 'VERIFICATION_UNAVAILABLE', retryable: true } } : {})
144
152
  }
145
- return wrapResponse(controls.responseMode === 'compact' ? evidence : {
153
+ return wrapResponse({
146
154
  ...s.result, ...evidence, timing: { ...timing, total_ms: performance.now() - started },
147
155
  effective_controls: { ...controls, actionTimeoutMs: s.unbounded ? null : budgetMs, timeout_bound: !(s.unbounded) }
148
156
  }, controls.responseMode === 'debug')
@@ -0,0 +1,91 @@
1
+ import { evidenceSessionId } from '../../observe/evidence.js'
2
+ import { interactionDiagnostics } from './interaction-diagnostics.js'
3
+ import { object, pick, boundedValue, boundedState, observedState, deltaSummary, type Payload } from './compact-state.js'
4
+ import type { ToolCallArgs, ToolCallResult } from '../common.js'
5
+
6
+ export const compactInteractionTools = new Set(['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'run_journey'])
7
+ const bytes = (value: unknown) => Buffer.byteLength(JSON.stringify(value))
8
+ // IDs are preserved verbatim; their allowance counts the UTF-8 wire encoding.
9
+ const idBytes = (value: unknown) => bytes(String(value ?? '')) - 2
10
+ const deviceBytes = (evidence: unknown) => idBytes(object(object(evidence).device).id)
11
+ function compactEvidence(value: unknown) {
12
+ const e = object(value)
13
+ return { ...pick(e, ['session_id', 'correlation_id', 'action_id', 'device']),
14
+ ...(e.association_reason ? pick(e, ['association', 'association_reason']) : {}),
15
+ ...(object(e.trace_context).trace_id ? { trace_id: object(e.trace_context).trace_id } : {}) }
16
+ }
17
+ function conditionSummary(value: unknown) {
18
+ const c = object(value)
19
+ return { ...pick(c, ['status', 'reason']), observed: c.observed == null ? null : pick(c.observed, ['snapshot_session_id', 'snapshot_revision', 'captured_at_ms']) }
20
+ }
21
+ function addCompactError(result: Payload, error: unknown, name: string) {
22
+ const message = typeof error === 'string' ? error : object(error).message
23
+ result.error = { ...pick(error, ['code']), message: boundedValue(message) }
24
+ result.action_type ??= name
25
+ result.verification ??= { status: 'unavailable' }
26
+ if (object(result.error).message !== message) result.truncated = true
27
+ }
28
+ function compactResult(source: Payload, name: string, limit = 1280): Payload {
29
+ const evidence = object(source.evidence)
30
+ const result: Payload = {
31
+ ...pick(source, ['action_id', 'action_type', 'success', 'lifecycle_state', 'failure_code', 'retryable', 'delivery_status', 'dispatch_started', 'verification', 'verification_diagnostic', 'state_delta_available']),
32
+ observed_state: !['wait', 'assert'].includes(name) && source.dispatch_started !== true
33
+ ? { status: 'unknown', reason: 'NO_POST_ACTION_OBSERVATION' } : observedState(evidence, source.dispatch_started === true),
34
+ evidence: compactEvidence(evidence), diagnostics: interactionDiagnostics.retain(source)
35
+ }
36
+ result.success ??= false
37
+ result.verification ??= { status: source.dispatch_started === false ? 'not_requested' : 'unavailable' }
38
+ if (source.error) addCompactError(result, source.error, name)
39
+ if (source.device_readiness) result.device_readiness = pick(source.device_readiness, ['wakefulness', 'lock_state', 'input_ready', 'blockers', 'limitations'])
40
+ if (source.condition) result.condition = conditionSummary(source.condition)
41
+ if (source.state_delta) result.state_delta = deltaSummary(source.state_delta)
42
+ if (source.timing) result.timing = pick(source.timing, ['total_ms'])
43
+ if (object(source.observed).matched_count !== undefined) result.matched_count = object(source.observed).matched_count
44
+ if (source.observed_fingerprint !== undefined) result.matched = !!source.success
45
+ const state = source.actual_state !== undefined ? source.actual_state : source.observed_state
46
+ if (state !== undefined) {
47
+ result.observed = boundedState(state)
48
+ if (JSON.stringify(result.observed) !== JSON.stringify(state)) result.truncated = true
49
+ }
50
+ result.evidence = { session_id: evidenceSessionId(), ...object(result.evidence) }
51
+ enforceBudget(result, limit + deviceBytes(evidence))
52
+ return result
53
+ }
54
+ function enforceBudget(result: Payload, limit: number) {
55
+ if (bytes(result) <= limit) return
56
+ result.truncated = true
57
+ for (const key of ['state_delta', 'observed', 'matched_count', 'matched', 'state_delta_available', 'timing']) {
58
+ delete result[key]
59
+ if (bytes(result) <= limit) return
60
+ }
61
+ if (result.condition) result.condition = pick(result.condition, ['status', 'reason'])
62
+ result.observed_state = { status: 'unknown', reason: 'OMITTED' }
63
+ if (result.failure_code) delete result.error
64
+ else if (result.error) result.error = pick(result.error, ['code'])
65
+ }
66
+ function compactJourney(source: Payload): Payload {
67
+ const steps = Array.isArray(source.steps) ? source.steps.map(object) : []
68
+ const result: Payload = { ...pick(source, ['stopped_at_step_id']), success: source.success === true,
69
+ evidence: compactEvidence(source.evidence), diagnostics: interactionDiagnostics.retain(source),
70
+ observed_state: observedState(source.evidence), timing: pick(source.timing, ['total_ms']),
71
+ steps: steps.map(step => ({ ...pick(step, ['id', 'type', 'status', 'success', 'reason']),
72
+ result: step.result ? compactResult(object(step.result), String(step.type), 1080) : null })) }
73
+ if (source.error) {
74
+ const message = object(source.error).message
75
+ const bounded = boundedValue(message)
76
+ result.error = { ...pick(source.error, ['code']), message: bounded }
77
+ if (bounded !== message) result.truncated = true
78
+ }
79
+ if (source.system_trace) result.system_trace = pick(source.system_trace, ['trace_id', 'state', 'coverage', 'clock_alignment'])
80
+ enforceBudget(result, 1280 + 1280 * steps.length + steps.reduce((sum, s) => sum + idBytes(s.id) + deviceBytes(object(s.result).evidence), 0) + idBytes(source.stopped_at_step_id) + deviceBytes(source.evidence))
81
+ return result
82
+ }
83
+ export function finalizeCompactInteraction(name: string, args: ToolCallArgs, response: ToolCallResult) {
84
+ if (!compactInteractionTools.has(name) || (args.responseMode ?? (name === 'run_journey' ? 'compact' : 'debug')) !== 'compact') return response
85
+ const item = response.content.find(item => item.type === 'text')
86
+ if (item?.text) {
87
+ const source = object(JSON.parse(item.text))
88
+ item.text = JSON.stringify(name === 'run_journey' ? compactJourney(source) : compactResult(source, name))
89
+ }
90
+ return response
91
+ }
@@ -0,0 +1,43 @@
1
+ export type Payload = Record<string, unknown>
2
+ export function object(value: unknown): Payload {
3
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Payload : {}
4
+ }
5
+ export function pick(value: unknown, keys: string[]): Payload {
6
+ const source = object(value)
7
+ return Object.fromEntries(keys.filter(key => source[key] !== undefined).map(key => [key, source[key]]))
8
+ }
9
+ export function boundedValue(value: unknown): unknown {
10
+ if (typeof value !== 'string') return ['number', 'boolean'].includes(typeof value) || value === null ? value : undefined
11
+ let text = ''
12
+ for (const char of value) {
13
+ if (Buffer.byteLength(text + char) > 128) break
14
+ text += char
15
+ }
16
+ return text
17
+ }
18
+ export function observedState(evidence: unknown, dispatchStarted = false): Payload {
19
+ const e = object(evidence), device = object(e.device)
20
+ const observations = Array.isArray(e.observations) ? e.observations.map(object) : []
21
+ const candidates = observations.filter(o => ['ui_tree', 'fingerprint', 'screenshot'].includes(String(o.kind)))
22
+ const last = candidates.at(-1)
23
+ if (!last) return { status: 'unknown', reason: 'NO_POST_ACTION_OBSERVATION' }
24
+ const summary = pick(last, ['kind', 'source_captured_at_ms', 'freshness', 'snapshot_session_id', 'snapshot_revision'])
25
+ summary.captured_at_ms = summary.source_captured_at_ms ?? null
26
+ delete summary.source_captured_at_ms
27
+ const same = object(last.device).id === device.id && object(last.device).platform === device.platform && !!device.id
28
+ const post = typeof e.dispatch_completed_at_ms === 'number' && typeof last.capture_started_at_ms === 'number' && last.capture_started_at_ms >= e.dispatch_completed_at_ms
29
+ if (typeof last.source_captured_at_ms !== 'number' || last.source_captured_at_ms <= 0) return { status: 'unknown', reason: 'CAPTURE_TIME_UNKNOWN', ...summary }
30
+ if (last.status === 'captured' && last.freshness === 'fresh' && same && (!dispatchStarted || post)) return { status: 'observed', ...summary }
31
+ return { status: 'unknown', reason: !same ? 'DEVICE_MISMATCH' : !post && dispatchStarted ? 'NO_POST_ACTION_OBSERVATION' : 'OBSERVATION_UNAVAILABLE', ...summary }
32
+ }
33
+ export function deltaSummary(value: unknown) {
34
+ const delta = object(value)
35
+ return { ...pick(delta, ['base_snapshot_revision', 'snapshot_revision', 'truncated', 'omitted_changes']),
36
+ ...Object.fromEntries(['added', 'removed', 'changed'].map(key => [key, Array.isArray(delta[key]) ? delta[key].length : 0])) }
37
+ }
38
+
39
+ export function boundedState(value: unknown): unknown {
40
+ if (!value || typeof value !== 'object') return boundedValue(value)
41
+ const fields = pick(value, ['property', 'value', 'raw_value', 'checked', 'selected', 'expanded', 'enabled', 'text_value'])
42
+ return Object.fromEntries(Object.entries(fields).map(([key, item]) => [key, boundedValue(item)]).filter(([, item]) => item !== undefined))
43
+ }
@@ -1,7 +1,8 @@
1
+ import { iosCapabilities } from '../../debugger/ios-worker.js'
1
2
  import { debuggerResponse } from './debugger-response.js'
2
3
  import { classify, type DebuggerDiagnostic } from '../../debugger/diagnostics.js'
3
4
  import { debuggerContracts } from '../schemas/debugger-schema.js'
4
- import { debuggerSessions, debuggerCapabilities } from '../../debugger/sessions.js'
5
+ import { debuggerSessions, iosDebuggerSessions, debuggerCapabilities } from '../../debugger/sessions.js'
5
6
  import { DebuggerError, withDebuggerBudget } from '../../debugger/worker.js'
6
7
  import { wrapResponse, type ToolCallArgs } from '../common.js'
7
8
  function failure(code: string, summary: Record<string, unknown> = {}, diagnostic?: DebuggerDiagnostic, command = '', mode: 'compact' | 'debug' = 'debug') {
@@ -17,23 +18,41 @@ export async function handleDebugger(name: string, args: ToolCallArgs, signal?:
17
18
  const parsed = debuggerContracts[name].schema.safeParse(args)
18
19
  if (!parsed.success) return failure('INVALID_ARGUMENT')
19
20
  const { responseMode, ...input } = parsed.data
20
- const a = input as { timeoutMs: number; deviceId?: string; appId?: string; processId?: number; debugSessionId?: string }
21
+ const a = input as { platform?: 'android' | 'ios'; timeoutMs: number; deviceId?: string; appId?: string; processId?: number; debugSessionId?: string }
21
22
  const command = debuggerContracts[name].command
23
+ const sessions = selectSessions(a)
22
24
  try {
23
25
  if (signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
24
- const result = await withDebuggerBudget(a.timeoutMs, signal, async () => command === 'capabilities' ? await debuggerSessions.capabilities(a.deviceId!, a.appId!, a.processId) :
25
- command === 'attach' ? await debuggerSessions.attach(a.deviceId!, a.appId!, a.processId, a.timeoutMs) :
26
- await debuggerSessions.command(a.debugSessionId!, command, { ...a, signal: command === 'events' ? signal : undefined }, a.timeoutMs))
26
+ const result = await withDebuggerBudget(a.timeoutMs, signal, async () => command === 'capabilities' ? await sessions.capabilities(a.deviceId!, a.appId!, a.processId) :
27
+ command === 'attach' ? await sessions.attach(a.deviceId!, a.appId!, a.processId, a.timeoutMs) :
28
+ await sessions.command(a.debugSessionId!, command, { ...a, signal: command === 'events' ? signal : undefined }, a.timeoutMs))
27
29
  if (signal?.aborted && command === 'capabilities') throw new DebuggerError('DEBUGGER_CANCELLED')
28
- return { ...wrapResponse(debuggerResponse(command, responseMode, result)), ...(!result.success ? { isError: true } : {}) }
30
+ const payload = sessions === iosDebuggerSessions ? iosDiagnostics(result) : result
31
+ return { ...wrapResponse(debuggerResponse(command, responseMode, payload)), ...(!result.success ? { isError: true } : {}) }
29
32
  } catch (e) {
30
33
  const error = classify(e, 'unknown', 'DEBUGGER_WORKER_FAILED')
31
34
  const code = error.code
32
35
  let summary: Partial<ReturnType<typeof debuggerSessions.summary>> = {}
33
- if (a.debugSessionId) { try { summary = debuggerSessions.summary(a.debugSessionId) } catch { /* Unknown session */ } }
34
- return failure(code, {
36
+ if (a.debugSessionId) { try { summary = sessions.summary(a.debugSessionId) } catch { /* Unknown session */ } }
37
+ const response = failure(code, {
38
+ ...(sessions === iosDebuggerSessions ? { capabilities: iosCapabilities } : {}),
35
39
  debug_session_id: a.debugSessionId ?? null,
36
- device: a.deviceId ? { platform: 'android', id: a.deviceId } : null, ...summary
40
+ device: a.deviceId ? { platform: a.platform ?? 'android', id: a.deviceId } : null, ...summary
37
41
  }, code === 'DEBUGGER_CLEANUP_FAILED' ? summary.error?.diagnostic : error.diagnostic, command, responseMode)
42
+ if (sessions === iosDebuggerSessions) response.content[0].text = JSON.stringify(iosDiagnostics(JSON.parse(response.content[0].text)))
43
+ return response
38
44
  }
39
45
  }
46
+
47
+ function selectSessions(a: { platform?: string; debugSessionId?: string }) {
48
+ return a.platform === 'ios' || (a.debugSessionId && iosDebuggerSessions.owns(a.debugSessionId)) ? iosDebuggerSessions : debuggerSessions
49
+ }
50
+ function iosRecovery(d: DebuggerDiagnostic) {
51
+ return { ...d, recovery: d.recovery.replace('JDK 17+ availability, including java, javac and jdk.jdi.', 'Xcode and its Swift-enabled LLDB/Python availability.').replace('JDK compiler and worker source/cache', 'LLDB worker source').replace('Java worker', 'LLDB worker').replace('ADB server', 'simulator') }
52
+ }
53
+ function iosDiagnostics<T extends Record<string, unknown>>(value: T) {
54
+ const result = { ...value }
55
+ const error = value.error as { diagnostic?: DebuggerDiagnostic } | undefined
56
+ return { ...result, ...(error?.diagnostic ? { error: { ...error, diagnostic: iosRecovery(error.diagnostic) } } : {}),
57
+ ...(Array.isArray(value.diagnostics) ? { diagnostics: (value.diagnostics as DebuggerDiagnostic[]).map(iosRecovery) } : {}) }
58
+ }
@@ -1,3 +1,4 @@
1
+ import type { IosTemplate } from '../../observe/ios-trace/capabilities.js'
1
2
  import { systemTraces, TraceError } from '../../observe/system-trace.js'
2
3
  import { traceTargetSchema, traceStartSchema, traceControlSchema } from '../schemas/system-trace-schema.js'
3
4
  import { resolveTargetDevice } from '../../utils/resolve-device.js'
@@ -57,10 +58,10 @@ export async function handleSystemTrace(name: string, args: ToolCallArgs) {
57
58
  const parsed = schema.safeParse(args)
58
59
  if (!parsed.success) throw new TraceError('INVALID_ARGUMENT')
59
60
  if (name === 'get_system_trace_capabilities' || name === 'start_system_trace') {
60
- const target = parsed.data as { platform: PlatformArg; deviceId?: string; durationMs?: number }
61
- if (target.platform !== 'android') {
62
- if (name === 'start_system_trace') throw new TraceError('UNSUPPORTED_PLATFORM')
63
- return wrapResponse({ supported: false, backend: 'perfetto', device: null, prerequisites: {}, reasons: ['UNSUPPORTED_PLATFORM'], enabled_sources: [], unavailable_categories: [], limitations: ['Android only'] })
61
+ const target = parsed.data as { platform: PlatformArg; deviceId?: string; durationMs?: number; appId?: string; template?: IosTemplate }
62
+ if (target.platform === 'android' && (target.appId !== undefined || target.template !== undefined)) throw new TraceError('INVALID_ARGUMENT')
63
+ if (target.platform === 'ios') {
64
+ return handleIosTrace(name, target)
64
65
  }
65
66
  let device
66
67
  try { device = await resolveTargetDevice(target) }
@@ -77,3 +78,9 @@ export async function handleSystemTrace(name: string, args: ToolCallArgs) {
77
78
  return { ...wrapResponse(result), ...(['failed', 'cancelled'].includes(result.state) ? { isError: true } : {}) }
78
79
  }
79
80
 
81
+ async function handleIosTrace(name: string, target: { deviceId?: string; appId?: string; durationMs?: number; template?: IosTemplate }) {
82
+ if (!target.deviceId || (name === 'start_system_trace' && !target.appId)) throw new TraceError('INVALID_ARGUMENT')
83
+ if (name === 'get_system_trace_capabilities') return wrapResponse(await systemTraces.iosCapabilities(target.deviceId, target.appId, target.template))
84
+ const result = await systemTraces.start(target.deviceId, target.durationMs, { appId: target.appId!, template: target.template })
85
+ return { ...wrapResponse(result), ...(result.state === 'failed' ? { isError: true } : {}) }
86
+ }
@@ -34,6 +34,7 @@ export async function executeTap(args: ToolCallArgs) {
34
34
  const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
35
35
  const result = buildActionExecutionResult({
36
36
  actionType: 'tap',
37
+ device: res.device,
37
38
  sourceModule: 'server',
38
39
  selector: { x, y },
39
40
  success: !!res.success,
@@ -84,6 +85,7 @@ export async function executeSwipe(args: ToolCallArgs) {
84
85
  const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
85
86
  const result = buildActionExecutionResult({
86
87
  actionType: 'swipe',
88
+ device: res.device,
87
89
  sourceModule: 'server',
88
90
  selector: { x1, y1, x2, y2, duration },
89
91
  success: !!res.success,
@@ -1,3 +1,4 @@
1
+ import { observeDeviceReadiness } from '../../observe/device-readiness.js'
1
2
  import type { UiSelector as ExpectElementSelectorArg } from '../../contracts/selector.js'
2
3
  import { markEvidenceVerification } from '../../observe/evidence.js'
3
4
  import { withReadyWait } from '../../observe/readiness-service.js'
@@ -86,6 +87,7 @@ export async function handleAdjustControl(args: ToolCallArgs) {
86
87
  platform,
87
88
  deviceId
88
89
  })
90
+ res.device_readiness = await observeDeviceReadiness(res.device)
89
91
  markEvidenceVerification()
90
92
  return wrapResponse(res)
91
93
  }