mobile-debug-mcp 0.32.4 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.cursor-plugin/marketplace.json +16 -0
  4. package/.github/copilot-instructions.md +7 -0
  5. package/.github/workflows/ci.yml +21 -0
  6. package/AGENTS.md +21 -10
  7. package/README.md +69 -4
  8. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  9. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  10. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  11. package/agent-plugin/.mcp.json +14 -0
  12. package/agent-plugin/README.md +9 -3
  13. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  14. package/agent-plugin/mcp.json +1 -1
  15. package/agent-plugin/plugin.json +1 -1
  16. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
  17. package/dist/debugger/android.js +78 -0
  18. package/dist/debugger/guard.js +30 -0
  19. package/dist/debugger/sessions.js +262 -0
  20. package/dist/debugger/worker.js +165 -0
  21. package/dist/interact/android.js +10 -4
  22. package/dist/interact/conditional-action.js +68 -0
  23. package/dist/interact/index.js +70 -14
  24. package/dist/interact/ios.js +14 -8
  25. package/dist/manage/android.js +4 -3
  26. package/dist/manage/ios.js +3 -2
  27. package/dist/observe/android.js +25 -4
  28. package/dist/observe/evidence.js +280 -0
  29. package/dist/observe/index.js +33 -6
  30. package/dist/observe/ios.js +25 -6
  31. package/dist/observe/readiness-service.js +124 -0
  32. package/dist/observe/readiness.js +88 -0
  33. package/dist/observe/recording.js +165 -0
  34. package/dist/observe/snapshot-metadata.js +10 -2
  35. package/dist/observe/system-trace-android.js +135 -0
  36. package/dist/observe/system-trace.js +285 -0
  37. package/dist/server/common.js +9 -0
  38. package/dist/server/condition-schema.js +32 -0
  39. package/dist/server/debugger-handlers.js +38 -0
  40. package/dist/server/debugger-schema.js +42 -0
  41. package/dist/server/journey-schema.js +4 -2
  42. package/dist/server/readiness-schema.js +12 -0
  43. package/dist/server/system-trace-schema.js +17 -0
  44. package/dist/server/tool-definitions.js +51 -0
  45. package/dist/server/tool-handlers.js +294 -105
  46. package/dist/server-core.js +3 -3
  47. package/dist/server.js +34 -0
  48. package/dist/system/index.js +5 -4
  49. package/dist/utils/exec.js +2 -0
  50. package/dist/utils/ios/utils.js +4 -4
  51. package/dist/utils/java.js +14 -12
  52. package/dist/utils/operation-budget.js +26 -2
  53. package/dist/utils/resolve-device.js +9 -0
  54. package/docs/CHANGELOG.md +15 -0
  55. package/docs/specs/020-conditional-actions/intent.md +62 -0
  56. package/docs/specs/020-conditional-actions/plan.md +214 -0
  57. package/docs/specs/020-conditional-actions/spec.md +301 -0
  58. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  59. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  60. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  61. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  62. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  63. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  64. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  65. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  66. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  67. package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
  68. package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
  69. package/docs/specs/023-android-system-traces/intent.md +40 -0
  70. package/docs/specs/023-android-system-traces/plan.md +117 -0
  71. package/docs/specs/023-android-system-traces/spec.md +120 -0
  72. package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
  73. package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
  74. package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
  75. package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
  76. package/docs/specs/process.md +213 -0
  77. package/docs/tools/TOOLS.md +5 -0
  78. package/docs/tools/debugger.md +104 -0
  79. package/docs/tools/evidence.md +82 -0
  80. package/docs/tools/interact.md +125 -2
  81. package/docs/tools/observe.md +70 -0
  82. package/docs/tools/system-traces.md +126 -0
  83. package/package.json +1 -1
  84. package/scripts/agent-plugin.mjs +68 -8
  85. package/src/debugger/android.ts +65 -0
  86. package/src/debugger/guard.ts +26 -0
  87. package/src/debugger/sessions.ts +168 -0
  88. package/src/debugger/worker.ts +122 -0
  89. package/src/interact/android.ts +10 -4
  90. package/src/interact/conditional-action.ts +75 -0
  91. package/src/interact/index.ts +57 -28
  92. package/src/interact/ios.ts +14 -8
  93. package/src/manage/android.ts +4 -3
  94. package/src/manage/ios.ts +3 -2
  95. package/src/observe/android.ts +33 -6
  96. package/src/observe/evidence.ts +262 -0
  97. package/src/observe/index.ts +32 -8
  98. package/src/observe/ios.ts +32 -8
  99. package/src/observe/readiness-service.ts +94 -0
  100. package/src/observe/readiness.ts +84 -0
  101. package/src/observe/snapshot-metadata.ts +12 -1
  102. package/src/observe/system-trace-android.ts +133 -0
  103. package/src/observe/system-trace.ts +218 -0
  104. package/src/server/common.ts +7 -0
  105. package/src/server/condition-schema.ts +32 -0
  106. package/src/server/debugger-handlers.ts +29 -0
  107. package/src/server/debugger-schema.ts +37 -0
  108. package/src/server/journey-schema.ts +4 -2
  109. package/src/server/readiness-schema.ts +12 -0
  110. package/src/server/system-trace-schema.ts +17 -0
  111. package/src/server/tool-definitions.ts +49 -0
  112. package/src/server/tool-handlers.ts +182 -21
  113. package/src/server-core.ts +3 -3
  114. package/src/server.ts +24 -0
  115. package/src/system/index.ts +7 -5
  116. package/src/types.ts +30 -1
  117. package/src/utils/exec.ts +2 -0
  118. package/src/utils/ios/utils.ts +4 -4
  119. package/src/utils/java.ts +13 -12
  120. package/src/utils/operation-budget.ts +24 -2
  121. package/src/utils/resolve-device.ts +10 -0
  122. package/test/device/automated/debugger/runtime.android.test.ts +203 -0
  123. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  124. package/test/device/automated/observe/system-trace.smoke.ts +104 -0
  125. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  126. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  127. package/test/device/manual/interact/readiness.manual.ts +99 -0
  128. package/test/fixtures/debugger-app/MainActivity.java +46 -0
  129. package/test/fixtures/debugger-app/NoLocals.java +7 -0
  130. package/test/fixtures/debugger-app/README.md +13 -0
  131. package/test/fixtures/debugger-app/build.ts +29 -0
  132. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  133. package/test/fixtures/readiness-app/README.md +12 -0
  134. package/test/fixtures/readiness-app/build.ts +37 -0
  135. package/test/fixtures/readiness-app/main.m +27 -0
  136. package/test/helpers/perfetto.ts +59 -0
  137. package/test/unit/debugger/guards.test.ts +46 -0
  138. package/test/unit/debugger/native.test.ts +33 -0
  139. package/test/unit/debugger/sessions.test.ts +83 -0
  140. package/test/unit/observe/android_tree_acquisition.test.ts +2 -0
  141. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  142. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  143. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  144. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  145. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  146. package/test/unit/observe/readiness.test.ts +41 -0
  147. package/test/unit/observe/readiness_native.test.ts +68 -0
  148. package/test/unit/observe/system-trace-android.test.ts +52 -0
  149. package/test/unit/observe/system-trace.test.ts +166 -0
  150. package/test/unit/server/conditional_actions.test.ts +299 -0
  151. package/test/unit/server/conditional_schema.test.ts +59 -0
  152. package/test/unit/server/correlated-evidence.test.ts +194 -0
  153. package/test/unit/server/readiness.test.ts +170 -0
  154. package/test/unit/server/selector_tap_target.test.ts +79 -0
  155. package/test/unit/server/system-trace.test.ts +64 -0
  156. package/test/unit/server/tap_selector.test.ts +2 -0
  157. package/worker/DebuggerWorker.java +160 -0
  158. package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
  159. /package/{skills → .agents/skills}/README.md +0 -0
  160. /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
  161. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  162. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  163. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  164. /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
  165. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  166. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  167. /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
  168. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  169. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  170. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -0,0 +1,262 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { randomUUID } from 'node:crypto'
3
+
4
+ export interface EvidenceDevice { platform: 'android' | 'ios'; id: string }
5
+ export interface ActionRef { sessionId: string; actionId: string }
6
+ type Kind = 'screenshot' | 'ui_tree' | 'fingerprint' | 'activity' | 'logs'
7
+ type Freshness = 'fresh' | 'stale' | 'unknown' | 'not_applicable'
8
+ interface Stamp { wall: number; mono: number }
9
+ interface DeviceOrder { sequence: number; active: number; lastStarted: number }
10
+ interface ActionRecord {
11
+ id: string; correlationId: string; device: EvidenceDevice | null
12
+ dispatchStart: Stamp | null; dispatchEnd: Stamp | null; verification: Stamp | null; completed: number | null
13
+ }
14
+ interface Component {
15
+ evidence_id: string; kind: Kind; status: 'captured' | 'failed' | 'omitted'
16
+ capture_started_at_ms: number | null; capture_completed_at_ms: number | null
17
+ source_captured_at_ms: number | null; age_ms: number | null
18
+ freshness: Freshness; freshness_reasons: string[]
19
+ device: EvidenceDevice | null; snapshot_session_id?: string; snapshot_revision?: number
20
+ error?: unknown; clock?: 'device_or_unknown'; event_time_comparable?: false
21
+ earliest_entry_at_ms?: number | null; latest_entry_at_ms?: number | null
22
+ }
23
+ interface Acquisition {
24
+ value: Component; start: Stamp; end: Stamp | null; order: DeviceOrder | null
25
+ startSequence: number; endSequence: number; overlapping: boolean; reused: boolean
26
+ }
27
+ interface EvidenceContext {
28
+ systemTraceId?: string
29
+ correlationId: string; action: ActionRecord | null; ref?: ActionRef
30
+ device: EvidenceDevice | null; start: Stamp; end: Stamp | null; verification: Stamp | null
31
+ observations: Acquisition[]; isAction: boolean; children: EvidenceContext[]
32
+ }
33
+ const sessionId = randomUUID()
34
+ const context = new AsyncLocalStorage<EvidenceContext>()
35
+ const history = new Map<string, ActionRecord>()
36
+ const active = new Map<string, ActionRecord>()
37
+ const orders = new Map<string, DeviceOrder>()
38
+ const stamp = (): Stamp => ({ wall: Date.now(), mono: performance.now() })
39
+ const deviceKey = (d: EvidenceDevice) => `${d.platform}:${d.id}`
40
+ const sameDevice = (a: EvidenceDevice | null, b: EvidenceDevice | null) => !!a && !!b && deviceKey(a) === deviceKey(b)
41
+ function validDevice(d: any): EvidenceDevice | null {
42
+ return d && ['android', 'ios'].includes(d.platform) && typeof d.id === 'string' && d.id && !['default', 'booted'].includes(d.id)
43
+ ? { platform: d.platform, id: d.id } : null
44
+ }
45
+ function orderFor(device: EvidenceDevice | null) {
46
+ if (!device) return null
47
+ const key = deviceKey(device)
48
+ let order = orders.get(key)
49
+ if (!order) {
50
+ order = { sequence: 0, active: 0, lastStarted: -Infinity }
51
+ orders.set(key, order)
52
+ if (orders.size > 1000) {
53
+ for (const [oldKey, oldOrder] of orders) {
54
+ if (oldKey !== key && oldOrder.active === 0) orders.delete(oldKey)
55
+ if (orders.size <= 1000) break
56
+ }
57
+ }
58
+ }
59
+ return order
60
+ }
61
+ function prune() {
62
+ const now = performance.now()
63
+ for (const [key, record] of history) {
64
+ if (record.completed !== null && now - record.completed >= 600000) history.delete(key)
65
+ }
66
+ while (history.size > 1000) history.delete(history.keys().next().value!)
67
+ }
68
+ export function validateActionRef(value: unknown): ActionRef | undefined {
69
+ if (value === undefined) return undefined
70
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
71
+ Object.keys(value).some(key => !['sessionId', 'actionId'].includes(key))) throw new Error('INVALID_ARGUMENT: invalid actionRef')
72
+ const ref = value as ActionRef
73
+ for (const v of [ref.sessionId, ref.actionId]) if (typeof v !== 'string' || v.length < 1 || v.length > 128) throw new Error('INVALID_ARGUMENT: invalid actionRef')
74
+ return { sessionId: ref.sessionId, actionId: ref.actionId }
75
+ }
76
+ export function evidenceSessionId() { return sessionId }
77
+ export function currentEvidenceCorrelationId() { return context.getStore()?.correlationId }
78
+ export function bindSystemTrace(traceId: string) {
79
+ const current = context.getStore()
80
+ if (current) current.systemTraceId = traceId
81
+ }
82
+ export function currentEvidenceActionId() { return context.getStore()?.action?.id }
83
+ export function bindEvidenceDevice(device: { platform: string; id: string }) {
84
+ const current = context.getStore()
85
+ const target = validDevice(device)
86
+ if (!current || !target) return
87
+ current.device ??= target
88
+ if (current.isAction && current.action) current.action.device ??= target
89
+ }
90
+ export function markEvidenceVerification() {
91
+ const current = context.getStore()
92
+ if (current) {
93
+ current.verification = stamp()
94
+ if (current.isAction && current.action) current.action.verification = current.verification
95
+ }
96
+ }
97
+ export async function evidenceDispatch<T>(device: EvidenceDevice, operation: () => Promise<T>): Promise<T> {
98
+ const current = context.getStore()
99
+ if (!current?.isAction || !current.action) return operation()
100
+ bindEvidenceDevice(device)
101
+ const record = current.action
102
+ const start = stamp()
103
+ record.dispatchStart ??= start
104
+ record.dispatchEnd = null
105
+ const order = orderFor(validDevice(device))
106
+ if (order) { order.sequence++; order.active++; order.lastStarted = start.mono }
107
+ try { return await operation() }
108
+ finally {
109
+ record.dispatchEnd = stamp()
110
+ if (order) order.active--
111
+ }
112
+ }
113
+ export function omitEvidence(kind: Kind) {
114
+ const current = context.getStore()
115
+ if (!current) return
116
+ const start = stamp()
117
+ current.observations.push({ value: {
118
+ evidence_id: randomUUID(), kind, status: 'omitted', capture_started_at_ms: null,
119
+ capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
120
+ freshness: kind === 'logs' ? 'not_applicable' : 'unknown', freshness_reasons: [], device: current.device
121
+ }, start, end: null, order: null, startSequence: 0, endSequence: 0, overlapping: false, reused: false })
122
+ }
123
+ export async function captureEvidence<T>(kind: Kind, device: EvidenceDevice | null, operation: () => Promise<T>): Promise<T> {
124
+ const current = context.getStore()
125
+ if (!current) return operation()
126
+ const target = validDevice(device)
127
+ if (target) bindEvidenceDevice(target)
128
+ const order = orderFor(target)
129
+ const start = stamp()
130
+ const acquisition: Acquisition = { value: {
131
+ evidence_id: randomUUID(), kind, status: 'failed', capture_started_at_ms: start.wall,
132
+ capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
133
+ freshness: 'unknown', freshness_reasons: [], device: target
134
+ }, start, end: null, order, startSequence: order?.sequence ?? 0,
135
+ endSequence: 0, overlapping: !!order?.active, reused: false }
136
+ current.observations.push(acquisition)
137
+ try {
138
+ const result = await operation() as any
139
+ const value = acquisition.value
140
+ const resolved = validDevice(result?.device) ?? target
141
+ value.device = resolved
142
+ if (resolved) bindEvidenceDevice(resolved)
143
+ const failure = result?.error ?? result?.meta?.error
144
+ value.status = failure || result?.success === false || (kind === 'fingerprint' && !result?.fingerprint) ? 'failed' : 'captured'
145
+ if (failure) value.error = failure
146
+ const source = result?.captured_at_ms
147
+ if (Number.isFinite(source) && source > 0) {
148
+ value.source_captured_at_ms = source
149
+ acquisition.reused = source < start.wall
150
+ }
151
+ if (kind === 'ui_tree') {
152
+ value.snapshot_session_id = result?.snapshot_session_id
153
+ value.snapshot_revision = result?.snapshot_revision
154
+ }
155
+ if (kind === 'logs') {
156
+ const entries = result?.logs ?? result?.entries ?? []
157
+ const times = entries.map((e: any) => {
158
+ const t = e?.timestamp ?? e?._iso
159
+ return typeof t === 'number' ? t : typeof t === 'string' ? Date.parse(t) : NaN
160
+ }).filter((t: number) => Number.isFinite(t)) as number[]
161
+ value.clock = 'device_or_unknown'; value.event_time_comparable = false
162
+ value.earliest_entry_at_ms = times.length ? times.reduce((a, b) => Math.min(a, b)) : null
163
+ value.latest_entry_at_ms = times.length ? times.reduce((a, b) => Math.max(a, b)) : null
164
+ }
165
+ return result
166
+ } catch (error) {
167
+ acquisition.value.error = error instanceof Error ? error.message : String(error)
168
+ throw error
169
+ } finally {
170
+ acquisition.end = stamp()
171
+ acquisition.value.capture_completed_at_ms = acquisition.end.wall
172
+ acquisition.endSequence = order?.sequence ?? 0
173
+ acquisition.overlapping ||= !!order?.active || acquisition.startSequence !== acquisition.endSequence
174
+ if (acquisition.value.status === 'captured' && acquisition.value.source_captured_at_ms === null && kind !== 'logs') {
175
+ acquisition.value.source_captured_at_ms = acquisition.end.wall
176
+ }
177
+ }
178
+ }
179
+ function association(current: EvidenceContext) {
180
+ if (current.isAction) return { association: 'self', association_reason: null }
181
+ if (!current.ref) return { association: 'none', association_reason: null }
182
+ const reason = current.ref.sessionId !== sessionId ? 'SESSION_MISMATCH' : !current.action ? 'ACTION_UNAVAILABLE' :
183
+ !sameDevice(current.device, current.action.device) ? 'DEVICE_MISMATCH' : null
184
+ return { association: reason ? 'unavailable' : 'explicit', association_reason: reason }
185
+ }
186
+ function assemble(current: EvidenceContext) {
187
+ const end = stamp()
188
+ const link = association(current)
189
+ const action = link.association === 'unavailable' ? null : current.action
190
+ const clockChanged = end.wall < current.start.wall
191
+ const observations = current.observations.map(a => {
192
+ const v = { ...a.value, freshness_reasons: [] as string[] }
193
+ if (v.status === 'omitted') return v
194
+ const componentAction = sameDevice(v.device, action?.device ?? null) ? action : null
195
+ const sourceMono = a.end && v.source_captured_at_ms !== null ? a.end.mono + v.source_captured_at_ms - a.end.wall : a.end?.mono
196
+ const reason = (freshness: Freshness, code: string) => { v.freshness = freshness; v.freshness_reasons.push(code) }
197
+ if (v.kind === 'logs') reason('unknown', 'DEVICE_CLOCK_UNVERIFIED')
198
+ else if (clockChanged || (a.end && a.end.wall < a.start.wall)) reason('unknown', 'CLOCK_DISCONTINUITY')
199
+ else if (v.source_captured_at_ms !== null && a.end && v.source_captured_at_ms > a.end.wall) reason('unknown', 'CLOCK_DISCONTINUITY')
200
+ else if (!v.device || !a.order || orders.get(deviceKey(v.device)) !== a.order || v.status !== 'captured' || !a.end) reason('unknown', 'CAPTURE_TIME_UNKNOWN')
201
+ else if (componentAction?.dispatchStart && sourceMono !== undefined && sourceMono < componentAction.dispatchStart.mono) reason('stale', 'BEFORE_ACTION')
202
+ else if (a.order && (a.order.sequence > a.endSequence || (a.reused && sourceMono !== undefined && sourceMono < a.order.lastStarted))) reason('stale', 'SUPERSEDED_BY_ACTION')
203
+ else if (a.overlapping) reason('unknown', 'OVERLAPPING_ACTION')
204
+ else if (a.reused) reason('unknown', 'REUSED_OBSERVATION')
205
+ else { v.freshness = 'fresh' }
206
+ if (!clockChanged && a.end && a.end.wall >= a.start.wall && v.source_captured_at_ms !== null && v.source_captured_at_ms <= end.wall) {
207
+ v.age_ms = Math.max(0, end.mono - a.end.mono + a.end.wall - v.source_captured_at_ms)
208
+ }
209
+ return { ...v, session_id: sessionId, correlation_id: componentAction?.correlationId ?? current.correlationId,
210
+ action_id: componentAction?.id ?? null, association: componentAction ? 'internal' : link.association }
211
+ })
212
+ return {
213
+ session_id: sessionId, correlation_id: action?.correlationId ?? current.correlationId,
214
+ action_id: action?.id ?? null, device: current.device, ...link,
215
+ ...(current.ref ? { requested_action_ref: current.ref } : {}),
216
+ operation_started_at_ms: current.start.wall, operation_completed_at_ms: current.end?.wall ?? end.wall,
217
+ dispatch_started_at_ms: action?.dispatchStart?.wall ?? null,
218
+ dispatch_completed_at_ms: action?.dispatchEnd?.wall ?? null,
219
+ verification_completed_at_ms: current.verification?.wall ?? action?.verification?.wall ?? null,
220
+ observations, trace_context: { supported: !!current.systemTraceId, trace_id: current.systemTraceId ?? null, markers: [] }
221
+ }
222
+ }
223
+ export async function withEvidence<T>(options: { action?: boolean; journey?: boolean; ref?: ActionRef }, operation: () => Promise<T>): Promise<{ result: T; evidence: ReturnType<typeof assemble> }> {
224
+ prune()
225
+ const parent = context.getStore()
226
+ const id = randomUUID()
227
+ const record: ActionRecord | null = options.action ? { id, correlationId: parent?.correlationId ?? id, device: null,
228
+ dispatchStart: null, dispatchEnd: null, verification: null, completed: null } : options.ref?.sessionId === sessionId ? active.get(options.ref.actionId) ?? history.get(options.ref.actionId) ?? null : null
229
+ const current: EvidenceContext = { systemTraceId: parent?.systemTraceId, correlationId: options.action ? record!.correlationId : options.journey ? id : randomUUID(),
230
+ action: record, ref: options.ref, device: null, start: stamp(), end: null, verification: null, observations: [], children: [], isAction: !!options.action }
231
+ if (options.action && record) active.set(id, record)
232
+ if (parent && options.action) parent.children.push(current)
233
+ try {
234
+ return await context.run(current, async () => {
235
+ const result = await operation()
236
+ current.end = stamp()
237
+ return { result, evidence: assemble(current) }
238
+ })
239
+ } finally {
240
+ if (options.action && record) {
241
+ active.delete(id); record.completed = performance.now(); history.set(id, record); prune()
242
+ }
243
+ }
244
+ }
245
+
246
+ export function refreshJourneyEvidence(steps: Array<{ result?: { evidence?: unknown } }>) {
247
+ const current = context.getStore()
248
+ if (!current) return
249
+ for (const step of steps) {
250
+ const previous = step.result?.evidence as { action_id?: string } | undefined
251
+ const child = current.children.find(c => c.action?.id === previous?.action_id)
252
+ if (child && step.result) step.result.evidence = assemble(child)
253
+ }
254
+ }
255
+
256
+ export function completeJourneyVerification(actionId: string) {
257
+ const child = context.getStore()?.children.find(c => c.action?.id === actionId)
258
+ if (child?.action) {
259
+ child.verification = stamp()
260
+ child.action.verification = child.verification
261
+ }
262
+ }
@@ -1,4 +1,7 @@
1
+ import { omitEvidence } from './evidence.js'
2
+ import { withResolvedDevice } from '../utils/resolve-device.js'
1
3
  import { measure } from '../utils/timing.js'
4
+ import { ReadinessTracker } from './readiness.js'
2
5
  import { resolveTargetDevice } from '../utils/resolve-device.js'
3
6
  import { AndroidObserve } from './android.js'
4
7
  import { iOSObserve } from './ios.js'
@@ -176,15 +179,20 @@ export class ToolsObserve {
176
179
 
177
180
  static async getUITreeHandler({ platform, deviceId, responseMode = 'debug', sinceSnapshotRevision }: { platform?: 'android' | 'ios', deviceId?: string, responseMode?: 'compact' | 'debug', sinceSnapshotRevision?: number }) {
178
181
  const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
182
+ const acquisitionStarted = Date.now()
179
183
  const tree = await observe.getUITree(resolved.id) as any
184
+ tree.readiness = new ReadinessTracker(resolved.platform, resolved.id).observe(tree, acquisitionStarted)
180
185
  if (responseMode !== 'compact') return tree
181
186
  const revision = tree?.snapshot_revision ?? null
187
+ const session = { snapshot_session_id: tree?.snapshot_session_id, readiness: tree.readiness }
188
+ if (tree?.error) return { ...session, delta_available: false, snapshot_revision: revision, error: tree.error }
182
189
  const baseRevision = sinceSnapshotRevision
183
190
  if (!Number.isInteger(baseRevision) || revision === null) {
184
- return { delta_available: false, snapshot_revision: revision }
191
+ return { ...session, delta_available: false, snapshot_revision: revision }
185
192
  }
186
193
  const delta = getStateDelta(`${resolved.platform}:${resolved.id}`, baseRevision as number, revision)
187
- return delta ? { delta_available: true, state_delta: delta, snapshot_revision: revision } : {
194
+ return delta ? { ...session, delta_available: true, state_delta: delta, snapshot_revision: revision } : {
195
+ ...session,
188
196
  delta_available: false,
189
197
  snapshot_revision: revision,
190
198
  reason: 'BASE_REVISION_UNAVAILABLE'
@@ -233,16 +241,24 @@ export class ToolsObserve {
233
241
  }
234
242
  }
235
243
 
236
- static async readLogStreamHandler({ platform, sessionId, limit, since }: { platform?: 'android' | 'ios', sessionId?: string, limit?: number, since?: string }) {
244
+ static async readLogStreamHandler({ platform, sessionId, limit, since, deviceId }: { deviceId?: string, platform?: 'android' | 'ios', sessionId?: string, limit?: number, since?: string }) {
237
245
  const sid = sessionId || 'default'
238
- const { observe } = await ToolsObserve.resolveObserve(platform)
239
- return await (observe as any).readLogStream(sid, limit ?? 100, since)
246
+ const android = new AndroidObserve()
247
+ const ios = new iOSObserve()
248
+ const observe = platform === 'android' ? android : platform === 'ios' ? ios :
249
+ ios.getLogStreamDevice(sid) && !android.getLogStreamDevice(sid) ? ios : android
250
+ const streamDevice = observe.getLogStreamDevice(sid)
251
+ if (deviceId && streamDevice && streamDevice.id !== deviceId) throw new Error('LOG_STREAM_DEVICE_MISMATCH')
252
+ return await observe.readLogStream(sid, limit ?? 100, since)
240
253
  }
241
254
 
242
255
  static async stopLogStreamHandler({ platform, sessionId }: { platform?: 'android' | 'ios', sessionId?: string }) {
243
256
  const sid = sessionId || 'default'
244
- const { observe } = await ToolsObserve.resolveObserve(platform)
245
- return await (observe as any).stopLogStream(sid)
257
+ const android = new AndroidObserve()
258
+ const ios = new iOSObserve()
259
+ const observe = platform === 'android' ? android : platform === 'ios' ? ios :
260
+ ios.getLogStreamDevice(sid) && !android.getLogStreamDevice(sid) ? ios : android
261
+ return await observe.stopLogStream(sid)
246
262
  }
247
263
 
248
264
  static async captureScreenshotHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string }) {
@@ -261,6 +277,14 @@ export class ToolsObserve {
261
277
  }
262
278
 
263
279
  static async captureDebugSnapshotHandler({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; includeScreenshot?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
280
+ const { resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId)
281
+ return withResolvedDevice(resolved, () => ToolsObserve.collectDebugSnapshot({ reason, includeLogs, includeScreenshot, logLines,
282
+ platform: resolved.platform as 'android' | 'ios', appId, deviceId: resolved.id, sessionId }))
283
+ }
284
+
285
+ private static async collectDebugSnapshot({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; includeScreenshot?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
286
+ if (!includeScreenshot) omitEvidence('screenshot')
287
+ if (!includeLogs) omitEvidence('logs')
264
288
  const timestamp = Date.now()
265
289
  const raw: CaptureDebugSnapshotRawResponse = {
266
290
  timestamp,
@@ -281,7 +305,7 @@ export class ToolsObserve {
281
305
  currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
282
306
  fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
283
307
  uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
284
- readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
308
+ readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, deviceId, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
285
309
  }
286
310
 
287
311
  const results = await Promise.allSettled(Object.values(tasks))
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { captureEvidence } from './evidence.js'
1
3
  import { spawn } from "child_process"
2
4
  import { remainingBudget } from '../utils/operation-budget.js'
3
5
  import { execCmd } from '../utils/exec.js'
@@ -283,10 +285,10 @@ export function traverseIDBNode(node: IDBElement, elements: UIElement[], parentI
283
285
  return currentIndex;
284
286
  }
285
287
 
286
- const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string }> = new Map()
288
+ const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string, device?: { platform: 'android' | 'ios'; id: string } }> = new Map()
287
289
 
288
- export function _setIOSActiveLogStream(sessionId: string, file: string) {
289
- iosActiveLogStreams.set(sessionId, { proc: {} as any, file })
290
+ export function _setIOSActiveLogStream(sessionId: string, file: string, device?: { platform: 'android' | 'ios'; id: string }) {
291
+ iosActiveLogStreams.set(sessionId, { proc: {} as any, file, device })
290
292
  }
291
293
 
292
294
  export function _clearIOSActiveLogStream(sessionId: string) {
@@ -294,11 +296,17 @@ export function _clearIOSActiveLogStream(sessionId: string) {
294
296
  }
295
297
 
296
298
  export class iOSObserve {
299
+ getLogStreamDevice(sessionId: string = 'default') { return iosActiveLogStreams.get(sessionId)?.device ?? null }
300
+
297
301
  async getDeviceMetadata(deviceId: string = "booted"): Promise<DeviceInfo> {
298
302
  return getIOSDeviceMetadata(deviceId);
299
303
  }
300
304
 
301
305
  async getLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
306
+ return captureEvidence('logs', { platform: 'ios', id: filters.deviceId ?? '' }, () => this.collectGetLogs(filters))
307
+ }
308
+
309
+ private async collectGetLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
302
310
  const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters
303
311
  const args: string[] = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog']
304
312
 
@@ -454,8 +462,12 @@ export class iOSObserve {
454
462
  }
455
463
 
456
464
  async captureScreenshot(deviceId: string = "booted"): Promise<CaptureIOSScreenshotResponse> {
465
+ return captureEvidence('screenshot', { platform: 'ios', id: deviceId ?? '' }, () => this.collectCaptureScreenshot(deviceId))
466
+ }
467
+
468
+ private async collectCaptureScreenshot(deviceId: string = "booted"): Promise<CaptureIOSScreenshotResponse> {
457
469
  const device = await getIOSDeviceMetadata(deviceId)
458
- const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`
470
+ const tmpFile = `/tmp/mcp-ios-screenshot-${randomUUID()}.png`
459
471
 
460
472
  try {
461
473
  await iosExecCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId)
@@ -527,6 +539,10 @@ export class iOSObserve {
527
539
  }
528
540
 
529
541
  async getUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
542
+ return captureEvidence('ui_tree', { platform: 'ios', id: deviceId ?? '' }, () => this.collectGetUITree(deviceId))
543
+ }
544
+
545
+ private async collectGetUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
530
546
  const measurement = createObservationMeasurement('ios')
531
547
  let device
532
548
  try {
@@ -653,12 +669,16 @@ export class iOSObserve {
653
669
  }
654
670
  }
655
671
 
656
- async getScreenFingerprint(deviceId: string = 'booted'): Promise<{ fingerprint: string | null; activity?: string; error?: string }> {
672
+ async getScreenFingerprint(deviceId: string = 'booted'): Promise<{ fingerprint: string | null; activity?: string; error?: string; captured_at_ms?: number }> {
673
+ return captureEvidence('fingerprint', { platform: 'ios', id: deviceId ?? '' }, () => this.collectGetScreenFingerprint(deviceId))
674
+ }
675
+
676
+ private async collectGetScreenFingerprint(deviceId: string = 'booted'): Promise<{ fingerprint: string | null; activity?: string; error?: string; captured_at_ms?: number }> {
657
677
  try {
658
678
  const tree = await this.getUITree(deviceId)
659
679
  if (!tree || tree.error) return { fingerprint: null, error: tree && (tree as any).error }
660
680
 
661
- return computeScreenFingerprint(tree, null, 'ios', 50)
681
+ return { ...computeScreenFingerprint(tree, null, 'ios', 50), captured_at_ms: tree.captured_at_ms }
662
682
  } catch (e) {
663
683
  return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
664
684
  }
@@ -701,10 +721,10 @@ export class iOSObserve {
701
721
 
702
722
  proc.on('close', () => {
703
723
  stream.end()
704
- iosActiveLogStreams.delete(sessionId)
724
+ if (iosActiveLogStreams.get(sessionId)?.proc === proc) iosActiveLogStreams.delete(sessionId)
705
725
  })
706
726
 
707
- iosActiveLogStreams.set(sessionId, { proc, file })
727
+ iosActiveLogStreams.set(sessionId, { proc, file, device: { platform: 'ios', id: deviceId ?? '' } })
708
728
  return { success: true, stream_started: true }
709
729
  } catch {
710
730
  return { success: false, error: 'log_stream_start_failed' }
@@ -720,6 +740,10 @@ export class iOSObserve {
720
740
  }
721
741
 
722
742
  async readLogStream(sessionId: string = 'default', limit: number = 100, since?: string): Promise<{ entries: any[], crash_summary?: { crash_detected: boolean, exception?: string, sample?: string } }> {
743
+ return captureEvidence('logs', this.getLogStreamDevice(sessionId), () => this.collectReadLogStream(sessionId, limit, since))
744
+ }
745
+
746
+ private async collectReadLogStream(sessionId: string = 'default', limit: number = 100, since?: string): Promise<{ entries: any[], crash_summary?: { crash_detected: boolean, exception?: string, sample?: string } }> {
723
747
  const entry = iosActiveLogStreams.get(sessionId)
724
748
  if (!entry) return { entries: [] }
725
749
  try {
@@ -0,0 +1,94 @@
1
+ import { pausedReadiness } from '../debugger/guard.js'
2
+ import type { DeviceInfo } from '../types.js'
3
+ import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js'
4
+ import { remainingBudget, runWithBudget } from '../utils/operation-budget.js'
5
+ import { ToolsObserve } from './index.js'
6
+ import { isDeviceUnavailable, ReadinessTracker, withReadinessTracker } from './readiness.js'
7
+
8
+ type Target = { platform?: 'android' | 'ios'; deviceId?: string }
9
+ function budgetExpired(error: unknown) {
10
+ if (/ACTION_TIMEOUT/.test(String(error))) return true
11
+ try { remainingBudget(); return false } catch { return true }
12
+ }
13
+ async function resolve(target: Target) {
14
+ if (target.platform) return resolveTargetDevice({ ...target, platform: target.platform })
15
+ try { return await resolveTargetDevice({ ...target, platform: 'android' }) }
16
+ catch { return resolveTargetDevice({ ...target, platform: 'ios' }) }
17
+ }
18
+ export async function readinessDelay(ms: number) {
19
+ await new Promise(resolve => setTimeout(resolve, Math.min(ms, remainingBudget(ms))))
20
+ remainingBudget()
21
+ }
22
+
23
+ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabilityMs?: number; pollIntervalMs?: number }, wait = false) {
24
+ const started = performance.now()
25
+ let device: DeviceInfo | null = null
26
+ let tracker = new ReadinessTracker(args.platform ?? 'android', args.deviceId ?? '')
27
+ let failureCode: string | undefined
28
+ let debugPause: ReturnType<typeof pausedReadiness> = null
29
+ await runWithBudget(args.timeoutMs ?? (wait ? 30000 : 15000), async () => {
30
+ try {
31
+ device = await resolve(args)
32
+ remainingBudget()
33
+ tracker = new ReadinessTracker(device.platform, device.id, args.stabilityMs ?? 500)
34
+ await withResolvedDevice(device, async () => {
35
+ let count = 0
36
+ while (wait || count < 2) {
37
+ remainingBudget()
38
+ debugPause = device!.platform === 'android' ? pausedReadiness(device!.id) : null
39
+ if (debugPause) { failureCode = wait ? 'DEBUGGER_PAUSED' : undefined; return }
40
+ const captureStarted = Date.now()
41
+ try {
42
+ const tree = await ToolsObserve.getUITreeHandler({ platform: device!.platform as 'android' | 'ios', deviceId: device!.id })
43
+ remainingBudget()
44
+ tracker.observe(tree, captureStarted)
45
+ if (isDeviceUnavailable((tree as any)?.error)) { failureCode = 'DEVICE_UNAVAILABLE'; return }
46
+ } catch (error) {
47
+ if (/ACTION_TIMEOUT/.test(String(error))) throw error
48
+ tracker.unavailable()
49
+ if (isDeviceUnavailable(error)) { failureCode = 'DEVICE_UNAVAILABLE'; return }
50
+ }
51
+ count++
52
+ if (tracker.summary.state === 'settled' || (!wait && tracker.summary.state === 'loading')) return
53
+ if (!wait && count === 2) return
54
+ await readinessDelay(wait ? args.pollIntervalMs ?? 500 : args.stabilityMs ?? 500)
55
+ }
56
+ })
57
+ } catch (error) {
58
+ const expired = budgetExpired(error)
59
+ if (expired) tracker.deadline()
60
+ else tracker.unavailable()
61
+ failureCode = !expired && (!device || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE'
62
+ }
63
+ })
64
+ if (debugPause) return { success: !wait, device, readiness: debugPause, elapsed_ms: performance.now() - started, ...(wait ? { failure_code: 'DEBUGGER_PAUSED' } : {}) }
65
+ const state = tracker.summary.state
66
+ const success = !failureCode && (wait ? state === 'settled' : state !== 'unknown')
67
+ return { success, device, readiness: tracker.summary, elapsed_ms: performance.now() - started,
68
+ ...(!success ? { failure_code: failureCode ?? (wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE') } : {}) }
69
+ }
70
+
71
+ export async function withReadyWait<T extends object | undefined, F extends object>(target: Target, timeoutMs: number, operation: (device: DeviceInfo) => Promise<T>, failure: (code: string, elapsedMs: number, samples: number) => F) {
72
+ const started = performance.now()
73
+ let tracker = new ReadinessTracker(target.platform ?? 'android', target.deviceId ?? '')
74
+ let resolved = false
75
+ return runWithBudget(timeoutMs, async () => {
76
+ try {
77
+ const device = await resolve(target)
78
+ resolved = true
79
+ remainingBudget()
80
+ tracker = new ReadinessTracker(device.platform, device.id)
81
+ const result = await withResolvedDevice(device, () => withReadinessTracker(tracker, () => operation(device)))
82
+ try { remainingBudget() } catch {
83
+ const alreadyFailed = result && (('status' in result && result.status !== 'success') || ('success' in result && result.success === false))
84
+ return { ...(alreadyFailed ? result : failure('TIMEOUT', performance.now() - started, tracker.summary.sample_count)), readiness: tracker.deadline() }
85
+ }
86
+ return { ...result, readiness: tracker.summary }
87
+ } catch (error) {
88
+ const expired = budgetExpired(error)
89
+ if (expired) tracker.deadline()
90
+ else tracker.unavailable()
91
+ return { ...failure(!expired && (!resolved || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : 'TIMEOUT', performance.now() - started, tracker.summary.sample_count), readiness: tracker.summary }
92
+ }
93
+ })
94
+ }
@@ -0,0 +1,84 @@
1
+ import { pausedReadiness } from '../debugger/guard.js'
2
+ import { AsyncLocalStorage } from 'node:async_hooks'
3
+ import type { ReadinessSummary } from '../types.js'
4
+ import { remainingBudget } from '../utils/operation-budget.js'
5
+ import { computeSnapshotSignature, getSnapshotSessionId } from './snapshot-metadata.js'
6
+
7
+ const baselineLimitations = ['NORMALIZED_UI_ONLY', 'FOREGROUND_APP_UNVERIFIED', 'UNRESPONSIVE_SIGNAL_UNSUPPORTED']
8
+ const capabilities = { loading: true, transitioning: true, settled: true, unresponsive: false, debugger_paused: false }
9
+
10
+ export function isDeviceUnavailable(error: unknown) {
11
+ return /device.*(?:not found|offline|disconnected)|no devices found|READINESS_DEVICE_UNAVAILABLE/i.test(String(error))
12
+ }
13
+
14
+ export class ReadinessTracker {
15
+ private signature: string | null = null
16
+ private stableSince = 0
17
+ summary: ReadinessSummary
18
+
19
+ constructor(private platform: string, private deviceId: string, private stabilityMs = 500) {
20
+ this.summary = {
21
+ state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
22
+ sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
23
+ evidence: [], limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
24
+ }
25
+
26
+ }
27
+
28
+ unavailable(code = 'OBSERVATION_FAILED'): ReadinessSummary {
29
+ this.signature = null
30
+ this.summary = { ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
31
+ sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', code],
32
+ evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: null, snapshot_revision: null }].slice(-8) }
33
+ return this.summary
34
+ }
35
+
36
+ deadline(): ReadinessSummary {
37
+ this.summary = { ...this.summary, limitations: [...new Set([...this.summary.limitations, 'DEADLINE_EXCEEDED'])] }
38
+ return this.summary
39
+ }
40
+
41
+ observe(tree: any, acquisitionStartedAtMs: number, now = performance.now()): ReadinessSummary {
42
+ const paused = this.platform === 'android' ? pausedReadiness(this.deviceId) : null
43
+ if (paused) { this.summary = paused; return this.summary }
44
+ if (tree?.error) return this.unavailable('OBSERVATION_FAILED')
45
+ if (!tree || tree.device?.platform !== this.platform || tree.device?.id !== this.deviceId ||
46
+ tree.snapshot_session_id !== getSnapshotSessionId() || !Number.isSafeInteger(tree.snapshot_revision) || tree.snapshot_revision <= 0 ||
47
+ !Number.isFinite(tree.captured_at_ms) || tree.captured_at_ms < acquisitionStartedAtMs || tree.captured_at_ms <= 0 ||
48
+ !Array.isArray(tree.elements) || !tree.elements.length || tree.elements.some((element: unknown) => !element || typeof element !== 'object') ||
49
+ !Number.isFinite(tree.resolution?.width) || !Number.isFinite(tree.resolution?.height) || tree.resolution.width <= 0 || tree.resolution.height <= 0) return this.unavailable('OBSERVATION_INVALID')
50
+ const signature = computeSnapshotSignature(tree)
51
+ if (!signature) return this.unavailable('OBSERVATION_INVALID')
52
+ const nativeLoading = tree.elements.some((element: any) => element.visible === true && ['android.widget.ProgressBar', 'ActivityIndicator', 'ProgressIndicator'].includes(element.type))
53
+ const textLoading = tree.elements.some((element: any) => element.visible === true && [element.text, element.contentDescription].some(label =>
54
+ typeof label === 'string' && /^(loading|please\s+wait)[.\s…]*$/i.test(label.trim())))
55
+ const previous = this.signature
56
+ const unchanged = previous === signature
57
+ if (!unchanged || nativeLoading || textLoading) this.stableSince = now
58
+ this.signature = signature
59
+ const stableFor = unchanged && !nativeLoading && !textLoading ? Math.max(0, now - this.stableSince) : 0
60
+ const state = nativeLoading || textLoading ? 'loading' : !previous ? 'unknown' : !unchanged ? 'transitioning' : stableFor >= this.stabilityMs ? 'settled' : 'unknown'
61
+ const code = nativeLoading ? 'NATIVE_LOADING_INDICATOR' : textLoading ? 'LOADING_LABEL' : state === 'transitioning' ? 'SNAPSHOT_CHANGED' : state === 'settled' ? 'SNAPSHOT_STABLE' : 'INSUFFICIENT_SAMPLES'
62
+ this.summary = {
63
+ state, scope: 'device_ui', basis: textLoading && !nativeLoading ? 'heuristic' : state === 'unknown' ? 'insufficient' : 'observed',
64
+ captured_at_ms: tree.captured_at_ms, snapshot_session_id: tree.snapshot_session_id, snapshot_revision: tree.snapshot_revision,
65
+ sample_count: this.summary.sample_count + 1, stable_for_ms: stableFor,
66
+ 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' }
68
+ }
69
+ return this.summary
70
+ }
71
+ }
72
+
73
+ const waits = new AsyncLocalStorage<ReadinessTracker>()
74
+ export function currentReadinessWait() { return waits.getStore() }
75
+ export function withReadinessTracker<T>(tracker: ReadinessTracker, operation: () => Promise<T>) { return waits.run(tracker, operation) }
76
+
77
+ export function observeReadinessWait(tree: unknown, startedAtMs: number) {
78
+ const tracker = waits.getStore()
79
+ if (!tracker) return
80
+ remainingBudget()
81
+ const summary = tracker.observe(tree, startedAtMs)
82
+ if (isDeviceUnavailable((tree as any)?.error)) throw new Error('READINESS_DEVICE_UNAVAILABLE')
83
+ if (summary.limitations.some(code => ['OBSERVATION_INVALID', 'OBSERVATION_FAILED'].includes(code))) throw new Error('READINESS_SAMPLE_INVALID')
84
+ }