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,168 @@
1
+ import { registerDebuggerPauseProvider } from './guard.js'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { AndroidDebugNative, type DebugNative, type DebugTarget } from './android.js'
4
+ import { createWorker, prepareWorker, DebuggerError, withDebuggerBudget, debuggerRemaining, type DebugWorker, type WorkerEvent } from './worker.js'
5
+
6
+ type State = 'attaching' | 'running' | 'paused' | 'detaching' | 'detached' | 'disconnected' | 'failed'
7
+ interface Session {
8
+ id: string; device: string; target: DebugTarget | null; state: State; stop: any; reason: string | null
9
+ worker?: DebugWorker; port?: number; queue: Promise<unknown>; events: any[]; sequence: number
10
+ cleanup: { status: 'pending' | 'complete' | 'failed'; remaining_resources: string[] }; terminalAt?: number
11
+ created: number; activity: number; pausedAt?: number; timer?: ReturnType<typeof setInterval>; cleaning?: Promise<void>
12
+ }
13
+ export const debuggerCapabilities = { attach: true, breakpoints: true, stacks: true, locals: true, step: true, resume: true,
14
+ evaluation: false, mutation: false, coroutines: false, native: false }
15
+ const limitations = ['JAVA_KOTLIN_BYTECODE_ONLY', 'DEBUG_METADATA_REQUIRED', 'SHALLOW_LOCALS', 'ALL_THREADS_SUSPEND', 'DEVICE_WIDE_UI_GUARD']
16
+ export function debuggerExpiry(created: number, activity: number, pausedAt: number | undefined, now: number) {
17
+ return pausedAt !== undefined && now - pausedAt >= 60000 ? 'pause_limit' : now - activity >= 300000 ? 'idle_limit' : now - created >= 1800000 ? 'session_limit' : null
18
+ }
19
+ export class DebuggerSessions {
20
+ private sessions = new Map<string, Session>()
21
+ private closing = false
22
+ constructor(private native: DebugNative = new AndroidDebugNative(), private workerFactory = createWorker, private prerequisite = prepareWorker) {}
23
+ private prune() {
24
+ const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.cleanup.status === 'complete')
25
+ for (const s of terminal) if (Date.now() - s.terminalAt! >= 600000) this.sessions.delete(s.id)
26
+ const remaining = terminal.filter(s => this.sessions.has(s.id))
27
+ for (const s of remaining.slice(0, Math.max(0, remaining.length - 100))) this.sessions.delete(s.id)
28
+ }
29
+ private get(id: string) { this.prune(); const s = this.sessions.get(id); if (!s) throw new DebuggerError('DEBUGGER_NOT_FOUND'); s.activity = Date.now(); return s }
30
+ private serial<T>(s: Session, fn: () => Promise<T>) { const out = s.queue.then(fn); s.queue = out.catch(() => {}); return out }
31
+ private result(s: Session, payload: any = {}) {
32
+ const success = payload.success ?? (!['failed', 'disconnected'].includes(s.state) && s.cleanup.status !== 'failed')
33
+ const code = s.cleanup.status === 'failed' ? 'DEBUGGER_CLEANUP_FAILED' : s.state === 'disconnected' ? 'DEBUGGER_DISCONNECTED' : s.reason?.match(/^[A-Z_]+$/) ? s.reason : 'DEBUGGER_WORKER_FAILED'
34
+ return structuredClone({ success, ...(!success ? { error: { code, message: code, retryable: false } } : {}), debug_session_id: s.id,
35
+ device: { platform: 'android', id: s.device }, target: s.target, state: s.state, captured_at_ms: Date.now(),
36
+ capabilities: debuggerCapabilities, limitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload })
37
+ }
38
+ summary(id: string) { return this.result(this.get(id)) }
39
+ hasActiveSessions() { return [...this.sessions.values()].some(s => s.cleanup.status !== 'complete') }
40
+ paused(device: string) { const s = [...this.sessions.values()].find(s => s.device === device && s.state === 'paused'); return s ? this.result(s) : null }
41
+ assertRunning(device: string) { if (this.paused(device)) throw new DebuggerError('DEBUGGER_PAUSED') }
42
+ private event(s: Session, event: WorkerEvent) {
43
+ if (s.cleanup.status === 'complete') return
44
+ if (event.event === 'stopped' && !s.cleaning) { s.state = 'paused'; s.stop = event; s.pausedAt ??= Date.now() }
45
+ if (event.event === 'terminated' || event.event === 'worker_failed') {
46
+ if (!s.cleaning && s.state !== 'failed') s.reason = event.reason ?? 'worker_failed'
47
+ if (!s.cleaning) {
48
+ s.state = event.event === 'worker_failed' ? 'failed' : event.reason === 'process_exit' || event.reason === 'transport_lost' ? 'disconnected' : 'detached'
49
+ s.stop = null
50
+ // The worker has disposed or lost its transport. Cleanup remains pending until host resources are checked.
51
+ void this.serial(s, () => this.cleanup(s, false))
52
+ }
53
+ }
54
+ s.events.push({ ...event, sequence: ++s.sequence, received_at_ms: Date.now() })
55
+ if (s.events.length > 256) s.events.shift()
56
+ }
57
+ async capabilities(device: string, app: string, pid?: number) {
58
+ const prerequisites: Record<string, boolean> = { jdk: false, device: false, process: false }
59
+ let candidates: DebugTarget[] = []
60
+ const reasons: string[] = []
61
+ try { await this.prerequisite(); prerequisites.jdk = true } catch (e) { if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code)) throw e; reasons.push('DEBUGGER_UNSUPPORTED') }
62
+ try { candidates = await this.native.candidates(device, app); prerequisites.device = true; prerequisites.process = candidates.some(t => pid === undefined || pid === t.pid); if (!prerequisites.process) reasons.push('PROCESS_NOT_FOUND') }
63
+ catch (e) { if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code)) throw e; reasons.push(e instanceof DebuggerError ? e.code : 'DEVICE_UNAVAILABLE') }
64
+ return { success: true, debug_session_id: null, device: { platform: 'android', id: device }, target: null, state: null,
65
+ captured_at_ms: Date.now(), supported: Object.values(prerequisites).every(Boolean), prerequisites, candidates,
66
+ capabilities: debuggerCapabilities, reasons, limitations, cleanup: { status: 'complete', remaining_resources: [] } }
67
+ }
68
+ async attach(device: string, app: string, pid?: number, timeout = 10000) {
69
+ this.prune()
70
+ const owned = [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete')
71
+ if (this.closing || owned.length >= 4 || owned.some(s => s.device === device)) throw new DebuggerError('DEBUGGER_BUSY')
72
+ const s: Session = { id: randomUUID(), device, target: null, state: 'attaching', stop: null, reason: null,
73
+ queue: Promise.resolve(), events: [], sequence: 0, cleanup: { status: 'pending', remaining_resources: [] }, created: Date.now(), activity: Date.now() }
74
+ this.sessions.set(s.id, s)
75
+ return this.serial(s, async () => {
76
+ const deadline = Date.now() + timeout
77
+ const remaining = () => { debuggerRemaining(timeout); const ms = deadline - Date.now(); if (ms <= 0) throw new DebuggerError('DEBUGGER_TIMEOUT'); return ms }
78
+ try {
79
+ await this.prerequisite(); remaining()
80
+ const candidates = (await this.native.candidates(device, app)).filter(t => pid === undefined || t.pid === pid); remaining()
81
+ if (!candidates.length) throw new DebuggerError('PROCESS_NOT_FOUND')
82
+ if (candidates.length > 1) throw new DebuggerError('PROCESS_AMBIGUOUS')
83
+ s.target = candidates[0]
84
+ s.port = await this.native.forward(device, s.target.pid); remaining()
85
+ s.cleanup.remaining_resources.push(`forward:tcp:${s.port}`)
86
+ s.worker = await this.workerFactory(event => this.event(s, event)); remaining()
87
+ s.cleanup.remaining_resources.push('jdi_worker')
88
+ await s.worker.call('attach', { port: s.port, timeoutMs: remaining() }, remaining())
89
+ await this.native.identity(device, s.target); remaining()
90
+ if (s.state === 'attaching') s.state = 'running'
91
+ if (['failed', 'disconnected', 'detached'].includes(s.state)) throw new DebuggerError('DEBUGGER_ATTACH_FAILED')
92
+ s.timer = setInterval(() => {
93
+ const now = Date.now()
94
+ const reason = debuggerExpiry(s.created, s.activity, s.pausedAt, now)
95
+ if (reason) { s.reason = reason; void this.serial(s, () => this.cleanup(s, true)) }
96
+ }, 1000); s.timer.unref()
97
+ } catch (e) {
98
+ s.state = 'failed'; s.reason = e instanceof DebuggerError ? e.code : 'DEBUGGER_ATTACH_FAILED'
99
+ await this.cleanup(s, true)
100
+ return this.result(s, { success: false, error: { code: s.reason, message: s.reason, retryable: false } })
101
+ }
102
+ return this.result(s)
103
+ })
104
+ }
105
+ private async cleanup(s: Session, requestDetach: boolean): Promise<void> {
106
+ if (s.cleaning) return s.cleaning
107
+ s.cleaning = withDebuggerBudget(10000, undefined, async () => {
108
+ if (s.timer) clearInterval(s.timer)
109
+ const terminal = ['failed', 'disconnected'].includes(s.state) ? s.state : 'detached'
110
+ s.state = 'detaching'; s.stop = null; s.pausedAt = undefined
111
+ const remaining: string[] = []
112
+ if (s.worker) {
113
+ if (requestDetach) { try { await s.worker.call('detach', {}, 3000) } catch { /* EOF/transport closure is the fallback; execution remains unknown. */ } }
114
+ try { await s.worker.close(); s.worker = undefined } catch { remaining.push('jdi_worker') }
115
+ }
116
+ if (s.port !== undefined && s.target) {
117
+ try { await this.native.removeForward(s.device, s.port, s.target.pid); s.port = undefined }
118
+ catch { remaining.push(`forward:tcp:${s.port}`) }
119
+ }
120
+ s.events = []; s.cleanup = { status: remaining.length ? 'failed' : 'complete', remaining_resources: remaining }
121
+ s.state = terminal; s.terminalAt = Date.now()
122
+ })
123
+ try { await s.cleaning } finally { s.cleaning = undefined }
124
+ }
125
+ async command(id: string, command: string, args: Record<string, any> = {}, timeout = 10000) {
126
+ const s = this.get(id)
127
+ if (command === 'events') {
128
+ const deadline = Date.now() + timeout
129
+ const after = args.afterSequence ?? 0
130
+ if (after > s.sequence) throw new DebuggerError('INVALID_ARGUMENT')
131
+ if (args.signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
132
+ if (s.worker && ['running', 'paused'].includes(s.state)) await s.worker.call('heartbeat', {}, Math.min(timeout, 1000))
133
+ while (Date.now() < deadline && !s.events.some(e => e.sequence > after) && s.cleanup.status === 'pending') {
134
+ if (args.signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
135
+ await new Promise(resolve => setTimeout(resolve, Math.min(50, deadline - Date.now())))
136
+ }
137
+ const events = s.events.filter(e => e.sequence > after).slice(0, 100)
138
+ return this.result(s, { events, next_cursor: events.at(-1)?.sequence ?? s.sequence, overflow: after < (s.events[0]?.sequence ?? s.sequence + 1) - 1, timed_out: events.length === 0 })
139
+ }
140
+ return this.serial(s, async () => {
141
+ if (command === 'detach') { if (s.cleanup.status !== 'complete') await this.cleanup(s, true); return this.result(s, { success: s.cleanup.status === 'complete', execution_state: 'unknown' }) }
142
+ if (command === 'state' && (s.cleanup.status === 'complete' || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state))) return this.result(s)
143
+ if (!s.worker || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state)) throw new DebuggerError('DEBUGGER_DISCONNECTED')
144
+ if (['stack', 'locals', 'step', 'resume'].includes(command)) {
145
+ if (args.stopId !== s.stop?.stop_id) throw new DebuggerError('DEBUGGER_STALE_REFERENCE')
146
+ if (s.state !== 'paused') throw new DebuggerError('DEBUGGER_NOT_PAUSED')
147
+ }
148
+ if (command === 'step' || command === 'resume') { s.state = 'running'; s.stop = null; s.pausedAt = undefined }
149
+ try {
150
+ const data = await s.worker.call(command, args, timeout)
151
+ return this.result(s, command === 'state' ? { breakpoints: data.breakpoints } : { result: data })
152
+ } catch (e) {
153
+ if (e instanceof DebuggerError && e.code === 'DEBUGGER_DISCONNECTED') {
154
+ s.state = 'disconnected'; s.reason = 'transport_lost'; await this.cleanup(s, false)
155
+ if (command === 'state') return this.result(s)
156
+ }
157
+ if ((command === 'step' || command === 'resume' || command === 'set' || command === 'remove') && e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED', 'DEBUGGER_WORKER_FAILED'].includes(e.code)) {
158
+ s.state = 'failed'; s.reason = e.code; await this.cleanup(s, true)
159
+ }
160
+ throw e
161
+ }
162
+ })
163
+ }
164
+ async shutdown() { this.closing = true; await Promise.allSettled([...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.serial(s, () => this.cleanup(s, true)))); return [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.result(s)) }
165
+ }
166
+ export const debuggerSessions = new DebuggerSessions()
167
+
168
+ registerDebuggerPauseProvider(device => debuggerSessions.paused(device))
@@ -0,0 +1,122 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+ import { readFile, mkdtemp, chmod, rm } from 'node:fs/promises'
5
+ import { createHash } from 'node:crypto'
6
+ import path from 'node:path'
7
+ import os from 'node:os'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { detectJavaHome } from '../utils/java.js'
10
+
11
+ const exec = promisify(execFile)
12
+ const operations = new AsyncLocalStorage<{ deadline: number; signal?: AbortSignal }>()
13
+ export function withDebuggerBudget<T>(timeout: number, signal: AbortSignal | undefined, operation: () => Promise<T>) {
14
+ return operations.run({ deadline: performance.now() + timeout, signal }, operation)
15
+ }
16
+ export function debuggerRemaining(fallback: number) {
17
+ const context = operations.getStore()
18
+ if (context?.signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
19
+ const remaining = Math.floor((context?.deadline ?? Infinity) - performance.now())
20
+ if (remaining <= 0) throw new DebuggerError('DEBUGGER_TIMEOUT')
21
+ return Math.min(fallback, remaining)
22
+ }
23
+ export function debuggerSignal() { return operations.getStore()?.signal }
24
+ export function nativeError(error: any, fallback: string): DebuggerError {
25
+ if (error instanceof DebuggerError) return error
26
+ if (debuggerSignal()?.aborted) return new DebuggerError('DEBUGGER_CANCELLED')
27
+ if (error?.killed) return new DebuggerError('DEBUGGER_TIMEOUT')
28
+ return new DebuggerError(fallback)
29
+ }
30
+ export class DebuggerError extends Error { constructor(public code: string) { super(code) } }
31
+ export const workerSource = fileURLToPath(new URL('../../worker/DebuggerWorker.java', import.meta.url))
32
+ let compiled: Promise<{ java: string; directory: string }> | undefined
33
+ export async function prepareWorker() {
34
+ compiled ??= (async () => {
35
+ const home = await detectJavaHome({ quiet: true })
36
+ const java = home ? path.join(home, 'bin', 'java') : 'java'
37
+ const javac = home ? path.join(home, 'bin', 'javac') : 'javac'
38
+ const version = await exec(java, ['-version'], { timeout: debuggerRemaining(5000), signal: debuggerSignal() })
39
+ const major = Number(/version "(\d+)/.exec(version.stderr)?.[1])
40
+ if (!(major >= 17)) throw new DebuggerError('DEBUGGER_UNSUPPORTED')
41
+ const source = await readFile(workerSource)
42
+ const key = createHash('sha256').update(source).update(version.stderr).digest('hex').slice(0, 16)
43
+ const directory = await mkdtemp(path.join(os.tmpdir(), `mobile-debug-jdi-${key}-`))
44
+ await chmod(directory, 0o700)
45
+ try { await exec(javac, ['--release', '17', '--add-modules', 'jdk.jdi', '-d', directory, workerSource], { timeout: debuggerRemaining(15000), signal: debuggerSignal(), maxBuffer: 1024 * 1024 }) }
46
+ catch (e) { await rm(directory, { recursive: true, force: true }); throw nativeError(e, 'DEBUGGER_UNSUPPORTED') }
47
+ return { java, directory }
48
+ })().catch(error => { compiled = undefined; throw error })
49
+ return compiled
50
+ }
51
+ export type WorkerEvent = Record<string, any>
52
+ export interface DebugWorker {
53
+ call(command: string, args?: Record<string, unknown>, timeout?: number): Promise<any>
54
+ close(): Promise<void>
55
+ }
56
+ export class JdiWorker implements DebugWorker {
57
+ private child: ChildProcessWithoutNullStreams
58
+ private pending = new Map<number, { resolve(value: any): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }>()
59
+ private sequence = 0
60
+ private dead = false
61
+ private exited: Promise<void>
62
+ constructor(java: string, directory: string, onEvent: (event: WorkerEvent) => void) {
63
+ this.child = spawn(java, ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'] })
64
+ let buffer = ''
65
+ const fail = () => {
66
+ if (this.dead) return
67
+ this.dead = true
68
+ for (const request of this.pending.values()) { clearTimeout(request.timer); request.reject(new DebuggerError('DEBUGGER_WORKER_FAILED')) }
69
+ this.pending.clear()
70
+ onEvent({ event: 'worker_failed' })
71
+ }
72
+ this.exited = new Promise(resolve => { this.child.once('exit', () => { fail(); resolve() }); this.child.once('error', () => { fail(); resolve() }) })
73
+ this.child.stdin.on('error', fail)
74
+ this.child.stderr.resume() // Never copy worker data or VM diagnostics into MCP stdout/logs.
75
+ this.child.stdout.setEncoding('utf8')
76
+ this.child.stdout.on('data', (chunk: string) => {
77
+ buffer += chunk
78
+ if (Buffer.byteLength(buffer) > 2 * 1024 * 1024) { fail(); this.child.kill('SIGKILL'); return }
79
+ let end: number
80
+ while ((end = buffer.indexOf('\n')) >= 0) {
81
+ const line = buffer.slice(0, end); buffer = buffer.slice(end + 1)
82
+ try {
83
+ if (Buffer.byteLength(line) > 1024 * 1024) throw new Error('IPC_LIMIT')
84
+ const msg = JSON.parse(line)
85
+ if (msg.event) onEvent(msg)
86
+ else {
87
+ const request = this.pending.get(msg.id)
88
+ if (!request) continue
89
+ clearTimeout(request.timer); this.pending.delete(msg.id)
90
+ if (msg.error) request.reject(new DebuggerError(msg.error.code ?? 'DEBUGGER_WORKER_FAILED'))
91
+ else request.resolve(msg.result)
92
+ }
93
+ } catch { fail(); this.child.kill('SIGKILL'); return }
94
+ }
95
+ })
96
+ }
97
+ call(command: string, args: Record<string, unknown> = {}, timeout = 10000): Promise<any> {
98
+ if (this.dead) return Promise.reject(new DebuggerError('DEBUGGER_WORKER_FAILED'))
99
+ try { timeout = Math.min(timeout, debuggerRemaining(timeout)) } catch (e) { return Promise.reject(e) }
100
+ const id = ++this.sequence
101
+ const message = JSON.stringify({ ...args, command, id }) + '\n'
102
+ if (Buffer.byteLength(message) > 1024 * 1024) return Promise.reject(new DebuggerError('INVALID_ARGUMENT'))
103
+ return new Promise((resolve, reject) => {
104
+ const signal = debuggerSignal()
105
+ const abort = () => { this.pending.delete(id); clearTimeout(timer); reject(new DebuggerError('DEBUGGER_CANCELLED')) }
106
+ const done = () => signal?.removeEventListener('abort', abort)
107
+ const timer = setTimeout(() => { this.pending.delete(id); done(); reject(new DebuggerError('DEBUGGER_TIMEOUT')) }, timeout)
108
+ this.pending.set(id, { resolve: value => { done(); resolve(value) }, reject: error => { done(); reject(error) }, timer })
109
+ signal?.addEventListener('abort', abort, { once: true })
110
+ this.child.stdin.write(message)
111
+ })
112
+ }
113
+ async close() {
114
+ this.child.stdin.end()
115
+ const kill = setTimeout(() => this.child.kill('SIGKILL'), 2000)
116
+ try { await this.exited } finally { clearTimeout(kill) }
117
+ }
118
+ }
119
+ export async function createWorker(onEvent: (event: WorkerEvent) => void) {
120
+ const { java, directory } = await prepareWorker()
121
+ return new JdiWorker(java, directory, onEvent)
122
+ }
@@ -1,3 +1,5 @@
1
+ import { evidenceDispatch } from '../observe/evidence.js'
2
+ import { beforeInputDispatch } from './conditional-action.js'
1
3
  import { markDispatchStarted } from '../utils/operation-budget.js'
2
4
  import { TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
3
5
  import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
@@ -13,8 +15,9 @@ export class AndroidInteract {
13
15
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
14
16
 
15
17
  try {
18
+ await beforeInputDispatch('android', deviceInfo.id)
16
19
  markDispatchStarted()
17
- await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs })
20
+ await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs }))
18
21
  return { device: deviceInfo, success: true, x, y }
19
22
  } catch (e) {
20
23
  return { device: deviceInfo, success: false, x, y, error: e instanceof Error ? e.message : String(e) }
@@ -26,8 +29,9 @@ export class AndroidInteract {
26
29
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
27
30
 
28
31
  try {
32
+ await beforeInputDispatch('android', deviceInfo.id)
29
33
  markDispatchStarted()
30
- await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs })
34
+ await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs }))
31
35
  return { device: deviceInfo, success: true, start: [x1, y1], end: [x2, y2], duration }
32
36
  } catch (e) {
33
37
  return { device: deviceInfo, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) }
@@ -42,8 +46,9 @@ export class AndroidInteract {
42
46
  // Encode spaces as %s to ensure proper input handling by adb shell input text
43
47
  const encodedText = text.replace(/\s/g, '%s')
44
48
  // Note: 'input text' might fail with some characters or if keyboard isn't ready, but it's the standard ADB way.
49
+ await beforeInputDispatch('android', deviceInfo.id)
45
50
  markDispatchStarted()
46
- await execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs })
51
+ await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs }))
47
52
  return { device: deviceInfo, success: true, text }
48
53
  } catch (e) {
49
54
  return { device: deviceInfo, success: false, text, error: e instanceof Error ? e.message : String(e) }
@@ -55,8 +60,9 @@ export class AndroidInteract {
55
60
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
56
61
 
57
62
  try {
63
+ await beforeInputDispatch('android', deviceInfo.id)
58
64
  markDispatchStarted()
59
- await execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs })
65
+ await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs }))
60
66
  return { device: deviceInfo, success: true }
61
67
  } catch (e) {
62
68
  return { device: deviceInfo, success: false, error: e instanceof Error ? e.message : String(e) }
@@ -0,0 +1,75 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import type { ActionConditionResult } from '../types.js'
3
+ import type { ExpectedState } from '../server/condition-schema.js'
4
+ import { ToolsObserve } from '../observe/index.js'
5
+ import { getSnapshotSessionId, hasSnapshotBaseline } from '../observe/snapshot-metadata.js'
6
+ import { runWithSeparateBudget } from '../utils/operation-budget.js'
7
+ import { ToolsNetwork } from '../network/index.js'
8
+
9
+ export interface ConditionalAction {
10
+ platform: 'android' | 'ios'
11
+ deviceId: string
12
+ expected: ExpectedState
13
+ result: ActionConditionResult
14
+ }
15
+
16
+ const context = new AsyncLocalStorage<ConditionalAction>()
17
+
18
+ export function createConditionalAction(platform: 'android' | 'ios', deviceId: string, expected: ExpectedState): ConditionalAction {
19
+ return { platform, deviceId, expected, result: {
20
+ status: 'not_checked',
21
+ expected: { snapshot_session_id: expected.snapshotSessionId, snapshot_revision: expected.snapshotRevision },
22
+ observed: null, elapsed_ms: 0, reason: 'ACTION_NOT_REACHED'
23
+ } }
24
+ }
25
+
26
+ export function withConditionalAction<T>(condition: ConditionalAction, operation: () => Promise<T>) {
27
+ return context.run(condition, operation)
28
+ }
29
+
30
+ export function isConditionBlocked() {
31
+ const status = context.getStore()?.result.status
32
+ return status === 'mismatch' || status === 'unavailable'
33
+ }
34
+
35
+ // Guarded calls reset the observation window only when input is about to spawn.
36
+ export function notifyActionStart() {
37
+ if (!context.getStore()) ToolsNetwork.notifyActionStart()
38
+ }
39
+
40
+ export function conditionFailureCode(condition: ConditionalAction) {
41
+ return condition.result.status === 'mismatch' ? 'PRECONDITION_FAILED' as const : 'PRECONDITION_UNAVAILABLE' as const
42
+ }
43
+
44
+ export async function beforeInputDispatch(platform: 'android' | 'ios', deviceId: string) {
45
+ const condition = context.getStore()
46
+ if (!condition) return
47
+ const started = performance.now()
48
+ const result = condition.result
49
+ result.status = 'unavailable'
50
+ delete result.reason
51
+ try {
52
+ if (platform !== condition.platform || deviceId !== condition.deviceId) result.reason = 'DEVICE_MISMATCH'
53
+ else if (condition.expected.snapshotSessionId !== getSnapshotSessionId()) result.reason = 'SESSION_MISMATCH'
54
+ else if (!hasSnapshotBaseline(`${platform}:${deviceId}`, condition.expected.snapshotRevision)) result.reason = 'BASELINE_UNAVAILABLE'
55
+ else {
56
+ const tree = await runWithSeparateBudget(condition.expected.timeoutMs ?? 15000, () =>
57
+ ToolsObserve.getUITreeHandler({ platform, deviceId })) as any
58
+ if (tree?.error) result.reason = 'OBSERVATION_FAILED'
59
+ else if (!tree || !Array.isArray(tree.elements) || !Number.isSafeInteger(tree.snapshot_revision) || tree.snapshot_revision < 1 ||
60
+ tree.snapshot_session_id !== getSnapshotSessionId() || !Number.isFinite(tree.captured_at_ms) || tree.captured_at_ms <= 0 ||
61
+ !tree.resolution || !Number.isFinite(tree.resolution.width) || !Number.isFinite(tree.resolution.height) ||
62
+ !(tree.resolution.width > 0) || !(tree.resolution.height > 0)) result.reason = 'OBSERVATION_INVALID'
63
+ else if (tree.device?.platform !== platform || tree.device?.id !== deviceId) result.reason = 'DEVICE_MISMATCH'
64
+ else {
65
+ result.observed = tree
66
+ result.status = tree.snapshot_revision === condition.expected.snapshotRevision ? 'matched' : 'mismatch'
67
+ }
68
+ }
69
+ } catch (error) {
70
+ result.reason = /TIMEOUT|timed out/i.test(String(error)) ? 'CONDITION_TIMEOUT' : 'OBSERVATION_FAILED'
71
+ }
72
+ result.elapsed_ms = performance.now() - started
73
+ if (result.status !== 'matched') throw new Error(conditionFailureCode(condition))
74
+ ToolsNetwork.notifyActionStart()
75
+ }