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
@@ -14,8 +14,18 @@ interface SnapshotState {
14
14
  }
15
15
 
16
16
  let nextRevision = 1
17
+ const snapshotSessionId = crypto.randomUUID()
17
18
  const snapshotStateByDevice = new Map<string, SnapshotState>()
18
19
 
20
+ export function getSnapshotSessionId() { return snapshotSessionId }
21
+
22
+ export function hasSnapshotBaseline(deviceKey: string, revision: number) {
23
+ const state = snapshotStateByDevice.get(deviceKey)
24
+ const capturedAt = state?.historyTimes.get(revision)
25
+ return !!state && capturedAt !== undefined && state.history.has(revision) &&
26
+ Date.now() - capturedAt <= 10 * 60 * 1000 && Date.now() - state.updatedAt <= 10 * 60 * 1000
27
+ }
28
+
19
29
  function normalize(value: unknown): string {
20
30
  if (value === null || value === undefined) return ''
21
31
  return String(value).trim().toLowerCase()
@@ -163,7 +173,7 @@ export function deriveSnapshotMetadata(
163
173
  const oversized = previous?.oversized ?? new Map<number, number>()
164
174
  if (hasValidTree) {
165
175
  history.set(revision, currentElements)
166
- if (!historyTimes.has(revision)) historyTimes.set(revision, Date.now())
176
+ historyTimes.set(revision, Date.now())
167
177
  if ((tree.elements?.length ?? 0) > 500) oversized.set(revision, (tree.elements?.length ?? 0) - 500)
168
178
  }
169
179
  for (const [id, timestamp] of historyTimes) {
@@ -191,6 +201,7 @@ export function deriveSnapshotMetadata(
191
201
  })
192
202
 
193
203
  return {
204
+ snapshot_session_id: snapshotSessionId,
194
205
  snapshot_revision: revision,
195
206
  captured_at_ms: Date.now(),
196
207
  snapshot_delta: hasValidTree ? summarizeSnapshotDelta(previous, currentElementSignatures) : null,
@@ -0,0 +1,133 @@
1
+ import { execCmd } from '../utils/exec.js'
2
+ import { getAdbCmd } from '../utils/android/utils.js'
3
+
4
+ export const TRACE_MAX_BYTES = 32 * 1024 * 1024
5
+ export const TRACE_CATEGORIES = ['sched', 'freq', 'idle', 'am', 'wm', 'gfx', 'view', 'input']
6
+ export interface TraceCapabilities {
7
+ supported: boolean; backend: 'perfetto'; device: { platform: string; id: string } | null
8
+ prerequisites: Record<string, boolean>; reasons: string[]; enabled_sources: string[]; unavailable_categories: string[]; limitations: string[]
9
+ }
10
+ export interface TraceProcess { pid: number; startTime: string }
11
+ export interface TraceNative {
12
+ capabilities(deviceId: string): Promise<TraceCapabilities>
13
+ start(deviceId: string, remote: string, duration: number, categories: string[]): Promise<void>
14
+ find(deviceId: string, remote: string): Promise<TraceProcess | null>
15
+ stop(deviceId: string, remote: string, owner: TraceProcess): Promise<void>
16
+ pull(deviceId: string, remote: string, local: string): Promise<void>
17
+ remove(deviceId: string, remote: string): Promise<void>
18
+ }
19
+
20
+ // Encode only this fixed preset using the public Perfetto proto field numbers:
21
+ // https://github.com/google/perfetto/tree/main/protos/perfetto/config
22
+ // Avoid --txt (explicitly unstable), arbitrary configuration and extra tooling.
23
+ function varint(n: number): Buffer {
24
+ const bytes: number[] = []
25
+ do { const byte = n % 128; n = Math.floor(n / 128); bytes.push(byte | (n ? 128 : 0)) } while (n)
26
+ return Buffer.from(bytes)
27
+ }
28
+ const uint = (field: number, n: number) => Buffer.concat([varint(field * 8), varint(n)])
29
+ const message = (field: number, value: Buffer | string) => {
30
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value)
31
+ return Buffer.concat([varint(field * 8 + 2), varint(bytes.length), bytes])
32
+ }
33
+ export function traceConfig(duration: number, categories: string[]): Buffer {
34
+ const ftrace = Buffer.concat(categories.map(cat => message(2, cat)))
35
+ const source = Buffer.concat([message(1, 'linux.ftrace'), message(100, ftrace)])
36
+ return Buffer.concat([
37
+ message(1, Buffer.concat([uint(1, 16384), uint(4, 1)])),
38
+ message(2, message(1, source)), uint(3, duration), uint(8, 1), uint(10, TRACE_MAX_BYTES)
39
+ ])
40
+ }
41
+ const quote = (value: string) => `'${value.replace(/'/g, `'"'"'`)}'`
42
+ function ownedPath(remote: string) {
43
+ if (!/^\/data\/misc\/perfetto-traces\/mcp-[0-9a-f-]{36}\.perfetto-trace$/.test(remote)) throw new Error('Invalid owned trace path')
44
+ }
45
+
46
+ export class AndroidTraceNative implements TraceNative {
47
+ constructor(private execute: typeof execCmd = execCmd) {}
48
+ private async command(device: string, args: string[], timeout = 10000) {
49
+ const result = await this.execute(getAdbCmd(), ['-s', device, ...args], { timeout })
50
+ if (result.exitCode !== 0) throw new Error(result.stderr === 'ACTION_TIMEOUT' ? 'ACTION_TIMEOUT' : 'ADB_TRACE_COMMAND_FAILED')
51
+ return result.stdout
52
+ }
53
+ private shell(device: string, script: string, timeout?: number) {
54
+ return this.command(device, ['shell', 'sh', '-c', quote(script)], timeout)
55
+ }
56
+ async capabilities(deviceId: string): Promise<TraceCapabilities> {
57
+ const checks: Record<string, boolean> = {}
58
+ const probe = async (name: string, fn: () => Promise<boolean>) => { try { checks[name] = await fn() } catch { checks[name] = false } }
59
+ let help = '', categories = ''
60
+ await probe('android_api_29', async () => Number(await this.command(deviceId, ['shell', 'getprop', 'ro.build.version.sdk'])) >= 29)
61
+ await probe('perfetto', async () => { const result = await this.execute(getAdbCmd(), ['-s', deviceId, 'shell', 'perfetto', '--help'], { timeout: 10000 }); help = result.stdout + result.stderr; return [0, 1].includes(result.exitCode!) && help.includes('Usage: perfetto') && help.includes('--background-wait') && help.includes('--config') })
62
+ await probe('tracing_service', async () => { await this.command(deviceId, ['shell', 'perfetto', '--query']); return true })
63
+ await probe('writable_storage', async () => { await this.shell(deviceId, 'test -w /data/misc/perfetto-traces'); return true })
64
+ await probe('process_identity', async () => { await this.shell(deviceId, 'command -v pidof >/dev/null && test -r /proc/self/stat && test -r /proc/self/cmdline'); return true })
65
+ await probe('base64', async () => (await this.shell(deviceId, "printf YQ== | base64 -d")) === 'a')
66
+ await probe('required_categories', async () => {
67
+ categories = await this.command(deviceId, ['shell', 'atrace', '--list_categories'])
68
+ return ['sched', 'input'].every(c => new RegExp(`^\\s*${c}\\s+-`, 'm').test(categories))
69
+ })
70
+ const enabled = TRACE_CATEGORIES.filter(c => new RegExp(`^\\s*${c}\\s+-`, 'm').test(categories))
71
+ return { supported: Object.values(checks).every(Boolean), backend: 'perfetto', device: { platform: 'android', id: deviceId },
72
+ prerequisites: checks, reasons: Object.keys(checks).filter(k => !checks[k]), enabled_sources: enabled,
73
+ unavailable_categories: TRACE_CATEGORIES.filter(c => !enabled.includes(c)), limitations: [
74
+ 'Device-wide scheduling and system annotations may contain sensitive information.',
75
+ 'Host times are not aligned with device trace clocks; source availability varies.',
76
+ 'A server crash can leave a bounded trace file on the device for manual removal.'
77
+ ] }
78
+ }
79
+ async start(device: string, remote: string, duration: number, categories: string[]) {
80
+ ownedPath(remote)
81
+ const encoded = traceConfig(duration, categories).toString('base64')
82
+ await this.shell(device, `umask 077; printf %s ${quote(encoded)} | base64 -d | perfetto --background-wait -c - -o ${quote(remote)}`, 35000)
83
+ }
84
+ // Scan only shell-owned readable processes. Exact output argv + executable name
85
+ // binds ownership even if start acknowledgement/PID output was lost.
86
+ async find(device: string, remote: string): Promise<TraceProcess | null> {
87
+ ownedPath(remote)
88
+ const output = await this.shell(device, `pids=$(pidof perfetto); rc=$?
89
+ [ "$rc" -le 1 ] || exit 1
90
+ for pid in $pids; do
91
+ p=/proc/$pid
92
+ [ -d "$p" ] || continue
93
+ [ -r "$p/cmdline" ] || { [ ! -d "$p" ] && continue; exit 1; }
94
+ cmd=$(tr '\\000' '\\n' < "$p/cmdline" 2>/dev/null) || { [ ! -d "$p" ] && continue; exit 1; }
95
+ printf '%s\\n' "$cmd" | grep -Fx ${quote(remote)} >/dev/null || continue
96
+ first=$(printf '%s\\n' "$cmd" | head -n 1)
97
+ case "$first" in perfetto|/system/bin/perfetto) ;; *) continue ;; esac
98
+ stat=$(cat "$p/stat" 2>/dev/null) || { [ ! -d "$p" ] && continue; exit 1; }
99
+ rest=\${stat##*) }
100
+ state=$(printf '%s\\n' "$rest" | awk '{print $1}')
101
+ [ "$state" = Z ] && continue
102
+ start=$(printf '%s\\n' "$rest" | awk '{print $20}')
103
+ printf '%s %s\\n' "\${p##*/}" "$start"
104
+ done`)
105
+ if (!output) return null
106
+ const match = /^(\d+) (\d+)$/.exec(output)
107
+ if (!match) throw new Error('TRACE_PROCESS_IDENTITY_UNAVAILABLE')
108
+ return { pid: Number(match[1]), startTime: match[2] }
109
+ }
110
+ async stop(device: string, remote: string, owner: TraceProcess) {
111
+ ownedPath(remote)
112
+ if (!Number.isSafeInteger(owner.pid) || owner.pid <= 1 || !/^\d+$/.test(owner.startTime)) throw new Error('TRACE_PROCESS_IDENTITY_UNAVAILABLE')
113
+ // Recheck start time and argv in the same shell immediately before signal.
114
+ await this.shell(device, `p=/proc/${owner.pid}
115
+ [ -d "$p" ] || exit 0
116
+ stat=$(cat "$p/stat") || exit 1
117
+ rest=\${stat##*) }
118
+ [ "$(printf '%s\\n' "$rest" | awk '{print $20}')" = ${quote(owner.startTime)} ] || exit 1
119
+ cmd=$(tr '\\000' '\\n' < "$p/cmdline") || exit 1
120
+ first=$(printf '%s\\n' "$cmd" | head -n 1)
121
+ case "$first" in perfetto|/system/bin/perfetto) ;; *) exit 1 ;; esac
122
+ printf '%s\\n' "$cmd" | grep -Fx ${quote(remote)} >/dev/null || exit 1
123
+ kill -TERM ${owner.pid}`)
124
+ }
125
+ async pull(device: string, remote: string, local: string) {
126
+ ownedPath(remote)
127
+ // Reject oversized output before initiating transfer as well as afterwards.
128
+ const size = Number(await this.command(device, ['shell', 'stat', '-c', '%s', remote]))
129
+ if (!Number.isSafeInteger(size) || size <= 0 || size > TRACE_MAX_BYTES) throw new Error('TRACE_ARTIFACT_SIZE_INVALID')
130
+ await this.command(device, ['pull', remote, local], 30000)
131
+ }
132
+ async remove(device: string, remote: string) { ownedPath(remote); await this.command(device, ['shell', 'rm', '-f', remote]) }
133
+ }
@@ -0,0 +1,218 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { promises as fs } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { AndroidTraceNative, TRACE_MAX_BYTES, type TraceCapabilities, type TraceNative, type TraceProcess } from './system-trace-android.js'
6
+ import { evidenceSessionId } from './evidence.js'
7
+ import { runWithBudget, remainingBudget } from '../utils/operation-budget.js'
8
+
9
+ export class TraceError extends Error {
10
+ constructor(public code: string) { super(code) }
11
+ }
12
+ type State = 'starting' | 'recording' | 'stopping' | 'completed' | 'cancelled' | 'failed'
13
+ type Completion = 'requested' | 'duration_limit' | 'cancelled' | 'error'
14
+ export interface TraceResult {
15
+ trace_id: string; session_id: string; device: { platform: 'android'; id: string }; backend: 'perfetto'
16
+ state: State; reason: string | null; completion_reason: Completion | null
17
+ capture_started_at_ms: number | null; capture_completed_at_ms: number | null
18
+ artifact: { path: string; format: 'perfetto'; size_bytes: number } | null
19
+ cleanup: { status: 'pending' | 'complete' | 'failed'; remaining_resources: string[] }
20
+ correlation_ids: string[]; enabled_sources: string[]; unavailable_categories: string[]
21
+ }
22
+ interface Session {
23
+ result: TraceResult; remote: string; owner: TraceProcess | null; stopped: boolean; discard: boolean
24
+ timer?: ReturnType<typeof setTimeout>; terminalAt?: number; deadline: number; local?: string
25
+ queue: Promise<unknown>; linked: Set<string>; partial: Set<string>
26
+ }
27
+ export interface TraceClock {
28
+ wall(): number; mono(): number; sleep(ms: number): Promise<void>
29
+ schedule(fn: () => void, ms: number): ReturnType<typeof setTimeout>
30
+ clear(timer: ReturnType<typeof setTimeout>): void
31
+ }
32
+ const clock: TraceClock = {
33
+ wall: Date.now, mono: () => performance.now(), sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
34
+ schedule: (fn, ms) => { const t = setTimeout(fn, ms); t.unref(); return t }, clear: clearTimeout
35
+ }
36
+
37
+ export class SystemTraceSessions {
38
+ private sessions = new Map<string, Session>()
39
+ private closing = false
40
+ constructor(private native: TraceNative = new AndroidTraceNative(), private time: TraceClock = clock) {}
41
+ private prune() {
42
+ const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.result.cleanup.status === 'complete')
43
+ for (const s of terminal) if (this.time.mono() - s.terminalAt! >= 600000) this.sessions.delete(s.result.trace_id)
44
+ const remaining = terminal.filter(s => this.sessions.has(s.result.trace_id))
45
+ for (const s of remaining.slice(0, Math.max(0, remaining.length - 100))) this.sessions.delete(s.result.trace_id)
46
+ }
47
+
48
+ private get(id: string) { this.prune(); const s = this.sessions.get(id); if (!s) throw new TraceError('TRACE_NOT_FOUND'); return s }
49
+ private snapshot(s: Session): TraceResult { return structuredClone(s.result) }
50
+ private serial<T>(s: Session, fn: () => Promise<T>): Promise<T> {
51
+ const result = s.queue.then(fn)
52
+ s.queue = result.catch(() => {})
53
+ return result
54
+ }
55
+ async capabilities(device: string) { return this.native.capabilities(device) }
56
+ async start(device: string, duration = 30000): Promise<TraceResult> {
57
+ if (!Number.isInteger(duration) || duration < 1000 || duration > 120000) throw new TraceError('INVALID_ARGUMENT')
58
+ this.prune()
59
+ const reserved = [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete')
60
+ if (this.closing || reserved.length >= 4 || reserved.some(s => s.result.device.id === device)) throw new TraceError('TRACE_BUSY')
61
+ const id = randomUUID()
62
+ const result: TraceResult = { trace_id: id, session_id: evidenceSessionId(), device: { platform: 'android', id: device }, backend: 'perfetto',
63
+ state: 'starting', reason: null, completion_reason: null, capture_started_at_ms: null, capture_completed_at_ms: null,
64
+ artifact: null, cleanup: { status: 'pending', remaining_resources: [] }, correlation_ids: [], enabled_sources: [], unavailable_categories: [] }
65
+ const s: Session = { result, remote: `/data/misc/perfetto-traces/mcp-${id}.perfetto-trace`, owner: null, stopped: true,
66
+ discard: false, deadline: Infinity, queue: Promise.resolve(), linked: new Set(), partial: new Set() }
67
+ this.sessions.set(id, s)
68
+ return this.serial(s, async () => {
69
+ let cap: TraceCapabilities
70
+ try { cap = await this.native.capabilities(device) }
71
+ catch { this.fail(s, 'TRACE_UNSUPPORTED'); this.clean(s); return this.snapshot(s) }
72
+ s.result.enabled_sources = cap.enabled_sources
73
+ s.result.unavailable_categories = cap.unavailable_categories
74
+ if (!cap.supported || this.closing) { this.fail(s, this.closing ? 'TRACE_BUSY' : 'TRACE_UNSUPPORTED'); this.clean(s); return this.snapshot(s) }
75
+ s.stopped = false
76
+ s.result.cleanup.remaining_resources = [s.remote, 'device_capture']
77
+ // Conservative host bounds include acknowledgement latency, not exact device start.
78
+ s.result.capture_started_at_ms = this.time.wall()
79
+ s.deadline = this.time.mono() + duration
80
+ try {
81
+ await this.native.start(device, s.remote, duration, cap.enabled_sources)
82
+ s.owner = await this.native.find(device, s.remote)
83
+ s.result.state = 'recording'
84
+ s.timer = this.time.schedule(() => { void this.control(id, 'duration').catch(() => {}) }, Math.max(0, s.deadline - this.time.mono()))
85
+ } catch (error) {
86
+ s.discard = true
87
+ this.fail(s, this.code(error, 'TRACE_START_FAILED'))
88
+ try { await this.halt(s); await this.remove(s) } catch { s.result.cleanup.status = 'failed' }
89
+ }
90
+ return this.snapshot(s)
91
+ })
92
+ }
93
+ private code(error: unknown, fallback: string) { return error instanceof Error && error.message === 'ACTION_TIMEOUT' ? 'ACTION_TIMEOUT' : fallback }
94
+ private fail(s: Session, reason: string) {
95
+ s.result.state = 'failed'; s.result.reason = reason; s.result.completion_reason = 'error'
96
+ s.terminalAt = this.time.mono()
97
+ }
98
+ private clean(s: Session) {
99
+ s.result.cleanup = { status: 'complete', remaining_resources: [] }
100
+ s.terminalAt = this.time.mono()
101
+ this.prune()
102
+ }
103
+ private async halt(s: Session) {
104
+ if (s.stopped) return
105
+ const deadline = this.time.mono() + 10000
106
+ await runWithBudget(10000, async () => {
107
+ const found = await this.native.find(s.result.device.id, s.remote)
108
+ if (found) {
109
+ if (s.owner && (s.owner.pid !== found.pid || s.owner.startTime !== found.startTime)) throw new TraceError('TRACE_STOP_FAILED')
110
+ s.owner ??= found
111
+ try { await this.native.stop(s.result.device.id, s.remote, s.owner) }
112
+ catch (error) {
113
+ // Natural expiry can win between identity lookup and SIGTERM. Only
114
+ // confirmed absence permits finalisation; unreadable/reused identity fails.
115
+ if (await this.native.find(s.result.device.id, s.remote)) throw error
116
+ }
117
+ while (await this.native.find(s.result.device.id, s.remote)) {
118
+ if (this.time.mono() >= deadline) throw new TraceError('ACTION_TIMEOUT')
119
+ remainingBudget(); await this.time.sleep(100)
120
+ }
121
+ }
122
+ })
123
+ s.stopped = true
124
+ s.result.capture_completed_at_ms = this.time.wall()
125
+ s.result.cleanup.remaining_resources = [s.remote]
126
+ for (const linked of s.linked) s.partial.add(linked)
127
+ }
128
+ private async remove(s: Session) {
129
+ if (!s.stopped) throw new TraceError('TRACE_STOP_FAILED')
130
+ await this.native.remove(s.result.device.id, s.remote)
131
+ if (s.local && !s.result.artifact) { await fs.rm(s.local, { recursive: true, force: true }); s.local = undefined }
132
+ this.clean(s)
133
+ }
134
+ private async artifact(s: Session) {
135
+ if (s.result.artifact) return
136
+ s.local ??= await fs.mkdtemp(path.join(os.tmpdir(), 'mobile-debug-trace-'))
137
+ const local = s.local
138
+ const partial = path.join(local, 'capture.partial')
139
+ try {
140
+ await fs.chmod(s.local, 0o700)
141
+ await this.native.pull(s.result.device.id, s.remote, partial)
142
+ const stat = await fs.stat(partial)
143
+ if (!stat.isFile() || stat.size <= 0 || stat.size > TRACE_MAX_BYTES) throw new TraceError('TRACE_ARTIFACT_FAILED')
144
+ await fs.chmod(partial, 0o600)
145
+ const target = path.join(s.local, `${s.result.trace_id}.perfetto-trace`)
146
+ await fs.rename(partial, target)
147
+ s.result.artifact = { path: target, format: 'perfetto', size_bytes: stat.size }
148
+ } catch (error) {
149
+ try { await fs.rm(local, { recursive: true, force: true }); s.local = undefined }
150
+ catch { s.result.cleanup.remaining_resources = [s.remote, local] }
151
+ throw error
152
+ }
153
+ }
154
+ async control(id: string, operation: 'status' | 'stop' | 'cancel' | 'duration'): Promise<TraceResult> {
155
+ const s = this.get(id)
156
+ return this.serial(s, async () => {
157
+ if (s.result.cleanup.status === 'complete') return this.snapshot(s)
158
+ if (operation === 'status' && s.result.state === 'recording') {
159
+ try {
160
+ if (await this.native.find(s.result.device.id, s.remote) && this.time.mono() < s.deadline) return this.snapshot(s)
161
+ } catch (error) { this.fail(s, this.code(error, 'TRACE_STOP_FAILED')); s.result.cleanup.status = 'failed'; return this.snapshot(s) }
162
+ }
163
+ if (s.timer) { this.time.clear(s.timer); s.timer = undefined }
164
+ if (operation === 'cancel') s.discard = true
165
+ const hadStartFailure = s.discard && s.result.state === 'failed' && operation !== 'cancel'
166
+ s.result.state = 'stopping'
167
+ s.result.cleanup.status = 'pending'
168
+ try { await this.halt(s) }
169
+ catch (error) { this.fail(s, this.code(error, 'TRACE_STOP_FAILED')); s.result.cleanup.status = 'failed'; return this.snapshot(s) }
170
+ if (!s.discard) {
171
+ try { await this.artifact(s) }
172
+ catch (error) { this.fail(s, this.code(error, 'TRACE_ARTIFACT_FAILED')); s.result.cleanup.status = 'failed'; return this.snapshot(s) }
173
+ }
174
+ try { await this.remove(s) }
175
+ catch { this.fail(s, 'TRACE_STOP_FAILED'); s.result.cleanup.status = 'failed'; return this.snapshot(s) }
176
+ if (hadStartFailure) s.result.state = 'failed'
177
+ else {
178
+ s.result.state = s.discard ? 'cancelled' : 'completed'
179
+ s.result.reason = s.discard ? 'TRACE_CANCELLED' : null
180
+ s.result.completion_reason = s.discard ? 'cancelled' : operation === 'duration' || this.time.mono() >= s.deadline ? 'duration_limit' : 'requested'
181
+ }
182
+ return this.snapshot(s)
183
+ })
184
+ }
185
+ async link(id: string, device: { platform: string; id: string }, correlation: string) {
186
+ const s = this.get(id)
187
+ if (device.platform !== 'android' || device.id !== s.result.device.id) throw new TraceError('TRACE_NOT_FOUND')
188
+ const status = await this.control(id, 'status')
189
+ if (device.platform !== 'android' || device.id !== status.device.id || status.state !== 'recording') throw new TraceError('TRACE_NOT_FOUND')
190
+ // Validate again after the asynchronous status call: stop/cancel may have won.
191
+ return this.serial(s, async () => {
192
+ if (s.result.state !== 'recording' || this.time.mono() >= s.deadline) throw new TraceError('TRACE_NOT_FOUND')
193
+ if (!s.result.correlation_ids.includes(correlation)) {
194
+ if (s.result.correlation_ids.length >= 1000) throw new TraceError('TRACE_BUSY')
195
+ s.result.correlation_ids.push(correlation)
196
+ }
197
+ s.linked.add(correlation)
198
+ let finish: Promise<{ trace_id: string; coverage: string; clock_alignment: string }> | undefined
199
+ return () => finish ??= (async () => {
200
+ let recording = false
201
+ try { recording = (await this.control(id, 'status')).state === 'recording' }
202
+ catch { /* Unavailable state cannot establish complete coverage. */ }
203
+ s.linked.delete(correlation)
204
+ return { trace_id: id, coverage: !recording || s.partial.has(correlation) || this.time.mono() >= s.deadline ? 'partial' : 'covered',
205
+ clock_alignment: 'unknown' }
206
+ })()
207
+ })
208
+ }
209
+ async shutdown() {
210
+ this.closing = true
211
+ // Independent device cleanup in parallel under one overall deadline.
212
+ return runWithBudget(10000, async () => {
213
+ await Promise.allSettled([...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete').map(s => this.control(s.result.trace_id, 'cancel')))
214
+ return [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete').map(s => this.snapshot(s))
215
+ })
216
+ }
217
+ }
218
+ export const systemTraces = new SystemTraceSessions()
@@ -1,3 +1,4 @@
1
+ import { currentEvidenceActionId } from '../observe/evidence.js'
1
2
  import { recordTiming } from '../utils/timing.js'
2
3
  import type {
3
4
  ActionTrace,
@@ -11,6 +12,7 @@ import type {
11
12
  TraceStep
12
13
  } from '../types.js'
13
14
  import { ToolsObserve } from '../observe/index.js'
15
+ import { isConditionBlocked } from '../interact/conditional-action.js'
14
16
 
15
17
  export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3
16
18
  export const DEFAULT_MAX_RETRY_DEPTH = 3
@@ -87,11 +89,14 @@ export function getArrayArg<T>(args: ToolCallArgs, key: string): T[] | undefined
87
89
  let actionSequence = 0
88
90
 
89
91
  export function nextActionId(actionType: string, timestamp: number) {
92
+ const current = currentEvidenceActionId()
93
+ if (current) return current
90
94
  actionSequence += 1
91
95
  return `${actionType}_${timestamp}_${actionSequence}`
92
96
  }
93
97
 
94
98
  export async function captureActionFingerprint(platform?: 'android' | 'ios', deviceId?: string): Promise<string | null> {
99
+ if (isConditionBlocked()) return null
95
100
  if (!platform) return null
96
101
  try {
97
102
  const result = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as any
@@ -317,6 +322,8 @@ function mapFailureCodeToFailureClass(code: ActionFailureCode): FailureClass {
317
322
  case 'STALE_REFERENCE':
318
323
  return 'TargetResolutionFailure'
319
324
  case 'ELEMENT_NOT_INTERACTABLE':
325
+ case 'PRECONDITION_FAILED':
326
+ case 'PRECONDITION_UNAVAILABLE':
320
327
  case 'ACTION_TIMEOUT':
321
328
  return 'ExecutionFailure'
322
329
  case 'TIMEOUT':
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod'
2
+
3
+ export const conditionalTools = new Set(['tap', 'tap_element', 'swipe', 'type_text', 'press_back'])
4
+ export const expectedStateSchema = z.object({
5
+ snapshotSessionId: z.string().min(1),
6
+ snapshotRevision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
7
+ timeoutMs: z.number().int().min(100).max(60000).optional()
8
+ }).strict()
9
+
10
+ export type ExpectedState = z.infer<typeof expectedStateSchema>
11
+
12
+ export const expectedStateJson = {
13
+ type: 'object', additionalProperties: false,
14
+ properties: {
15
+ snapshotSessionId: { type: 'string', minLength: 1 },
16
+ snapshotRevision: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
17
+ timeoutMs: { type: 'integer', minimum: 100, maximum: 60000, default: 15000 }
18
+ },
19
+ required: ['snapshotSessionId', 'snapshotRevision']
20
+ }
21
+
22
+ export function validateExpectedState(name: string, args: Record<string, unknown>): ExpectedState | undefined {
23
+ if (args.expectedState === undefined) return undefined
24
+ if (!conditionalTools.has(name)) throw new Error('INVALID_ARGUMENT: expectedState is not supported on this tool')
25
+ const parsed = expectedStateSchema.safeParse(args.expectedState)
26
+ if (!parsed.success) throw new Error('INVALID_ARGUMENT: invalid expectedState')
27
+ if ((args.platform !== 'android' && args.platform !== 'ios') ||
28
+ typeof args.deviceId !== 'string' || !args.deviceId.trim() ||
29
+ ['booted', 'default'].includes(args.deviceId)) throw new Error('INVALID_ARGUMENT: expectedState requires an explicit platform and concrete deviceId')
30
+ if (['type_text', 'press_back'].includes(name) && args.platform !== 'android') throw new Error('INVALID_ARGUMENT: this action supports only Android')
31
+ return parsed.data
32
+ }
@@ -0,0 +1,29 @@
1
+ import { debuggerContracts } from './debugger-schema.js'
2
+ import { debuggerSessions, debuggerCapabilities } from '../debugger/sessions.js'
3
+ import { DebuggerError, withDebuggerBudget } from '../debugger/worker.js'
4
+ import { wrapResponse, type ToolCallArgs } from './common.js'
5
+ function failure(code: string, summary: Record<string, unknown> = {}) {
6
+ return { ...wrapResponse({ debug_session_id: null, device: null, target: null, state: null,
7
+ captured_at_ms: Date.now(), capabilities: debuggerCapabilities, limitations: [], cleanup: null,
8
+ ...summary, success: false, error: { code, message: code, retryable: false } }), isError: true }
9
+ }
10
+ export async function handleDebugger(name: string, args: ToolCallArgs, signal?: AbortSignal) {
11
+ const parsed = debuggerContracts[name].schema.safeParse(args)
12
+ if (!parsed.success) return failure('INVALID_ARGUMENT')
13
+ const a = parsed.data as Record<string, any>
14
+ const command = debuggerContracts[name].command
15
+ try {
16
+ if (signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
17
+ const result = await withDebuggerBudget(a.timeoutMs, signal, async () => command === 'capabilities' ? await debuggerSessions.capabilities(a.deviceId, a.appId, a.processId) :
18
+ command === 'attach' ? await debuggerSessions.attach(a.deviceId, a.appId, a.processId, a.timeoutMs) :
19
+ await debuggerSessions.command(a.debugSessionId, command, { ...a, signal: command === 'events' ? signal : undefined }, a.timeoutMs))
20
+ if (signal?.aborted && command === 'capabilities') throw new DebuggerError('DEBUGGER_CANCELLED')
21
+ return { ...wrapResponse(result), ...(!result.success ? { isError: true } : {}) }
22
+ } catch (e) {
23
+ const code = e instanceof DebuggerError ? e.code : 'DEBUGGER_WORKER_FAILED'
24
+ let summary: any = {}
25
+ if (a.debugSessionId) { try { summary = debuggerSessions.summary(a.debugSessionId) } catch { /* Unknown session */ } }
26
+ return failure(code, { debug_session_id: a.debugSessionId ?? null,
27
+ device: a.deviceId ? { platform: 'android', id: a.deviceId } : null, ...summary })
28
+ }
29
+ }
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod'
2
+ const text = z.string().min(1).max(512)
3
+ const integer = z.number().int().safe().positive()
4
+ const timeout = z.number().int().min(100).max(30000)
5
+ const target = { platform: z.literal('android'), deviceId: text, appId: text.regex(/^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/), processId: integer.optional() }
6
+ const session = { debugSessionId: text }
7
+ const stop = { ...session, stopId: text }
8
+ const specs = {
9
+ get_debugger_capabilities: { fields: target, command: 'capabilities', description: 'Probe optional Android JDK/JDWP prerequisites and matching running debuggable processes; does not attach.' },
10
+ attach_debugger: { fields: target, command: 'attach', description: 'Attach an owned Android JDI session. Requires an explicit device and debuggable app; choose processId when ambiguous. Stops suspend all app threads. Auto-detach: 60s paused, 5m idle, 30m total.' },
11
+ get_debugger_state: { fields: session, command: 'state', description: 'Read debugger state, current stop and breakpoint/cleanup status.' },
12
+ set_debugger_breakpoint: { fields: { ...session, className: text.regex(/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/), line: integer }, command: 'set', description: 'Set an exact binary-class and executable-source-line breakpoint; unloaded classes remain pending. No silent line relocation.' },
13
+ remove_debugger_breakpoint: { fields: { ...session, breakpointId: text }, command: 'remove', description: 'Remove an owned breakpoint; repeated removal succeeds.' },
14
+ wait_for_debugger_event: { fields: { ...session, afterSequence: z.number().int().safe().min(0).optional() }, command: 'events', description: 'Wait for bounded debugger events after a sequence cursor. Empty timeout does not affect execution; overflow is explicit.' },
15
+ get_debugger_stack: { fields: { ...stop, threadId: text, offset: z.number().int().min(0).max(999).optional(), limit: z.number().int().min(1).max(100).optional() }, command: 'stack', description: 'Read bounded stack frames for a stopped thread; IDs expire on resume.' },
16
+ get_debugger_locals: { fields: { ...stop, frameId: text }, command: 'locals', description: 'Read at most 100 shallow locals. Sensitive values returned only on request; strings truncate at 256 characters. No evaluation or field traversal.' },
17
+ step_debugger: { fields: { ...stop, threadId: text, kind: z.enum(['into', 'over', 'out']) }, command: 'step', description: 'Acknowledge one-shot line stepping and resume. Wait for a subsequent stop event separately.' },
18
+ resume_debugger: { fields: stop, command: 'resume', description: 'Resume owned debugger suspension and immediately expire stopped references.' },
19
+ detach_debugger: { fields: session, command: 'detach', description: 'Dispose the owned debugger connection, release owned suspension and remove its exact forward. Idempotent; retries incomplete cleanup.' }
20
+ }
21
+ // Derive JSON schemas and runtime validation from the same small Zod field vocabulary.
22
+ function jsonField(schema: z.ZodTypeAny): any {
23
+ if (schema instanceof z.ZodOptional) return jsonField(schema.unwrap())
24
+ if (schema instanceof z.ZodLiteral) return { type: 'string', enum: [schema.value] }
25
+ if (schema instanceof z.ZodEnum) return { type: 'string', enum: schema.options }
26
+ if (schema instanceof z.ZodString) return { type: 'string', minLength: 1, maxLength: 512, ...Object.assign({}, ...schema._def.checks.filter(c => c.kind === 'regex').map(c => ({ pattern: (c as any).regex.source }))) }
27
+ if (schema instanceof z.ZodNumber) return { type: 'integer', minimum: schema.minValue, maximum: schema.maxValue }
28
+ throw new Error('Unsupported debugger field schema')
29
+ }
30
+ export const debuggerContracts = Object.fromEntries(Object.entries(specs).map(([name, spec]) => [name, {
31
+ ...spec, schema: z.object({ ...spec.fields, timeoutMs: timeout.default(name === 'wait_for_debugger_event' ? 1000 : 10000) }).strict()
32
+ }]))
33
+ export const debuggerTools = Object.entries(debuggerContracts).map(([name, spec]) => ({ name, description: spec.description,
34
+ inputSchema: { type: 'object', additionalProperties: false,
35
+ required: Object.entries(spec.fields).filter(([, field]) => !field.isOptional()).map(([key]) => key),
36
+ properties: { ...Object.fromEntries(Object.entries(spec.fields).map(([key, field]) => [key, jsonField(field)])), timeoutMs: { type: 'integer', minimum: 100, maximum: 30000, default: name === 'wait_for_debugger_event' ? 1000 : 10000 } } }
37
+ }))
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import { expectedStateSchema, expectedStateJson } from './condition-schema.js'
2
3
 
3
4
  const selector = z.object({
4
5
  text: z.string().min(1).optional(), resource_id: z.string().min(1).optional(),
@@ -23,7 +24,7 @@ const assertion = z.union([
23
24
  const id = z.string().min(1)
24
25
  const step = z.union([
25
26
  z.object({ id, type: z.literal('start_app'), appId: z.string().min(1), verificationMode: verificationMode.optional() }).strict(),
26
- z.object({ id, type: z.literal('tap'), ...target, waitFor: waitFor.optional(), verificationMode: verificationMode.optional() }).strict().refine(exactlyOneTarget),
27
+ z.object({ id, type: z.literal('tap'), ...target, waitFor: waitFor.optional(), verificationMode: verificationMode.optional(), expectedState: expectedStateSchema.optional() }).strict().refine(exactlyOneTarget),
27
28
  z.object({ id, type: z.literal('wait'), selector, condition: z.enum(['exists', 'not_exists', 'visible', 'clickable']).optional(), timeoutMs, pollIntervalMs }).strict(),
28
29
  z.object({ id, type: z.literal('assert'), assertion, timeoutMs, pollIntervalMs }).strict()
29
30
  ])
@@ -33,6 +34,7 @@ export const tapElementControlsSchema = z.object({
33
34
 
34
35
  export const journeySchema = z.object({
35
36
  platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional(),
37
+ systemTraceId: z.string().uuid().optional(),
36
38
  responseMode: z.enum(['compact', 'debug']).default('compact'), captureOnFailure: z.boolean().default(false),
37
39
  defaults: z.object({ verificationMode: verificationMode.optional(), actionTimeoutMs: z.number().int().finite().optional(), verificationTimeoutMs: z.number().int().finite().optional() }).strict().optional(),
38
40
  steps: z.array(step).min(1).max(50)
@@ -49,7 +51,7 @@ const objectJson = (properties: Record<string, unknown>, required: string[], ext
49
51
  export const journeyStepsJson = {
50
52
  type: 'array', minItems: 1, maxItems: 50, items: { oneOf: [
51
53
  objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'start_app' }, appId: { type: 'string', minLength: 1 }, verificationMode: modeJson }, ['id', 'type', 'appId']),
52
- objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'tap' }, ...targetJson, verificationMode: modeJson,
54
+ objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'tap' }, ...targetJson, verificationMode: modeJson, expectedState: expectedStateJson,
53
55
  waitFor: objectJson({ ...intervalJson, timeoutMs: { type: 'integer', minimum: 100, maximum: 10000 }, condition: { enum: ['exists', 'visible', 'clickable'] }, match: objectJson({ index: { type: 'integer', minimum: 0 } }, []) }, [])
54
56
  }, ['id', 'type'], { oneOf: oneTargetJson }),
55
57
  objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'wait' }, selector: selectorJson, condition: { enum: ['exists', 'not_exists', 'visible', 'clickable'] }, ...intervalJson }, ['id', 'type', 'selector']),
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod'
2
+ const timeout = z.number().int().min(100).max(60000)
3
+ export const readinessSchema = z.object({
4
+ platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional(),
5
+ timeoutMs: timeout.optional(), stabilityMs: z.number().int().min(100).max(10000).optional()
6
+ }).strict()
7
+ export const readinessWaitSchema = readinessSchema.extend({ pollIntervalMs: z.number().int().min(100).max(1000).optional() }).strict()
8
+ export const readinessPropertiesJson = {
9
+ platform: { type: 'string', enum: ['android', 'ios'] }, deviceId: { type: 'string', minLength: 1 },
10
+ timeoutMs: { type: 'integer', minimum: 100, maximum: 60000 },
11
+ stabilityMs: { type: 'integer', minimum: 100, maximum: 10000, default: 500 }
12
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod'
2
+ const target = { platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional() }
3
+ export const traceTargetSchema = z.object(target).strict()
4
+ export const traceStartSchema = z.object({ ...target, durationMs: z.number().int().min(1000).max(120000).default(30000) }).strict()
5
+ export const traceControlSchema = z.object({ traceId: z.string().uuid() }).strict()
6
+ const properties = { platform: { type: 'string', enum: ['android', 'ios'] }, deviceId: { type: 'string', minLength: 1 } }
7
+ export const systemTraceTools = [
8
+ { name: 'get_system_trace_capabilities', description: 'Probe Android Perfetto prerequisites without recording. Device-wide system tracing is optional; iOS is unsupported.',
9
+ inputSchema: { type: 'object', additionalProperties: false, required: ['platform'], properties } },
10
+ { name: 'start_system_trace', description: 'Start bounded Android Perfetto recording, returning an owned trace_id. Run an existing journey with systemTraceId, then stop to retrieve a local artifact or cancel to discard. Device-wide traces may contain sensitive system annotations; no upload.',
11
+ inputSchema: { type: 'object', additionalProperties: false, required: ['platform'], properties: { ...properties, durationMs: { type: 'integer', minimum: 1000, maximum: 120000, default: 30000 } } } },
12
+ ...['get_system_trace_status', 'stop_system_trace', 'cancel_system_trace'].map(name => ({ name,
13
+ description: name === 'get_system_trace_status' ? 'Reconcile an owned trace and retrieve its state; finalise duration-completed capture.' :
14
+ name === 'stop_system_trace' ? 'Stop owned capture gracefully and retrieve a local Perfetto artifact. Retry after a transfer/cleanup failure.' : 'Stop owned capture and discard partial files. Already completed local artifacts are retained.',
15
+ inputSchema: { type: 'object', additionalProperties: false, required: ['traceId'], properties: { traceId: { type: 'string', format: 'uuid' } } }
16
+ }))
17
+ ]