mobile-debug-mcp 0.33.0 → 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 (87) hide show
  1. package/.github/workflows/ci.yml +21 -0
  2. package/README.md +7 -0
  3. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  4. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  5. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  6. package/agent-plugin/.mcp.json +1 -1
  7. package/agent-plugin/README.md +4 -0
  8. package/agent-plugin/mcp.json +1 -1
  9. package/agent-plugin/plugin.json +1 -1
  10. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +88 -4
  11. package/dist/debugger/android.js +78 -0
  12. package/dist/debugger/guard.js +30 -0
  13. package/dist/debugger/sessions.js +262 -0
  14. package/dist/debugger/worker.js +165 -0
  15. package/dist/interact/index.js +16 -0
  16. package/dist/observe/evidence.js +9 -2
  17. package/dist/observe/readiness-service.js +9 -0
  18. package/dist/observe/readiness.js +15 -8
  19. package/dist/observe/system-trace-android.js +135 -0
  20. package/dist/observe/system-trace.js +285 -0
  21. package/dist/server/debugger-handlers.js +38 -0
  22. package/dist/server/debugger-schema.js +42 -0
  23. package/dist/server/journey-schema.js +1 -0
  24. package/dist/server/system-trace-schema.js +17 -0
  25. package/dist/server/tool-definitions.js +6 -1
  26. package/dist/server/tool-handlers.js +203 -96
  27. package/dist/server-core.js +3 -3
  28. package/dist/server.js +34 -0
  29. package/dist/system/index.js +5 -4
  30. package/dist/utils/exec.js +2 -0
  31. package/dist/utils/java.js +14 -12
  32. package/dist/utils/operation-budget.js +2 -0
  33. package/dist/utils/resolve-device.js +2 -0
  34. package/docs/CHANGELOG.md +4 -0
  35. package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
  36. package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
  37. package/docs/specs/023-android-system-traces/intent.md +40 -0
  38. package/docs/specs/023-android-system-traces/plan.md +117 -0
  39. package/docs/specs/023-android-system-traces/spec.md +120 -0
  40. package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
  41. package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
  42. package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
  43. package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
  44. package/docs/tools/TOOLS.md +3 -1
  45. package/docs/tools/debugger.md +104 -0
  46. package/docs/tools/evidence.md +5 -2
  47. package/docs/tools/observe.md +12 -5
  48. package/docs/tools/system-traces.md +126 -0
  49. package/package.json +1 -1
  50. package/src/debugger/android.ts +65 -0
  51. package/src/debugger/guard.ts +26 -0
  52. package/src/debugger/sessions.ts +168 -0
  53. package/src/debugger/worker.ts +122 -0
  54. package/src/interact/index.ts +16 -15
  55. package/src/observe/evidence.ts +9 -2
  56. package/src/observe/readiness-service.ts +5 -0
  57. package/src/observe/readiness.ts +12 -6
  58. package/src/observe/system-trace-android.ts +133 -0
  59. package/src/observe/system-trace.ts +218 -0
  60. package/src/server/debugger-handlers.ts +29 -0
  61. package/src/server/debugger-schema.ts +37 -0
  62. package/src/server/journey-schema.ts +1 -0
  63. package/src/server/system-trace-schema.ts +17 -0
  64. package/src/server/tool-definitions.ts +6 -1
  65. package/src/server/tool-handlers.ts +92 -7
  66. package/src/server-core.ts +3 -3
  67. package/src/server.ts +24 -0
  68. package/src/system/index.ts +7 -5
  69. package/src/utils/exec.ts +2 -0
  70. package/src/utils/java.ts +13 -12
  71. package/src/utils/operation-budget.ts +2 -0
  72. package/src/utils/resolve-device.ts +2 -0
  73. package/test/device/automated/debugger/runtime.android.test.ts +203 -0
  74. package/test/device/automated/observe/system-trace.smoke.ts +104 -0
  75. package/test/fixtures/debugger-app/MainActivity.java +46 -0
  76. package/test/fixtures/debugger-app/NoLocals.java +7 -0
  77. package/test/fixtures/debugger-app/README.md +13 -0
  78. package/test/fixtures/debugger-app/build.ts +29 -0
  79. package/test/helpers/perfetto.ts +59 -0
  80. package/test/unit/debugger/guards.test.ts +46 -0
  81. package/test/unit/debugger/native.test.ts +33 -0
  82. package/test/unit/debugger/sessions.test.ts +83 -0
  83. package/test/unit/observe/readiness.test.ts +3 -1
  84. package/test/unit/observe/system-trace-android.test.ts +52 -0
  85. package/test/unit/observe/system-trace.test.ts +166 -0
  86. package/test/unit/server/system-trace.test.ts +64 -0
  87. package/worker/DebuggerWorker.java +160 -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,4 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js'
1
2
  import { currentReadinessWait, observeReadinessWait, isDeviceUnavailable } from '../observe/readiness.js'
2
3
  import { readinessDelay } from '../observe/readiness-service.js'
3
4
  import { isConditionBlocked } from './conditional-action.js'
@@ -2349,13 +2350,13 @@ export class ToolsInteract {
2349
2350
  break
2350
2351
  }
2351
2352
  }
2352
- } catch (e) { console.error('Error scoring element:', e) }
2353
+ } catch (e) { checkDebuggerGuard(); console.error('Error scoring element:', e) }
2353
2354
  }
2354
2355
  if (iterationImprovedBest) {
2355
2356
  bestIterationCandidates = iterationCandidates.slice()
2356
2357
  }
2357
2358
  }
2358
- } catch (e) { console.error('Error fetching UI tree:', e) }
2359
+ } catch (e) { checkDebuggerGuard(); console.error('Error fetching UI tree:', e) }
2359
2360
  if (shouldStop || Date.now() > deadline) break
2360
2361
  await new Promise(r => setTimeout(r, 100))
2361
2362
  }
@@ -2443,7 +2444,7 @@ export class ToolsInteract {
2443
2444
  }
2444
2445
  }
2445
2446
  }
2446
- } catch (e) { console.error('Error resolving ancestor:', e) }
2447
+ } catch (e) { checkDebuggerGuard(); console.error('Error resolving ancestor:', e) }
2447
2448
 
2448
2449
  if (!best) return { found: false, error: 'Element not found' }
2449
2450
 
@@ -2697,7 +2698,7 @@ export class ToolsInteract {
2697
2698
  matchedAt = null
2698
2699
  }
2699
2700
 
2700
- } catch (e) {
2701
+ } catch (e) { checkDebuggerGuard();
2701
2702
  // Deadline expiry does not invalidate the last successfully observed state.
2702
2703
  if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
2703
2704
  if (readinessTracker && !String(e).includes('READINESS_')) readinessTracker.unavailable()
@@ -2737,7 +2738,7 @@ export class ToolsInteract {
2737
2738
  }
2738
2739
  }
2739
2740
 
2740
- } catch (err) {
2741
+ } catch (err) { checkDebuggerGuard();
2741
2742
  if (!readinessTracker && err instanceof Error && err.message === 'ACTION_TIMEOUT') throw err
2742
2743
  if (readinessTracker && isDeviceUnavailable(err)) throw err
2743
2744
  const elapsed = Date.now() - overallStart
@@ -2805,9 +2806,9 @@ export class ToolsInteract {
2805
2806
  }
2806
2807
  lastFingerprint = confirmFp
2807
2808
  continue
2808
- } catch (e) { console.error('Error confirming fingerprint:', e); continue }
2809
+ } catch (e) { checkDebuggerGuard(); console.error('Error confirming fingerprint:', e); continue }
2809
2810
  }
2810
- } catch (e) { console.error('Error getting screen fingerprint:', e) }
2811
+ } catch (e) { checkDebuggerGuard(); console.error('Error getting screen fingerprint:', e) }
2811
2812
 
2812
2813
  await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
2813
2814
  }
@@ -2940,7 +2941,7 @@ export class ToolsInteract {
2940
2941
  candidateSinceMs = null
2941
2942
  }
2942
2943
  }
2943
- } catch (error) {
2944
+ } catch (error) { checkDebuggerGuard();
2944
2945
  if (readinessTracker && !String(error).includes('READINESS_')) readinessTracker.unavailable()
2945
2946
  if (readinessTracker && isDeviceUnavailable(error)) throw error
2946
2947
  if (readinessTracker && String(error).includes('ACTION_TIMEOUT')) { readinessTracker.deadline(); break }
@@ -3533,7 +3534,7 @@ export class ToolsInteract {
3533
3534
  const msgs = ToolsInteract._logsToMessages(logsArr)
3534
3535
  baselineLastLine = msgs.length ? msgs[msgs.length - 1] : null
3535
3536
  }
3536
- } catch (err) {
3537
+ } catch (err) { checkDebuggerGuard();
3537
3538
  try { console.warn('waitForUI: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
3538
3539
  }
3539
3540
 
@@ -3583,8 +3584,8 @@ export class ToolsInteract {
3583
3584
  stableDuration = 0
3584
3585
  lastObservedState = false
3585
3586
  }
3586
- } catch (err) { console.error('waitForUI(ui) find error:', err) }
3587
- } catch (err) { console.error('waitForUI(ui) outer error:', err) }
3587
+ } catch (err) { checkDebuggerGuard(); console.error('waitForUI(ui) find error:', err) }
3588
+ } catch (err) { checkDebuggerGuard(); console.error('waitForUI(ui) outer error:', err) }
3588
3589
  } else if (type === 'log') {
3589
3590
  try {
3590
3591
  // Logs: presence semantics only (match 'present'). Stability not applicable (immediate)
@@ -3614,7 +3615,7 @@ export class ToolsInteract {
3614
3615
  return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true }
3615
3616
  }
3616
3617
  }
3617
- } catch (err) { console.error('waitForUI(log) error:', err) }
3618
+ } catch (err) { checkDebuggerGuard(); console.error('waitForUI(log) error:', err) }
3618
3619
  } else if (type === 'screen') {
3619
3620
  try {
3620
3621
  const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
@@ -3636,7 +3637,7 @@ export class ToolsInteract {
3636
3637
  lastObservedState = false
3637
3638
  }
3638
3639
  }
3639
- } catch (err) { console.error('waitForUI(screen) error:', err) }
3640
+ } catch (err) { checkDebuggerGuard(); console.error('waitForUI(screen) error:', err) }
3640
3641
  } else if (type === 'idle') {
3641
3642
  try {
3642
3643
  const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
@@ -3655,7 +3656,7 @@ export class ToolsInteract {
3655
3656
  return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: idleMs, matchSource: 'idle-stable', timestamp: now2, type: 'idle', observed_state: lastObservedState ?? null }
3656
3657
  }
3657
3658
  }
3658
- } catch (err) { console.error('waitForUI(idle) error:', err) }
3659
+ } catch (err) { checkDebuggerGuard(); console.error('waitForUI(idle) error:', err) }
3659
3660
  }
3660
3661
 
3661
3662
  // Respect poll interval and avoid tight loop
@@ -3671,7 +3672,7 @@ export class ToolsInteract {
3671
3672
  // time ensures the latest exported ToolsObserve object is used.
3672
3673
  const Obs = await import('../observe/index.js')
3673
3674
  snapshot = await (Obs as any).ToolsObserve.captureDebugSnapshotHandler({ reason: `wait_for_ui timeout for ${type}`, includeLogs: true, platform, deviceId })
3674
- } catch (err) {
3675
+ } catch (err) { checkDebuggerGuard();
3675
3676
  snapshot = { error: err instanceof Error ? err.message : String(err) }
3676
3677
  }
3677
3678
  }
@@ -25,6 +25,7 @@ interface Acquisition {
25
25
  startSequence: number; endSequence: number; overlapping: boolean; reused: boolean
26
26
  }
27
27
  interface EvidenceContext {
28
+ systemTraceId?: string
28
29
  correlationId: string; action: ActionRecord | null; ref?: ActionRef
29
30
  device: EvidenceDevice | null; start: Stamp; end: Stamp | null; verification: Stamp | null
30
31
  observations: Acquisition[]; isAction: boolean; children: EvidenceContext[]
@@ -72,6 +73,12 @@ export function validateActionRef(value: unknown): ActionRef | undefined {
72
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')
73
74
  return { sessionId: ref.sessionId, actionId: ref.actionId }
74
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
+ }
75
82
  export function currentEvidenceActionId() { return context.getStore()?.action?.id }
76
83
  export function bindEvidenceDevice(device: { platform: string; id: string }) {
77
84
  const current = context.getStore()
@@ -210,7 +217,7 @@ function assemble(current: EvidenceContext) {
210
217
  dispatch_started_at_ms: action?.dispatchStart?.wall ?? null,
211
218
  dispatch_completed_at_ms: action?.dispatchEnd?.wall ?? null,
212
219
  verification_completed_at_ms: current.verification?.wall ?? action?.verification?.wall ?? null,
213
- observations, trace_context: { supported: false, trace_id: null, markers: [] }
220
+ observations, trace_context: { supported: !!current.systemTraceId, trace_id: current.systemTraceId ?? null, markers: [] }
214
221
  }
215
222
  }
216
223
  export async function withEvidence<T>(options: { action?: boolean; journey?: boolean; ref?: ActionRef }, operation: () => Promise<T>): Promise<{ result: T; evidence: ReturnType<typeof assemble> }> {
@@ -219,7 +226,7 @@ export async function withEvidence<T>(options: { action?: boolean; journey?: boo
219
226
  const id = randomUUID()
220
227
  const record: ActionRecord | null = options.action ? { id, correlationId: parent?.correlationId ?? id, device: null,
221
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
222
- const current: EvidenceContext = { correlationId: options.action ? record!.correlationId : options.journey ? id : randomUUID(),
229
+ const current: EvidenceContext = { systemTraceId: parent?.systemTraceId, correlationId: options.action ? record!.correlationId : options.journey ? id : randomUUID(),
223
230
  action: record, ref: options.ref, device: null, start: stamp(), end: null, verification: null, observations: [], children: [], isAction: !!options.action }
224
231
  if (options.action && record) active.set(id, record)
225
232
  if (parent && options.action) parent.children.push(current)
@@ -1,3 +1,4 @@
1
+ import { pausedReadiness } from '../debugger/guard.js'
1
2
  import type { DeviceInfo } from '../types.js'
2
3
  import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js'
3
4
  import { remainingBudget, runWithBudget } from '../utils/operation-budget.js'
@@ -24,6 +25,7 @@ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabi
24
25
  let device: DeviceInfo | null = null
25
26
  let tracker = new ReadinessTracker(args.platform ?? 'android', args.deviceId ?? '')
26
27
  let failureCode: string | undefined
28
+ let debugPause: ReturnType<typeof pausedReadiness> = null
27
29
  await runWithBudget(args.timeoutMs ?? (wait ? 30000 : 15000), async () => {
28
30
  try {
29
31
  device = await resolve(args)
@@ -33,6 +35,8 @@ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabi
33
35
  let count = 0
34
36
  while (wait || count < 2) {
35
37
  remainingBudget()
38
+ debugPause = device!.platform === 'android' ? pausedReadiness(device!.id) : null
39
+ if (debugPause) { failureCode = wait ? 'DEBUGGER_PAUSED' : undefined; return }
36
40
  const captureStarted = Date.now()
37
41
  try {
38
42
  const tree = await ToolsObserve.getUITreeHandler({ platform: device!.platform as 'android' | 'ios', deviceId: device!.id })
@@ -57,6 +61,7 @@ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabi
57
61
  failureCode = !expired && (!device || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE'
58
62
  }
59
63
  })
64
+ if (debugPause) return { success: !wait, device, readiness: debugPause, elapsed_ms: performance.now() - started, ...(wait ? { failure_code: 'DEBUGGER_PAUSED' } : {}) }
60
65
  const state = tracker.summary.state
61
66
  const success = !failureCode && (wait ? state === 'settled' : state !== 'unknown')
62
67
  return { success, device, readiness: tracker.summary, elapsed_ms: performance.now() - started,
@@ -1,9 +1,10 @@
1
+ import { pausedReadiness } from '../debugger/guard.js'
1
2
  import { AsyncLocalStorage } from 'node:async_hooks'
2
3
  import type { ReadinessSummary } from '../types.js'
3
4
  import { remainingBudget } from '../utils/operation-budget.js'
4
5
  import { computeSnapshotSignature, getSnapshotSessionId } from './snapshot-metadata.js'
5
6
 
6
- const baselineLimitations = ['NORMALIZED_UI_ONLY', 'FOREGROUND_APP_UNVERIFIED', 'UNRESPONSIVE_SIGNAL_UNSUPPORTED', 'DEBUGGER_SIGNAL_UNSUPPORTED']
7
+ const baselineLimitations = ['NORMALIZED_UI_ONLY', 'FOREGROUND_APP_UNVERIFIED', 'UNRESPONSIVE_SIGNAL_UNSUPPORTED']
7
8
  const capabilities = { loading: true, transitioning: true, settled: true, unresponsive: false, debugger_paused: false }
8
9
 
9
10
  export function isDeviceUnavailable(error: unknown) {
@@ -13,18 +14,21 @@ export function isDeviceUnavailable(error: unknown) {
13
14
  export class ReadinessTracker {
14
15
  private signature: string | null = null
15
16
  private stableSince = 0
16
- summary: ReadinessSummary = {
17
+ summary: ReadinessSummary
18
+
19
+ constructor(private platform: string, private deviceId: string, private stabilityMs = 500) {
20
+ this.summary = {
17
21
  state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
18
22
  sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
19
- evidence: [], limitations: [...baselineLimitations, 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities }
23
+ evidence: [], limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
20
24
  }
21
25
 
22
- constructor(private platform: string, private deviceId: string, private stabilityMs = 500) {}
26
+ }
23
27
 
24
28
  unavailable(code = 'OBSERVATION_FAILED'): ReadinessSummary {
25
29
  this.signature = null
26
30
  this.summary = { ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
27
- sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, code],
31
+ sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', code],
28
32
  evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: null, snapshot_revision: null }].slice(-8) }
29
33
  return this.summary
30
34
  }
@@ -35,6 +39,8 @@ export class ReadinessTracker {
35
39
  }
36
40
 
37
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 }
38
44
  if (tree?.error) return this.unavailable('OBSERVATION_FAILED')
39
45
  if (!tree || tree.device?.platform !== this.platform || tree.device?.id !== this.deviceId ||
40
46
  tree.snapshot_session_id !== getSnapshotSessionId() || !Number.isSafeInteger(tree.snapshot_revision) || tree.snapshot_revision <= 0 ||
@@ -58,7 +64,7 @@ export class ReadinessTracker {
58
64
  captured_at_ms: tree.captured_at_ms, snapshot_session_id: tree.snapshot_session_id, snapshot_revision: tree.snapshot_revision,
59
65
  sample_count: this.summary.sample_count + 1, stable_for_ms: stableFor,
60
66
  evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: tree.captured_at_ms, snapshot_revision: tree.snapshot_revision }].slice(-8),
61
- limitations: [...baselineLimitations, ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities }
67
+ limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
62
68
  }
63
69
  return this.summary
64
70
  }