mobile-debug-mcp 0.32.2 → 0.33.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 (127) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/{skills → .agents/skills}/mcp-builder/SKILL.md +1 -2
  3. package/{skills → .agents/skills}/spec-review/SKILL.md +1 -1
  4. package/{skills → .agents/skills}/test-authoring/SKILL.md +1 -1
  5. package/.claude-plugin/marketplace.json +14 -0
  6. package/.cursor-plugin/marketplace.json +16 -0
  7. package/.github/copilot-instructions.md +7 -0
  8. package/AGENTS.md +21 -10
  9. package/README.md +67 -5
  10. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  11. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  12. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  13. package/agent-plugin/.mcp.json +14 -0
  14. package/agent-plugin/LICENSE +201 -0
  15. package/agent-plugin/README.md +29 -0
  16. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  17. package/agent-plugin/mcp.json +11 -0
  18. package/agent-plugin/plugin.json +29 -0
  19. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +124 -0
  20. package/dist/interact/android.js +10 -4
  21. package/dist/interact/conditional-action.js +68 -0
  22. package/dist/interact/index.js +54 -14
  23. package/dist/interact/ios.js +14 -8
  24. package/dist/manage/android.js +4 -3
  25. package/dist/manage/ios.js +3 -2
  26. package/dist/observe/android.js +27 -22
  27. package/dist/observe/evidence.js +273 -0
  28. package/dist/observe/index.js +33 -6
  29. package/dist/observe/ios.js +25 -6
  30. package/dist/observe/readiness-service.js +115 -0
  31. package/dist/observe/readiness.js +81 -0
  32. package/dist/observe/recording.js +165 -0
  33. package/dist/observe/snapshot-metadata.js +10 -2
  34. package/dist/server/common.js +9 -0
  35. package/dist/server/condition-schema.js +32 -0
  36. package/dist/server/journey-schema.js +3 -2
  37. package/dist/server/readiness-schema.js +12 -0
  38. package/dist/server/tool-definitions.js +46 -0
  39. package/dist/server/tool-handlers.js +99 -17
  40. package/dist/server-core.js +1 -1
  41. package/dist/utils/ios/utils.js +4 -4
  42. package/dist/utils/operation-budget.js +24 -2
  43. package/dist/utils/resolve-device.js +7 -0
  44. package/docs/CHANGELOG.md +16 -2
  45. package/docs/benchmarks/ui-tree-performance/README.md +4 -3
  46. package/docs/specs/020-conditional-actions/intent.md +62 -0
  47. package/docs/specs/020-conditional-actions/plan.md +214 -0
  48. package/docs/specs/020-conditional-actions/spec.md +301 -0
  49. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  50. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  51. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  52. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  53. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  54. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  55. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  56. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  57. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  58. package/docs/specs/process.md +213 -0
  59. package/docs/tools/TOOLS.md +3 -0
  60. package/docs/tools/evidence.md +79 -0
  61. package/docs/tools/interact.md +125 -2
  62. package/docs/tools/observe.md +64 -2
  63. package/package.json +4 -2
  64. package/scripts/agent-plugin.mjs +98 -0
  65. package/src/interact/android.ts +10 -4
  66. package/src/interact/conditional-action.ts +75 -0
  67. package/src/interact/index.ts +42 -14
  68. package/src/interact/ios.ts +14 -8
  69. package/src/manage/android.ts +4 -3
  70. package/src/manage/ios.ts +3 -2
  71. package/src/observe/android.ts +35 -21
  72. package/src/observe/evidence.ts +255 -0
  73. package/src/observe/index.ts +32 -8
  74. package/src/observe/ios.ts +32 -8
  75. package/src/observe/readiness-service.ts +89 -0
  76. package/src/observe/readiness.ts +78 -0
  77. package/src/observe/snapshot-metadata.ts +12 -1
  78. package/src/server/common.ts +7 -0
  79. package/src/server/condition-schema.ts +32 -0
  80. package/src/server/journey-schema.ts +3 -2
  81. package/src/server/readiness-schema.ts +12 -0
  82. package/src/server/tool-definitions.ts +44 -0
  83. package/src/server/tool-handlers.ts +93 -17
  84. package/src/server-core.ts +1 -1
  85. package/src/types.ts +30 -1
  86. package/src/utils/ios/utils.ts +4 -4
  87. package/src/utils/operation-budget.ts +22 -2
  88. package/src/utils/resolve-device.ts +8 -0
  89. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  90. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  91. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  92. package/test/device/manual/interact/readiness.manual.ts +99 -0
  93. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  94. package/test/fixtures/readiness-app/README.md +12 -0
  95. package/test/fixtures/readiness-app/build.ts +37 -0
  96. package/test/fixtures/readiness-app/main.m +27 -0
  97. package/test/unit/observe/android_tree_acquisition.test.ts +6 -0
  98. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  99. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  100. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  101. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  102. package/test/unit/observe/ios-getlogs.test.ts +3 -3
  103. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  104. package/test/unit/observe/readiness.test.ts +39 -0
  105. package/test/unit/observe/readiness_native.test.ts +68 -0
  106. package/test/unit/server/conditional_actions.test.ts +299 -0
  107. package/test/unit/server/conditional_schema.test.ts +59 -0
  108. package/test/unit/server/correlated-evidence.test.ts +194 -0
  109. package/test/unit/server/readiness.test.ts +170 -0
  110. package/test/unit/server/selector_tap_target.test.ts +79 -0
  111. package/test/unit/server/tap_selector.test.ts +2 -0
  112. package/skills/mobile-debug-tools-usage/SKILL.md +0 -74
  113. package/src/observe/android-tree-bridge.ts +0 -178
  114. package/test/device/automated/ui-tree-performance/bridge-probe.ts +0 -197
  115. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +0 -6
  116. package/test/fixtures/ui-tree-bridge/build.ts +0 -34
  117. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +0 -126
  118. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +0 -95
  119. /package/{skills → .agents/skills}/README.md +0 -0
  120. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  121. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  122. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  123. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  124. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  125. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  126. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  127. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -1,3 +1,4 @@
1
+ import { captureEvidence } from './evidence.js';
1
2
  import { spawn } from "child_process";
2
3
  import { XMLParser } from "fast-xml-parser";
3
4
  import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "../utils/android/utils.js";
@@ -9,13 +10,16 @@ import { parsePngSize } from "../utils/image.js";
9
10
  import { deriveSnapshotMetadata } from "./snapshot-metadata.js";
10
11
  import { remainingBudget } from '../utils/operation-budget.js';
11
12
  import { createObservationMeasurement } from './performance-metrics.js';
12
- import { androidTreeBridgeEnabled, readAndroidTreeFromBridge } from './android-tree-bridge.js';
13
13
  const activeLogStreams = new Map();
14
14
  export class AndroidObserve {
15
+ getLogStreamDevice(sessionId = 'default') { return activeLogStreams.get(sessionId)?.device ?? null; }
15
16
  async getDeviceMetadata(appId, deviceId) {
16
17
  return getAndroidDeviceMetadata(appId, deviceId);
17
18
  }
18
19
  async getUITree(deviceId) {
20
+ return captureEvidence('ui_tree', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetUITree(deviceId));
21
+ }
22
+ async collectGetUITree(deviceId) {
19
23
  const measurement = createObservationMeasurement('android');
20
24
  let metadata;
21
25
  try {
@@ -47,22 +51,7 @@ export class AndroidObserve {
47
51
  await measurement.timed('observation_wait_ms', () => delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))));
48
52
  }
49
53
  remainingBudget();
50
- let bridgeContent = null;
51
- if (androidTreeBridgeEnabled()) {
52
- try {
53
- bridgeContent = await measurement.physicalRead(async () => {
54
- const content = await readAndroidTreeFromBridge(deviceInfo.id);
55
- if (!content)
56
- throw new Error('Android UI tree bridge unavailable');
57
- return content;
58
- });
59
- }
60
- catch (error) {
61
- if (error instanceof Error && (error.message === 'ACTION_TIMEOUT' || error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED')))
62
- throw error;
63
- }
64
- }
65
- xmlContent = bridgeContent ?? await measurement.physicalRead(async () => {
54
+ xmlContent = await measurement.physicalRead(async () => {
66
55
  await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
67
56
  const content = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId);
68
57
  if (!content || !content.trim() || content.includes('ERROR:'))
@@ -72,7 +61,7 @@ export class AndroidObserve {
72
61
  break;
73
62
  }
74
63
  catch (e) {
75
- if (e instanceof Error && (e.message === 'ACTION_TIMEOUT' || e.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED')))
64
+ if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
76
65
  throw e;
77
66
  console.error(`Attempt ${attempts} failed: ${e}`);
78
67
  }
@@ -129,6 +118,9 @@ export class AndroidObserve {
129
118
  }
130
119
  }
131
120
  async getLogs(filters = {}) {
121
+ return captureEvidence('logs', { platform: 'android', id: filters.deviceId ?? '' }, () => this.collectGetLogs(filters));
122
+ }
123
+ async collectGetLogs(filters = {}) {
132
124
  const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters;
133
125
  const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
134
126
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
@@ -229,6 +221,9 @@ export class AndroidObserve {
229
221
  }
230
222
  }
231
223
  async captureScreen(deviceId) {
224
+ return captureEvidence('screenshot', { platform: 'android', id: deviceId ?? '' }, () => this.collectCaptureScreen(deviceId));
225
+ }
226
+ async collectCaptureScreen(deviceId) {
232
227
  const metadata = await getAndroidDeviceMetadata("", deviceId);
233
228
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
234
229
  return new Promise((resolve, reject) => {
@@ -347,6 +342,9 @@ export class AndroidObserve {
347
342
  });
348
343
  }
349
344
  async getCurrentScreen(deviceId) {
345
+ return captureEvidence('activity', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetCurrentScreen(deviceId));
346
+ }
347
+ async collectGetCurrentScreen(deviceId) {
350
348
  const metadata = await getAndroidDeviceMetadata("", deviceId);
351
349
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
352
350
  try {
@@ -401,12 +399,15 @@ export class AndroidObserve {
401
399
  }
402
400
  }
403
401
  async getScreenFingerprint(deviceId) {
402
+ return captureEvidence('fingerprint', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetScreenFingerprint(deviceId));
403
+ }
404
+ async collectGetScreenFingerprint(deviceId) {
404
405
  try {
405
406
  const tree = await this.getUITree(deviceId);
406
407
  if (!tree || tree.error)
407
408
  return { fingerprint: null, error: tree.error };
408
409
  const current = await this.getCurrentScreen(deviceId).catch(() => null);
409
- return computeScreenFingerprint(tree, current, 'android', 50);
410
+ return { ...computeScreenFingerprint(tree, current, 'android', 50), captured_at_ms: tree.captured_at_ms };
410
411
  }
411
412
  catch (e) {
412
413
  return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
@@ -427,7 +428,7 @@ export class AndroidObserve {
427
428
  catch { }
428
429
  activeLogStreams.delete(sessionId);
429
430
  }
430
- const args = ['logcat', `--pid=${pid}`, filter];
431
+ const args = [...(deviceId ? ['-s', deviceId] : []), 'logcat', `--pid=${pid}`, filter];
431
432
  const proc = spawn(getAdbCmd(), args);
432
433
  const tmpDir = process.env.TMPDIR || '/tmp';
433
434
  const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`);
@@ -450,9 +451,10 @@ export class AndroidObserve {
450
451
  });
451
452
  proc.on('close', () => {
452
453
  stream.end();
453
- activeLogStreams.delete(sessionId);
454
+ if (activeLogStreams.get(sessionId)?.proc === proc)
455
+ activeLogStreams.delete(sessionId);
454
456
  });
455
- activeLogStreams.set(sessionId, { proc, file });
457
+ activeLogStreams.set(sessionId, { proc, file, device: { platform: 'android', id: deviceId ?? '' } });
456
458
  return { success: true, stream_started: true };
457
459
  }
458
460
  catch (e) {
@@ -471,6 +473,9 @@ export class AndroidObserve {
471
473
  return { success: true };
472
474
  }
473
475
  async readLogStream(sessionId = 'default', limit = 100, since) {
476
+ return captureEvidence('logs', this.getLogStreamDevice(sessionId), () => this.collectReadLogStream(sessionId, limit, since));
477
+ }
478
+ async collectReadLogStream(sessionId = 'default', limit = 100, since) {
474
479
  const entry = activeLogStreams.get(sessionId);
475
480
  let file;
476
481
  if (entry && entry.file)
@@ -0,0 +1,273 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
+ const sessionId = randomUUID();
4
+ const context = new AsyncLocalStorage();
5
+ const history = new Map();
6
+ const active = new Map();
7
+ const orders = new Map();
8
+ const stamp = () => ({ wall: Date.now(), mono: performance.now() });
9
+ const deviceKey = (d) => `${d.platform}:${d.id}`;
10
+ const sameDevice = (a, b) => !!a && !!b && deviceKey(a) === deviceKey(b);
11
+ function validDevice(d) {
12
+ return d && ['android', 'ios'].includes(d.platform) && typeof d.id === 'string' && d.id && !['default', 'booted'].includes(d.id)
13
+ ? { platform: d.platform, id: d.id } : null;
14
+ }
15
+ function orderFor(device) {
16
+ if (!device)
17
+ return null;
18
+ const key = deviceKey(device);
19
+ let order = orders.get(key);
20
+ if (!order) {
21
+ order = { sequence: 0, active: 0, lastStarted: -Infinity };
22
+ orders.set(key, order);
23
+ if (orders.size > 1000) {
24
+ for (const [oldKey, oldOrder] of orders) {
25
+ if (oldKey !== key && oldOrder.active === 0)
26
+ orders.delete(oldKey);
27
+ if (orders.size <= 1000)
28
+ break;
29
+ }
30
+ }
31
+ }
32
+ return order;
33
+ }
34
+ function prune() {
35
+ const now = performance.now();
36
+ for (const [key, record] of history) {
37
+ if (record.completed !== null && now - record.completed >= 600000)
38
+ history.delete(key);
39
+ }
40
+ while (history.size > 1000)
41
+ history.delete(history.keys().next().value);
42
+ }
43
+ export function validateActionRef(value) {
44
+ if (value === undefined)
45
+ return undefined;
46
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
47
+ Object.keys(value).some(key => !['sessionId', 'actionId'].includes(key)))
48
+ throw new Error('INVALID_ARGUMENT: invalid actionRef');
49
+ const ref = value;
50
+ for (const v of [ref.sessionId, ref.actionId])
51
+ if (typeof v !== 'string' || v.length < 1 || v.length > 128)
52
+ throw new Error('INVALID_ARGUMENT: invalid actionRef');
53
+ return { sessionId: ref.sessionId, actionId: ref.actionId };
54
+ }
55
+ export function currentEvidenceActionId() { return context.getStore()?.action?.id; }
56
+ export function bindEvidenceDevice(device) {
57
+ const current = context.getStore();
58
+ const target = validDevice(device);
59
+ if (!current || !target)
60
+ return;
61
+ current.device ??= target;
62
+ if (current.isAction && current.action)
63
+ current.action.device ??= target;
64
+ }
65
+ export function markEvidenceVerification() {
66
+ const current = context.getStore();
67
+ if (current) {
68
+ current.verification = stamp();
69
+ if (current.isAction && current.action)
70
+ current.action.verification = current.verification;
71
+ }
72
+ }
73
+ export async function evidenceDispatch(device, operation) {
74
+ const current = context.getStore();
75
+ if (!current?.isAction || !current.action)
76
+ return operation();
77
+ bindEvidenceDevice(device);
78
+ const record = current.action;
79
+ const start = stamp();
80
+ record.dispatchStart ??= start;
81
+ record.dispatchEnd = null;
82
+ const order = orderFor(validDevice(device));
83
+ if (order) {
84
+ order.sequence++;
85
+ order.active++;
86
+ order.lastStarted = start.mono;
87
+ }
88
+ try {
89
+ return await operation();
90
+ }
91
+ finally {
92
+ record.dispatchEnd = stamp();
93
+ if (order)
94
+ order.active--;
95
+ }
96
+ }
97
+ export function omitEvidence(kind) {
98
+ const current = context.getStore();
99
+ if (!current)
100
+ return;
101
+ const start = stamp();
102
+ current.observations.push({ value: {
103
+ evidence_id: randomUUID(), kind, status: 'omitted', capture_started_at_ms: null,
104
+ capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
105
+ freshness: kind === 'logs' ? 'not_applicable' : 'unknown', freshness_reasons: [], device: current.device
106
+ }, start, end: null, order: null, startSequence: 0, endSequence: 0, overlapping: false, reused: false });
107
+ }
108
+ export async function captureEvidence(kind, device, operation) {
109
+ const current = context.getStore();
110
+ if (!current)
111
+ return operation();
112
+ const target = validDevice(device);
113
+ if (target)
114
+ bindEvidenceDevice(target);
115
+ const order = orderFor(target);
116
+ const start = stamp();
117
+ const acquisition = { value: {
118
+ evidence_id: randomUUID(), kind, status: 'failed', capture_started_at_ms: start.wall,
119
+ capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
120
+ freshness: 'unknown', freshness_reasons: [], device: target
121
+ }, start, end: null, order, startSequence: order?.sequence ?? 0,
122
+ endSequence: 0, overlapping: !!order?.active, reused: false };
123
+ current.observations.push(acquisition);
124
+ try {
125
+ const result = await operation();
126
+ const value = acquisition.value;
127
+ const resolved = validDevice(result?.device) ?? target;
128
+ value.device = resolved;
129
+ if (resolved)
130
+ bindEvidenceDevice(resolved);
131
+ const failure = result?.error ?? result?.meta?.error;
132
+ value.status = failure || result?.success === false || (kind === 'fingerprint' && !result?.fingerprint) ? 'failed' : 'captured';
133
+ if (failure)
134
+ value.error = failure;
135
+ const source = result?.captured_at_ms;
136
+ if (Number.isFinite(source) && source > 0) {
137
+ value.source_captured_at_ms = source;
138
+ acquisition.reused = source < start.wall;
139
+ }
140
+ if (kind === 'ui_tree') {
141
+ value.snapshot_session_id = result?.snapshot_session_id;
142
+ value.snapshot_revision = result?.snapshot_revision;
143
+ }
144
+ if (kind === 'logs') {
145
+ const entries = result?.logs ?? result?.entries ?? [];
146
+ const times = entries.map((e) => {
147
+ const t = e?.timestamp ?? e?._iso;
148
+ return typeof t === 'number' ? t : typeof t === 'string' ? Date.parse(t) : NaN;
149
+ }).filter((t) => Number.isFinite(t));
150
+ value.clock = 'device_or_unknown';
151
+ value.event_time_comparable = false;
152
+ value.earliest_entry_at_ms = times.length ? times.reduce((a, b) => Math.min(a, b)) : null;
153
+ value.latest_entry_at_ms = times.length ? times.reduce((a, b) => Math.max(a, b)) : null;
154
+ }
155
+ return result;
156
+ }
157
+ catch (error) {
158
+ acquisition.value.error = error instanceof Error ? error.message : String(error);
159
+ throw error;
160
+ }
161
+ finally {
162
+ acquisition.end = stamp();
163
+ acquisition.value.capture_completed_at_ms = acquisition.end.wall;
164
+ acquisition.endSequence = order?.sequence ?? 0;
165
+ acquisition.overlapping ||= !!order?.active || acquisition.startSequence !== acquisition.endSequence;
166
+ if (acquisition.value.status === 'captured' && acquisition.value.source_captured_at_ms === null && kind !== 'logs') {
167
+ acquisition.value.source_captured_at_ms = acquisition.end.wall;
168
+ }
169
+ }
170
+ }
171
+ function association(current) {
172
+ if (current.isAction)
173
+ return { association: 'self', association_reason: null };
174
+ if (!current.ref)
175
+ return { association: 'none', association_reason: null };
176
+ const reason = current.ref.sessionId !== sessionId ? 'SESSION_MISMATCH' : !current.action ? 'ACTION_UNAVAILABLE' :
177
+ !sameDevice(current.device, current.action.device) ? 'DEVICE_MISMATCH' : null;
178
+ return { association: reason ? 'unavailable' : 'explicit', association_reason: reason };
179
+ }
180
+ function assemble(current) {
181
+ const end = stamp();
182
+ const link = association(current);
183
+ const action = link.association === 'unavailable' ? null : current.action;
184
+ const clockChanged = end.wall < current.start.wall;
185
+ const observations = current.observations.map(a => {
186
+ const v = { ...a.value, freshness_reasons: [] };
187
+ if (v.status === 'omitted')
188
+ return v;
189
+ const componentAction = sameDevice(v.device, action?.device ?? null) ? action : null;
190
+ const sourceMono = a.end && v.source_captured_at_ms !== null ? a.end.mono + v.source_captured_at_ms - a.end.wall : a.end?.mono;
191
+ const reason = (freshness, code) => { v.freshness = freshness; v.freshness_reasons.push(code); };
192
+ if (v.kind === 'logs')
193
+ reason('unknown', 'DEVICE_CLOCK_UNVERIFIED');
194
+ else if (clockChanged || (a.end && a.end.wall < a.start.wall))
195
+ reason('unknown', 'CLOCK_DISCONTINUITY');
196
+ else if (v.source_captured_at_ms !== null && a.end && v.source_captured_at_ms > a.end.wall)
197
+ reason('unknown', 'CLOCK_DISCONTINUITY');
198
+ else if (!v.device || !a.order || orders.get(deviceKey(v.device)) !== a.order || v.status !== 'captured' || !a.end)
199
+ reason('unknown', 'CAPTURE_TIME_UNKNOWN');
200
+ else if (componentAction?.dispatchStart && sourceMono !== undefined && sourceMono < componentAction.dispatchStart.mono)
201
+ reason('stale', 'BEFORE_ACTION');
202
+ else if (a.order && (a.order.sequence > a.endSequence || (a.reused && sourceMono !== undefined && sourceMono < a.order.lastStarted)))
203
+ reason('stale', 'SUPERSEDED_BY_ACTION');
204
+ else if (a.overlapping)
205
+ reason('unknown', 'OVERLAPPING_ACTION');
206
+ else if (a.reused)
207
+ reason('unknown', 'REUSED_OBSERVATION');
208
+ else {
209
+ v.freshness = 'fresh';
210
+ }
211
+ if (!clockChanged && a.end && a.end.wall >= a.start.wall && v.source_captured_at_ms !== null && v.source_captured_at_ms <= end.wall) {
212
+ v.age_ms = Math.max(0, end.mono - a.end.mono + a.end.wall - v.source_captured_at_ms);
213
+ }
214
+ return { ...v, session_id: sessionId, correlation_id: componentAction?.correlationId ?? current.correlationId,
215
+ action_id: componentAction?.id ?? null, association: componentAction ? 'internal' : link.association };
216
+ });
217
+ return {
218
+ session_id: sessionId, correlation_id: action?.correlationId ?? current.correlationId,
219
+ action_id: action?.id ?? null, device: current.device, ...link,
220
+ ...(current.ref ? { requested_action_ref: current.ref } : {}),
221
+ operation_started_at_ms: current.start.wall, operation_completed_at_ms: current.end?.wall ?? end.wall,
222
+ dispatch_started_at_ms: action?.dispatchStart?.wall ?? null,
223
+ dispatch_completed_at_ms: action?.dispatchEnd?.wall ?? null,
224
+ verification_completed_at_ms: current.verification?.wall ?? action?.verification?.wall ?? null,
225
+ observations, trace_context: { supported: false, trace_id: null, markers: [] }
226
+ };
227
+ }
228
+ export async function withEvidence(options, operation) {
229
+ prune();
230
+ const parent = context.getStore();
231
+ const id = randomUUID();
232
+ const record = options.action ? { id, correlationId: parent?.correlationId ?? id, device: null,
233
+ dispatchStart: null, dispatchEnd: null, verification: null, completed: null } : options.ref?.sessionId === sessionId ? active.get(options.ref.actionId) ?? history.get(options.ref.actionId) ?? null : null;
234
+ const current = { correlationId: options.action ? record.correlationId : options.journey ? id : randomUUID(),
235
+ action: record, ref: options.ref, device: null, start: stamp(), end: null, verification: null, observations: [], children: [], isAction: !!options.action };
236
+ if (options.action && record)
237
+ active.set(id, record);
238
+ if (parent && options.action)
239
+ parent.children.push(current);
240
+ try {
241
+ return await context.run(current, async () => {
242
+ const result = await operation();
243
+ current.end = stamp();
244
+ return { result, evidence: assemble(current) };
245
+ });
246
+ }
247
+ finally {
248
+ if (options.action && record) {
249
+ active.delete(id);
250
+ record.completed = performance.now();
251
+ history.set(id, record);
252
+ prune();
253
+ }
254
+ }
255
+ }
256
+ export function refreshJourneyEvidence(steps) {
257
+ const current = context.getStore();
258
+ if (!current)
259
+ return;
260
+ for (const step of steps) {
261
+ const previous = step.result?.evidence;
262
+ const child = current.children.find(c => c.action?.id === previous?.action_id);
263
+ if (child && step.result)
264
+ step.result.evidence = assemble(child);
265
+ }
266
+ }
267
+ export function completeJourneyVerification(actionId) {
268
+ const child = context.getStore()?.children.find(c => c.action?.id === actionId);
269
+ if (child?.action) {
270
+ child.verification = stamp();
271
+ child.action.verification = child.verification;
272
+ }
273
+ }
@@ -1,4 +1,7 @@
1
+ import { omitEvidence } from './evidence.js';
2
+ import { withResolvedDevice } from '../utils/resolve-device.js';
1
3
  import { measure } from '../utils/timing.js';
4
+ import { ReadinessTracker } from './readiness.js';
2
5
  import { resolveTargetDevice } from '../utils/resolve-device.js';
3
6
  import { AndroidObserve } from './android.js';
4
7
  import { iOSObserve } from './ios.js';
@@ -136,16 +139,22 @@ export class ToolsObserve {
136
139
  }
137
140
  static async getUITreeHandler({ platform, deviceId, responseMode = 'debug', sinceSnapshotRevision }) {
138
141
  const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId);
142
+ const acquisitionStarted = Date.now();
139
143
  const tree = await observe.getUITree(resolved.id);
144
+ tree.readiness = new ReadinessTracker(resolved.platform, resolved.id).observe(tree, acquisitionStarted);
140
145
  if (responseMode !== 'compact')
141
146
  return tree;
142
147
  const revision = tree?.snapshot_revision ?? null;
148
+ const session = { snapshot_session_id: tree?.snapshot_session_id, readiness: tree.readiness };
149
+ if (tree?.error)
150
+ return { ...session, delta_available: false, snapshot_revision: revision, error: tree.error };
143
151
  const baseRevision = sinceSnapshotRevision;
144
152
  if (!Number.isInteger(baseRevision) || revision === null) {
145
- return { delta_available: false, snapshot_revision: revision };
153
+ return { ...session, delta_available: false, snapshot_revision: revision };
146
154
  }
147
155
  const delta = getStateDelta(`${resolved.platform}:${resolved.id}`, baseRevision, revision);
148
- return delta ? { delta_available: true, state_delta: delta, snapshot_revision: revision } : {
156
+ return delta ? { ...session, delta_available: true, state_delta: delta, snapshot_revision: revision } : {
157
+ ...session,
149
158
  delta_available: false,
150
159
  snapshot_revision: revision,
151
160
  reason: 'BASE_REVISION_UNAVAILABLE'
@@ -192,14 +201,23 @@ export class ToolsObserve {
192
201
  return await observe.startLogStream(packageName, resolved.id, sid);
193
202
  }
194
203
  }
195
- static async readLogStreamHandler({ platform, sessionId, limit, since }) {
204
+ static async readLogStreamHandler({ platform, sessionId, limit, since, deviceId }) {
196
205
  const sid = sessionId || 'default';
197
- const { observe } = await ToolsObserve.resolveObserve(platform);
206
+ const android = new AndroidObserve();
207
+ const ios = new iOSObserve();
208
+ const observe = platform === 'android' ? android : platform === 'ios' ? ios :
209
+ ios.getLogStreamDevice(sid) && !android.getLogStreamDevice(sid) ? ios : android;
210
+ const streamDevice = observe.getLogStreamDevice(sid);
211
+ if (deviceId && streamDevice && streamDevice.id !== deviceId)
212
+ throw new Error('LOG_STREAM_DEVICE_MISMATCH');
198
213
  return await observe.readLogStream(sid, limit ?? 100, since);
199
214
  }
200
215
  static async stopLogStreamHandler({ platform, sessionId }) {
201
216
  const sid = sessionId || 'default';
202
- const { observe } = await ToolsObserve.resolveObserve(platform);
217
+ const android = new AndroidObserve();
218
+ const ios = new iOSObserve();
219
+ const observe = platform === 'android' ? android : platform === 'ios' ? ios :
220
+ ios.getLogStreamDevice(sid) && !android.getLogStreamDevice(sid) ? ios : android;
203
221
  return await observe.stopLogStream(sid);
204
222
  }
205
223
  static async captureScreenshotHandler({ platform, deviceId }) {
@@ -217,6 +235,15 @@ export class ToolsObserve {
217
235
  return await observe.getScreenFingerprint(resolved.id);
218
236
  }
219
237
  static async captureDebugSnapshotHandler({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId } = {}) {
238
+ const { resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId);
239
+ return withResolvedDevice(resolved, () => ToolsObserve.collectDebugSnapshot({ reason, includeLogs, includeScreenshot, logLines,
240
+ platform: resolved.platform, appId, deviceId: resolved.id, sessionId }));
241
+ }
242
+ static async collectDebugSnapshot({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId } = {}) {
243
+ if (!includeScreenshot)
244
+ omitEvidence('screenshot');
245
+ if (!includeLogs)
246
+ omitEvidence('logs');
220
247
  const timestamp = Date.now();
221
248
  const raw = {
222
249
  timestamp,
@@ -236,7 +263,7 @@ export class ToolsObserve {
236
263
  currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
237
264
  fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
238
265
  uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
239
- readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
266
+ readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, deviceId, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
240
267
  };
241
268
  const results = await Promise.allSettled(Object.values(tasks));
242
269
  const keys = Object.keys(tasks);
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { captureEvidence } from './evidence.js';
1
3
  import { spawn } from "child_process";
2
4
  import { remainingBudget } from '../utils/operation-budget.js';
3
5
  import { execCmd } from '../utils/exec.js';
@@ -256,17 +258,21 @@ export function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
256
258
  return currentIndex;
257
259
  }
258
260
  const iosActiveLogStreams = new Map();
259
- export function _setIOSActiveLogStream(sessionId, file) {
260
- iosActiveLogStreams.set(sessionId, { proc: {}, file });
261
+ export function _setIOSActiveLogStream(sessionId, file, device) {
262
+ iosActiveLogStreams.set(sessionId, { proc: {}, file, device });
261
263
  }
262
264
  export function _clearIOSActiveLogStream(sessionId) {
263
265
  iosActiveLogStreams.delete(sessionId);
264
266
  }
265
267
  export class iOSObserve {
268
+ getLogStreamDevice(sessionId = 'default') { return iosActiveLogStreams.get(sessionId)?.device ?? null; }
266
269
  async getDeviceMetadata(deviceId = "booted") {
267
270
  return getIOSDeviceMetadata(deviceId);
268
271
  }
269
272
  async getLogs(filters = {}) {
273
+ return captureEvidence('logs', { platform: 'ios', id: filters.deviceId ?? '' }, () => this.collectGetLogs(filters));
274
+ }
275
+ async collectGetLogs(filters = {}) {
270
276
  const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters;
271
277
  const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog'];
272
278
  // Default to last N seconds if no since_seconds provided; limit lines handled after parsing
@@ -418,8 +424,11 @@ export class iOSObserve {
418
424
  }
419
425
  }
420
426
  async captureScreenshot(deviceId = "booted") {
427
+ return captureEvidence('screenshot', { platform: 'ios', id: deviceId ?? '' }, () => this.collectCaptureScreenshot(deviceId));
428
+ }
429
+ async collectCaptureScreenshot(deviceId = "booted") {
421
430
  const device = await getIOSDeviceMetadata(deviceId);
422
- const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`;
431
+ const tmpFile = `/tmp/mcp-ios-screenshot-${randomUUID()}.png`;
423
432
  try {
424
433
  await iosExecCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId);
425
434
  const buffer = await fs.readFile(tmpFile);
@@ -485,6 +494,9 @@ export class iOSObserve {
485
494
  }
486
495
  }
487
496
  async getUITree(deviceId = "booted") {
497
+ return captureEvidence('ui_tree', { platform: 'ios', id: deviceId ?? '' }, () => this.collectGetUITree(deviceId));
498
+ }
499
+ async collectGetUITree(deviceId = "booted") {
488
500
  const measurement = createObservationMeasurement('ios');
489
501
  let device;
490
502
  try {
@@ -608,11 +620,14 @@ export class iOSObserve {
608
620
  }
609
621
  }
610
622
  async getScreenFingerprint(deviceId = 'booted') {
623
+ return captureEvidence('fingerprint', { platform: 'ios', id: deviceId ?? '' }, () => this.collectGetScreenFingerprint(deviceId));
624
+ }
625
+ async collectGetScreenFingerprint(deviceId = 'booted') {
611
626
  try {
612
627
  const tree = await this.getUITree(deviceId);
613
628
  if (!tree || tree.error)
614
629
  return { fingerprint: null, error: tree && tree.error };
615
- return computeScreenFingerprint(tree, null, 'ios', 50);
630
+ return { ...computeScreenFingerprint(tree, null, 'ios', 50), captured_at_ms: tree.captured_at_ms };
616
631
  }
617
632
  catch (e) {
618
633
  return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
@@ -652,9 +667,10 @@ export class iOSObserve {
652
667
  });
653
668
  proc.on('close', () => {
654
669
  stream.end();
655
- iosActiveLogStreams.delete(sessionId);
670
+ if (iosActiveLogStreams.get(sessionId)?.proc === proc)
671
+ iosActiveLogStreams.delete(sessionId);
656
672
  });
657
- iosActiveLogStreams.set(sessionId, { proc, file });
673
+ iosActiveLogStreams.set(sessionId, { proc, file, device: { platform: 'ios', id: deviceId ?? '' } });
658
674
  return { success: true, stream_started: true };
659
675
  }
660
676
  catch {
@@ -673,6 +689,9 @@ export class iOSObserve {
673
689
  return { success: true };
674
690
  }
675
691
  async readLogStream(sessionId = 'default', limit = 100, since) {
692
+ return captureEvidence('logs', this.getLogStreamDevice(sessionId), () => this.collectReadLogStream(sessionId, limit, since));
693
+ }
694
+ async collectReadLogStream(sessionId = 'default', limit = 100, since) {
676
695
  const entry = iosActiveLogStreams.get(sessionId);
677
696
  if (!entry)
678
697
  return { entries: [] };