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,165 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdir, readdir, rm, stat } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { execCmd } from '../utils/exec.js';
8
+ import { getAdbCmd } from '../utils/android/utils.js';
9
+ import { resolveTargetDevice } from '../utils/resolve-device.js';
10
+ const recordings = new Map();
11
+ const activeDevices = new Map();
12
+ const adb = (deviceId, args, timeout = 15000) => execCmd(getAdbCmd(), ['-s', deviceId, ...args], { timeout });
13
+ const tool = (name, args, timeout = 30000) => execCmd(name, args, { timeout });
14
+ const swiftScript = fileURLToPath(new URL('../../scripts/recording-frames.swift', import.meta.url));
15
+ async function swiftDecode(video, directory, startMs, endMs) {
16
+ const result = await tool('xcrun', ['swift', swiftScript, video, directory, String(startMs), String(endMs)], 120000);
17
+ if (result.exitCode !== 0)
18
+ throw new Error(`Swift frame decoder failed: ${result.stderr}`);
19
+ return JSON.parse(result.stdout);
20
+ }
21
+ function positiveInteger(value, name, max) {
22
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > max)
23
+ throw new Error(`${name} must be an integer from 1 to ${max}`);
24
+ return value;
25
+ }
26
+ async function probe(video) {
27
+ let result;
28
+ try {
29
+ result = await tool('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height,nb_frames:format=duration', '-of', 'json', video]);
30
+ }
31
+ catch (error) {
32
+ if (process.platform !== 'darwin')
33
+ throw error;
34
+ const metadata = await swiftDecode(video, path.join(path.dirname(video), 'metadata'), 1, 0);
35
+ const { frames: _frames, ...rest } = metadata;
36
+ return rest;
37
+ }
38
+ if (result.exitCode !== 0)
39
+ throw new Error(`ffprobe failed: ${result.stderr || 'ffprobe is required for recording metadata and frame extraction'}`);
40
+ const data = JSON.parse(result.stdout);
41
+ const stream = data.streams?.[0];
42
+ if (!stream)
43
+ throw new Error('Recording contains no video stream');
44
+ const frames = await frameTimes(video);
45
+ return { durationMs: Math.round(Number(data.format.duration) * 1000), resolution: { width: stream.width, height: stream.height }, frameCount: frames.length, frameTimestampsMs: frames };
46
+ }
47
+ async function frameTimes(video) {
48
+ const result = await tool('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'frame=best_effort_timestamp_time', '-of', 'json', video], 60000);
49
+ if (result.exitCode !== 0)
50
+ throw new Error(`ffprobe failed: ${result.stderr || 'ffprobe is required'}`);
51
+ return (JSON.parse(result.stdout).frames ?? []).map((frame) => Math.round(Number(frame.best_effort_timestamp_time) * 1000)).filter((value) => Number.isFinite(value));
52
+ }
53
+ export async function startScreenRecording(deviceId, maxDurationSeconds = 15) {
54
+ if (deviceId !== undefined && (typeof deviceId !== 'string' || !deviceId))
55
+ throw new Error('deviceId must be a nonempty string');
56
+ const durationLimit = positiveInteger(maxDurationSeconds, 'maxDurationSeconds', 180);
57
+ const device = await resolveTargetDevice({ platform: 'android', deviceId: deviceId });
58
+ if (activeDevices.has(device.id))
59
+ throw new Error(`A recording is already active for device ${device.id}`);
60
+ const id = randomUUID();
61
+ const directory = path.join(tmpdir(), 'mobile-debug-mcp-recordings', id);
62
+ const remote = `/sdcard/mobile-debug-mcp-${id}.mp4`;
63
+ await mkdir(directory, { recursive: true });
64
+ let finish;
65
+ const finished = new Promise(resolve => { finish = resolve; });
66
+ const child = spawn(getAdbCmd(), ['-s', device.id, 'shell', 'screenrecord', '--time-limit', String(durationLimit), remote], { stdio: 'ignore' });
67
+ const timer = setTimeout(() => { void stopScreenRecording(id).catch(() => undefined); }, (durationLimit + 2) * 1000);
68
+ timer.unref();
69
+ const recording = { id, deviceId: device.id, remote, directory, video: path.join(directory, 'recording.mp4'), startedAt: new Date().toISOString(), durationLimit, process: child, timer, state: 'recording', finished, finish };
70
+ recordings.set(id, recording);
71
+ activeDevices.set(device.id, id);
72
+ child.once('close', () => { finish(); if (recording.state === 'recording')
73
+ void stopScreenRecording(id).catch(() => undefined); });
74
+ child.once('error', () => { finish(); if (recording.state === 'recording')
75
+ void stopScreenRecording(id).catch(() => undefined); });
76
+ return { recordingId: id, deviceId: device.id, startedAt: recording.startedAt, maxDurationSeconds: durationLimit };
77
+ }
78
+ export async function stopScreenRecording(recordingId) {
79
+ const recording = typeof recordingId === 'string' ? recordings.get(recordingId) : undefined;
80
+ if (!recording)
81
+ throw new Error('Unknown recordingId');
82
+ if (recording.stopPromise)
83
+ return recording.stopPromise;
84
+ recording.state = 'stopping';
85
+ clearTimeout(recording.timer);
86
+ recording.stopPromise = (async () => {
87
+ try {
88
+ if (recording.process.exitCode === null) {
89
+ const processes = await adb(recording.deviceId, ['shell', 'ps', '-A', '-o', 'PID,ARGS']).catch(() => null);
90
+ const pid = processes?.stdout.split('\n').find(line => line.includes('screenrecord') && line.includes(recording.remote))?.trim().match(/^\d+/)?.[0];
91
+ if (pid)
92
+ await adb(recording.deviceId, ['shell', 'kill', '-2', pid]).catch(() => undefined);
93
+ }
94
+ await Promise.race([recording.finished, new Promise(resolve => setTimeout(resolve, 3000))]);
95
+ if (recording.process.exitCode === null)
96
+ recording.process.kill('SIGKILL');
97
+ const pulled = await adb(recording.deviceId, ['pull', recording.remote, recording.video], 30000);
98
+ if (pulled.exitCode !== 0)
99
+ throw new Error(`Could not pull recording: ${pulled.stderr}`);
100
+ if ((await stat(recording.video)).size === 0)
101
+ throw new Error('Recording is empty');
102
+ let metadata;
103
+ try {
104
+ metadata = await probe(recording.video);
105
+ }
106
+ catch (error) {
107
+ metadata = { metadataError: String(error), durationMs: null, resolution: null, frameCount: null, frameTimestampsMs: [] };
108
+ }
109
+ recording.result = { recordingId: recording.id, deviceId: recording.deviceId, videoPath: recording.video, startedAt: recording.startedAt, stoppedAt: new Date().toISOString(), ...metadata };
110
+ return recording.result;
111
+ }
112
+ finally {
113
+ await adb(recording.deviceId, ['shell', 'rm', '-f', recording.remote]).catch(() => undefined);
114
+ activeDevices.delete(recording.deviceId);
115
+ recording.state = 'stopped';
116
+ if (!recording.result) {
117
+ recordings.delete(recording.id);
118
+ await rm(recording.directory, { recursive: true, force: true }).catch(() => undefined);
119
+ }
120
+ }
121
+ })();
122
+ return recording.stopPromise;
123
+ }
124
+ export async function extractRecordingFrames(recordingId, startMs, endMs) {
125
+ const recording = typeof recordingId === 'string' ? recordings.get(recordingId) : undefined;
126
+ if (!recording || recording.state !== 'stopped' || !recording.result)
127
+ throw new Error('Unknown or unfinished recordingId');
128
+ if (typeof startMs !== 'number' || !Number.isInteger(startMs) || startMs < 0 || typeof endMs !== 'number' || !Number.isInteger(endMs) || endMs <= startMs || endMs - startMs > 10000)
129
+ throw new Error('Window must be 1 to 10000 ms with nonnegative integer timestamps');
130
+ const directory = path.join(recording.directory, `frames-${startMs}-${endMs}`);
131
+ await rm(directory, { recursive: true, force: true });
132
+ await mkdir(directory, { recursive: true });
133
+ let allTimes;
134
+ let frames;
135
+ try {
136
+ allTimes = await frameTimes(recording.video);
137
+ const selected = allTimes.filter(time => time >= startMs && time <= endMs);
138
+ const result = await tool('ffmpeg', ['-v', 'error', '-i', recording.video, '-vf', `select=between(t\,${startMs / 1000}\,${endMs / 1000})`, '-vsync', '0', path.join(directory, '%06d.png')], 60000);
139
+ if (result.exitCode !== 0)
140
+ throw new Error(`ffmpeg failed: ${result.stderr || 'ffmpeg is required for frame extraction'}`);
141
+ const files = (await readdir(directory)).filter(file => file.endsWith('.png')).sort();
142
+ if (files.length !== selected.length)
143
+ throw new Error(`Frame count mismatch: ffprobe found ${selected.length}, ffmpeg extracted ${files.length}`);
144
+ frames = files.map((file, index) => ({ timestampMs: selected[index], path: path.join(directory, file) }));
145
+ }
146
+ catch (error) {
147
+ if (process.platform !== 'darwin')
148
+ throw error;
149
+ const decoded = await swiftDecode(recording.video, directory, startMs, endMs);
150
+ allTimes = decoded.frameTimestampsMs;
151
+ frames = decoded.frames;
152
+ }
153
+ const selected = frames.map(frame => frame.timestampMs);
154
+ const deltas = allTimes.slice(1).map((time, index) => time - allTimes[index]).filter(delta => delta > 0).sort((a, b) => a - b);
155
+ const nominalMs = deltas.length ? deltas[Math.floor((deltas.length - 1) / 2)] : null;
156
+ const gaps = allTimes.slice(1).flatMap((time, index) => nominalMs && time - allTimes[index] > nominalMs * 1.5 && time >= startMs && allTimes[index] <= endMs ? [{ startMs: allTimes[index], endMs: time, durationMs: time - allTimes[index] }] : []);
157
+ if (selected.length === 0 || selected[0] > startMs + (nominalMs ?? 0) * 1.5)
158
+ gaps.unshift({ startMs, endMs: selected[0] ?? endMs, durationMs: (selected[0] ?? endMs) - startMs });
159
+ if (selected.length && selected[selected.length - 1] < endMs - (nominalMs ?? 0) * 1.5)
160
+ gaps.push({ startMs: selected[selected.length - 1], endMs, durationMs: endMs - selected[selected.length - 1] });
161
+ return { recordingId, startMs, endMs, frames, frameCount: frames.length, nominalFrameIntervalMs: nominalMs, captureGaps: gaps, continuousCoverage: gaps.length === 0, note: 'Frames are encoded samples. A gap may hide a brief flash; absence cannot be established across gaps.' };
162
+ }
163
+ export async function shutdownRecordings() {
164
+ await Promise.allSettled([...activeDevices.values()].map(id => stopScreenRecording(id)));
165
+ }
@@ -1,6 +1,14 @@
1
1
  import crypto from 'crypto';
2
2
  let nextRevision = 1;
3
+ const snapshotSessionId = crypto.randomUUID();
3
4
  const snapshotStateByDevice = new Map();
5
+ export function getSnapshotSessionId() { return snapshotSessionId; }
6
+ export function hasSnapshotBaseline(deviceKey, revision) {
7
+ const state = snapshotStateByDevice.get(deviceKey);
8
+ const capturedAt = state?.historyTimes.get(revision);
9
+ return !!state && capturedAt !== undefined && state.history.has(revision) &&
10
+ Date.now() - capturedAt <= 10 * 60 * 1000 && Date.now() - state.updatedAt <= 10 * 60 * 1000;
11
+ }
4
12
  function normalize(value) {
5
13
  if (value === null || value === undefined)
6
14
  return '';
@@ -135,8 +143,7 @@ export function deriveSnapshotMetadata(deviceKey, tree, source, signatureOverrid
135
143
  const oversized = previous?.oversized ?? new Map();
136
144
  if (hasValidTree) {
137
145
  history.set(revision, currentElements);
138
- if (!historyTimes.has(revision))
139
- historyTimes.set(revision, Date.now());
146
+ historyTimes.set(revision, Date.now());
140
147
  if ((tree.elements?.length ?? 0) > 500)
141
148
  oversized.set(revision, (tree.elements?.length ?? 0) - 500);
142
149
  }
@@ -164,6 +171,7 @@ export function deriveSnapshotMetadata(deviceKey, tree, source, signatureOverrid
164
171
  updatedAt: Date.now()
165
172
  });
166
173
  return {
174
+ snapshot_session_id: snapshotSessionId,
167
175
  snapshot_revision: revision,
168
176
  captured_at_ms: Date.now(),
169
177
  snapshot_delta: hasValidTree ? summarizeSnapshotDelta(previous, currentElementSignatures) : null,
@@ -0,0 +1,135 @@
1
+ import { execCmd } from '../utils/exec.js';
2
+ import { getAdbCmd } from '../utils/android/utils.js';
3
+ export const TRACE_MAX_BYTES = 32 * 1024 * 1024;
4
+ export const TRACE_CATEGORIES = ['sched', 'freq', 'idle', 'am', 'wm', 'gfx', 'view', 'input'];
5
+ // Encode only this fixed preset using the public Perfetto proto field numbers:
6
+ // https://github.com/google/perfetto/tree/main/protos/perfetto/config
7
+ // Avoid --txt (explicitly unstable), arbitrary configuration and extra tooling.
8
+ function varint(n) {
9
+ const bytes = [];
10
+ do {
11
+ const byte = n % 128;
12
+ n = Math.floor(n / 128);
13
+ bytes.push(byte | (n ? 128 : 0));
14
+ } while (n);
15
+ return Buffer.from(bytes);
16
+ }
17
+ const uint = (field, n) => Buffer.concat([varint(field * 8), varint(n)]);
18
+ const message = (field, value) => {
19
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value);
20
+ return Buffer.concat([varint(field * 8 + 2), varint(bytes.length), bytes]);
21
+ };
22
+ export function traceConfig(duration, categories) {
23
+ const ftrace = Buffer.concat(categories.map(cat => message(2, cat)));
24
+ const source = Buffer.concat([message(1, 'linux.ftrace'), message(100, ftrace)]);
25
+ return Buffer.concat([
26
+ message(1, Buffer.concat([uint(1, 16384), uint(4, 1)])),
27
+ message(2, message(1, source)), uint(3, duration), uint(8, 1), uint(10, TRACE_MAX_BYTES)
28
+ ]);
29
+ }
30
+ const quote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
31
+ function ownedPath(remote) {
32
+ if (!/^\/data\/misc\/perfetto-traces\/mcp-[0-9a-f-]{36}\.perfetto-trace$/.test(remote))
33
+ throw new Error('Invalid owned trace path');
34
+ }
35
+ export class AndroidTraceNative {
36
+ execute;
37
+ constructor(execute = execCmd) {
38
+ this.execute = execute;
39
+ }
40
+ async command(device, args, timeout = 10000) {
41
+ const result = await this.execute(getAdbCmd(), ['-s', device, ...args], { timeout });
42
+ if (result.exitCode !== 0)
43
+ throw new Error(result.stderr === 'ACTION_TIMEOUT' ? 'ACTION_TIMEOUT' : 'ADB_TRACE_COMMAND_FAILED');
44
+ return result.stdout;
45
+ }
46
+ shell(device, script, timeout) {
47
+ return this.command(device, ['shell', 'sh', '-c', quote(script)], timeout);
48
+ }
49
+ async capabilities(deviceId) {
50
+ const checks = {};
51
+ const probe = async (name, fn) => { try {
52
+ checks[name] = await fn();
53
+ }
54
+ catch {
55
+ checks[name] = false;
56
+ } };
57
+ let help = '', categories = '';
58
+ await probe('android_api_29', async () => Number(await this.command(deviceId, ['shell', 'getprop', 'ro.build.version.sdk'])) >= 29);
59
+ 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'); });
60
+ await probe('tracing_service', async () => { await this.command(deviceId, ['shell', 'perfetto', '--query']); return true; });
61
+ await probe('writable_storage', async () => { await this.shell(deviceId, 'test -w /data/misc/perfetto-traces'); return true; });
62
+ 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; });
63
+ await probe('base64', async () => (await this.shell(deviceId, "printf YQ== | base64 -d")) === 'a');
64
+ await probe('required_categories', async () => {
65
+ categories = await this.command(deviceId, ['shell', 'atrace', '--list_categories']);
66
+ return ['sched', 'input'].every(c => new RegExp(`^\\s*${c}\\s+-`, 'm').test(categories));
67
+ });
68
+ const enabled = TRACE_CATEGORIES.filter(c => new RegExp(`^\\s*${c}\\s+-`, 'm').test(categories));
69
+ return { supported: Object.values(checks).every(Boolean), backend: 'perfetto', device: { platform: 'android', id: deviceId },
70
+ prerequisites: checks, reasons: Object.keys(checks).filter(k => !checks[k]), enabled_sources: enabled,
71
+ unavailable_categories: TRACE_CATEGORIES.filter(c => !enabled.includes(c)), limitations: [
72
+ 'Device-wide scheduling and system annotations may contain sensitive information.',
73
+ 'Host times are not aligned with device trace clocks; source availability varies.',
74
+ 'A server crash can leave a bounded trace file on the device for manual removal.'
75
+ ] };
76
+ }
77
+ async start(device, remote, duration, categories) {
78
+ ownedPath(remote);
79
+ const encoded = traceConfig(duration, categories).toString('base64');
80
+ await this.shell(device, `umask 077; printf %s ${quote(encoded)} | base64 -d | perfetto --background-wait -c - -o ${quote(remote)}`, 35000);
81
+ }
82
+ // Scan only shell-owned readable processes. Exact output argv + executable name
83
+ // binds ownership even if start acknowledgement/PID output was lost.
84
+ async find(device, remote) {
85
+ ownedPath(remote);
86
+ const output = await this.shell(device, `pids=$(pidof perfetto); rc=$?
87
+ [ "$rc" -le 1 ] || exit 1
88
+ for pid in $pids; do
89
+ p=/proc/$pid
90
+ [ -d "$p" ] || continue
91
+ [ -r "$p/cmdline" ] || { [ ! -d "$p" ] && continue; exit 1; }
92
+ cmd=$(tr '\\000' '\\n' < "$p/cmdline" 2>/dev/null) || { [ ! -d "$p" ] && continue; exit 1; }
93
+ printf '%s\\n' "$cmd" | grep -Fx ${quote(remote)} >/dev/null || continue
94
+ first=$(printf '%s\\n' "$cmd" | head -n 1)
95
+ case "$first" in perfetto|/system/bin/perfetto) ;; *) continue ;; esac
96
+ stat=$(cat "$p/stat" 2>/dev/null) || { [ ! -d "$p" ] && continue; exit 1; }
97
+ rest=\${stat##*) }
98
+ state=$(printf '%s\\n' "$rest" | awk '{print $1}')
99
+ [ "$state" = Z ] && continue
100
+ start=$(printf '%s\\n' "$rest" | awk '{print $20}')
101
+ printf '%s %s\\n' "\${p##*/}" "$start"
102
+ done`);
103
+ if (!output)
104
+ return null;
105
+ const match = /^(\d+) (\d+)$/.exec(output);
106
+ if (!match)
107
+ throw new Error('TRACE_PROCESS_IDENTITY_UNAVAILABLE');
108
+ return { pid: Number(match[1]), startTime: match[2] };
109
+ }
110
+ async stop(device, remote, owner) {
111
+ ownedPath(remote);
112
+ if (!Number.isSafeInteger(owner.pid) || owner.pid <= 1 || !/^\d+$/.test(owner.startTime))
113
+ throw new Error('TRACE_PROCESS_IDENTITY_UNAVAILABLE');
114
+ // Recheck start time and argv in the same shell immediately before signal.
115
+ await this.shell(device, `p=/proc/${owner.pid}
116
+ [ -d "$p" ] || exit 0
117
+ stat=$(cat "$p/stat") || exit 1
118
+ rest=\${stat##*) }
119
+ [ "$(printf '%s\\n' "$rest" | awk '{print $20}')" = ${quote(owner.startTime)} ] || exit 1
120
+ cmd=$(tr '\\000' '\\n' < "$p/cmdline") || exit 1
121
+ first=$(printf '%s\\n' "$cmd" | head -n 1)
122
+ case "$first" in perfetto|/system/bin/perfetto) ;; *) exit 1 ;; esac
123
+ printf '%s\\n' "$cmd" | grep -Fx ${quote(remote)} >/dev/null || exit 1
124
+ kill -TERM ${owner.pid}`);
125
+ }
126
+ async pull(device, remote, local) {
127
+ ownedPath(remote);
128
+ // Reject oversized output before initiating transfer as well as afterwards.
129
+ const size = Number(await this.command(device, ['shell', 'stat', '-c', '%s', remote]));
130
+ if (!Number.isSafeInteger(size) || size <= 0 || size > TRACE_MAX_BYTES)
131
+ throw new Error('TRACE_ARTIFACT_SIZE_INVALID');
132
+ await this.command(device, ['pull', remote, local], 30000);
133
+ }
134
+ async remove(device, remote) { ownedPath(remote); await this.command(device, ['shell', 'rm', '-f', remote]); }
135
+ }
@@ -0,0 +1,285 @@
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 } from './system-trace-android.js';
6
+ import { evidenceSessionId } from './evidence.js';
7
+ import { runWithBudget, remainingBudget } from '../utils/operation-budget.js';
8
+ export class TraceError extends Error {
9
+ code;
10
+ constructor(code) {
11
+ super(code);
12
+ this.code = code;
13
+ }
14
+ }
15
+ const clock = {
16
+ wall: Date.now, mono: () => performance.now(), sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
17
+ schedule: (fn, ms) => { const t = setTimeout(fn, ms); t.unref(); return t; }, clear: clearTimeout
18
+ };
19
+ export class SystemTraceSessions {
20
+ native;
21
+ time;
22
+ sessions = new Map();
23
+ closing = false;
24
+ constructor(native = new AndroidTraceNative(), time = clock) {
25
+ this.native = native;
26
+ this.time = time;
27
+ }
28
+ prune() {
29
+ const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.result.cleanup.status === 'complete');
30
+ for (const s of terminal)
31
+ if (this.time.mono() - s.terminalAt >= 600000)
32
+ this.sessions.delete(s.result.trace_id);
33
+ const remaining = terminal.filter(s => this.sessions.has(s.result.trace_id));
34
+ for (const s of remaining.slice(0, Math.max(0, remaining.length - 100)))
35
+ this.sessions.delete(s.result.trace_id);
36
+ }
37
+ get(id) { this.prune(); const s = this.sessions.get(id); if (!s)
38
+ throw new TraceError('TRACE_NOT_FOUND'); return s; }
39
+ snapshot(s) { return structuredClone(s.result); }
40
+ serial(s, fn) {
41
+ const result = s.queue.then(fn);
42
+ s.queue = result.catch(() => { });
43
+ return result;
44
+ }
45
+ async capabilities(device) { return this.native.capabilities(device); }
46
+ async start(device, duration = 30000) {
47
+ if (!Number.isInteger(duration) || duration < 1000 || duration > 120000)
48
+ throw new TraceError('INVALID_ARGUMENT');
49
+ this.prune();
50
+ const reserved = [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete');
51
+ if (this.closing || reserved.length >= 4 || reserved.some(s => s.result.device.id === device))
52
+ throw new TraceError('TRACE_BUSY');
53
+ const id = randomUUID();
54
+ const result = { trace_id: id, session_id: evidenceSessionId(), device: { platform: 'android', id: device }, backend: 'perfetto',
55
+ state: 'starting', reason: null, completion_reason: null, capture_started_at_ms: null, capture_completed_at_ms: null,
56
+ artifact: null, cleanup: { status: 'pending', remaining_resources: [] }, correlation_ids: [], enabled_sources: [], unavailable_categories: [] };
57
+ const s = { result, remote: `/data/misc/perfetto-traces/mcp-${id}.perfetto-trace`, owner: null, stopped: true,
58
+ discard: false, deadline: Infinity, queue: Promise.resolve(), linked: new Set(), partial: new Set() };
59
+ this.sessions.set(id, s);
60
+ return this.serial(s, async () => {
61
+ let cap;
62
+ try {
63
+ cap = await this.native.capabilities(device);
64
+ }
65
+ catch {
66
+ this.fail(s, 'TRACE_UNSUPPORTED');
67
+ this.clean(s);
68
+ return this.snapshot(s);
69
+ }
70
+ s.result.enabled_sources = cap.enabled_sources;
71
+ s.result.unavailable_categories = cap.unavailable_categories;
72
+ if (!cap.supported || this.closing) {
73
+ this.fail(s, this.closing ? 'TRACE_BUSY' : 'TRACE_UNSUPPORTED');
74
+ this.clean(s);
75
+ return this.snapshot(s);
76
+ }
77
+ s.stopped = false;
78
+ s.result.cleanup.remaining_resources = [s.remote, 'device_capture'];
79
+ // Conservative host bounds include acknowledgement latency, not exact device start.
80
+ s.result.capture_started_at_ms = this.time.wall();
81
+ s.deadline = this.time.mono() + duration;
82
+ try {
83
+ await this.native.start(device, s.remote, duration, cap.enabled_sources);
84
+ s.owner = await this.native.find(device, s.remote);
85
+ s.result.state = 'recording';
86
+ s.timer = this.time.schedule(() => { void this.control(id, 'duration').catch(() => { }); }, Math.max(0, s.deadline - this.time.mono()));
87
+ }
88
+ catch (error) {
89
+ s.discard = true;
90
+ this.fail(s, this.code(error, 'TRACE_START_FAILED'));
91
+ try {
92
+ await this.halt(s);
93
+ await this.remove(s);
94
+ }
95
+ catch {
96
+ s.result.cleanup.status = 'failed';
97
+ }
98
+ }
99
+ return this.snapshot(s);
100
+ });
101
+ }
102
+ code(error, fallback) { return error instanceof Error && error.message === 'ACTION_TIMEOUT' ? 'ACTION_TIMEOUT' : fallback; }
103
+ fail(s, reason) {
104
+ s.result.state = 'failed';
105
+ s.result.reason = reason;
106
+ s.result.completion_reason = 'error';
107
+ s.terminalAt = this.time.mono();
108
+ }
109
+ clean(s) {
110
+ s.result.cleanup = { status: 'complete', remaining_resources: [] };
111
+ s.terminalAt = this.time.mono();
112
+ this.prune();
113
+ }
114
+ async halt(s) {
115
+ if (s.stopped)
116
+ return;
117
+ const deadline = this.time.mono() + 10000;
118
+ await runWithBudget(10000, async () => {
119
+ const found = await this.native.find(s.result.device.id, s.remote);
120
+ if (found) {
121
+ if (s.owner && (s.owner.pid !== found.pid || s.owner.startTime !== found.startTime))
122
+ throw new TraceError('TRACE_STOP_FAILED');
123
+ s.owner ??= found;
124
+ try {
125
+ await this.native.stop(s.result.device.id, s.remote, s.owner);
126
+ }
127
+ catch (error) {
128
+ // Natural expiry can win between identity lookup and SIGTERM. Only
129
+ // confirmed absence permits finalisation; unreadable/reused identity fails.
130
+ if (await this.native.find(s.result.device.id, s.remote))
131
+ throw error;
132
+ }
133
+ while (await this.native.find(s.result.device.id, s.remote)) {
134
+ if (this.time.mono() >= deadline)
135
+ throw new TraceError('ACTION_TIMEOUT');
136
+ remainingBudget();
137
+ await this.time.sleep(100);
138
+ }
139
+ }
140
+ });
141
+ s.stopped = true;
142
+ s.result.capture_completed_at_ms = this.time.wall();
143
+ s.result.cleanup.remaining_resources = [s.remote];
144
+ for (const linked of s.linked)
145
+ s.partial.add(linked);
146
+ }
147
+ async remove(s) {
148
+ if (!s.stopped)
149
+ throw new TraceError('TRACE_STOP_FAILED');
150
+ await this.native.remove(s.result.device.id, s.remote);
151
+ if (s.local && !s.result.artifact) {
152
+ await fs.rm(s.local, { recursive: true, force: true });
153
+ s.local = undefined;
154
+ }
155
+ this.clean(s);
156
+ }
157
+ async artifact(s) {
158
+ if (s.result.artifact)
159
+ return;
160
+ s.local ??= await fs.mkdtemp(path.join(os.tmpdir(), 'mobile-debug-trace-'));
161
+ const local = s.local;
162
+ const partial = path.join(local, 'capture.partial');
163
+ try {
164
+ await fs.chmod(s.local, 0o700);
165
+ await this.native.pull(s.result.device.id, s.remote, partial);
166
+ const stat = await fs.stat(partial);
167
+ if (!stat.isFile() || stat.size <= 0 || stat.size > TRACE_MAX_BYTES)
168
+ throw new TraceError('TRACE_ARTIFACT_FAILED');
169
+ await fs.chmod(partial, 0o600);
170
+ const target = path.join(s.local, `${s.result.trace_id}.perfetto-trace`);
171
+ await fs.rename(partial, target);
172
+ s.result.artifact = { path: target, format: 'perfetto', size_bytes: stat.size };
173
+ }
174
+ catch (error) {
175
+ try {
176
+ await fs.rm(local, { recursive: true, force: true });
177
+ s.local = undefined;
178
+ }
179
+ catch {
180
+ s.result.cleanup.remaining_resources = [s.remote, local];
181
+ }
182
+ throw error;
183
+ }
184
+ }
185
+ async control(id, operation) {
186
+ const s = this.get(id);
187
+ return this.serial(s, async () => {
188
+ if (s.result.cleanup.status === 'complete')
189
+ return this.snapshot(s);
190
+ if (operation === 'status' && s.result.state === 'recording') {
191
+ try {
192
+ if (await this.native.find(s.result.device.id, s.remote) && this.time.mono() < s.deadline)
193
+ return this.snapshot(s);
194
+ }
195
+ catch (error) {
196
+ this.fail(s, this.code(error, 'TRACE_STOP_FAILED'));
197
+ s.result.cleanup.status = 'failed';
198
+ return this.snapshot(s);
199
+ }
200
+ }
201
+ if (s.timer) {
202
+ this.time.clear(s.timer);
203
+ s.timer = undefined;
204
+ }
205
+ if (operation === 'cancel')
206
+ s.discard = true;
207
+ const hadStartFailure = s.discard && s.result.state === 'failed' && operation !== 'cancel';
208
+ s.result.state = 'stopping';
209
+ s.result.cleanup.status = 'pending';
210
+ try {
211
+ await this.halt(s);
212
+ }
213
+ catch (error) {
214
+ this.fail(s, this.code(error, 'TRACE_STOP_FAILED'));
215
+ s.result.cleanup.status = 'failed';
216
+ return this.snapshot(s);
217
+ }
218
+ if (!s.discard) {
219
+ try {
220
+ await this.artifact(s);
221
+ }
222
+ catch (error) {
223
+ this.fail(s, this.code(error, 'TRACE_ARTIFACT_FAILED'));
224
+ s.result.cleanup.status = 'failed';
225
+ return this.snapshot(s);
226
+ }
227
+ }
228
+ try {
229
+ await this.remove(s);
230
+ }
231
+ catch {
232
+ this.fail(s, 'TRACE_STOP_FAILED');
233
+ s.result.cleanup.status = 'failed';
234
+ return this.snapshot(s);
235
+ }
236
+ if (hadStartFailure)
237
+ s.result.state = 'failed';
238
+ else {
239
+ s.result.state = s.discard ? 'cancelled' : 'completed';
240
+ s.result.reason = s.discard ? 'TRACE_CANCELLED' : null;
241
+ s.result.completion_reason = s.discard ? 'cancelled' : operation === 'duration' || this.time.mono() >= s.deadline ? 'duration_limit' : 'requested';
242
+ }
243
+ return this.snapshot(s);
244
+ });
245
+ }
246
+ async link(id, device, correlation) {
247
+ const s = this.get(id);
248
+ if (device.platform !== 'android' || device.id !== s.result.device.id)
249
+ throw new TraceError('TRACE_NOT_FOUND');
250
+ const status = await this.control(id, 'status');
251
+ if (device.platform !== 'android' || device.id !== status.device.id || status.state !== 'recording')
252
+ throw new TraceError('TRACE_NOT_FOUND');
253
+ // Validate again after the asynchronous status call: stop/cancel may have won.
254
+ return this.serial(s, async () => {
255
+ if (s.result.state !== 'recording' || this.time.mono() >= s.deadline)
256
+ throw new TraceError('TRACE_NOT_FOUND');
257
+ if (!s.result.correlation_ids.includes(correlation)) {
258
+ if (s.result.correlation_ids.length >= 1000)
259
+ throw new TraceError('TRACE_BUSY');
260
+ s.result.correlation_ids.push(correlation);
261
+ }
262
+ s.linked.add(correlation);
263
+ let finish;
264
+ return () => finish ??= (async () => {
265
+ let recording = false;
266
+ try {
267
+ recording = (await this.control(id, 'status')).state === 'recording';
268
+ }
269
+ catch { /* Unavailable state cannot establish complete coverage. */ }
270
+ s.linked.delete(correlation);
271
+ return { trace_id: id, coverage: !recording || s.partial.has(correlation) || this.time.mono() >= s.deadline ? 'partial' : 'covered',
272
+ clock_alignment: 'unknown' };
273
+ })();
274
+ });
275
+ }
276
+ async shutdown() {
277
+ this.closing = true;
278
+ // Independent device cleanup in parallel under one overall deadline.
279
+ return runWithBudget(10000, async () => {
280
+ await Promise.allSettled([...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete').map(s => this.control(s.result.trace_id, 'cancel')));
281
+ return [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete').map(s => this.snapshot(s));
282
+ });
283
+ }
284
+ }
285
+ export const systemTraces = new SystemTraceSessions();
@@ -1,5 +1,7 @@
1
+ import { currentEvidenceActionId } from '../observe/evidence.js';
1
2
  import { recordTiming } from '../utils/timing.js';
2
3
  import { ToolsObserve } from '../observe/index.js';
4
+ import { isConditionBlocked } from '../interact/conditional-action.js';
3
5
  export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3;
4
6
  export const DEFAULT_MAX_RETRY_DEPTH = 3;
5
7
  export function wrapResponse(data, includeSerializationTiming = true) {
@@ -62,10 +64,15 @@ export function getArrayArg(args, key) {
62
64
  }
63
65
  let actionSequence = 0;
64
66
  export function nextActionId(actionType, timestamp) {
67
+ const current = currentEvidenceActionId();
68
+ if (current)
69
+ return current;
65
70
  actionSequence += 1;
66
71
  return `${actionType}_${timestamp}_${actionSequence}`;
67
72
  }
68
73
  export async function captureActionFingerprint(platform, deviceId) {
74
+ if (isConditionBlocked())
75
+ return null;
69
76
  if (!platform)
70
77
  return null;
71
78
  try {
@@ -235,6 +242,8 @@ function mapFailureCodeToFailureClass(code) {
235
242
  case 'STALE_REFERENCE':
236
243
  return 'TargetResolutionFailure';
237
244
  case 'ELEMENT_NOT_INTERACTABLE':
245
+ case 'PRECONDITION_FAILED':
246
+ case 'PRECONDITION_UNAVAILABLE':
238
247
  case 'ACTION_TIMEOUT':
239
248
  return 'ExecutionFailure';
240
249
  case 'TIMEOUT':