mobile-debug-mcp 0.24.1 → 0.24.2

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.
@@ -123,10 +123,23 @@ start_app response example:
123
123
  "action_id": "start_app_1710000000000_1",
124
124
  "timestamp": 1710000000000,
125
125
  "action_type": "start_app",
126
+ "device": { "platform": "android", "id": "emulator-5554", "osVersion": "14", "model": "Pixel", "simulator": true },
126
127
  "target": { "selector": { "appId": "com.example.app" }, "resolved": null },
127
128
  "success": true,
128
129
  "ui_fingerprint_before": "fp_before",
129
- "ui_fingerprint_after": "fp_after"
130
+ "ui_fingerprint_after": "fp_after",
131
+ "details": {
132
+ "launch_time_ms": 1000,
133
+ "output": "Events injected: 1",
134
+ "device_id": "emulator-5554",
135
+ "observed_app": {
136
+ "appId": "com.example.app",
137
+ "package": "com.example.app",
138
+ "activity": "com.example.app.MainActivity",
139
+ "screen": "MainActivity",
140
+ "matchedTarget": true
141
+ }
142
+ }
130
143
  }
131
144
  ```
132
145
 
@@ -137,5 +150,7 @@ terminate_app and reset_app_data return operation-specific lifecycle results ins
137
150
  Notes:
138
151
 
139
152
  - `start_app` and `restart_app` report execution success, not outcome correctness.
153
+ - Use `details.observed_app` as the quick decision signal for what the tool actually saw after launch.
154
+ - Android launch feedback usually includes package/activity matching; iOS launch feedback includes launch output and PID when available.
140
155
  - When the landing screen is known, use `expect_screen` as the final verification step.
141
156
  - If launch timing is uncertain, insert `wait_for_screen_change` before `expect_screen`.
@@ -16,6 +16,7 @@ Response (example):
16
16
  ```json
17
17
  {
18
18
  "success": true,
19
+ "status": "ready",
19
20
  "adbAvailable": true,
20
21
  "adbVersion": "8.1.0",
21
22
  "devices": 1,
@@ -25,7 +26,26 @@ Response (example):
25
26
  "issues": [],
26
27
  "appInstalled": true,
27
28
  "iosAvailable": true,
28
- "iosDevices": 1
29
+ "iosDevices": 1,
30
+ "summary": {
31
+ "overall": "ready",
32
+ "android": {
33
+ "ready": true,
34
+ "summary": "1 Android device(s) connected; log access available",
35
+ "blockers": []
36
+ },
37
+ "ios": {
38
+ "ready": true,
39
+ "summary": "1 iOS simulator(s) booted",
40
+ "blockers": []
41
+ },
42
+ "gradle": {
43
+ "ready": true,
44
+ "summary": "No explicit Gradle JDK override detected",
45
+ "blockers": [],
46
+ "suggestedFixes": []
47
+ }
48
+ }
29
49
  }
30
50
  ```
31
51
 
@@ -39,6 +59,8 @@ Checks performed (fast, best-effort):
39
59
 
40
60
  Behavior notes:
41
61
  - Always returns structured JSON and never throws; any failures are surfaced in the `issues` array.
62
+ - `status` gives a quick overall gate: `ready`, `degraded`, or `blocked`.
63
+ - `summary.android`, `summary.ios`, and `summary.gradle` provide the fastest path to the actual blocker category.
42
64
  - Designed to be fast (<~1s probes where possible); startup callers may prefer a `fastMode` variant that only checks existence.
43
65
  - Useful to call at the start of an agent session to gate subsequent actions.
44
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.24.1",
3
+ "version": "0.24.2",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -490,6 +490,12 @@ export class ToolsInteract {
490
490
 
491
491
  static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string }) {
492
492
  const overallStart = Date.now()
493
+ const requestedIndex = typeof match?.index === 'number' ? match.index : null
494
+ const requested = {
495
+ selector: selector ?? {},
496
+ condition,
497
+ match: requestedIndex === null ? null : { index: requestedIndex }
498
+ }
493
499
 
494
500
  // Validate selector: require at least one non-empty field (text, resource_id, or accessibility_id)
495
501
  const hasText = typeof selector?.text === 'string' && selector.text.trim().length > 0;
@@ -503,18 +509,20 @@ export class ToolsInteract {
503
509
  code: 'INVALID_SELECTOR',
504
510
  message: 'Selector must include at least one non-empty field: text, resource_id, or accessibility_id'
505
511
  },
506
- metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 }
512
+ metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 },
513
+ requested,
514
+ observed: { matched_count: 0, condition_satisfied: false, selected_index: null, last_matched_element: null }
507
515
  };
508
516
  }
509
517
 
510
518
  // Validate condition
511
519
  if (!['exists','not_exists','visible','clickable'].includes(condition)) {
512
- return { status: 'timeout', error: { code: 'INVALID_CONDITION', message: `Unsupported condition: ${condition}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } }
520
+ return { status: 'timeout', error: { code: 'INVALID_CONDITION', message: `Unsupported condition: ${condition}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 }, requested, observed: { matched_count: 0, condition_satisfied: false, selected_index: null, last_matched_element: null } }
513
521
  }
514
522
 
515
523
  // Platform check
516
524
  if (platform && !['android','ios'].includes(platform)) {
517
- return { status: 'timeout', error: { code: 'PLATFORM_NOT_SUPPORTED', message: `Unsupported platform: ${platform}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } }
525
+ return { status: 'timeout', error: { code: 'PLATFORM_NOT_SUPPORTED', message: `Unsupported platform: ${platform}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 }, requested, observed: { matched_count: 0, condition_satisfied: false, selected_index: null, last_matched_element: null } }
518
526
  }
519
527
 
520
528
  const effectivePoll = Math.max(50, Math.min(poll_interval_ms || 300, 2000))
@@ -523,6 +531,9 @@ export class ToolsInteract {
523
531
 
524
532
  let attempts = 0
525
533
  let totalPollCount = 0
534
+ let lastMatchedCount = 0
535
+ let lastMatchedElement: ActionTargetResolved | null = null
536
+ let lastConditionSatisfied = false
526
537
 
527
538
  // Precompute normalized selector values and helpers (constant across polls)
528
539
  const normalize = ToolsInteract._normalize
@@ -623,19 +634,29 @@ export class ToolsInteract {
623
634
  } else conditionMet = false
624
635
  }
625
636
 
637
+ const resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
638
+ const resolvedDeviceId = tree?.device?.id || deviceId
639
+ lastMatchedCount = matchedCount
640
+ lastConditionSatisfied = conditionMet
641
+ lastMatchedElement = matchedElement ? ToolsInteract._buildResolvedElement(resolvedPlatform, resolvedDeviceId, matchedElement.el, matchedElement.idx) : null
642
+
626
643
  if (conditionMet) {
627
644
  const now = Date.now()
628
645
  const latency_ms = now - overallStart
629
- // Build element output per spec
630
- const resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
631
- const resolvedDeviceId = tree?.device?.id || deviceId
632
- const outEl = matchedElement ? ToolsInteract._buildResolvedElement(resolvedPlatform, resolvedDeviceId, matchedElement.el, matchedElement.idx) : null
646
+ const outEl = lastMatchedElement
633
647
 
634
648
  return {
635
649
  status: 'success',
636
650
  matched: matchedCount,
637
651
  element: outEl,
638
- metrics: { latency_ms, poll_count: totalPollCount, attempts }
652
+ metrics: { latency_ms, poll_count: totalPollCount, attempts },
653
+ requested,
654
+ observed: {
655
+ matched_count: matchedCount,
656
+ condition_satisfied: true,
657
+ selected_index: outEl?.index ?? null,
658
+ last_matched_element: outEl
659
+ }
639
660
  }
640
661
  }
641
662
 
@@ -656,16 +677,38 @@ export class ToolsInteract {
656
677
 
657
678
  // Final failure for this call
658
679
  const elapsed = Date.now() - overallStart
680
+ const observed = {
681
+ matched_count: lastMatchedCount,
682
+ condition_satisfied: lastConditionSatisfied,
683
+ selected_index: lastMatchedElement?.index ?? null,
684
+ last_matched_element: lastMatchedElement
685
+ }
686
+ const matchNote = requestedIndex !== null && lastMatchedCount <= requestedIndex
687
+ ? ` requested match.index=${requestedIndex} but observed ${lastMatchedCount} match(es)`
688
+ : ` observed ${lastMatchedCount} match(es)`
659
689
  return {
660
690
  status: 'timeout',
661
- error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout` },
662
- metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts }
691
+ error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout;${matchNote}` },
692
+ metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts },
693
+ requested,
694
+ observed
663
695
  }
664
696
  }
665
697
 
666
698
  } catch (err) {
667
699
  const elapsed = Date.now() - overallStart
668
- return { status: 'timeout', error: { code: 'INTERNAL_ERROR', message: err instanceof Error ? err.message : String(err) }, metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts } }
700
+ return {
701
+ status: 'timeout',
702
+ error: { code: 'INTERNAL_ERROR', message: err instanceof Error ? err.message : String(err) },
703
+ metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts },
704
+ requested,
705
+ observed: {
706
+ matched_count: lastMatchedCount,
707
+ condition_satisfied: false,
708
+ selected_index: lastMatchedElement?.index ?? null,
709
+ last_matched_element: lastMatchedElement
710
+ }
711
+ }
669
712
  }
670
713
  }
671
714
 
@@ -682,11 +725,13 @@ export class ToolsInteract {
682
725
  static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
683
726
  const start = Date.now()
684
727
  let lastFingerprint: string | null = null
728
+ let lastActivity: string | null = null
685
729
 
686
730
  while (Date.now() - start < timeoutMs) {
687
731
  try {
688
732
  const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
689
733
  const fp = res?.fingerprint ?? null
734
+ lastActivity = (res as any)?.activity ?? lastActivity
690
735
  if (fp === null || fp === undefined) {
691
736
  lastFingerprint = null
692
737
  await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
@@ -701,8 +746,18 @@ export class ToolsInteract {
701
746
  try {
702
747
  const confirmRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
703
748
  const confirmFp = confirmRes?.fingerprint ?? null
749
+ lastActivity = (confirmRes as any)?.activity ?? lastActivity
704
750
  if (confirmFp === fp) {
705
- return { success: true, newFingerprint: fp, elapsedMs: Date.now() - start }
751
+ return {
752
+ success: true,
753
+ previousFingerprint,
754
+ newFingerprint: fp,
755
+ elapsedMs: Date.now() - start,
756
+ observed_screen: {
757
+ fingerprint: fp,
758
+ activity: lastActivity
759
+ }
760
+ }
706
761
  }
707
762
  lastFingerprint = confirmFp
708
763
  continue
@@ -713,7 +768,17 @@ export class ToolsInteract {
713
768
  await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
714
769
  }
715
770
 
716
- return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
771
+ return {
772
+ success: false,
773
+ reason: 'timeout',
774
+ previousFingerprint,
775
+ lastFingerprint,
776
+ elapsedMs: Date.now() - start,
777
+ observed_screen: {
778
+ fingerprint: lastFingerprint,
779
+ activity: lastActivity
780
+ }
781
+ }
717
782
  }
718
783
 
719
784
  static async expectScreenHandler({
@@ -749,13 +814,23 @@ export class ToolsInteract {
749
814
  }
750
815
 
751
816
  let success = false
817
+ let basis: 'fingerprint' | 'screen' | 'none' = 'none'
818
+ let reason = 'No fingerprint or screen expectation provided'
752
819
  if (fingerprint) {
820
+ basis = 'fingerprint'
753
821
  success = observedScreen.fingerprint === fingerprint
822
+ reason = success
823
+ ? `observed fingerprint matches expected fingerprint ${fingerprint}`
824
+ : `expected fingerprint ${fingerprint} but observed ${observedScreen.fingerprint ?? 'null'}`
754
825
  } else if (screen) {
826
+ basis = 'screen'
755
827
  const candidates = new Set<string>()
756
828
  if (observedScreen.screen) candidates.add(observedScreen.screen)
757
829
  if (observedScreenLabel) candidates.add(observedScreenLabel)
758
830
  success = candidates.has(screen)
831
+ reason = success
832
+ ? `observed screen matches expected screen ${screen}`
833
+ : `expected screen ${screen} but observed ${observedScreenLabel ?? observedScreen.screen ?? 'null'}`
759
834
  }
760
835
 
761
836
  return {
@@ -765,7 +840,12 @@ export class ToolsInteract {
765
840
  screen: observedScreenLabel
766
841
  },
767
842
  expected_screen: expectedScreen,
768
- confidence: success ? 1 : 0
843
+ confidence: success ? 1 : 0,
844
+ comparison: {
845
+ basis,
846
+ matched: success,
847
+ reason
848
+ }
769
849
  }
770
850
  }
771
851
 
@@ -798,6 +878,7 @@ export class ToolsInteract {
798
878
  success: true,
799
879
  selector,
800
880
  element_id: result.element.elementId ?? element_id ?? null,
881
+ expected_condition: 'visible',
801
882
  element: {
802
883
  elementId: result.element.elementId ?? null,
803
884
  text: result.element.text ?? null,
@@ -806,7 +887,23 @@ export class ToolsInteract {
806
887
  class: result.element.class ?? null,
807
888
  bounds: result.element.bounds ?? null,
808
889
  index: typeof result.element.index === 'number' ? result.element.index : null
809
- }
890
+ },
891
+ observed: {
892
+ status: result.status,
893
+ matched_count: typeof result.matched === 'number' ? result.matched : result?.observed?.matched_count ?? null,
894
+ condition_satisfied: true,
895
+ selected_index: typeof result.element.index === 'number' ? result.element.index : null,
896
+ last_matched_element: {
897
+ elementId: result.element.elementId ?? null,
898
+ text: result.element.text ?? null,
899
+ resource_id: result.element.resource_id ?? null,
900
+ accessibility_id: result.element.accessibility_id ?? null,
901
+ class: result.element.class ?? null,
902
+ bounds: result.element.bounds ?? null,
903
+ index: typeof result.element.index === 'number' ? result.element.index : null
904
+ }
905
+ },
906
+ reason: 'selector is visible'
810
907
  }
811
908
  }
812
909
 
@@ -815,6 +912,15 @@ export class ToolsInteract {
815
912
  success: false,
816
913
  selector,
817
914
  element_id: element_id ?? null,
915
+ expected_condition: 'visible',
916
+ observed: {
917
+ status: result?.status,
918
+ matched_count: result?.observed?.matched_count,
919
+ condition_satisfied: result?.observed?.condition_satisfied ?? false,
920
+ selected_index: result?.observed?.selected_index ?? null,
921
+ last_matched_element: result?.observed?.last_matched_element ?? null
922
+ },
923
+ reason: result?.error?.message ?? 'selector is not visible',
818
924
  failure_code: errorCode,
819
925
  retryable: errorCode === 'TIMEOUT'
820
926
  }
@@ -5,6 +5,7 @@ import { existsSync } from 'fs'
5
5
  import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from '../utils/android/utils.js'
6
6
  import { execAdbWithDiagnostics } from '../utils/diagnostics.js'
7
7
  import { detectJavaHome } from '../utils/java.js'
8
+ import { AndroidObserve } from '../observe/android.js'
8
9
  import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
9
10
 
10
11
  export class AndroidManage {
@@ -141,8 +142,21 @@ export class AndroidManage {
141
142
  const metadata = await getAndroidDeviceMetadata(appId, deviceId)
142
143
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
143
144
  try {
144
- await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
145
- return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
145
+ const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
146
+ const current = await new AndroidObserve().getCurrentScreen(deviceId).catch(() => null)
147
+ return {
148
+ device: deviceInfo,
149
+ appStarted: true,
150
+ launchTimeMs: 1000,
151
+ output,
152
+ observedApp: {
153
+ appId,
154
+ package: current?.package ?? null,
155
+ activity: current?.activity ?? null,
156
+ screen: current?.shortActivity ?? current?.activity ?? null,
157
+ matchedTarget: current ? current.package === appId : null
158
+ }
159
+ }
146
160
  } catch (e: unknown) {
147
161
  const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
148
162
  return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
@@ -162,12 +176,18 @@ export class AndroidManage {
162
176
  }
163
177
 
164
178
  async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
165
- await this.terminateApp(appId, deviceId)
179
+ const terminateResult = await this.terminateApp(appId, deviceId)
166
180
  const startResult = await this.startApp(appId, deviceId)
167
181
  return {
168
182
  device: startResult.device,
169
183
  appRestarted: startResult.appStarted,
170
- launchTimeMs: startResult.launchTimeMs
184
+ launchTimeMs: startResult.launchTimeMs,
185
+ output: startResult.output,
186
+ observedApp: startResult.observedApp,
187
+ terminatedBeforeRestart: terminateResult.appTerminated,
188
+ ...(terminateResult.error ? { terminateError: terminateResult.error } : {}),
189
+ ...(startResult.error ? { error: startResult.error } : {}),
190
+ ...(startResult.diagnostics ? { diagnostics: startResult.diagnostics } : {})
171
191
  }
172
192
  }
173
193
 
package/src/manage/ios.ts CHANGED
@@ -2,6 +2,7 @@ import { promises as fs } from "fs"
2
2
  import { spawn, spawnSync } from "child_process"
3
3
  import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, InstallAppResponse } from "../types.js"
4
4
  import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "../utils/ios/utils.js"
5
+ import { iOSObserve } from "../observe/ios.js"
5
6
  import path from "path"
6
7
 
7
8
  export class iOSManage {
@@ -301,7 +302,20 @@ export class iOSManage {
301
302
  try {
302
303
  const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
303
304
  const device = await getIOSDeviceMetadata(deviceId)
304
- return { device, appStarted: !!result.output, launchTimeMs: 1000 }
305
+ const fingerprint = await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null)
306
+ const pidMatch = result.output.match(/:\s*(\d+)\s*$/)
307
+ return {
308
+ device,
309
+ appStarted: !!result.output,
310
+ launchTimeMs: 1000,
311
+ output: result.output,
312
+ observedApp: {
313
+ appId: bundleId,
314
+ pid: pidMatch ? Number(pidMatch[1]) : null,
315
+ screen: fingerprint?.activity ?? null,
316
+ matchedTarget: null
317
+ }
318
+ }
305
319
  } catch (e: unknown) {
306
320
  const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
307
321
  const device = await getIOSDeviceMetadata(deviceId)
@@ -323,9 +337,19 @@ export class iOSManage {
323
337
  }
324
338
 
325
339
  async restartApp(bundleId: string, deviceId: string = "booted"): Promise<RestartAppResponse> {
326
- await this.terminateApp(bundleId, deviceId)
340
+ const terminateResult = await this.terminateApp(bundleId, deviceId)
327
341
  const startResult = await this.startApp(bundleId, deviceId)
328
- return { device: startResult.device, appRestarted: startResult.appStarted, launchTimeMs: startResult.launchTimeMs }
342
+ return {
343
+ device: startResult.device,
344
+ appRestarted: startResult.appStarted,
345
+ launchTimeMs: startResult.launchTimeMs,
346
+ output: startResult.output,
347
+ observedApp: startResult.observedApp,
348
+ terminatedBeforeRestart: terminateResult.appTerminated,
349
+ ...(terminateResult.error ? { terminateError: terminateResult.error } : {}),
350
+ ...(startResult.error ? { error: startResult.error } : {}),
351
+ ...(startResult.diagnostics ? { diagnostics: startResult.diagnostics } : {})
352
+ }
329
353
  }
330
354
 
331
355
  async resetAppData(bundleId: string, deviceId: string = "booted"): Promise<ResetAppDataResponse> {
@@ -63,26 +63,31 @@ export function inferScrollFailure(message: string | undefined): { failureCode:
63
63
 
64
64
  export function buildActionExecutionResult({
65
65
  actionType,
66
+ device,
66
67
  selector,
67
68
  resolved,
68
69
  success,
69
70
  uiFingerprintBefore,
70
71
  uiFingerprintAfter,
71
- failure
72
+ failure,
73
+ details
72
74
  }: {
73
75
  actionType: string
76
+ device?: ActionExecutionResult['device']
74
77
  selector: Record<string, unknown> | null
75
78
  resolved?: Partial<ActionTargetResolved> | null
76
79
  success: boolean
77
80
  uiFingerprintBefore: string | null
78
81
  uiFingerprintAfter: string | null
79
82
  failure?: { failureCode: ActionFailureCode; retryable: boolean }
83
+ details?: Record<string, unknown>
80
84
  }): ActionExecutionResult {
81
85
  const timestamp = Date.now()
82
86
  return {
83
87
  action_id: nextActionId(actionType, timestamp),
84
88
  timestamp,
85
89
  action_type: actionType,
90
+ ...(device ? { device } : {}),
86
91
  target: {
87
92
  selector,
88
93
  resolved: normalizeResolvedTarget(resolved)
@@ -90,6 +95,7 @@ export function buildActionExecutionResult({
90
95
  success,
91
96
  ...(failure ? { failure_code: failure.failureCode, retryable: failure.retryable } : {}),
92
97
  ui_fingerprint_before: uiFingerprintBefore,
93
- ui_fingerprint_after: uiFingerprintAfter
98
+ ui_fingerprint_after: uiFingerprintAfter,
99
+ ...(details ? { details } : {})
94
100
  }
95
101
  }
@@ -27,11 +27,19 @@ async function handleStartApp(args: ToolCallArgs) {
27
27
  const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
28
28
  return wrapResponse(buildActionExecutionResult({
29
29
  actionType: 'start_app',
30
+ device: res.device,
30
31
  selector: { appId },
31
32
  success: !!res.appStarted,
32
33
  uiFingerprintBefore,
33
34
  uiFingerprintAfter,
34
- failure: res.appStarted ? undefined : inferGenericFailure((res as any).error)
35
+ failure: res.appStarted ? undefined : inferGenericFailure(res.error),
36
+ details: {
37
+ launch_time_ms: res.launchTimeMs,
38
+ ...(typeof res.output === 'string' ? { output: res.output } : {}),
39
+ ...(res.device ? { device_id: res.device.id } : {}),
40
+ ...(typeof res.error === 'string' ? { error: res.error } : {}),
41
+ ...(res.observedApp ? { observed_app: res.observedApp } : {})
42
+ }
35
43
  }))
36
44
  }
37
45
 
@@ -50,11 +58,20 @@ async function handleRestartApp(args: ToolCallArgs) {
50
58
  const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
51
59
  return wrapResponse(buildActionExecutionResult({
52
60
  actionType: 'restart_app',
61
+ device: res.device,
53
62
  selector: { appId },
54
63
  success: !!res.appRestarted,
55
64
  uiFingerprintBefore,
56
65
  uiFingerprintAfter,
57
- failure: res.appRestarted ? undefined : inferGenericFailure((res as any).error)
66
+ failure: res.appRestarted ? undefined : inferGenericFailure(res.error),
67
+ details: {
68
+ launch_time_ms: res.launchTimeMs,
69
+ ...(typeof res.output === 'string' ? { output: res.output } : {}),
70
+ ...(typeof res.terminatedBeforeRestart === 'boolean' ? { terminated_before_restart: res.terminatedBeforeRestart } : {}),
71
+ ...(typeof res.terminateError === 'string' ? { terminate_error: res.terminateError } : {}),
72
+ ...(typeof res.error === 'string' ? { error: res.error } : {}),
73
+ ...(res.observedApp ? { observed_app: res.observedApp } : {})
74
+ }
58
75
  }))
59
76
  }
60
77
 
@@ -10,8 +10,34 @@ export async function getSystemStatus() {
10
10
  const issues = [...android.issues, ...ios.issues, ...(gradle.issues || [])]
11
11
 
12
12
  const success = issues.length === 0
13
+ const androidReady = android.adbAvailable && android.devices > 0 && !android.issues.some((issue) => /unauthorized|offline/i.test(issue))
14
+ const iosReady = ios.iosAvailable && ios.iosDevices > 0
15
+ const gradleReady = (gradle.issues || []).length === 0
16
+ const overallStatus = success ? 'ready' : (androidReady || iosReady ? 'degraded' : 'blocked')
17
+
18
+ const androidSummary = !android.adbAvailable
19
+ ? 'ADB unavailable'
20
+ : android.devices === 0
21
+ ? 'ADB available but no Android devices connected'
22
+ : android.logsAvailable
23
+ ? `${android.devices} Android device(s) connected; log access available`
24
+ : `${android.devices} Android device(s) connected; log access unavailable`
25
+
26
+ const iosSummary = !ios.iosAvailable
27
+ ? 'xcrun unavailable'
28
+ : ios.iosDevices === 0
29
+ ? 'xcrun available but no iOS simulators booted'
30
+ : `${ios.iosDevices} iOS simulator(s) booted`
31
+
32
+ const gradleSummary = !gradle.gradleJavaHome
33
+ ? 'No explicit Gradle JDK override detected'
34
+ : gradleReady
35
+ ? `Gradle JDK configured at ${gradle.gradleJavaHome}`
36
+ : `Gradle JDK override invalid: ${gradle.gradleJavaHome}`
37
+
13
38
  return {
14
39
  success,
40
+ status: overallStatus,
15
41
  adbAvailable: android.adbAvailable,
16
42
  adbVersion: android.adbVersion,
17
43
  devices: android.devices,
@@ -25,9 +51,38 @@ export async function getSystemStatus() {
25
51
  gradleJavaHome: gradle.gradleJavaHome,
26
52
  gradleValid: gradle.gradleValid,
27
53
  gradleFilesChecked: gradle.filesChecked,
28
- gradleSuggestedFixes: gradle.suggestedFixes
54
+ gradleSuggestedFixes: gradle.suggestedFixes,
55
+ summary: {
56
+ overall: overallStatus,
57
+ android: {
58
+ ready: androidReady,
59
+ summary: androidSummary,
60
+ blockers: android.issues
61
+ },
62
+ ios: {
63
+ ready: iosReady,
64
+ summary: iosSummary,
65
+ blockers: ios.issues
66
+ },
67
+ gradle: {
68
+ ready: gradleReady,
69
+ summary: gradleSummary,
70
+ blockers: gradle.issues || [],
71
+ suggestedFixes: gradle.suggestedFixes || []
72
+ }
73
+ }
29
74
  }
30
75
  } catch (e: unknown) {
31
- return { success: false, issues: ['Internal error: ' + (e instanceof Error ? e.message : String(e))] }
76
+ return {
77
+ success: false,
78
+ status: 'blocked',
79
+ issues: ['Internal error: ' + (e instanceof Error ? e.message : String(e))],
80
+ summary: {
81
+ overall: 'blocked',
82
+ android: { ready: false, summary: 'Android status unavailable', blockers: [] },
83
+ ios: { ready: false, summary: 'iOS status unavailable', blockers: [] },
84
+ gradle: { ready: false, summary: 'Gradle status unavailable', blockers: [], suggestedFixes: [] }
85
+ }
86
+ }
32
87
  }
33
88
  }