mobile-debug-mcp 0.35.0 → 0.36.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.
- package/.cursor-plugin/marketplace.json +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/README.md +86 -24
- package/agent-plugin/.antigravity-plugin/README.md +48 -0
- package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
- package/agent-plugin/.claude-plugin/plugin.json +2 -2
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/README.md +37 -8
- package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/cursor/LICENSE +201 -0
- package/agent-plugin/cursor/README.md +9 -0
- package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +253 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/mcp_config.json +13 -0
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +36 -16
- package/dist/debugger/guard.js +4 -4
- package/dist/debugger/ios-worker.js +62 -0
- package/dist/debugger/ios.js +57 -0
- package/dist/debugger/sessions.js +33 -16
- package/dist/debugger/worker.js +6 -4
- package/dist/interact/index.js +12 -2
- package/dist/manage/ios.js +2 -0
- package/dist/observe/ios-trace/artifact.js +63 -0
- package/dist/observe/ios-trace/capabilities.js +86 -0
- package/dist/observe/ios-trace/native.js +93 -0
- package/dist/observe/ios-trace/notification.js +35 -0
- package/dist/observe/ios-trace/process.js +98 -0
- package/dist/observe/ios-trace/sessions.js +244 -0
- package/dist/observe/readiness-service.js +1 -1
- package/dist/observe/readiness.js +4 -4
- package/dist/observe/system-trace-error.js +7 -0
- package/dist/observe/system-trace-router.js +81 -0
- package/dist/observe/system-trace.js +4 -8
- package/dist/server/handlers/debugger-handlers.js +27 -8
- package/dist/server/handlers/diagnostic.js +12 -4
- package/dist/server/handlers/tool-handlers.js +19 -9
- package/dist/server/schemas/debugger-schema.js +10 -7
- package/dist/server/schemas/system-trace-schema.js +14 -9
- package/dist/server-core.js +1 -1
- package/dist/server.js +4 -4
- package/dist/utils/ios/utils.js +3 -0
- package/docs/CHANGELOG.md +23 -1
- package/docs/agent-plugin-installation-verification.md +39 -0
- package/docs/code-structure.md +3 -1
- package/docs/specs/031-ios-system-traces/intent.md +38 -0
- package/docs/specs/031-ios-system-traces/investigation.md +92 -0
- package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
- package/docs/specs/031-ios-system-traces/plan.md +138 -0
- package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
- package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
- package/docs/specs/031-ios-system-traces/spec.md +135 -0
- package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
- package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
- package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
- package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
- package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
- package/docs/tools/TOOLS.md +2 -2
- package/docs/tools/debugger.md +45 -5
- package/docs/tools/system-traces.md +81 -14
- package/package.json +2 -1
- package/scripts/agent-plugin.mjs +55 -5
- package/src/debugger/android.ts +1 -1
- package/src/debugger/guard.ts +5 -5
- package/src/debugger/ios-worker.ts +49 -0
- package/src/debugger/ios.ts +45 -0
- package/src/debugger/lldb/breakpoints.py +51 -0
- package/src/debugger/lldb/inspection.py +109 -0
- package/src/debugger/lldb/worker.py +203 -0
- package/src/debugger/sessions.ts +24 -12
- package/src/debugger/worker.ts +7 -4
- package/src/interact/index.ts +13 -2
- package/src/manage/ios.ts +2 -0
- package/src/observe/ios-trace/artifact.ts +55 -0
- package/src/observe/ios-trace/capabilities.ts +74 -0
- package/src/observe/ios-trace/native.ts +72 -0
- package/src/observe/ios-trace/notification.ts +30 -0
- package/src/observe/ios-trace/process.ts +82 -0
- package/src/observe/ios-trace/sessions.ts +204 -0
- package/src/observe/readiness-service.ts +1 -1
- package/src/observe/readiness.ts +4 -4
- package/src/observe/system-trace-error.ts +3 -0
- package/src/observe/system-trace-router.ts +61 -0
- package/src/observe/system-trace.ts +4 -4
- package/src/server/handlers/debugger-handlers.ts +28 -9
- package/src/server/handlers/diagnostic.ts +11 -4
- package/src/server/handlers/tool-handlers.ts +20 -9
- package/src/server/schemas/debugger-schema.ts +10 -7
- package/src/server/schemas/system-trace-schema.ts +15 -9
- package/src/server-core.ts +1 -1
- package/src/server.ts +3 -3
- package/src/utils/ios/utils.ts +3 -0
- package/test/device/automated/debugger/runtime.ios.test.ts +221 -0
- package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
- package/test/fixtures/contracts/tool-definitions.json +192 -32
- package/test/fixtures/ios-debugger-app/Main.swift +95 -0
- package/test/fixtures/ios-debugger-app/README.md +27 -0
- package/test/fixtures/ios-debugger-app/build.ts +21 -0
- package/test/helpers/ios-trace-shutdown.ts +29 -0
- package/test/unit/debugger/handle-guards.test.ts +88 -0
- package/test/unit/debugger/ios-preparation.test.ts +57 -0
- package/test/unit/debugger/ios.test.ts +104 -0
- package/test/unit/debugger/lldb-worker.py +166 -0
- package/test/unit/debugger/lldb-worker.test.ts +10 -0
- package/test/unit/observe/ios-trace-native.test.ts +90 -0
- package/test/unit/observe/ios-trace-sessions.test.ts +131 -0
- package/test/unit/observe/readiness.test.ts +1 -1
- package/test/unit/observe/system-trace-router.test.ts +49 -0
- package/test/unit/quality/agent-plugin-export.test.ts +38 -0
- package/test/unit/server/system-trace.test.ts +12 -3
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { classifyTraceFailure } from './process.js';
|
|
2
|
+
import { TraceError } from '../system-trace-error.js';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { evidenceSessionId } from '../evidence.js';
|
|
8
|
+
import { IosTraceNative } from './native.js';
|
|
9
|
+
const reasonFor = (error, fallback) => {
|
|
10
|
+
const code = error instanceof Error ? error.message : '';
|
|
11
|
+
return ['STORAGE_LIMIT', 'APP_IDENTITY_CHANGED', 'ACTION_TIMEOUT', 'READINESS_TIMEOUT', 'RECORDER_EXITED', 'PROFILING_PERMISSION_REQUIRED', 'APP_NOT_RUNNING', 'TARGET_DISCONNECTED'].includes(code) ? code : fallback;
|
|
12
|
+
};
|
|
13
|
+
export class IosTraceSessions {
|
|
14
|
+
native;
|
|
15
|
+
time;
|
|
16
|
+
sessions = new Map();
|
|
17
|
+
closing = false;
|
|
18
|
+
constructor(native = new IosTraceNative(), time = {
|
|
19
|
+
wall: Date.now, mono: () => performance.now(), sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
20
|
+
schedule: (fn, ms) => { const timer = setTimeout(fn, ms); timer.unref(); return timer; }, clear: clearTimeout
|
|
21
|
+
}) {
|
|
22
|
+
this.native = native;
|
|
23
|
+
this.time = time;
|
|
24
|
+
}
|
|
25
|
+
get(id) {
|
|
26
|
+
this.prune();
|
|
27
|
+
const s = this.sessions.get(id);
|
|
28
|
+
if (!s)
|
|
29
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
prune() {
|
|
33
|
+
const terminal = [...this.sessions.values()].filter(s => s.terminal !== undefined && s.result.cleanup.status === 'complete');
|
|
34
|
+
for (const [index, s] of terminal.entries())
|
|
35
|
+
if (this.time.mono() - s.terminal >= 600000 || index < terminal.length - 100)
|
|
36
|
+
this.sessions.delete(s.result.trace_id);
|
|
37
|
+
}
|
|
38
|
+
serial(s, fn) {
|
|
39
|
+
const promise = s.queue.then(fn);
|
|
40
|
+
s.queue = promise.catch(() => { });
|
|
41
|
+
return promise;
|
|
42
|
+
}
|
|
43
|
+
snapshot(s) { return structuredClone(s.result); }
|
|
44
|
+
fail(s, reason) {
|
|
45
|
+
s.result.state = 'failed';
|
|
46
|
+
s.result.reason = reason;
|
|
47
|
+
s.result.completion_reason = 'error';
|
|
48
|
+
s.result.recovery = reason === 'PROFILING_PERMISSION_REQUIRED' ? 'Grant the required profiling permission through the macOS or Xcode UI, then explicitly start a new capture.' : reason === 'TRACE_ARTIFACT_FAILED' ? 'Retry stop to validate the finalised owned artifact, or cancel to discard.' : 'Check target, app and profiling permissions; cancel the owned session before retrying start.';
|
|
49
|
+
for (const id of s.linked)
|
|
50
|
+
s.partial.add(id);
|
|
51
|
+
}
|
|
52
|
+
async start(device, appId, duration = 30000) {
|
|
53
|
+
this.prune();
|
|
54
|
+
const active = [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete');
|
|
55
|
+
if (this.closing || active.length >= 4 || active.some(s => s.result.device.id === device.id))
|
|
56
|
+
throw new TraceError('TRACE_BUSY');
|
|
57
|
+
if (!Number.isInteger(duration) || duration < 1000 || duration > 120000)
|
|
58
|
+
throw new TraceError('INVALID_ARGUMENT');
|
|
59
|
+
const result = {
|
|
60
|
+
trace_id: randomUUID(), session_id: evidenceSessionId(), device: structuredClone(device), backend: 'xctrace', state: 'starting',
|
|
61
|
+
reason: null, recovery: null, completion_reason: null, capture_started_at_ms: null, capture_completed_at_ms: null,
|
|
62
|
+
recording_ready_at_ms: null, app_id: appId, pid: null, executable: null, template: 'Time Profiler', artifact: null,
|
|
63
|
+
cleanup: { status: 'pending', remaining_resources: [] }, warnings: [], correlation_ids: [], clock_alignment: 'unknown'
|
|
64
|
+
};
|
|
65
|
+
const s = { result, deadline: Infinity, discard: false, queue: Promise.resolve(), linked: new Set(), partial: new Set() };
|
|
66
|
+
this.sessions.set(result.trace_id, s);
|
|
67
|
+
return this.serial(s, async () => {
|
|
68
|
+
try {
|
|
69
|
+
s.directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mobile-debug-ios-trace-'));
|
|
70
|
+
await fs.chmod(s.directory, 0o700);
|
|
71
|
+
s.result.cleanup.remaining_resources = [s.directory];
|
|
72
|
+
s.app = await this.native.resolve(device.id, appId);
|
|
73
|
+
s.result.pid = s.app.pid;
|
|
74
|
+
s.result.executable = s.app.executable;
|
|
75
|
+
if (this.closing)
|
|
76
|
+
throw new TraceError('TRACE_BUSY');
|
|
77
|
+
s.result.capture_started_at_ms = this.time.wall();
|
|
78
|
+
s.deadline = Infinity;
|
|
79
|
+
s.recording = await this.native.start(device.id, s.app, s.directory, duration, recording => { s.recording = recording; if (this.closing)
|
|
80
|
+
recording.recorder.signal('SIGINT'); });
|
|
81
|
+
if (this.closing)
|
|
82
|
+
throw new Error('READINESS_TIMEOUT');
|
|
83
|
+
s.deadline = this.time.mono() + duration;
|
|
84
|
+
s.result.recording_ready_at_ms = s.recording.ready_at_ms;
|
|
85
|
+
s.result.state = 'recording';
|
|
86
|
+
this.schedule(s);
|
|
87
|
+
void s.recording.recorder.done.then(() => this.control(result.trace_id, 'status')).catch(() => { });
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
this.fail(s, reasonFor(error, 'TRACE_START_FAILED'));
|
|
91
|
+
s.discard = true;
|
|
92
|
+
await this.finalise(s, 'cancel', true);
|
|
93
|
+
}
|
|
94
|
+
return this.snapshot(s);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
schedule(s) {
|
|
98
|
+
if (s.result.state !== 'recording')
|
|
99
|
+
return;
|
|
100
|
+
s.timer = this.time.schedule(() => {
|
|
101
|
+
void this.serial(s, () => this.poll(s)).finally(() => this.schedule(s)).catch(() => { });
|
|
102
|
+
}, 1000);
|
|
103
|
+
}
|
|
104
|
+
async poll(s) {
|
|
105
|
+
if (s.result.state !== 'recording' || !s.recording || !s.app)
|
|
106
|
+
return;
|
|
107
|
+
try {
|
|
108
|
+
await this.native.monitor(s.result.device.id, s.app, s.recording);
|
|
109
|
+
s.monitorFailures = 0;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const reason = error instanceof Error && error.message === 'COMMAND_FAILED'
|
|
113
|
+
? 'COMMAND_FAILED' : reasonFor(error, 'TRACE_MONITOR_FAILED');
|
|
114
|
+
const definitive = ['STORAGE_LIMIT', 'APP_IDENTITY_CHANGED', 'APP_NOT_RUNNING'].includes(reason);
|
|
115
|
+
s.monitorFailures = (s.monitorFailures ?? 0) + 1;
|
|
116
|
+
if (definitive || s.monitorFailures >= 3) {
|
|
117
|
+
this.fail(s, reason);
|
|
118
|
+
s.discard = true;
|
|
119
|
+
await this.finalise(s, 'cancel', true);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (this.time.mono() >= s.deadline || !s.recording.recorder.alive())
|
|
124
|
+
await this.finalise(s, 'duration');
|
|
125
|
+
}
|
|
126
|
+
async control(id, op, timeout) {
|
|
127
|
+
const s = this.get(id);
|
|
128
|
+
return this.serial(s, async () => {
|
|
129
|
+
if (s.result.cleanup.status === 'complete')
|
|
130
|
+
return this.snapshot(s);
|
|
131
|
+
if (op === 'status' && s.result.state === 'recording' && s.recording?.recorder.alive() && this.time.mono() < s.deadline) {
|
|
132
|
+
await this.poll(s);
|
|
133
|
+
return this.snapshot(s);
|
|
134
|
+
}
|
|
135
|
+
await this.finalise(s, op === 'status' ? 'duration' : op, false, timeout);
|
|
136
|
+
return this.snapshot(s);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async finalise(s, op, preserveFailure = false, timeout) {
|
|
140
|
+
if (s.timer) {
|
|
141
|
+
this.time.clear(s.timer);
|
|
142
|
+
s.timer = undefined;
|
|
143
|
+
}
|
|
144
|
+
if (op === 'cancel')
|
|
145
|
+
s.discard = true;
|
|
146
|
+
if (!preserveFailure)
|
|
147
|
+
s.result.state = 'stopping';
|
|
148
|
+
for (const id of s.linked)
|
|
149
|
+
s.partial.add(id);
|
|
150
|
+
try {
|
|
151
|
+
await this.finishRecorder(s, op, timeout);
|
|
152
|
+
s.result.capture_completed_at_ms ??= this.time.wall();
|
|
153
|
+
await this.finishArtifact(s);
|
|
154
|
+
s.result.cleanup = { status: 'complete', remaining_resources: [] };
|
|
155
|
+
s.terminal = this.time.mono();
|
|
156
|
+
if (!preserveFailure) {
|
|
157
|
+
s.result.state = s.discard ? 'cancelled' : 'completed';
|
|
158
|
+
s.result.reason = s.discard ? 'TRACE_CANCELLED' : null;
|
|
159
|
+
s.result.recovery = null;
|
|
160
|
+
s.result.completion_reason = s.discard ? 'cancelled' : op === 'duration' ? 'duration_limit' : 'requested';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
this.fail(s, reasonFor(error, s.recording?.recorder.alive() ? 'TRACE_STOP_FAILED' : 'TRACE_ARTIFACT_FAILED'));
|
|
165
|
+
s.result.cleanup.status = 'failed';
|
|
166
|
+
if (s.result.reason === 'STORAGE_LIMIT' && !s.recording?.recorder.alive())
|
|
167
|
+
await this.discardOversized(s);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async discardOversized(s) {
|
|
171
|
+
try {
|
|
172
|
+
if (s.directory)
|
|
173
|
+
await fs.rm(s.directory, { recursive: true, force: true });
|
|
174
|
+
s.result.cleanup = { status: 'complete', remaining_resources: [] };
|
|
175
|
+
s.terminal = this.time.mono();
|
|
176
|
+
}
|
|
177
|
+
catch { /* retain failed cleanup and owned path for cancel retry */ }
|
|
178
|
+
}
|
|
179
|
+
async finishRecorder(s, op, timeout) {
|
|
180
|
+
if (!s.recording || s.recorderOk !== undefined)
|
|
181
|
+
return;
|
|
182
|
+
const ended = await this.native.stop(s.recording, timeout);
|
|
183
|
+
s.recorderOk = ended.code === 0 && !ended.timedOut;
|
|
184
|
+
if (ended.output.includes('[Warning]'))
|
|
185
|
+
s.result.warnings = ['INSTRUMENTS_RUN_WARNING: one or more Instruments warnings; coverage may be incomplete.'];
|
|
186
|
+
if (!s.recorderOk && !s.discard)
|
|
187
|
+
throw new Error(classifyTraceFailure(ended));
|
|
188
|
+
if (op === 'duration' && !ended.output.includes('Reached specified time limit') && this.time.mono() < s.deadline && !s.discard)
|
|
189
|
+
throw new Error('RECORDER_EXITED');
|
|
190
|
+
}
|
|
191
|
+
async finishArtifact(s) {
|
|
192
|
+
if (!s.discard && !s.result.artifact) {
|
|
193
|
+
if (!s.recording || !s.app || !s.recorderOk)
|
|
194
|
+
throw new Error('RECORDER_EXITED');
|
|
195
|
+
s.result.artifact = await this.native.validate(s.recording, s.result.device.id, s.app);
|
|
196
|
+
}
|
|
197
|
+
if (s.discard && s.directory)
|
|
198
|
+
await fs.rm(s.directory, { recursive: true, force: true });
|
|
199
|
+
else if (s.directory)
|
|
200
|
+
await Promise.all(['notification', 'notification.c'].map(p => fs.rm(path.join(s.directory, p), { force: true })));
|
|
201
|
+
}
|
|
202
|
+
async link(id, device, correlation) {
|
|
203
|
+
const s = this.get(id);
|
|
204
|
+
if (device.platform !== 'ios' || device.id !== s.result.device.id)
|
|
205
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
206
|
+
const status = await this.control(id, 'status');
|
|
207
|
+
if (status.state !== 'recording')
|
|
208
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
209
|
+
return this.serial(s, async () => {
|
|
210
|
+
if (s.result.state !== 'recording' || !s.recording?.recorder.alive() || this.time.mono() >= s.deadline)
|
|
211
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
212
|
+
if (!s.result.correlation_ids.includes(correlation)) {
|
|
213
|
+
if (s.result.correlation_ids.length >= 1000)
|
|
214
|
+
throw new TraceError('TRACE_BUSY');
|
|
215
|
+
s.result.correlation_ids.push(correlation);
|
|
216
|
+
}
|
|
217
|
+
s.linked.add(correlation);
|
|
218
|
+
let finished;
|
|
219
|
+
return () => finished ??= (async () => {
|
|
220
|
+
let active = false;
|
|
221
|
+
try {
|
|
222
|
+
active = (await this.control(id, 'status')).state === 'recording';
|
|
223
|
+
}
|
|
224
|
+
catch { /* unknown is partial */ }
|
|
225
|
+
s.linked.delete(correlation);
|
|
226
|
+
return { trace_id: id, coverage: !active || s.partial.has(correlation) ? 'partial' : 'covered', clock_alignment: 'unknown' };
|
|
227
|
+
})();
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
async shutdown() {
|
|
231
|
+
this.closing = true;
|
|
232
|
+
this.native.shutdown();
|
|
233
|
+
const active = [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete');
|
|
234
|
+
// Signal immediately even when an in-flight start/validation owns the serial queue.
|
|
235
|
+
for (const s of active)
|
|
236
|
+
s.recording?.recorder.signal('SIGINT');
|
|
237
|
+
let timer;
|
|
238
|
+
await Promise.race([Promise.allSettled(active.map(s => this.control(s.result.trace_id, 'cancel', 8000))),
|
|
239
|
+
new Promise(resolve => { timer = setTimeout(resolve, 9000); })]);
|
|
240
|
+
if (timer)
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
return active.filter(s => s.result.cleanup.status !== 'complete').map(s => this.snapshot(s));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -43,7 +43,7 @@ export async function getAppReadiness(args, wait = false) {
|
|
|
43
43
|
let count = 0;
|
|
44
44
|
while (wait || count < 2) {
|
|
45
45
|
remainingBudget();
|
|
46
|
-
debugPause = device.
|
|
46
|
+
debugPause = pausedReadiness(device.id, device.platform);
|
|
47
47
|
if (debugPause) {
|
|
48
48
|
failureCode = wait ? 'DEBUGGER_PAUSED' : undefined;
|
|
49
49
|
return;
|
|
@@ -21,14 +21,14 @@ export class ReadinessTracker {
|
|
|
21
21
|
this.summary = {
|
|
22
22
|
state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
|
|
23
23
|
sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
|
|
24
|
-
evidence: [], limitations: [...baselineLimitations, this.platform
|
|
24
|
+
evidence: [], limitations: [...baselineLimitations, ['android', 'ios'].includes(this.platform) ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities, debugger_paused: ['android', 'ios'].includes(this.platform) }
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
unavailable(code = 'OBSERVATION_FAILED') {
|
|
28
28
|
this.signature = null;
|
|
29
29
|
this.summary = {
|
|
30
30
|
...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
|
|
31
|
-
sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, this.platform
|
|
31
|
+
sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, ['android', 'ios'].includes(this.platform) ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', code],
|
|
32
32
|
evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: null, snapshot_revision: null }].slice(-8)
|
|
33
33
|
};
|
|
34
34
|
return this.summary;
|
|
@@ -38,7 +38,7 @@ export class ReadinessTracker {
|
|
|
38
38
|
return this.summary;
|
|
39
39
|
}
|
|
40
40
|
observe(sample, acquisitionStartedAtMs, now = performance.now()) {
|
|
41
|
-
const paused = this.
|
|
41
|
+
const paused = pausedReadiness(this.deviceId, this.platform);
|
|
42
42
|
if (paused) {
|
|
43
43
|
this.summary = paused;
|
|
44
44
|
return this.summary;
|
|
@@ -66,7 +66,7 @@ export class ReadinessTracker {
|
|
|
66
66
|
captured_at_ms: tree.captured_at_ms, snapshot_session_id: tree.snapshot_session_id, snapshot_revision: tree.snapshot_revision,
|
|
67
67
|
sample_count: this.summary.sample_count + 1, stable_for_ms: stableFor,
|
|
68
68
|
evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: tree.captured_at_ms, snapshot_revision: tree.snapshot_revision }].slice(-8),
|
|
69
|
-
limitations: [...baselineLimitations, this.platform
|
|
69
|
+
limitations: [...baselineLimitations, ['android', 'ios'].includes(this.platform) ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities, debugger_paused: ['android', 'ios'].includes(this.platform) }
|
|
70
70
|
};
|
|
71
71
|
return this.summary;
|
|
72
72
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { TraceError } from './system-trace-error.js';
|
|
2
|
+
import { IosTraceSessions } from './ios-trace/sessions.js';
|
|
3
|
+
import { IosTraceNative } from './ios-trace/native.js';
|
|
4
|
+
export class SystemTraceRouter {
|
|
5
|
+
android;
|
|
6
|
+
ios;
|
|
7
|
+
native;
|
|
8
|
+
routes = new Map();
|
|
9
|
+
pending = new Set();
|
|
10
|
+
closing = false;
|
|
11
|
+
constructor(android, ios = new IosTraceSessions(), native = new IosTraceNative()) {
|
|
12
|
+
this.android = android;
|
|
13
|
+
this.ios = ios;
|
|
14
|
+
this.native = native;
|
|
15
|
+
}
|
|
16
|
+
capabilities(device) { return this.android.capabilities(device); }
|
|
17
|
+
iosCapabilities(device, appId, template) { return this.native.capabilities(device, appId, template); }
|
|
18
|
+
backend(platform) { return platform === 'ios' ? this.ios : this.android; }
|
|
19
|
+
track(result) {
|
|
20
|
+
this.routes.set(result.trace_id, { platform: result.device.platform, device: result.device.id,
|
|
21
|
+
active: result.cleanup.status !== 'complete', touched: performance.now() });
|
|
22
|
+
const terminal = [...this.routes.entries()].filter(([, r]) => !r.active);
|
|
23
|
+
for (const [index, [id, r]] of terminal.entries())
|
|
24
|
+
if (performance.now() - r.touched > 600000 || index < terminal.length - 100)
|
|
25
|
+
this.routes.delete(id);
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
async start(device, duration, ios) {
|
|
29
|
+
if (this.closing)
|
|
30
|
+
throw new TraceError('TRACE_BUSY');
|
|
31
|
+
// Refresh automatically finalised reservations without trusting cached state.
|
|
32
|
+
await Promise.all([...this.routes.entries()].filter(([, r]) => r.active).map(async ([id, r]) => {
|
|
33
|
+
try {
|
|
34
|
+
this.track(await this.backend(r.platform).control(id, 'status'));
|
|
35
|
+
}
|
|
36
|
+
catch { /* uncertain cleanup continues to reserve capacity */ }
|
|
37
|
+
}));
|
|
38
|
+
const key = (ios ? 'ios:' : 'android:') + device;
|
|
39
|
+
const active = [...this.routes.values()].filter(r => r.active);
|
|
40
|
+
if (this.closing || this.pending.has(key) || active.some(r => r.platform + ':' + r.device === key) || active.length + this.pending.size >= 4)
|
|
41
|
+
throw new TraceError('TRACE_BUSY');
|
|
42
|
+
this.pending.add(key);
|
|
43
|
+
try {
|
|
44
|
+
if (!ios)
|
|
45
|
+
return this.track(await this.android.start(device, duration));
|
|
46
|
+
const cap = await this.native.capabilities(device, ios.appId, ios.template);
|
|
47
|
+
if (!cap.supported || !cap.device)
|
|
48
|
+
throw new TraceError('TRACE_UNSUPPORTED');
|
|
49
|
+
if (this.closing)
|
|
50
|
+
throw new TraceError('TRACE_BUSY');
|
|
51
|
+
return this.track(await this.ios.start(cap.device, ios.appId, duration));
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
this.pending.delete(key);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async control(id, operation) {
|
|
58
|
+
const route = this.routes.get(id);
|
|
59
|
+
if (!route)
|
|
60
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
61
|
+
return this.track(await this.backend(route.platform).control(id, operation));
|
|
62
|
+
}
|
|
63
|
+
async link(id, device, correlation) {
|
|
64
|
+
const route = this.routes.get(id);
|
|
65
|
+
if (!route || route.platform !== device.platform || route.device !== device.id)
|
|
66
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
67
|
+
return this.backend(route.platform).link(id, device, correlation);
|
|
68
|
+
}
|
|
69
|
+
async shutdown() {
|
|
70
|
+
this.closing = true;
|
|
71
|
+
this.native.shutdown();
|
|
72
|
+
const results = await Promise.allSettled([this.android.shutdown(), this.ios.shutdown()]);
|
|
73
|
+
if (results.some(result => result.status === 'rejected'))
|
|
74
|
+
throw new TraceError('TRACE_STOP_FAILED');
|
|
75
|
+
const remaining = [];
|
|
76
|
+
for (const result of results)
|
|
77
|
+
if (result.status === 'fulfilled')
|
|
78
|
+
remaining.push(...result.value);
|
|
79
|
+
return remaining;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SystemTraceRouter } from './system-trace-router.js';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { promises as fs } from 'node:fs';
|
|
3
4
|
import os from 'node:os';
|
|
@@ -5,13 +6,8 @@ import path from 'node:path';
|
|
|
5
6
|
import { AndroidTraceNative, TRACE_MAX_BYTES } from './system-trace-android.js';
|
|
6
7
|
import { evidenceSessionId } from './evidence.js';
|
|
7
8
|
import { runWithBudget, remainingBudget } from '../utils/operation-budget.js';
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
constructor(code) {
|
|
11
|
-
super(code);
|
|
12
|
-
this.code = code;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
9
|
+
export { TraceError } from './system-trace-error.js';
|
|
10
|
+
import { TraceError } from './system-trace-error.js';
|
|
15
11
|
const clock = {
|
|
16
12
|
wall: Date.now, mono: () => performance.now(), sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
17
13
|
schedule: (fn, ms) => { const t = setTimeout(fn, ms); t.unref(); return t; }, clear: clearTimeout
|
|
@@ -291,4 +287,4 @@ export class SystemTraceSessions {
|
|
|
291
287
|
});
|
|
292
288
|
}
|
|
293
289
|
}
|
|
294
|
-
export const systemTraces = new SystemTraceSessions();
|
|
290
|
+
export const systemTraces = new SystemTraceRouter(new SystemTraceSessions());
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { iosCapabilities } from '../../debugger/ios-worker.js';
|
|
1
2
|
import { debuggerResponse } from './debugger-response.js';
|
|
2
3
|
import { classify } from '../../debugger/diagnostics.js';
|
|
3
4
|
import { debuggerContracts } from '../schemas/debugger-schema.js';
|
|
4
|
-
import { debuggerSessions, debuggerCapabilities } from '../../debugger/sessions.js';
|
|
5
|
+
import { debuggerSessions, iosDebuggerSessions, debuggerCapabilities } from '../../debugger/sessions.js';
|
|
5
6
|
import { DebuggerError, withDebuggerBudget } from '../../debugger/worker.js';
|
|
6
7
|
import { wrapResponse } from '../common.js';
|
|
7
8
|
function failure(code, summary = {}, diagnostic, command = '', mode = 'debug') {
|
|
@@ -20,15 +21,17 @@ export async function handleDebugger(name, args, signal) {
|
|
|
20
21
|
const { responseMode, ...input } = parsed.data;
|
|
21
22
|
const a = input;
|
|
22
23
|
const command = debuggerContracts[name].command;
|
|
24
|
+
const sessions = selectSessions(a);
|
|
23
25
|
try {
|
|
24
26
|
if (signal?.aborted)
|
|
25
27
|
throw new DebuggerError('DEBUGGER_CANCELLED');
|
|
26
|
-
const result = await withDebuggerBudget(a.timeoutMs, signal, async () => command === 'capabilities' ? await
|
|
27
|
-
command === 'attach' ? await
|
|
28
|
-
await
|
|
28
|
+
const result = await withDebuggerBudget(a.timeoutMs, signal, async () => command === 'capabilities' ? await sessions.capabilities(a.deviceId, a.appId, a.processId) :
|
|
29
|
+
command === 'attach' ? await sessions.attach(a.deviceId, a.appId, a.processId, a.timeoutMs) :
|
|
30
|
+
await sessions.command(a.debugSessionId, command, { ...a, signal: command === 'events' ? signal : undefined }, a.timeoutMs));
|
|
29
31
|
if (signal?.aborted && command === 'capabilities')
|
|
30
32
|
throw new DebuggerError('DEBUGGER_CANCELLED');
|
|
31
|
-
|
|
33
|
+
const payload = sessions === iosDebuggerSessions ? iosDiagnostics(result) : result;
|
|
34
|
+
return { ...wrapResponse(debuggerResponse(command, responseMode, payload)), ...(!result.success ? { isError: true } : {}) };
|
|
32
35
|
}
|
|
33
36
|
catch (e) {
|
|
34
37
|
const error = classify(e, 'unknown', 'DEBUGGER_WORKER_FAILED');
|
|
@@ -36,13 +39,29 @@ export async function handleDebugger(name, args, signal) {
|
|
|
36
39
|
let summary = {};
|
|
37
40
|
if (a.debugSessionId) {
|
|
38
41
|
try {
|
|
39
|
-
summary =
|
|
42
|
+
summary = sessions.summary(a.debugSessionId);
|
|
40
43
|
}
|
|
41
44
|
catch { /* Unknown session */ }
|
|
42
45
|
}
|
|
43
|
-
|
|
46
|
+
const response = failure(code, {
|
|
47
|
+
...(sessions === iosDebuggerSessions ? { capabilities: iosCapabilities } : {}),
|
|
44
48
|
debug_session_id: a.debugSessionId ?? null,
|
|
45
|
-
device: a.deviceId ? { platform: 'android', id: a.deviceId } : null, ...summary
|
|
49
|
+
device: a.deviceId ? { platform: a.platform ?? 'android', id: a.deviceId } : null, ...summary
|
|
46
50
|
}, code === 'DEBUGGER_CLEANUP_FAILED' ? summary.error?.diagnostic : error.diagnostic, command, responseMode);
|
|
51
|
+
if (sessions === iosDebuggerSessions)
|
|
52
|
+
response.content[0].text = JSON.stringify(iosDiagnostics(JSON.parse(response.content[0].text)));
|
|
53
|
+
return response;
|
|
47
54
|
}
|
|
48
55
|
}
|
|
56
|
+
function selectSessions(a) {
|
|
57
|
+
return a.platform === 'ios' || (a.debugSessionId && iosDebuggerSessions.owns(a.debugSessionId)) ? iosDebuggerSessions : debuggerSessions;
|
|
58
|
+
}
|
|
59
|
+
function iosRecovery(d) {
|
|
60
|
+
return { ...d, recovery: d.recovery.replace('JDK 17+ availability, including java, javac and jdk.jdi.', 'Xcode and its Swift-enabled LLDB/Python availability.').replace('JDK compiler and worker source/cache', 'LLDB worker source').replace('Java worker', 'LLDB worker').replace('ADB server', 'simulator') };
|
|
61
|
+
}
|
|
62
|
+
function iosDiagnostics(value) {
|
|
63
|
+
const result = { ...value };
|
|
64
|
+
const error = value.error;
|
|
65
|
+
return { ...result, ...(error?.diagnostic ? { error: { ...error, diagnostic: iosRecovery(error.diagnostic) } } : {}),
|
|
66
|
+
...(Array.isArray(value.diagnostics) ? { diagnostics: value.diagnostics.map(iosRecovery) } : {}) };
|
|
67
|
+
}
|
|
@@ -44,10 +44,10 @@ export async function handleSystemTrace(name, args) {
|
|
|
44
44
|
throw new TraceError('INVALID_ARGUMENT');
|
|
45
45
|
if (name === 'get_system_trace_capabilities' || name === 'start_system_trace') {
|
|
46
46
|
const target = parsed.data;
|
|
47
|
-
if (target.platform
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return
|
|
47
|
+
if (target.platform === 'android' && (target.appId !== undefined || target.template !== undefined))
|
|
48
|
+
throw new TraceError('INVALID_ARGUMENT');
|
|
49
|
+
if (target.platform === 'ios') {
|
|
50
|
+
return handleIosTrace(name, target);
|
|
51
51
|
}
|
|
52
52
|
let device;
|
|
53
53
|
try {
|
|
@@ -67,3 +67,11 @@ export async function handleSystemTrace(name, args) {
|
|
|
67
67
|
const result = await systemTraces.control(parsed.data.traceId, operation);
|
|
68
68
|
return { ...wrapResponse(result), ...(['failed', 'cancelled'].includes(result.state) ? { isError: true } : {}) };
|
|
69
69
|
}
|
|
70
|
+
async function handleIosTrace(name, target) {
|
|
71
|
+
if (!target.deviceId || (name === 'start_system_trace' && !target.appId))
|
|
72
|
+
throw new TraceError('INVALID_ARGUMENT');
|
|
73
|
+
if (name === 'get_system_trace_capabilities')
|
|
74
|
+
return wrapResponse(await systemTraces.iosCapabilities(target.deviceId, target.appId, target.template));
|
|
75
|
+
const result = await systemTraces.start(target.deviceId, target.durationMs, { appId: target.appId, template: target.template });
|
|
76
|
+
return { ...wrapResponse(result), ...(result.state === 'failed' ? { isError: true } : {}) };
|
|
77
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { ToolsInteract } from '../../interact/index.js';
|
|
1
2
|
import { handleDebugger } from './debugger-handlers.js';
|
|
2
3
|
import { debuggerContracts } from '../schemas/debugger-schema.js';
|
|
3
|
-
import { debuggerSessions } from '../../debugger/sessions.js';
|
|
4
|
+
import { debuggerSessions, iosDebuggerSessions } from '../../debugger/sessions.js';
|
|
4
5
|
import { withDebuggerGuard, guardDispatched } from '../../debugger/guard.js';
|
|
5
6
|
import { TraceError } from '../../observe/system-trace.js';
|
|
6
7
|
import { systemTraceTools } from '../schemas/system-trace-schema.js';
|
|
@@ -71,9 +72,17 @@ export const toolHandlers = {
|
|
|
71
72
|
for (const name of ['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'read_log_stream', 'run_journey', 'adjust_control']) {
|
|
72
73
|
toolHandlers[name] = correlatedHandler(name, toolHandlers[name], name === 'adjust_control', name === 'run_journey');
|
|
73
74
|
}
|
|
75
|
+
function resolveGuardDevice(name, args) {
|
|
76
|
+
const platform = args.platform;
|
|
77
|
+
const deviceId = args.deviceId;
|
|
78
|
+
const stored = name === 'tap_element' && typeof args.elementId === 'string'
|
|
79
|
+
? ToolsInteract.getResolvedElementTarget(args.elementId, platform, deviceId) : null;
|
|
80
|
+
return resolveTargetDevice(stored ?? { platform: platform === 'ios' ? 'ios' : 'android', deviceId });
|
|
81
|
+
}
|
|
74
82
|
async function dispatchGuarded(device, args, handler, addTiming) {
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
const sessions = device.platform === 'ios' ? iosDebuggerSessions : debuggerSessions;
|
|
84
|
+
return withResolvedDevice(device, () => withDebuggerGuard(device.platform, device.id, async () => {
|
|
85
|
+
const before = sessions.paused(device.id);
|
|
77
86
|
if (before)
|
|
78
87
|
return wrapResponse({ success: false, failure_code: 'DEBUGGER_PAUSED', delivery_status: 'not_delivered', dispatch_started: false, debugger: before });
|
|
79
88
|
let response;
|
|
@@ -81,11 +90,11 @@ async function dispatchGuarded(device, args, handler, addTiming) {
|
|
|
81
90
|
response = await handler(args);
|
|
82
91
|
}
|
|
83
92
|
catch (error) {
|
|
84
|
-
if (!
|
|
93
|
+
if (!sessions.paused(device.id))
|
|
85
94
|
throw error;
|
|
86
95
|
response = wrapResponse({ success: false });
|
|
87
96
|
}
|
|
88
|
-
const pause =
|
|
97
|
+
const pause = sessions.paused(device.id);
|
|
89
98
|
if (pause) {
|
|
90
99
|
const item = response.content.find(item => item.type === 'text');
|
|
91
100
|
if (item?.text) {
|
|
@@ -100,14 +109,15 @@ async function dispatchGuarded(device, args, handler, addTiming) {
|
|
|
100
109
|
}));
|
|
101
110
|
}
|
|
102
111
|
function attachPausedObservation(name, args, response) {
|
|
103
|
-
if (['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'get_current_screen', 'read_log_stream'].includes(name)
|
|
112
|
+
if (['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'get_current_screen', 'read_log_stream'].includes(name)) {
|
|
104
113
|
const item = response.content.find(item => item.type === 'text');
|
|
105
114
|
if (item?.text) {
|
|
106
115
|
try {
|
|
107
116
|
const payload = JSON.parse(item.text);
|
|
108
117
|
const device = payload.device ?? payload.evidence?.device;
|
|
109
118
|
const id = device?.id ?? args.deviceId;
|
|
110
|
-
const
|
|
119
|
+
const sessions = (device?.platform ?? args.platform) === 'ios' ? iosDebuggerSessions : debuggerSessions;
|
|
120
|
+
const pause = id ? sessions.paused(String(id)) : null;
|
|
111
121
|
if (pause)
|
|
112
122
|
item.text = JSON.stringify({ ...payload, debugger: pause });
|
|
113
123
|
}
|
|
@@ -142,8 +152,8 @@ export async function handleToolCall(name, args = {}, signal) {
|
|
|
142
152
|
if (!systemTraceToolNames.has(name))
|
|
143
153
|
validateExpectedState(name, args);
|
|
144
154
|
const guarded = ['start_app', 'terminate_app', 'restart_app', 'reset_app_data', 'install_app', 'build_and_install', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'adjust_control', 'run_journey', 'wait_for_ui', 'wait_for_ui_change', 'wait_for_screen_change', 'expect_screen', 'expect_element_visible', 'expect_state'].includes(name);
|
|
145
|
-
if (guarded &&
|
|
146
|
-
const device = await
|
|
155
|
+
if (guarded && (debuggerSessions.hasActiveSessions() || iosDebuggerSessions.hasActiveSessions())) {
|
|
156
|
+
const device = await resolveGuardDevice(name, args);
|
|
147
157
|
return dispatchGuarded(device, args, handler, addTiming);
|
|
148
158
|
}
|
|
149
159
|
const response = await handler(args);
|
|
@@ -2,21 +2,22 @@ import { z } from 'zod';
|
|
|
2
2
|
const text = z.string().min(1).max(512);
|
|
3
3
|
const integer = z.number().int().safe().positive();
|
|
4
4
|
const timeout = z.number().int().min(100).max(30000);
|
|
5
|
-
const
|
|
5
|
+
const iosUdid = /^[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}$/;
|
|
6
|
+
const target = { platform: z.enum(['android', 'ios']), deviceId: text, appId: text.regex(/^[A-Za-z][\w-]*(?:\.[A-Za-z][\w-]*)+$/), processId: integer.optional() };
|
|
6
7
|
const session = { debugSessionId: text };
|
|
7
8
|
const stop = { ...session, stopId: text };
|
|
8
9
|
const specs = {
|
|
9
|
-
get_debugger_capabilities: { fields: target, command: 'capabilities', description: 'Probe optional
|
|
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.' },
|
|
10
|
+
get_debugger_capabilities: { fields: target, command: 'capabilities', description: 'Probe optional platform debugger prerequisites and matching running debuggable processes; does not attach.' },
|
|
11
|
+
attach_debugger: { fields: target, command: 'attach', description: 'Attach an owned Android JDI or iOS simulator LLDB 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
12
|
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
|
|
13
|
+
set_debugger_breakpoint: { fields: { ...session, className: text.regex(/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/).optional(), sourceFile: text.regex(/^\/[^\0\r\n]+$/).optional(), line: integer }, command: 'set', description: 'Set an exact source breakpoint: Android className or iOS absolute sourceFile (exactly one), plus line; unloaded classes/images remain pending. No silent line relocation.' },
|
|
13
14
|
remove_debugger_breakpoint: { fields: { ...session, breakpointId: text }, command: 'remove', description: 'Remove an owned breakpoint; repeated removal succeeds.' },
|
|
14
15
|
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
16
|
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
|
+
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; iOS aggregates return type summaries. No evaluation or field traversal.' },
|
|
17
18
|
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
19
|
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
|
|
20
|
+
detach_debugger: { fields: session, command: 'detach', description: 'Dispose the owned debugger connection, release owned suspension and remove its owned transport. Idempotent; retries incomplete cleanup.' }
|
|
20
21
|
};
|
|
21
22
|
// Derive JSON schemas and runtime validation from the same small Zod field vocabulary.
|
|
22
23
|
function jsonField(schema) {
|
|
@@ -35,12 +36,14 @@ function jsonField(schema) {
|
|
|
35
36
|
throw new Error('Unsupported debugger field schema');
|
|
36
37
|
}
|
|
37
38
|
export const debuggerContracts = Object.fromEntries(Object.entries(specs).map(([name, spec]) => [name, {
|
|
38
|
-
...spec, schema: z.object({ ...spec.fields, responseMode: z.enum(['compact', 'debug']).default('debug'), timeoutMs: timeout.default(name === 'wait_for_debugger_event' ? 1000 : 10000) }).strict()
|
|
39
|
+
...spec, schema: z.object({ ...spec.fields, responseMode: z.enum(['compact', 'debug']).default('debug'), timeoutMs: timeout.default(name === 'wait_for_debugger_event' ? 1000 : 10000) }).strict().refine(a => name !== 'set_debugger_breakpoint' || (('className' in a && a.className !== undefined) !== ('sourceFile' in a && a.sourceFile !== undefined)), { message: 'Exactly one breakpoint source selector is required' }).refine(a => !('platform' in a) || a.platform !== 'ios' || ('deviceId' in a && typeof a.deviceId === 'string' && iosUdid.test(a.deviceId)), { message: 'iOS requires an explicit simulator UDID' })
|
|
39
40
|
}]));
|
|
40
41
|
export const debuggerTools = Object.entries(debuggerContracts).map(([name, spec]) => ({
|
|
41
42
|
name, description: spec.description,
|
|
42
43
|
inputSchema: {
|
|
43
44
|
type: 'object', additionalProperties: false,
|
|
45
|
+
...(['get_debugger_capabilities', 'attach_debugger'].includes(name) ? { allOf: [{ if: { properties: { platform: { const: 'ios' } } }, then: { properties: { deviceId: { pattern: iosUdid.source } } } }] } : {}),
|
|
46
|
+
...(name === 'set_debugger_breakpoint' ? { oneOf: [{ required: ['className'], not: { required: ['sourceFile'] } }, { required: ['sourceFile'], not: { required: ['className'] } }] } : {}),
|
|
44
47
|
required: Object.entries(spec.fields).filter(([, field]) => !field.isOptional()).map(([key]) => key),
|
|
45
48
|
properties: { responseMode: { type: 'string', enum: ['compact', 'debug'], default: 'debug' }, ...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 } }
|
|
46
49
|
}
|