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,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();
@@ -0,0 +1,38 @@
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 } from './common.js';
5
+ function failure(code, summary = {}) {
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, args, signal) {
11
+ const parsed = debuggerContracts[name].schema.safeParse(args);
12
+ if (!parsed.success)
13
+ return failure('INVALID_ARGUMENT');
14
+ const a = parsed.data;
15
+ const command = debuggerContracts[name].command;
16
+ try {
17
+ if (signal?.aborted)
18
+ throw new DebuggerError('DEBUGGER_CANCELLED');
19
+ const result = await withDebuggerBudget(a.timeoutMs, signal, async () => command === 'capabilities' ? await debuggerSessions.capabilities(a.deviceId, a.appId, a.processId) :
20
+ command === 'attach' ? await debuggerSessions.attach(a.deviceId, a.appId, a.processId, a.timeoutMs) :
21
+ await debuggerSessions.command(a.debugSessionId, command, { ...a, signal: command === 'events' ? signal : undefined }, a.timeoutMs));
22
+ if (signal?.aborted && command === 'capabilities')
23
+ throw new DebuggerError('DEBUGGER_CANCELLED');
24
+ return { ...wrapResponse(result), ...(!result.success ? { isError: true } : {}) };
25
+ }
26
+ catch (e) {
27
+ const code = e instanceof DebuggerError ? e.code : 'DEBUGGER_WORKER_FAILED';
28
+ let summary = {};
29
+ if (a.debugSessionId) {
30
+ try {
31
+ summary = debuggerSessions.summary(a.debugSessionId);
32
+ }
33
+ catch { /* Unknown session */ }
34
+ }
35
+ return failure(code, { debug_session_id: a.debugSessionId ?? null,
36
+ device: a.deviceId ? { platform: 'android', id: a.deviceId } : null, ...summary });
37
+ }
38
+ }
@@ -0,0 +1,42 @@
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) {
23
+ if (schema instanceof z.ZodOptional)
24
+ return jsonField(schema.unwrap());
25
+ if (schema instanceof z.ZodLiteral)
26
+ return { type: 'string', enum: [schema.value] };
27
+ if (schema instanceof z.ZodEnum)
28
+ return { type: 'string', enum: schema.options };
29
+ if (schema instanceof z.ZodString)
30
+ return { type: 'string', minLength: 1, maxLength: 512, ...Object.assign({}, ...schema._def.checks.filter(c => c.kind === 'regex').map(c => ({ pattern: c.regex.source }))) };
31
+ if (schema instanceof z.ZodNumber)
32
+ return { type: 'integer', minimum: schema.minValue, maximum: schema.maxValue };
33
+ throw new Error('Unsupported debugger field schema');
34
+ }
35
+ export const debuggerContracts = Object.fromEntries(Object.entries(specs).map(([name, spec]) => [name, {
36
+ ...spec, schema: z.object({ ...spec.fields, timeoutMs: timeout.default(name === 'wait_for_debugger_event' ? 1000 : 10000) }).strict()
37
+ }]));
38
+ export const debuggerTools = Object.entries(debuggerContracts).map(([name, spec]) => ({ name, description: spec.description,
39
+ inputSchema: { type: 'object', additionalProperties: false,
40
+ required: Object.entries(spec.fields).filter(([, field]) => !field.isOptional()).map(([key]) => key),
41
+ 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 } } }
42
+ }));
@@ -32,6 +32,7 @@ export const tapElementControlsSchema = z.object({
32
32
  }).strict().refine(value => Number(!!value.elementId) + Number(!!value.selector) === 1 && (!value.elementId || !value.waitFor));
33
33
  export const journeySchema = z.object({
34
34
  platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional(),
35
+ systemTraceId: z.string().uuid().optional(),
35
36
  responseMode: z.enum(['compact', 'debug']).default('compact'), captureOnFailure: z.boolean().default(false),
36
37
  defaults: z.object({ verificationMode: verificationMode.optional(), actionTimeoutMs: z.number().int().finite().optional(), verificationTimeoutMs: z.number().int().finite().optional() }).strict().optional(),
37
38
  steps: z.array(step).min(1).max(50)
@@ -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
+ ];
@@ -1,10 +1,14 @@
1
+ import { debuggerTools } from './debugger-schema.js';
2
+ import { systemTraceTools } from './system-trace-schema.js';
1
3
  import { journeyStepsJson } from './journey-schema.js';
2
4
  import { conditionalTools, expectedStateJson } from './condition-schema.js';
3
5
  import { readinessPropertiesJson } from './readiness-schema.js';
4
6
  export const toolDefinitions = [
7
+ ...debuggerTools,
8
+ ...systemTraceTools,
5
9
  ...['get_app_readiness', 'wait_for_app_ready'].map(name => ({
6
10
  name,
7
- description: `${name === 'get_app_readiness' ? 'Observe readiness with at most two fresh UI-tree samples.' : 'Wait within a single bounded budget for settled UI evidence.'} Reports loading, transitioning, settled or unknown with evidence and limitations. Unresponsive/debugger-paused detection is unsupported. Settled normalized foreground UI is not proof of app identity, responsiveness or backend completion.`,
11
+ description: `${name === 'get_app_readiness' ? 'Observe readiness with at most two fresh UI-tree samples.' : 'Wait within a single bounded budget for settled UI evidence.'} Reports loading, transitioning, settled or unknown with evidence and limitations. Managed Android debugger pauses are reported from debugger evidence; unresponsive detection remains unsupported. Settled normalized foreground UI is not proof of app identity, responsiveness or backend completion.`,
8
12
  inputSchema: { type: 'object', additionalProperties: false, required: ['platform'], properties: {
9
13
  ...readinessPropertiesJson,
10
14
  timeoutMs: { ...readinessPropertiesJson.timeoutMs, default: name === 'get_app_readiness' ? 15000 : 30000 },
@@ -1175,6 +1179,7 @@ for (const tool of toolDefinitions) {
1175
1179
  if (tool.name === 'run_journey')
1176
1180
  Object.assign(tool.inputSchema.properties, {
1177
1181
  steps: journeyStepsJson,
1182
+ systemTraceId: { type: 'string', format: 'uuid', description: 'Explicit active same-device system trace. Invalid references prevent all journey input. Capture lifetime is controlled separately.' },
1178
1183
  captureOnFailure: { type: 'boolean', default: false },
1179
1184
  defaults: { type: 'object', additionalProperties: false, properties: {
1180
1185
  verificationMode: { type: 'string', enum: ['none', 'light', 'full'] },