mobile-debug-mcp 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.cursor-plugin/marketplace.json +1 -1
  2. package/.github/workflows/ci.yml +1 -1
  3. package/README.md +86 -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 +253 -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 +36 -16
  19. package/dist/debugger/guard.js +4 -4
  20. package/dist/debugger/ios-worker.js +62 -0
  21. package/dist/debugger/ios.js +57 -0
  22. package/dist/debugger/sessions.js +33 -16
  23. package/dist/debugger/worker.js +6 -4
  24. package/dist/interact/index.js +12 -2
  25. package/dist/manage/ios.js +2 -0
  26. package/dist/observe/ios-trace/artifact.js +63 -0
  27. package/dist/observe/ios-trace/capabilities.js +86 -0
  28. package/dist/observe/ios-trace/native.js +93 -0
  29. package/dist/observe/ios-trace/notification.js +35 -0
  30. package/dist/observe/ios-trace/process.js +98 -0
  31. package/dist/observe/ios-trace/sessions.js +244 -0
  32. package/dist/observe/readiness-service.js +1 -1
  33. package/dist/observe/readiness.js +4 -4
  34. package/dist/observe/system-trace-error.js +7 -0
  35. package/dist/observe/system-trace-router.js +81 -0
  36. package/dist/observe/system-trace.js +4 -8
  37. package/dist/server/handlers/debugger-handlers.js +27 -8
  38. package/dist/server/handlers/diagnostic.js +12 -4
  39. package/dist/server/handlers/tool-handlers.js +19 -9
  40. package/dist/server/schemas/debugger-schema.js +10 -7
  41. package/dist/server/schemas/system-trace-schema.js +14 -9
  42. package/dist/server-core.js +1 -1
  43. package/dist/server.js +4 -4
  44. package/dist/utils/ios/utils.js +3 -0
  45. package/docs/CHANGELOG.md +23 -1
  46. package/docs/agent-plugin-installation-verification.md +39 -0
  47. package/docs/code-structure.md +3 -1
  48. package/docs/specs/031-ios-system-traces/intent.md +38 -0
  49. package/docs/specs/031-ios-system-traces/investigation.md +92 -0
  50. package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
  51. package/docs/specs/031-ios-system-traces/plan.md +138 -0
  52. package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
  53. package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
  54. package/docs/specs/031-ios-system-traces/spec.md +135 -0
  55. package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
  56. package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
  57. package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
  58. package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
  59. package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
  60. package/docs/tools/TOOLS.md +2 -2
  61. package/docs/tools/debugger.md +45 -5
  62. package/docs/tools/system-traces.md +81 -14
  63. package/package.json +2 -1
  64. package/scripts/agent-plugin.mjs +55 -5
  65. package/src/debugger/android.ts +1 -1
  66. package/src/debugger/guard.ts +5 -5
  67. package/src/debugger/ios-worker.ts +49 -0
  68. package/src/debugger/ios.ts +45 -0
  69. package/src/debugger/lldb/breakpoints.py +51 -0
  70. package/src/debugger/lldb/inspection.py +109 -0
  71. package/src/debugger/lldb/worker.py +203 -0
  72. package/src/debugger/sessions.ts +24 -12
  73. package/src/debugger/worker.ts +7 -4
  74. package/src/interact/index.ts +13 -2
  75. package/src/manage/ios.ts +2 -0
  76. package/src/observe/ios-trace/artifact.ts +55 -0
  77. package/src/observe/ios-trace/capabilities.ts +74 -0
  78. package/src/observe/ios-trace/native.ts +72 -0
  79. package/src/observe/ios-trace/notification.ts +30 -0
  80. package/src/observe/ios-trace/process.ts +82 -0
  81. package/src/observe/ios-trace/sessions.ts +204 -0
  82. package/src/observe/readiness-service.ts +1 -1
  83. package/src/observe/readiness.ts +4 -4
  84. package/src/observe/system-trace-error.ts +3 -0
  85. package/src/observe/system-trace-router.ts +61 -0
  86. package/src/observe/system-trace.ts +4 -4
  87. package/src/server/handlers/debugger-handlers.ts +28 -9
  88. package/src/server/handlers/diagnostic.ts +11 -4
  89. package/src/server/handlers/tool-handlers.ts +20 -9
  90. package/src/server/schemas/debugger-schema.ts +10 -7
  91. package/src/server/schemas/system-trace-schema.ts +15 -9
  92. package/src/server-core.ts +1 -1
  93. package/src/server.ts +3 -3
  94. package/src/utils/ios/utils.ts +3 -0
  95. package/test/device/automated/debugger/runtime.ios.test.ts +221 -0
  96. package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
  97. package/test/fixtures/contracts/tool-definitions.json +192 -32
  98. package/test/fixtures/ios-debugger-app/Main.swift +95 -0
  99. package/test/fixtures/ios-debugger-app/README.md +27 -0
  100. package/test/fixtures/ios-debugger-app/build.ts +21 -0
  101. package/test/helpers/ios-trace-shutdown.ts +29 -0
  102. package/test/unit/debugger/handle-guards.test.ts +88 -0
  103. package/test/unit/debugger/ios-preparation.test.ts +57 -0
  104. package/test/unit/debugger/ios.test.ts +104 -0
  105. package/test/unit/debugger/lldb-worker.py +166 -0
  106. package/test/unit/debugger/lldb-worker.test.ts +10 -0
  107. package/test/unit/observe/ios-trace-native.test.ts +90 -0
  108. package/test/unit/observe/ios-trace-sessions.test.ts +131 -0
  109. package/test/unit/observe/readiness.test.ts +1 -1
  110. package/test/unit/observe/system-trace-router.test.ts +49 -0
  111. package/test/unit/quality/agent-plugin-export.test.ts +38 -0
  112. package/test/unit/server/system-trace.test.ts +12 -3
@@ -0,0 +1,45 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import path from 'node:path'
4
+ import { realpath } from 'node:fs/promises'
5
+ import { getXcrunCmd } from '../utils/ios/utils.js'
6
+ import type { DebugNative, DebugTarget } from './android.js'
7
+ import { DebuggerError, debuggerRemaining, debuggerSignal, nativeError } from './worker.js'
8
+ const exec = promisify(execFile)
9
+ export async function iosExec(file: string, args: string[], env?: NodeJS.ProcessEnv) {
10
+ try { return (await exec(file, args, { timeout: debuggerRemaining(5000), signal: debuggerSignal(), maxBuffer: 1024 * 1024, ...(env ? { env } : {}) })).stdout.trim() }
11
+ catch (e) { throw nativeError(e, 'DEVICE_UNAVAILABLE') }
12
+ }
13
+ export class IOSDebugNative implements DebugNative {
14
+ constructor(private run = iosExec) {}
15
+ private sim(args: string[]) { return this.run(getXcrunCmd(), ['simctl', ...args]) }
16
+ async candidates(device: string, app: string): Promise<DebugTarget[]> {
17
+ if (!/^[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}$/.test(device) || !/^[A-Za-z0-9][A-Za-z0-9.-]+$/.test(app)) throw new DebuggerError('INVALID_ARGUMENT')
18
+ const inventory = JSON.parse(await this.sim(['list', 'devices', 'booted', '--json'])) as { devices: Record<string, { udid: string; state: string }[]> }
19
+ if (!Object.values(inventory.devices).flat().some(d => d.udid === device && d.state === 'Booted')) throw new DebuggerError('DEBUGGER_UNSUPPORTED')
20
+ const services = await this.sim(['spawn', device, 'launchctl', 'list'])
21
+ const pids = this.processes(services, app)
22
+ if (!pids.length) return []
23
+ const container = await this.sim(['get_app_container', device, app, 'app'])
24
+ const executable = await this.run('/usr/bin/plutil', ['-extract', 'CFBundleExecutable', 'raw', '-o', '-', path.join(container, 'Info.plist')])
25
+ if (path.basename(executable) !== executable || !executable) throw new DebuggerError('PROCESS_NOT_FOUND')
26
+ const executablePath = await realpath(path.join(container, executable))
27
+ return Promise.all(pids.map(pid => this.identity(device, { app_id: app, pid, process_name: executable, executable_path: executablePath, start_identity: '', uid: null })))
28
+ }
29
+ private processes(services: string, app: string) {
30
+ return services.split('\n').flatMap(line => {
31
+ const [pid, , label] = line.trim().split(/\s+/)
32
+ return /^\d+$/.test(pid) && label?.startsWith(`UIKitApplication:${app}[`) ? [Number(pid)] : []
33
+ })
34
+ }
35
+ async identity(device: string, target: DebugTarget): Promise<DebugTarget> {
36
+ const services = await this.sim(['spawn', device, 'launchctl', 'list'])
37
+ if (!this.processes(services, target.app_id).includes(target.pid)) throw new DebuggerError('PROCESS_CHANGED')
38
+ const command = await this.run('/bin/ps', ['-ww', '-p', String(target.pid), '-o', 'comm='])
39
+ const started = await this.run('/bin/ps', ['-p', String(target.pid), '-o', 'lstart='])
40
+ if (command !== target.executable_path || !started || (target.start_identity && started !== target.start_identity)) throw new DebuggerError('PROCESS_CHANGED')
41
+ return { ...target, start_identity: started }
42
+ }
43
+ async forward(): Promise<number> { throw new DebuggerError('DEBUGGER_UNSUPPORTED') }
44
+ async removeForward() { throw new DebuggerError('DEBUGGER_UNSUPPORTED') }
45
+ }
@@ -0,0 +1,51 @@
1
+ """Owned source breakpoints with exact-line checking, including late image loads."""
2
+ import os
3
+ import uuid
4
+ import lldb
5
+
6
+
7
+ class Breakpoints:
8
+ def __init__(self, target):
9
+ self.target = target
10
+ self.owned = {}
11
+
12
+ def describe(self, key):
13
+ bp, source, line = self.owned[key]
14
+ locations = []
15
+ rejected = False
16
+ for loc in bp:
17
+ entry = loc.GetAddress().GetLineEntry()
18
+ actual = str(entry.GetFileSpec())
19
+ exact = entry.IsValid() and os.path.normpath(actual) == source and entry.GetLine() == line
20
+ loc.SetEnabled(exact)
21
+ if exact:
22
+ locations.append({"source_file": actual, "line": line})
23
+ else:
24
+ rejected = True
25
+ known_source = self.target.FindCompileUnits(lldb.SBFileSpec(source)).GetSize() > 0
26
+ status = "resolved" if locations else "rejected" if rejected or known_source else "pending"
27
+ return {"breakpoint_id": key, "status": status, "locations": locations}
28
+
29
+ def set(self, args):
30
+ source = os.path.normpath(args["sourceFile"])
31
+ bp = self.target.BreakpointCreateByLocation(lldb.SBFileSpec(source), args["line"], 0, 0, lldb.SBFileSpecList(), False)
32
+ key = str(uuid.uuid4())
33
+ self.owned[key] = (bp, source, args["line"])
34
+ result = self.describe(key)
35
+ if result["status"] == "rejected":
36
+ self.target.BreakpointDelete(bp.GetID())
37
+ del self.owned[key]
38
+ return result
39
+
40
+ def refresh(self):
41
+ return [self.describe(key) for key in list(self.owned)]
42
+
43
+ def remove(self, key):
44
+ if key in self.owned:
45
+ bp, _, _ = self.owned.pop(key)
46
+ self.target.BreakpointDelete(bp.GetID())
47
+ return {"removed": True}
48
+
49
+ def clear(self):
50
+ for key in list(self.owned):
51
+ self.remove(key)
@@ -0,0 +1,109 @@
1
+ """Bounded, static inspection. Never evaluate expressions or object descriptions."""
2
+ import uuid
3
+ import re
4
+ import lldb
5
+
6
+
7
+ def location(frame):
8
+ entry = frame.GetLineEntry()
9
+ file = entry.GetFileSpec()
10
+ source = str(file)[:512] if file.IsValid() else None
11
+ return {"source_file": source, "line": entry.GetLine() if entry.IsValid() else None,
12
+ "method_name": (frame.GetFunctionName() or "")[:512], "class_name": None}
13
+
14
+
15
+ class Inspection:
16
+ def __init__(self):
17
+ self.invalidate()
18
+
19
+ def invalidate(self):
20
+ self.stop_id = None
21
+ self.threads = {}
22
+ self.frames = {}
23
+
24
+ def stopped(self, process):
25
+ self.invalidate()
26
+ self.stop_id = str(uuid.uuid4())
27
+ selected = None
28
+ chosen = process.GetSelectedThread()
29
+ threads = [chosen] if chosen.IsValid() else []
30
+ for index in range(min(process.GetNumThreads(), 100)):
31
+ thread = process.GetThreadAtIndex(index)
32
+ if not chosen.IsValid() or thread.GetThreadID() != chosen.GetThreadID():
33
+ threads.append(thread)
34
+ for thread in threads[:100]:
35
+ key = str(uuid.uuid4())
36
+ self.threads[key] = thread
37
+ if selected is None and thread.GetStopReason() not in (lldb.eStopReasonNone, lldb.eStopReasonInvalid):
38
+ selected = key
39
+ if selected is None or process.GetState() != lldb.eStateStopped:
40
+ self.invalidate()
41
+ return None
42
+ thread = self.threads.get(selected)
43
+ return {"event": "stopped", "stop_id": self.stop_id, "thread_id": selected,
44
+ "reason": stop_reason(thread),
45
+ "location": location(thread.GetFrameAtIndex(0)) if thread else None,
46
+ "threads": [{"thread_id": key, "name": (t.GetName() or "")[:256]} for key, t in self.threads.items()],
47
+ "threads_truncated": process.GetNumThreads() > 100}
48
+
49
+ def thread(self, args):
50
+ if args.get("stopId") != self.stop_id or args.get("threadId") not in self.threads:
51
+ raise ValueError("DEBUGGER_STALE_REFERENCE")
52
+ return self.threads[args["threadId"]]
53
+
54
+ def stack(self, args):
55
+ thread = self.thread(args)
56
+ offset, limit = args.get("offset", 0), args.get("limit", 20)
57
+ count = min(thread.GetNumFrames(), 1000)
58
+ frames = []
59
+ for index in range(offset, min(count, offset + limit)):
60
+ frame = thread.GetFrameAtIndex(index)
61
+ key = self.stop_id + ":" + args["threadId"] + ":" + str(index)
62
+ if key not in self.frames and len(self.frames) >= 1000:
63
+ break
64
+ self.frames[key] = frame
65
+ frames.append({"frame_id": key, "index": index, **location(frame)})
66
+ return {"frames": frames, "truncated": count > offset + len(frames) or thread.GetNumFrames() > 1000}
67
+
68
+ def locals(self, args):
69
+ if args.get("stopId") != self.stop_id or args.get("frameId") not in self.frames:
70
+ raise ValueError("DEBUGGER_STALE_REFERENCE")
71
+ frame = self.frames[args["frameId"]]
72
+ values = frame.GetVariables(True, True, False, True, lldb.eNoDynamicValues)
73
+ result = []
74
+ for index in range(min(values.GetSize(), 100)):
75
+ value = values.GetValueAtIndex(index).GetNonSyntheticValue()
76
+ name, kind = (value.GetName() or "")[:256], (value.GetTypeName() or "")[:256]
77
+ if not value.GetError().Success():
78
+ result.append({"name": name, "type": kind, "availability": "unavailable"})
79
+ continue
80
+ primitive = value.GetType().GetTypeFlags() & (lldb.eTypeIsScalar | lldb.eTypeIsInteger | lldb.eTypeIsFloat)
81
+ text = value.GetValue() if primitive else None
82
+ # Swift value wrappers are not C scalars. Decode only standard primitive storage.
83
+ if kind in ("Swift.Int", "Swift.Int32", "Swift.Int64", "Swift.UInt", "Swift.Bool", "Swift.Double", "Swift.Float"):
84
+ storage = value.GetChildMemberWithName("_value").GetNonSyntheticValue()
85
+ if storage.IsValid() and storage.GetError().Success():
86
+ text = storage.GetValue()
87
+ if kind == "Swift.Bool" and text in ("0", "1"):
88
+ text = "true" if text == "1" else "false"
89
+ result.append({"name": name, "type": kind, "value": text[:256] if text else None,
90
+ "kind": "primitive" if text is not None else "object",
91
+ "truncated": bool(text and len(text) > 256),
92
+ "object_id": self.object_identity(value, kind) if text is None else None})
93
+ return {"availability": "available" if result else "unknown", "locals": result,
94
+ "reason": None if result else "EMPTY_OR_UNAVAILABLE_VARIABLE_METADATA",
95
+ "truncated": values.GetSize() > 100}
96
+
97
+ def object_identity(self, value, kind):
98
+ pointer = value.GetValue()
99
+ if pointer and re.fullmatch(r"0x[0-9a-fA-F]+", pointer):
100
+ return str(uuid.uuid5(uuid.UUID(self.stop_id), kind + ":" + pointer))
101
+ return None
102
+
103
+
104
+ def stop_reason(thread):
105
+ if thread is None:
106
+ return "unknown"
107
+ return {lldb.eStopReasonBreakpoint: "breakpoint", lldb.eStopReasonPlanComplete: "step",
108
+ lldb.eStopReasonSignal: "signal", lldb.eStopReasonException: "exception",
109
+ lldb.eStopReasonWatchpoint: "watchpoint", lldb.eStopReasonExec: "exec"}.get(thread.GetStopReason(), "unknown")
@@ -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
 
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());