mobile-debug-mcp 0.35.0 → 0.37.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 (247) hide show
  1. package/.cursor-plugin/marketplace.json +1 -1
  2. package/.github/workflows/ci.yml +1 -1
  3. package/README.md +96 -24
  4. package/agent-plugin/.antigravity-plugin/README.md +48 -0
  5. package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
  6. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  7. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  8. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  9. package/agent-plugin/.mcp.json +1 -1
  10. package/agent-plugin/README.md +37 -8
  11. package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
  12. package/agent-plugin/cursor/LICENSE +201 -0
  13. package/agent-plugin/cursor/README.md +9 -0
  14. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +261 -0
  15. package/agent-plugin/mcp.json +1 -1
  16. package/agent-plugin/mcp_config.json +13 -0
  17. package/agent-plugin/plugin.json +1 -1
  18. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +46 -18
  19. package/dist/contracts/device-readiness.js +1 -0
  20. package/dist/debugger/guard.js +4 -4
  21. package/dist/debugger/ios-worker.js +62 -0
  22. package/dist/debugger/ios.js +57 -0
  23. package/dist/debugger/sessions.js +33 -16
  24. package/dist/debugger/worker.js +6 -4
  25. package/dist/interact/index.js +12 -2
  26. package/dist/interact/waits/ui-change-wait.js +9 -2
  27. package/dist/interact/waits/ui-wait.js +7 -3
  28. package/dist/manage/index.js +3 -0
  29. package/dist/manage/ios.js +2 -0
  30. package/dist/observe/device-readiness.js +116 -0
  31. package/dist/observe/evidence.js +31 -0
  32. package/dist/observe/ios-trace/artifact.js +63 -0
  33. package/dist/observe/ios-trace/capabilities.js +86 -0
  34. package/dist/observe/ios-trace/native.js +93 -0
  35. package/dist/observe/ios-trace/notification.js +35 -0
  36. package/dist/observe/ios-trace/process.js +98 -0
  37. package/dist/observe/ios-trace/sessions.js +246 -0
  38. package/dist/observe/readiness-service.js +22 -10
  39. package/dist/observe/readiness.js +34 -4
  40. package/dist/observe/screenshot-encoding.js +4 -0
  41. package/dist/observe/snapshot-collection.js +7 -2
  42. package/dist/observe/system-trace-error.js +7 -0
  43. package/dist/observe/system-trace-router.js +81 -0
  44. package/dist/observe/system-trace.js +4 -8
  45. package/dist/server/definitions/manage.js +3 -2
  46. package/dist/server/handlers/action-execution.js +23 -14
  47. package/dist/server/handlers/compact-interaction.js +106 -0
  48. package/dist/server/handlers/compact-state.js +47 -0
  49. package/dist/server/handlers/debugger-handlers.js +27 -8
  50. package/dist/server/handlers/diagnostic.js +12 -4
  51. package/dist/server/handlers/input.js +2 -0
  52. package/dist/server/handlers/interact.js +2 -0
  53. package/dist/server/handlers/interaction-diagnostics.js +62 -0
  54. package/dist/server/handlers/journey.js +18 -20
  55. package/dist/server/handlers/observe.js +2 -1
  56. package/dist/server/handlers/tool-handlers.js +28 -14
  57. package/dist/server/schemas/debugger-schema.js +10 -7
  58. package/dist/server/schemas/interaction-diagnostics.js +9 -0
  59. package/dist/server/schemas/system-trace-schema.js +14 -9
  60. package/dist/server/tool-definitions.js +9 -4
  61. package/dist/server-core.js +5 -2
  62. package/dist/server.js +5 -5
  63. package/dist/system/android.js +19 -15
  64. package/dist/system/index.js +2 -1
  65. package/dist/utils/android/utils.js +7 -16
  66. package/dist/utils/app-installation.js +77 -0
  67. package/dist/utils/benchmark-metrics.js +43 -0
  68. package/dist/utils/exec.js +4 -0
  69. package/dist/utils/ios/utils.js +7 -11
  70. package/dist/utils/resolve-device.js +3 -2
  71. package/dist/utils/timing.js +3 -3
  72. package/docs/CHANGELOG.md +48 -1
  73. package/docs/agent-plugin-installation-verification.md +39 -0
  74. package/docs/benchmarks/README.md +1 -0
  75. package/docs/benchmarks/end-to-end-latency/README.md +98 -0
  76. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
  77. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
  78. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
  79. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
  80. package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
  81. package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
  82. package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
  83. package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
  84. package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
  85. package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
  86. package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
  87. package/docs/code-structure.md +3 -1
  88. package/docs/specs/031-ios-system-traces/intent.md +38 -0
  89. package/docs/specs/031-ios-system-traces/investigation.md +92 -0
  90. package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
  91. package/docs/specs/031-ios-system-traces/plan.md +166 -0
  92. package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
  93. package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
  94. package/docs/specs/031-ios-system-traces/spec.md +135 -0
  95. package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
  96. package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
  97. package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
  98. package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
  99. package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
  100. package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
  101. package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
  102. package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
  103. package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
  104. package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
  105. package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
  106. package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
  107. package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
  108. package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
  109. package/docs/specs/034-app-installation-status/intent.md +41 -0
  110. package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
  111. package/docs/specs/034-app-installation-status/plan.md +73 -0
  112. package/docs/specs/034-app-installation-status/spec.md +114 -0
  113. package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
  114. package/docs/specs/035-compact-interaction-results/intent.md +38 -0
  115. package/docs/specs/035-compact-interaction-results/plan.md +43 -0
  116. package/docs/specs/035-compact-interaction-results/spec.md +118 -0
  117. package/docs/specs/035-compact-interaction-results/verification.md +111 -0
  118. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
  119. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
  120. package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
  121. package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
  122. package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
  123. package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
  124. package/docs/tools/TOOLS.md +3 -2
  125. package/docs/tools/compact-interactions.md +56 -0
  126. package/docs/tools/correlated-evidence.md +6 -0
  127. package/docs/tools/debugger.md +45 -5
  128. package/docs/tools/interact.md +17 -8
  129. package/docs/tools/manage.md +10 -2
  130. package/docs/tools/observe.md +42 -2
  131. package/docs/tools/system-traces.md +81 -14
  132. package/docs/tools/system.md +6 -0
  133. package/package.json +2 -1
  134. package/scripts/agent-plugin.mjs +55 -5
  135. package/src/contracts/device-readiness.ts +8 -0
  136. package/src/debugger/android.ts +1 -1
  137. package/src/debugger/guard.ts +5 -5
  138. package/src/debugger/ios-worker.ts +49 -0
  139. package/src/debugger/ios.ts +45 -0
  140. package/src/debugger/lldb/breakpoints.py +51 -0
  141. package/src/debugger/lldb/inspection.py +109 -0
  142. package/src/debugger/lldb/worker.py +203 -0
  143. package/src/debugger/sessions.ts +24 -12
  144. package/src/debugger/worker.ts +7 -4
  145. package/src/interact/index.ts +13 -2
  146. package/src/interact/waits/ui-change-wait.ts +7 -2
  147. package/src/interact/waits/ui-wait.ts +6 -3
  148. package/src/manage/index.ts +2 -0
  149. package/src/manage/ios.ts +2 -0
  150. package/src/observe/device-readiness.ts +96 -0
  151. package/src/observe/evidence.ts +30 -1
  152. package/src/observe/ios-trace/artifact.ts +55 -0
  153. package/src/observe/ios-trace/capabilities.ts +74 -0
  154. package/src/observe/ios-trace/native.ts +72 -0
  155. package/src/observe/ios-trace/notification.ts +30 -0
  156. package/src/observe/ios-trace/process.ts +82 -0
  157. package/src/observe/ios-trace/sessions.ts +206 -0
  158. package/src/observe/readiness-service.ts +22 -10
  159. package/src/observe/readiness.ts +37 -4
  160. package/src/observe/screenshot-encoding.ts +4 -0
  161. package/src/observe/snapshot-collection.ts +7 -2
  162. package/src/observe/system-trace-error.ts +3 -0
  163. package/src/observe/system-trace-router.ts +61 -0
  164. package/src/observe/system-trace.ts +4 -4
  165. package/src/server/common.ts +2 -2
  166. package/src/server/definitions/manage.ts +3 -2
  167. package/src/server/handlers/action-execution.ts +22 -14
  168. package/src/server/handlers/compact-interaction.ts +91 -0
  169. package/src/server/handlers/compact-state.ts +43 -0
  170. package/src/server/handlers/debugger-handlers.ts +28 -9
  171. package/src/server/handlers/diagnostic.ts +11 -4
  172. package/src/server/handlers/input.ts +2 -0
  173. package/src/server/handlers/interact.ts +2 -0
  174. package/src/server/handlers/interaction-diagnostics.ts +50 -0
  175. package/src/server/handlers/journey.ts +15 -17
  176. package/src/server/handlers/observe.ts +2 -1
  177. package/src/server/handlers/payload.ts +2 -1
  178. package/src/server/handlers/tool-handlers.ts +29 -14
  179. package/src/server/schemas/debugger-schema.ts +10 -7
  180. package/src/server/schemas/interaction-diagnostics.ts +10 -0
  181. package/src/server/schemas/system-trace-schema.ts +15 -9
  182. package/src/server/tool-definitions.ts +8 -4
  183. package/src/server-core.ts +5 -2
  184. package/src/server.ts +4 -4
  185. package/src/system/android.ts +18 -11
  186. package/src/system/index.ts +2 -1
  187. package/src/types.ts +2 -0
  188. package/src/utils/android/utils.ts +8 -10
  189. package/src/utils/app-installation.ts +73 -0
  190. package/src/utils/benchmark-metrics.ts +42 -0
  191. package/src/utils/exec.ts +5 -0
  192. package/src/utils/ios/utils.ts +7 -10
  193. package/src/utils/resolve-device.ts +4 -2
  194. package/src/utils/timing.ts +3 -2
  195. package/test/device/automated/debugger/runtime.android.test.ts +5 -0
  196. package/test/device/automated/debugger/runtime.ios.test.ts +227 -0
  197. package/test/device/automated/interact/compact-results.smoke.ts +58 -0
  198. package/test/device/automated/latency/end-to-end-environment.ts +61 -0
  199. package/test/device/automated/latency/end-to-end-report.ts +90 -0
  200. package/test/device/automated/latency/end-to-end-session.ts +98 -0
  201. package/test/device/automated/latency/end-to-end.ts +149 -0
  202. package/test/device/automated/latency/owned-transport.ts +81 -0
  203. package/test/device/automated/latency/snapshot-pair.ts +143 -0
  204. package/test/device/automated/latency-ios/benchmark.ts +126 -0
  205. package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
  206. package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
  207. package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
  208. package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
  209. package/test/device/automated/observe/system-trace.smoke.ts +2 -2
  210. package/test/fixtures/contracts/tool-definitions.json +243 -47
  211. package/test/fixtures/ios-debugger-app/Main.swift +95 -0
  212. package/test/fixtures/ios-debugger-app/README.md +27 -0
  213. package/test/fixtures/ios-debugger-app/build.ts +21 -0
  214. package/test/fixtures/latency-journey-app/build.ts +3 -3
  215. package/test/helpers/ios-trace-shutdown.ts +29 -0
  216. package/test/unit/debugger/handle-guards.test.ts +89 -0
  217. package/test/unit/debugger/ios-preparation.test.ts +57 -0
  218. package/test/unit/debugger/ios.test.ts +104 -0
  219. package/test/unit/debugger/lldb-worker.py +166 -0
  220. package/test/unit/debugger/lldb-worker.test.ts +10 -0
  221. package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
  222. package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
  223. package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
  224. package/test/unit/observe/device-readiness.test.ts +97 -0
  225. package/test/unit/observe/ios-trace-native.test.ts +90 -0
  226. package/test/unit/observe/ios-trace-sessions.test.ts +154 -0
  227. package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
  228. package/test/unit/observe/readiness.test.ts +1 -1
  229. package/test/unit/observe/system-trace-router.test.ts +49 -0
  230. package/test/unit/quality/agent-plugin-export.test.ts +38 -0
  231. package/test/unit/server/compact-interaction.test.ts +87 -0
  232. package/test/unit/server/compact-journey-evidence.test.ts +40 -0
  233. package/test/unit/server/compact-review-regressions.test.ts +78 -0
  234. package/test/unit/server/conditional_actions.test.ts +7 -3
  235. package/test/unit/server/correlated-evidence.test.ts +18 -6
  236. package/test/unit/server/end-to-end-report.test.ts +46 -0
  237. package/test/unit/server/input-device-readiness.test.ts +55 -0
  238. package/test/unit/server/interaction-diagnostics.test.ts +45 -0
  239. package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
  240. package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
  241. package/test/unit/server/readiness.test.ts +24 -1
  242. package/test/unit/server/run_journey.test.ts +2 -1
  243. package/test/unit/server/selector_tap_target.test.ts +2 -1
  244. package/test/unit/server/system-trace.test.ts +18 -7
  245. package/test/unit/utils/app-installation.test.ts +78 -0
  246. package/test/unit/utils/benchmark-metrics.test.ts +36 -0
  247. package/test/unit/utils/resolve_device.test.ts +11 -0
@@ -0,0 +1,203 @@
1
+ """Private JSON worker for simulator LLDB. Stdout is exclusively bounded IPC."""
2
+ import json
3
+ import os
4
+ import queue
5
+ import sys
6
+ import threading
7
+ import time
8
+ import lldb
9
+ from inspection import Inspection
10
+ from breakpoints import Breakpoints
11
+
12
+ requests = queue.Queue(maxsize=64)
13
+ output_lock = threading.Lock()
14
+
15
+
16
+ def send(value):
17
+ data = json.dumps(value, separators=(",", ":"))
18
+ if len(data.encode()) > 1024 * 1024:
19
+ raise ValueError("DEBUGGER_WORKER_FAILED")
20
+ with output_lock:
21
+ sys.stdout.write(data + "\n")
22
+ sys.stdout.flush()
23
+
24
+
25
+ def read_input():
26
+ try:
27
+ while True:
28
+ line = sys.stdin.buffer.readline(1024 * 1024 + 1)
29
+ if not line:
30
+ break
31
+ if len(line) > 1024 * 1024:
32
+ break
33
+ requests.put(json.loads(line), timeout=1)
34
+ except Exception:
35
+ pass
36
+ finally:
37
+ # EOF must remain observable even when the queue was filled by a dead parent.
38
+ eof.set()
39
+
40
+
41
+ eof = threading.Event()
42
+
43
+
44
+ class Worker:
45
+ def __init__(self):
46
+ self.debugger = lldb.SBDebugger.Create(False)
47
+ self.debugger.SetAsync(True)
48
+ self.debugger.SetUseColor(False)
49
+ lldb.SBDebugger.SetInternalVariable("target.load-script-from-symbol-file", "false", self.debugger.GetInstanceName())
50
+ self.output = open(os.devnull, "w")
51
+ self.debugger.SetOutputFileHandle(self.output, False)
52
+ self.debugger.SetErrorFileHandle(self.output, False)
53
+ self.process = None
54
+ self.attached = False
55
+ self.breakpoints = None
56
+ self.inspection = Inspection()
57
+ self.created = self.activity = time.monotonic()
58
+ self.paused_at = None
59
+ self.last_stop = 0
60
+ self.terminal = False
61
+
62
+ def attach(self, args):
63
+ self.debugger.SetAsync(False)
64
+ target = self.debugger.CreateTarget(args["executablePath"])
65
+ if not target.IsValid():
66
+ raise ValueError("DEBUGGER_WORKER_FAILED")
67
+ self.breakpoints = Breakpoints(target)
68
+ error = lldb.SBError()
69
+ self.process = target.AttachToProcessWithID(self.debugger.GetListener(), args["pid"], error)
70
+ self.debugger.SetAsync(True)
71
+ self.attached = self.process.IsValid()
72
+ if not error.Success() or not self.process.IsValid():
73
+ raise ValueError("DEBUGGER_WORKER_FAILED")
74
+ actual = self.process.GetProcessInfo().GetExecutableFile()
75
+ if not actual.IsValid() or os.path.realpath(str(actual)) != os.path.realpath(args["executablePath"]):
76
+ self.process.Detach()
77
+ raise ValueError("DEBUGGER_WORKER_FAILED")
78
+ self.last_stop = self.process.GetStopID()
79
+ if not self.process.Continue().Success():
80
+ raise ValueError("DEBUGGER_WORKER_FAILED")
81
+ return {"attached": True}
82
+
83
+ def detach(self):
84
+ self.inspection.invalidate()
85
+ if self.breakpoints:
86
+ self.breakpoints.clear()
87
+ if self.attached:
88
+ if not self.process.IsValid():
89
+ raise ValueError("DEBUGGER_WORKER_FAILED")
90
+ if self.process.GetState() not in (lldb.eStateExited, lldb.eStateDetached):
91
+ error = self.process.Detach()
92
+ if not error.Success():
93
+ raise ValueError("DEBUGGER_WORKER_FAILED")
94
+ self.attached = False
95
+ self.terminal = True
96
+ return {"detached": True}
97
+
98
+ def poll(self):
99
+ # Removing process events updates LLDB's public state/thread snapshot.
100
+ # Polling GetState alone can leave the initial attach stop cached.
101
+ for _ in range(32):
102
+ event = lldb.SBEvent()
103
+ if not self.debugger.GetListener().GetNextEvent(event):
104
+ break
105
+ if lldb.SBProcess.EventIsProcessEvent(event):
106
+ lldb.SBProcess.GetStateFromEvent(event)
107
+ if not self.process or self.terminal:
108
+ return
109
+ state = self.process.GetState()
110
+ if state in (lldb.eStateExited, lldb.eStateDetached):
111
+ self.inspection.invalidate()
112
+ self.terminal = True
113
+ send({"event": "terminated", "reason": "process_exit" if state == lldb.eStateExited else "detached"})
114
+ elif state == lldb.eStateStopped and self.process.GetStopID() != self.last_stop:
115
+ self.breakpoints.refresh()
116
+ event = self.inspection.stopped(self.process)
117
+ if event:
118
+ self.last_stop = self.process.GetStopID()
119
+ self.paused_at = time.monotonic()
120
+ send(event)
121
+
122
+ def command(self, args):
123
+ command = args["command"]
124
+ if command == "attach":
125
+ return self.attach(args)
126
+ if command == "detach":
127
+ return self.detach()
128
+ if not self.process or self.terminal:
129
+ raise ValueError("DEBUGGER_DISCONNECTED")
130
+ if command in ("heartbeat", "state"):
131
+ return {"breakpoints": self.breakpoints.refresh()}
132
+ if command == "set":
133
+ return self.breakpoints.set(args)
134
+ if command == "remove":
135
+ return self.breakpoints.remove(args["breakpointId"])
136
+ if command in ("stack", "locals", "step", "resume"):
137
+ if args.get("stopId") != self.inspection.stop_id:
138
+ raise ValueError("DEBUGGER_STALE_REFERENCE")
139
+ if self.process.GetState() != lldb.eStateStopped:
140
+ raise ValueError("DEBUGGER_NOT_PAUSED")
141
+ if command == "stack":
142
+ return self.inspection.stack(args)
143
+ if command == "locals":
144
+ return self.inspection.locals(args)
145
+ if command in ("step", "resume"):
146
+ thread = self.inspection.thread(args) if command == "step" else None
147
+ self.inspection.invalidate()
148
+ self.paused_at = None
149
+ if command == "resume":
150
+ if not self.process.Continue().Success():
151
+ raise ValueError("DEBUGGER_WORKER_FAILED")
152
+ else:
153
+ error = lldb.SBError()
154
+ if args["kind"] == "into":
155
+ thread.StepInto(None, lldb.LLDB_INVALID_LINE_NUMBER, error, lldb.eOnlyDuringStepping)
156
+ elif args["kind"] == "over":
157
+ thread.StepOver(lldb.eOnlyDuringStepping, error)
158
+ else:
159
+ thread.StepOut(error)
160
+ if not error.Success():
161
+ raise ValueError("DEBUGGER_WORKER_FAILED")
162
+ return {"resumed": True}
163
+ raise ValueError("INVALID_ARGUMENT")
164
+
165
+ def expired(self):
166
+ now = time.monotonic()
167
+ return ("pause_limit" if self.paused_at and now - self.paused_at >= 60 else
168
+ "idle_limit" if now - self.activity >= 300 else
169
+ "session_limit" if now - self.created >= 1800 else None)
170
+
171
+
172
+ def main():
173
+ worker = Worker()
174
+ threading.Thread(target=read_input, daemon=True).start()
175
+ try:
176
+ while not eof.is_set():
177
+ worker.poll()
178
+ reason = worker.expired()
179
+ if reason:
180
+ worker.detach()
181
+ send({"event": "terminated", "reason": reason})
182
+ break
183
+ try:
184
+ args = requests.get(timeout=0.02)
185
+ except queue.Empty:
186
+ continue
187
+ worker.activity = time.monotonic()
188
+ try:
189
+ result = worker.command(args)
190
+ send({"id": args["id"], "result": result})
191
+ except Exception as error:
192
+ allowed = {"INVALID_ARGUMENT", "DEBUGGER_NOT_PAUSED", "DEBUGGER_STALE_REFERENCE", "DEBUGGER_DISCONNECTED"}
193
+ code = str(error) if isinstance(error, ValueError) and str(error) in allowed else "DEBUGGER_WORKER_FAILED"
194
+ send({"id": args["id"], "error": {"code": code}})
195
+ finally:
196
+ try:
197
+ worker.detach()
198
+ finally:
199
+ lldb.SBDebugger.Destroy(worker.debugger)
200
+
201
+
202
+ if __name__ == "__main__":
203
+ main()
@@ -1,6 +1,8 @@
1
1
  import { atStage, classify, diagnostic, type DebuggerDiagnostic, type DiagnosticStage } from './diagnostics.js';
2
2
  import type { WorkerReply } from './protocol.js';
3
3
  import { registerDebuggerPauseProvider } from './guard.js';
4
+ import { IOSDebugNative } from './ios.js';
5
+ import { createIOSWorker, prepareIOSWorker, iosCapabilities, iosLimitations } from './ios-worker.js';
4
6
  import { randomUUID } from 'node:crypto';
5
7
  import { AndroidDebugNative, type DebugNative, type DebugTarget } from './android.js';
6
8
  import { createWorker, prepareWorker, DebuggerError, withDebuggerBudget, debuggerRemaining, type DebugWorker, type WorkerEvent } from './worker.js';
@@ -56,14 +58,16 @@ interface ResultPayload {
56
58
  }
57
59
  export const debuggerCapabilities = { attach: true, breakpoints: true, stacks: true, locals: true, step: true, resume: true,
58
60
  evaluation: false, mutation: false, coroutines: false, native: false };
59
- const limitations = ['JAVA_KOTLIN_BYTECODE_ONLY', 'DEBUG_METADATA_REQUIRED', 'SHALLOW_LOCALS', 'ALL_THREADS_SUSPEND', 'DEVICE_WIDE_UI_GUARD'];
61
+ export const androidLimitations = ['JAVA_KOTLIN_BYTECODE_ONLY', 'DEBUG_METADATA_REQUIRED', 'SHALLOW_LOCALS', 'ALL_THREADS_SUSPEND', 'DEVICE_WIDE_UI_GUARD'];
60
62
  export function debuggerExpiry(created: number, activity: number, pausedAt: number | undefined, now: number) {
61
63
  return pausedAt !== undefined && now - pausedAt >= 60000 ? 'pause_limit' : now - activity >= 300000 ? 'idle_limit' : now - created >= 1800000 ? 'session_limit' : null;
62
64
  }
63
65
  export class DebuggerSessions {
64
66
  private sessions = new Map<string, Session>();
65
67
  private closing = false;
66
- constructor(private native: DebugNative = new AndroidDebugNative(), private workerFactory = createWorker, private prerequisite = prepareWorker) { }
68
+ constructor(private native: DebugNative = new AndroidDebugNative(), private workerFactory: (event: (event: WorkerEvent) => void) => Promise<DebugWorker> = createWorker, private prerequisite: () => Promise<unknown> = prepareWorker, private options: { platform?: 'android' | 'ios'; capabilities?: Record<string, boolean>; limitations?: string[] } = {}) { }
69
+ private get platform() { return this.options.platform ?? 'android'; }
70
+ private get workerResource() { return this.platform === 'ios' ? 'lldb_worker' : 'jdi_worker'; }
67
71
  private prune() {
68
72
  const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.cleanup.status === 'complete');
69
73
  for (const s of terminal)
@@ -80,9 +84,10 @@ export class DebuggerSessions {
80
84
  const success = payload.success ?? (!['failed', 'disconnected'].includes(s.state) && s.cleanup.status !== 'failed');
81
85
  const code = s.cleanup.status === 'failed' ? 'DEBUGGER_CLEANUP_FAILED' : s.state === 'disconnected' ? 'DEBUGGER_DISCONNECTED' : s.reason?.match(/^[A-Z_]+$/) ? s.reason : 'DEBUGGER_WORKER_FAILED';
82
86
  return structuredClone({ success, ...(!success ? { error: { code, message: code, retryable: false, diagnostic: s.cleanup.status === 'failed' ? diagnostic('cleanup', 'operation_failed') : s.diagnostic ?? diagnostic('unknown', 'unknown') } } : {}), debug_session_id: s.id,
83
- device: { platform: 'android', id: s.device }, target: s.target, state: s.state, captured_at_ms: Date.now(),
84
- capabilities: debuggerCapabilities, limitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
87
+ device: { platform: this.platform, id: s.device }, target: s.target, state: s.state, captured_at_ms: Date.now(),
88
+ capabilities: this.options.capabilities ?? debuggerCapabilities, limitations: this.options.limitations ?? androidLimitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
85
89
  }
90
+ owns(id: string) { return this.sessions.has(id); }
86
91
  summary(id: string) { return this.result(this.get(id)); }
87
92
  hasActiveSessions() { return [...this.sessions.values()].some(s => s.cleanup.status !== 'complete'); }
88
93
  paused(device: string) { const s = [...this.sessions.values()].find(s => s.device === device && s.state === 'paused'); return s ? this.result(s) : null; }
@@ -112,13 +117,13 @@ export class DebuggerSessions {
112
117
  s.events.shift();
113
118
  }
114
119
  async capabilities(device: string, app: string, pid?: number) {
115
- const prerequisites: Record<string, boolean> = { jdk: false, device: false, process: false };
120
+ const prerequisites: Record<string, boolean> = { [this.platform === 'ios' ? 'lldb' : 'jdk']: false, device: false, process: false };
116
121
  let candidates: DebugTarget[] = [];
117
122
  const reasons: string[] = [];
118
123
  const diagnostics: DebuggerDiagnostic[] = [];
119
124
  try {
120
125
  await atStage('prerequisite', 'DEBUGGER_UNSUPPORTED', () => this.prerequisite());
121
- prerequisites.jdk = true;
126
+ prerequisites[this.platform === 'ios' ? 'lldb' : 'jdk'] = true;
122
127
  }
123
128
  catch (e) {
124
129
  if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code))
@@ -139,9 +144,9 @@ export class DebuggerSessions {
139
144
  reasons.push(e instanceof DebuggerError ? e.code : 'DEVICE_UNAVAILABLE');
140
145
  diagnostics.push(classify(e, 'target_resolution', 'DEVICE_UNAVAILABLE').diagnostic ?? diagnostic('target_resolution', 'unknown'));
141
146
  }
142
- return { success: true, debug_session_id: null, device: { platform: 'android', id: device }, target: null, state: null,
147
+ return { success: true, debug_session_id: null, device: { platform: this.platform, id: device }, target: null, state: null,
143
148
  captured_at_ms: Date.now(), supported: Object.values(prerequisites).every(Boolean), prerequisites, candidates,
144
- ...(diagnostics.length ? { diagnostics } : {}), capabilities: debuggerCapabilities, reasons, limitations, cleanup: { status: 'complete', remaining_resources: [] } };
149
+ ...(diagnostics.length ? { diagnostics } : {}), capabilities: this.options.capabilities ?? debuggerCapabilities, reasons, limitations: this.options.limitations ?? androidLimitations, cleanup: { status: 'complete', remaining_resources: [] } };
145
150
  }
146
151
  async attach(device: string, app: string, pid?: number, timeout = 10000) {
147
152
  this.prune();
@@ -167,16 +172,19 @@ export class DebuggerSessions {
167
172
  if (candidates.length > 1)
168
173
  throw new DebuggerError('PROCESS_AMBIGUOUS');
169
174
  s.target = candidates[0];
175
+ if (this.platform === 'android') {
170
176
  stage = 'forwarding';
171
177
  s.port = await atStage('forwarding', 'DEBUGGER_ATTACH_FAILED', () => this.native.forward(device, s.target!.pid));
172
178
  remaining();
173
179
  s.cleanup.remaining_resources.push(`forward:tcp:${s.port}`);
180
+ }
174
181
  stage = 'worker_start';
175
182
  s.worker = await atStage('worker_start', 'DEBUGGER_ATTACH_FAILED', () => this.workerFactory(event => this.event(s, event)));
176
183
  remaining();
177
- s.cleanup.remaining_resources.push('jdi_worker');
184
+ s.cleanup.remaining_resources.push(this.workerResource);
185
+ if (this.platform === 'ios') await atStage('target_resolution', 'DEBUGGER_ATTACH_FAILED', () => this.native.identity(device, s.target!));
178
186
  stage = 'attach';
179
- await atStage('attach', 'DEBUGGER_ATTACH_FAILED', () => s.worker!.call('attach', { port: s.port, timeoutMs: remaining() }, remaining()));
187
+ await atStage('attach', 'DEBUGGER_ATTACH_FAILED', () => s.worker!.call('attach', { port: s.port, ...(this.platform === 'ios' ? { pid: s.target!.pid, executablePath: s.target!.executable_path } : {}), timeoutMs: remaining() }, remaining()));
180
188
  stage = 'target_resolution';
181
189
  await atStage('target_resolution', 'DEBUGGER_ATTACH_FAILED', () => this.native.identity(device, s.target!));
182
190
  remaining();
@@ -227,7 +235,7 @@ export class DebuggerSessions {
227
235
  s.worker = undefined;
228
236
  }
229
237
  catch {
230
- remaining.push('jdi_worker');
238
+ remaining.push(this.workerResource);
231
239
  }
232
240
  }
233
241
  if (s.port !== undefined && s.target) {
@@ -275,6 +283,7 @@ export class DebuggerSessions {
275
283
  private assertCommandTarget(s: Session, command: string, args: CommandArgs) {
276
284
  if (!s.worker || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state))
277
285
  throw new DebuggerError('DEBUGGER_DISCONNECTED', s.diagnostic ?? diagnostic('command', 'unavailable'));
286
+ if (command === 'set' && (this.platform === 'ios' ? typeof args.sourceFile !== 'string' || args.className !== undefined : typeof args.className !== 'string' || args.sourceFile !== undefined)) throw new DebuggerError('INVALID_ARGUMENT');
278
287
  if (['stack', 'locals', 'step', 'resume'].includes(command)) {
279
288
  if (args.stopId !== s.stop?.stop_id)
280
289
  throw new DebuggerError('DEBUGGER_STALE_REFERENCE');
@@ -291,6 +300,7 @@ export class DebuggerSessions {
291
300
  if (command === 'state' && (s.cleanup.status === 'complete' || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state)))
292
301
  return this.result(s);
293
302
  this.assertCommandTarget(s, command, args);
303
+ const previous = { state: s.state, stop: s.stop, pausedAt: s.pausedAt };
294
304
  if (command === 'step' || command === 'resume') {
295
305
  s.state = 'running';
296
306
  s.stop = null;
@@ -301,6 +311,7 @@ export class DebuggerSessions {
301
311
  return this.result(s, command === 'state' ? { breakpoints: data.breakpoints } : { result: data });
302
312
  }
303
313
  catch (e) {
314
+ if (['step', 'resume'].includes(command) && s.state === 'running' && s.stop === null && e instanceof DebuggerError && ['INVALID_ARGUMENT', 'DEBUGGER_STALE_REFERENCE'].includes(e.code)) Object.assign(s, previous);
304
315
  return this.handleCommandError(s, command, classify(e, 'command', 'DEBUGGER_WORKER_FAILED'));
305
316
  }
306
317
  }
@@ -324,4 +335,5 @@ export class DebuggerSessions {
324
335
  async shutdown() { this.closing = true; await Promise.allSettled([...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.serial(s, () => this.cleanup(s, true)))); return [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.result(s)); }
325
336
  }
326
337
  export const debuggerSessions = new DebuggerSessions();
327
- registerDebuggerPauseProvider(device => debuggerSessions.paused(device));
338
+ export const iosDebuggerSessions = new DebuggerSessions(new IOSDebugNative(), createIOSWorker, prepareIOSWorker, { platform: 'ios', capabilities: iosCapabilities, limitations: iosLimitations });
339
+ registerDebuggerPauseProvider((device, platform = 'android') => (platform === 'ios' ? iosDebuggerSessions : debuggerSessions).paused(device));
@@ -58,7 +58,7 @@ export interface DebugWorker {
58
58
  close(): Promise<void>
59
59
  }
60
60
  type WorkerFailureReason = 'spawn_failed' | 'process_exited' | 'write_failed' | 'invalid_message' | 'message_limit'
61
- export class JdiWorker implements DebugWorker {
61
+ export class DebuggerProcess implements DebugWorker {
62
62
  private child: ChildProcessWithoutNullStreams
63
63
  private pending = new Map<number, { resolve(value: WorkerReply): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout>; command: string }>()
64
64
  private sequence = 0
@@ -66,8 +66,8 @@ export class JdiWorker implements DebugWorker {
66
66
  private failure?: DebuggerError
67
67
  private replied = false
68
68
  private exited: Promise<void>
69
- constructor(java: string, directory: string, private onEvent: (event: WorkerEvent) => void) {
70
- this.child = spawn(java, ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'] })
69
+ constructor(java: string, directory: string, private onEvent: (event: WorkerEvent) => void, launch?: { args: string[]; env: NodeJS.ProcessEnv }) {
70
+ this.child = spawn(java, launch?.args ?? ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'], ...(launch ? { env: launch.env } : {}) })
71
71
  let buffer = ''
72
72
  this.exited = new Promise(resolve => { this.child.once('exit', () => { this.fail('process_exited'); resolve() }); this.child.once('error', () => { this.fail('spawn_failed'); resolve() }) })
73
73
  this.child.stdin.on('error', () => this.fail('write_failed'))
@@ -131,5 +131,8 @@ export class JdiWorker implements DebugWorker {
131
131
  }
132
132
  export async function createWorker(onEvent: (event: WorkerEvent) => void) {
133
133
  const { java, directory } = await prepareWorker()
134
- return new JdiWorker(java, directory, onEvent)
134
+ return new DebuggerProcess(java, directory, onEvent)
135
135
  }
136
+
137
+ // Compatibility export for existing JDI transport callers/tests.
138
+ export { DebuggerProcess as JdiWorker }
@@ -157,11 +157,22 @@ export class ToolsInteract {
157
157
  return await interact.tap(x, y, resolved.id, timeoutMs)
158
158
  }
159
159
 
160
+ private static _getResolvedElement(elementId: string, platform?: 'android' | 'ios', deviceId?: string) {
161
+ const resolved = ToolsInteract._resolvedUiElements.get(elementId)
162
+ if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId)) return null
163
+ return resolved
164
+ }
165
+
166
+ static getResolvedElementTarget(elementId: string, platform?: 'android' | 'ios', deviceId?: string) {
167
+ const resolved = ToolsInteract._getResolvedElement(elementId, platform, deviceId)
168
+ return resolved ? { platform: resolved.platform, deviceId: resolved.deviceId } : null
169
+ }
170
+
160
171
  static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }: { elementId: string, verificationMode?: 'none' | 'light' | 'full', timeoutMs?: number, platform?: 'android' | 'ios', deviceId?: string, freshTree?: UiTree }): Promise<TapElementResponse> {
161
172
  const actionType = 'tap_element'
162
173
  const selector = { elementId }
163
- const resolved = ToolsInteract._resolvedUiElements.get(elementId)
164
- if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId)) {
174
+ const resolved = ToolsInteract._getResolvedElement(elementId, platform, deviceId)
175
+ if (!resolved) {
165
176
  return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null)
166
177
  }
167
178
 
@@ -5,7 +5,7 @@ import type { ScopeTree } from '../targeting/target-scope.js'
5
5
  import type { UiChangeScopeResult, UiChangeSignatureSet } from '../model.js'
6
6
  import { remainingBudget } from '../../utils/operation-budget.js'
7
7
  import { checkDebuggerGuard } from '../../debugger/guard.js'
8
- import { observeReadinessWait, isDeviceUnavailable } from '../../observe/readiness.js'
8
+ import { observeReadinessWait, observeReadinessDeviceWait, isDeviceUnavailable } from '../../observe/readiness.js'
9
9
  import { readinessDelay } from '../../observe/readiness-service.js'
10
10
  import { _buildUiChangeSignatures, _summarizeUiChangeDelta, _matchesUiChange, _uiChangeSignaturesEqual } from './ui-change-helpers.js'
11
11
  import { createChangeWait, changeResult, type ChangeInput, type ChangeWait } from './ui-change-state.js'
@@ -24,11 +24,16 @@ function changed(s: ChangeWait, signatures: UiChangeSignatureSet) {
24
24
  s.candidate = signatures; s.change = change; s.since = s.clock.now()
25
25
  }
26
26
  const stable = s.since === null ? 0 : s.clock.now() - s.since
27
- return (s.stability === 0 || stable >= s.stability) && (!s.tracker || s.tracker.summary.state === 'settled')
27
+ return (s.stability === 0 || stable >= s.stability) && (!s.tracker || s.tracker.ready)
28
28
  }
29
29
  async function sample(s: ChangeWait, deps: ChangeDependencies) {
30
30
  if (s.tracker) remainingBudget()
31
31
  const started = s.clock.now(), a = s.input
32
+ await observeReadinessDeviceWait()
33
+ if (s.tracker?.deviceReadiness.input_ready === false) {
34
+ s.candidate = null; s.change = null; s.since = null
35
+ return null
36
+ }
32
37
  const tree = await deps.observe({ platform: a.platform, deviceId: a.deviceId })
33
38
  observeReadinessWait(tree, started)
34
39
  const scoped = deps.scope(tree, a.scope, a.target)
@@ -1,21 +1,22 @@
1
1
  import { systemClock } from '../../runtime/clock.js'
2
+ import { benchmarkStage } from '../../utils/benchmark-metrics.js'
2
3
  import type { UiTree } from '../observation.js'
3
4
  import { remainingBudget } from '../../utils/operation-budget.js'
4
5
  import { checkDebuggerGuard } from '../../debugger/guard.js'
5
- import { observeReadinessWait, isDeviceUnavailable } from '../../observe/readiness.js'
6
+ import { observeReadinessWait, observeReadinessDeviceWait, isDeviceUnavailable } from '../../observe/readiness.js'
6
7
  import { readinessDelay } from '../../observe/readiness-service.js'
7
8
  import { matchingElements, evaluateCondition } from './ui-wait-condition.js'
8
9
  import { createUIWait, validateUIWait, waitObserved, waitFailure,
9
10
  type UIWaitInput, type UIWaitDependencies, type UIWaitState, type UIWaitResult } from './ui-wait-state.js'
10
11
 
11
12
  async function delay(s: UIWaitState, ms: number) {
12
- await (s.tracker && s.clock === systemClock ? readinessDelay(ms) : s.clock.sleep(ms))
13
+ await benchmarkStage('poll_sleep_ms', () => s.tracker && s.clock === systemClock ? readinessDelay(ms) : s.clock.sleep(ms))
13
14
  }
14
15
  function acceptObservation(s: UIWaitState, tree: UiTree, now: number): UIWaitResult | null {
15
16
  const age = typeof tree?.captured_at_ms === 'number' ? now - tree.captured_at_ms : null
16
17
  if (!(s.met && (age === null || age <= 500))) { s.stable = 0; return null }
17
18
  s.stable++
18
- if (s.stable < (s.args.singleObservation ? 1 : 2) || (s.tracker && s.tracker.summary.state !== 'settled')) return null
19
+ if (s.stable < (s.args.singleObservation ? 1 : 2) || (s.tracker && !s.tracker.ready)) return null
19
20
  return { status: 'success', ...(s.args.singleObservation ? { _tree: tree } : {}), matched: s.count,
20
21
  element: s.element, metrics: { latency_ms: now - s.start, poll_count: s.polls, attempts: s.attempts }, requested: s.requested, observed: waitObserved(s, true) }
21
22
  }
@@ -42,6 +43,8 @@ async function poll(s: UIWaitState, deps: UIWaitDependencies): Promise<UIWaitRes
42
43
  remainingBudget(); s.polls++
43
44
  try {
44
45
  const started = s.clock.now(), a = s.args
46
+ await observeReadinessDeviceWait()
47
+ if (s.tracker?.deviceReadiness.input_ready === false) return null
45
48
  const tree = await deps.observe({ platform: a.platform, deviceId: a.deviceId })
46
49
  observeReadinessWait(tree, started)
47
50
  if (a.resolveTapTarget && tree?.error && /ACTION_TIMEOUT|timed out/i.test(String(tree.error))) throw new Error('ACTION_TIMEOUT')
@@ -1,3 +1,4 @@
1
+ import { validAppId } from '../utils/app-installation.js'
1
2
  import type { IOSBuildOptions } from './ios.js'
2
3
  import { promises as fs } from 'fs'
3
4
  import path from 'path'
@@ -303,6 +304,7 @@ export class ToolsManage {
303
304
  }
304
305
 
305
306
  static async listDevicesHandler({ platform, appId }: { platform?: 'android' | 'ios', appId?: string }) {
307
+ if (appId !== undefined && !validAppId(appId)) throw new Error('Invalid appId')
306
308
  const devices = await listDevices(platform, appId)
307
309
  return { devices }
308
310
  }
package/src/manage/ios.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js'
1
2
  import { buildIOS } from './ios/build.js'
2
3
  import type { IOSBuildOptions } from './ios/build-types.js'
3
4
  export type { IOSBuildOptions } from './ios/build-types.js'
@@ -160,6 +161,7 @@ export class iOSManage {
160
161
 
161
162
  async function installWithIdb(toInstall: string, deviceId: string) {
162
163
  await new Promise<void>((resolve, reject) => {
164
+ checkDebuggerGuard(true)
163
165
  const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', deviceId]);
164
166
  let stderr = '';
165
167
  proc.stderr.on('data', d => stderr += d.toString());
@@ -0,0 +1,96 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import type { DeviceReadiness } from '../contracts/device-readiness.js'
3
+ import type { DeviceInfo } from '../types.js'
4
+ import { pausedReadiness } from '../debugger/guard.js'
5
+ import { execCmd } from '../utils/exec.js'
6
+ import { getAdbCmd } from '../utils/android/commands.js'
7
+ import { getXcrunCmd } from '../utils/ios/utils.js'
8
+ import { remainingBudget } from '../utils/operation-budget.js'
9
+ import { benchmarkStage } from '../utils/benchmark-metrics.js'
10
+
11
+ export function unknownDeviceReadiness(code?: string): DeviceReadiness {
12
+ return { wakefulness: 'unknown', lock_state: 'unknown', input_ready: null, blockers: [], evidence: [], limitations: code ? [code] : [] }
13
+ }
14
+ function values(text: string, pattern: RegExp) { return [...text.matchAll(pattern)].map(match => match[1]) }
15
+ function unique(items: string[]) { return items.length > 0 && new Set(items).size === 1 ? items[0] : null }
16
+ export function parseAndroidPower(text: string): DeviceReadiness['wakefulness'] {
17
+ // Later vendor/debug sections can repeat mWakefulness as a numeric enum.
18
+ // Classify only the named current-state section when its boundary is present.
19
+ const main = text.split('Power Manager State:')[1]
20
+ if (main?.includes('Settings and Configuration:')) text = main.split('Settings and Configuration:')[0]
21
+ const wake = unique(values(text, /^\s*mWakefulness=(\w+)\s*$/gm))
22
+ const changing = unique(values(text, /^\s*mWakefulnessChanging=(true|false)\s*$/gm))
23
+ if (!wake || !changing) return 'unknown'
24
+ if (changing === 'true') return 'transitioning'
25
+ return wake === 'Awake' ? 'awake' : ['Asleep', 'Dozing'].includes(wake) ? 'asleep' : 'unknown'
26
+ }
27
+ export function parseAndroidLock(text: string): DeviceReadiness['lock_state'] {
28
+ const section = text.split(/\bKeyguardServiceDelegate\s*\n/)[1]
29
+ if (!section) return 'unknown'
30
+ const showing = unique(values(section, /^\s*(?:showing|mIsShowing)=(true|false)\s*$/gm))
31
+ return showing === 'true' ? 'locked' : showing === 'false' ? 'unlocked' : 'unknown'
32
+ }
33
+ export function finishDeviceReadiness(state: DeviceReadiness): DeviceReadiness {
34
+ if (state.wakefulness === 'asleep') state.blockers.push('DEVICE_ASLEEP')
35
+ if (state.wakefulness === 'transitioning') state.blockers.push('DEVICE_TRANSITIONING')
36
+ if (state.lock_state === 'locked') state.blockers.push('DEVICE_LOCKED')
37
+ state.blockers = [...new Set(state.blockers)]
38
+ state.input_ready = state.blockers.length ? false : null
39
+ return state
40
+ }
41
+ export function parseIosLock(text: string): DeviceReadiness['lock_state'] {
42
+ try {
43
+ const data = JSON.parse(text)
44
+ if (data.error?.domain === 'com.apple.dt.CoreDeviceError' && data.error.code === 1001 &&
45
+ data.error.userInfo?.CapabilityFeatureIdentifier?.string === 'com.apple.coredevice.feature.getlockstate') return 'unsupported'
46
+ // Successful response semantics require runtime validation before classification.
47
+ } catch { /* Non-JSON output supplies no state evidence. */ }
48
+ return 'unknown'
49
+ }
50
+ type Probe = (command: string, args: string[], timeout: number) => Promise<{ exitCode: number | null; stdout: string; stderr: string }>
51
+ const probes = new AsyncLocalStorage<Probe>()
52
+ export function withDeviceReadinessProbe<T>(probe: Probe, operation: () => Promise<T>) { return probes.run(probe, operation) }
53
+ const nativeProbe: Probe = (command, args, timeout) => execCmd(command, args, { timeout })
54
+ export async function observeDeviceReadiness(device?: Pick<DeviceInfo, 'platform' | 'id'> | null, probe: Probe = probes.getStore() ?? nativeProbe): Promise<DeviceReadiness> {
55
+ return benchmarkStage('device_readiness_ms', () => observe(device, probe))
56
+ }
57
+ async function observe(device: Pick<DeviceInfo, 'platform' | 'id'> | null | undefined, probe: Probe): Promise<DeviceReadiness> {
58
+ const state = unknownDeviceReadiness()
59
+ if (!device?.id || !['android', 'ios'].includes(device.platform) || ['booted', 'default'].includes(device.id)) return unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE')
60
+ const paused = pausedReadiness(device.id, device.platform)
61
+ if (paused) {
62
+ state.blockers.push('DEBUGGER_PAUSED')
63
+ state.evidence.push({ code: 'DEBUGGER_PAUSED', source: 'debugger', captured_at_ms: paused.captured_at_ms })
64
+ return finishDeviceReadiness(state)
65
+ }
66
+ if (device.platform === 'android') {
67
+ await androidProbe(state, device.id, 'power', probe)
68
+ await androidProbe(state, device.id, 'window', probe)
69
+ } else {
70
+ state.limitations.push('DEVICE_WAKEFULNESS_UNVERIFIED')
71
+ try {
72
+ const timeout = remainingBudget(1000)
73
+ // CoreDevice requires a CLI timeout of at least five seconds; the parent
74
+ // subprocess deadline still caps this read at one second.
75
+ const result = await probe(getXcrunCmd(), ['devicectl', 'device', 'info', 'lockState', '--device', device.id, '--timeout', '5', '--json-output', '-'], timeout)
76
+ remainingBudget()
77
+ state.lock_state = parseIosLock(result.stdout)
78
+ if (state.lock_state === 'unsupported') state.evidence.push({ code: 'DEVICE_LOCK_SIGNAL_UNSUPPORTED', source: 'coredevice', captured_at_ms: Date.now() })
79
+ else state.limitations.push('DEVICE_LOCK_OBSERVATION_UNAVAILABLE')
80
+ } catch { state.limitations.push('DEVICE_LOCK_OBSERVATION_FAILED') }
81
+ }
82
+ return finishDeviceReadiness(state)
83
+ }
84
+ async function androidProbe(state: DeviceReadiness, id: string, kind: 'power' | 'window', probe: Probe) {
85
+ try {
86
+ const timeout = remainingBudget(1000)
87
+ const result = await probe(getAdbCmd(), ['-s', id, 'shell', 'dumpsys', kind, ...(kind === 'window' ? ['policy'] : [])], timeout)
88
+ remainingBudget()
89
+ if (result.exitCode !== 0) throw new Error('PROBE_FAILED')
90
+ const value = kind === 'power' ? parseAndroidPower(result.stdout) : parseAndroidLock(result.stdout)
91
+ if (kind === 'power') state.wakefulness = value as DeviceReadiness['wakefulness']
92
+ else state.lock_state = value as DeviceReadiness['lock_state']
93
+ if (value === 'unknown') state.limitations.push(kind === 'power' ? 'DEVICE_WAKEFULNESS_UNVERIFIED' : 'DEVICE_LOCK_STATE_UNVERIFIED')
94
+ else state.evidence.push({ code: `DEVICE_${value.toUpperCase()}`, source: `android_${kind}`, captured_at_ms: Date.now() })
95
+ } catch { state.limitations.push(kind === 'power' ? 'DEVICE_POWER_OBSERVATION_FAILED' : 'DEVICE_LOCK_OBSERVATION_FAILED') }
96
+ }
@@ -214,15 +214,25 @@ export function refreshJourneyEvidence(steps: Array<{ result?: { evidence?: unkn
214
214
  const current = context.getStore()
215
215
  if (!current) return
216
216
  for (const step of steps) {
217
- const previous = step.result?.evidence as { action_id?: string } | undefined
217
+ const previous = step.result?.evidence as { action_id?: string; observations?: Array<{ evidence_id: string }> } | undefined
218
218
  const child = current.children.find(c => c.action?.id === previous?.action_id)
219
219
  if (child && step.result) step.result.evidence = assemble(child)
220
+ else if (previous?.observations && step.result) {
221
+ const ids = new Set(previous.observations.map(o => o.evidence_id))
222
+ step.result.evidence = assemble({ ...current, observations: current.observations.filter(o => ids.has(o.value.evidence_id)) })
223
+ }
220
224
  }
221
225
  }
222
226
 
223
227
  export function completeJourneyVerification(actionId: string) {
224
228
  const child = context.getStore()?.children.find(c => c.action?.id === actionId)
225
229
  if (child?.action) {
230
+ const parent = context.getStore()!
231
+ const completed = child.action.dispatchEnd?.mono
232
+ if (completed !== undefined) {
233
+ const after = parent.observations.filter(o => o.start.mono >= completed && sameDevice(o.value.device, child.action!.device))
234
+ child.observations.push(...after)
235
+ }
226
236
  child.verification = stamp()
227
237
  child.action.verification = child.verification
228
238
  }
@@ -312,3 +322,22 @@ function recordCaptureTime(acquisition: Acquisition, result: EvidencePayload | n
312
322
  acquisition.reused = source < start.wall
313
323
  }
314
324
  }
325
+
326
+ export class JourneyStepError extends Error {
327
+ constructor(readonly evidence: ReturnType<typeof assemble>, cause: unknown) {
328
+ super(cause instanceof Error ? cause.message : String(cause), { cause })
329
+ }
330
+ }
331
+
332
+ /** Retain per-step evidence without changing the parent journey's acquisition flow. */
333
+ export async function withJourneyStepEvidence<T>(operation: () => Promise<T>) {
334
+ const current = context.getStore()
335
+ const start = current?.observations.length ?? 0
336
+ try {
337
+ const result = await operation()
338
+ return { ...result, ...(current ? { evidence: assemble({ ...current, observations: current.observations.slice(start) }) } : {}) }
339
+ } catch (error) {
340
+ if (current) throw new JourneyStepError(assemble({ ...current, observations: current.observations.slice(start) }), error)
341
+ throw error
342
+ }
343
+ }