@phone-use/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1848 @@
1
+ import { a as registerBackend, c as DeviceInUseError, d as SessionNotFoundError, f as TimeoutError, i as listBackends, l as DeviceNotFoundError, m as toPhoneUseError, n as BaseDeviceBackend, o as AbortedError, p as UnsupportedCapabilityError, r as getBackendFactory, s as ActionFailedError, t as ALL_CAPABILITIES, u as PhoneUseError } from "./device-BzPnHvQy.mjs";
2
+ import { createAgentDeviceClient } from "agent-device";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ //#region src/observe.ts
6
+ const NOISE_LABELS = /* @__PURE__ */ new Set([
7
+ "|",
8
+ "(",
9
+ ")",
10
+ ",",
11
+ "·",
12
+ "•"
13
+ ]);
14
+ function isNoise(n) {
15
+ const kind = n.type ?? n.role ?? "";
16
+ if (!n.label) return false;
17
+ return (kind === "StaticText" || kind === "Other") && NOISE_LABELS.has(n.label.trim());
18
+ }
19
+ function intersectsViewport(rect, vw, vh) {
20
+ if (!rect) return true;
21
+ return rect.x < vw && rect.y < vh && rect.x + rect.width > 0 && rect.y + rect.height > 0;
22
+ }
23
+ const RENDER_TEXT_MAX = 160;
24
+ function renderText(s) {
25
+ return s.length <= RENDER_TEXT_MAX ? JSON.stringify(s) : `${JSON.stringify(s.slice(0, RENDER_TEXT_MAX))} [truncated]`;
26
+ }
27
+ function formatNode(n, opts) {
28
+ const role = n.role ?? n.type ?? "element";
29
+ const parts = [`${n.ref && !n.ref.startsWith("@") ? `@${n.ref}` : n.ref ?? ""} [${role}]`];
30
+ const label = n.label ?? n.identifier;
31
+ if (label) parts.push(renderText(label));
32
+ if (n.value && n.value !== n.label) parts.push(`value=${renderText(n.value)}`);
33
+ if (n.rect) parts.push(`(${Math.round(n.rect.x)},${Math.round(n.rect.y)} ${Math.round(n.rect.width)}x${Math.round(n.rect.height)})`);
34
+ if (n.rect && opts.vw !== void 0) {
35
+ const cx = n.rect.x + n.rect.width / 2;
36
+ if (cx < 0 || cx > opts.vw) parts.push("(center off-screen)");
37
+ }
38
+ if (n.enabled === false) parts.push("(disabled)");
39
+ if (n.selected) parts.push("(selected)");
40
+ if (n.focused && !opts.suppressFocused) parts.push("(focused)");
41
+ if (n.interactionBlocked) parts.push(`(blocked: ${n.interactionBlocked})`);
42
+ return parts.join(" ");
43
+ }
44
+ function keptViewportNodes(nodes) {
45
+ const root = nodes.find((n) => (n.type === "Application" || n.type === "Window") && n.rect);
46
+ const vw = root?.rect?.width ?? 500;
47
+ const vh = root?.rect?.height ?? 1e3;
48
+ const suppressFocused = nodes.filter((n) => n.focused).length > nodes.length / 3;
49
+ const kept = [];
50
+ let above = 0;
51
+ let below = 0;
52
+ for (const n of nodes) {
53
+ if (isNoise(n)) continue;
54
+ if (!intersectsViewport(n.rect, vw, vh)) {
55
+ if (n.rect && n.rect.y >= vh) below += 1;
56
+ else above += 1;
57
+ continue;
58
+ }
59
+ kept.push(n);
60
+ }
61
+ return {
62
+ kept,
63
+ above,
64
+ below,
65
+ suppressFocused,
66
+ vw
67
+ };
68
+ }
69
+ function compressNodes(nodes) {
70
+ const { kept, above, below, suppressFocused, vw } = keptViewportNodes(nodes);
71
+ const lines = kept.map((n) => formatNode(n, {
72
+ suppressFocused,
73
+ vw
74
+ }));
75
+ if (above > 0) lines.unshift(`[${above} elements above the viewport — scroll up to reach them]`);
76
+ if (below > 0) lines.push(`[${below} elements below the viewport — scroll down to reach them]`);
77
+ return lines.join("\n");
78
+ }
79
+ function elementKey(n) {
80
+ return `${n.role ?? n.type ?? "element"}|${(n.label ?? n.identifier ?? "").trim()}|${n.rect ? `${Math.round(n.rect.x)},${Math.round(n.rect.y)}` : ""}`;
81
+ }
82
+ function arraysEqual(a, b) {
83
+ if (a.length !== b.length) return false;
84
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
85
+ return true;
86
+ }
87
+ const TAPPABLE = /* @__PURE__ */ new Set([
88
+ "Button",
89
+ "Cell",
90
+ "Link",
91
+ "MenuItem",
92
+ "Tab",
93
+ "StaticText",
94
+ "Switch"
95
+ ]);
96
+ const EDITABLE = /* @__PURE__ */ new Set([
97
+ "SearchField",
98
+ "TextField",
99
+ "SecureTextField"
100
+ ]);
101
+ const EDITABLE_MULTILINE = /* @__PURE__ */ new Set([
102
+ ...EDITABLE,
103
+ "TextView",
104
+ "TextEditor"
105
+ ]);
106
+ function labelTokens(s) {
107
+ return s.toLowerCase().split(/[^a-z0-9]+/i).filter((t) => t.length > 2);
108
+ }
109
+ function fuzzyScore(label, query) {
110
+ const qt = new Set(labelTokens(query));
111
+ const lt = labelTokens(label);
112
+ if (!qt.size || !lt.length) return 0;
113
+ return lt.filter((t) => qt.has(t)).length / lt.length;
114
+ }
115
+ /**
116
+ * Does a label match a query — by substring, or a strict punctuation/spacing-
117
+ * tolerant fuzzy match? Shared by the task layer's cached-map lookups so
118
+ * ask/toggle tolerate rewording the same way findElement does.
119
+ */
120
+ function labelMatches(label, query) {
121
+ return label.toLowerCase().includes(query.toLowerCase()) || fuzzyScore(label, query) >= .75;
122
+ }
123
+ function center(e) {
124
+ return e.rect ? {
125
+ x: e.rect.x + e.rect.width / 2,
126
+ y: e.rect.y + e.rect.height / 2
127
+ } : null;
128
+ }
129
+ function disambiguate(matches, via, opts, els) {
130
+ let pool = matches;
131
+ if (opts.role) {
132
+ const byRole = pool.filter((e) => e.role.toLowerCase() === opts.role.toLowerCase());
133
+ if (byRole.length) pool = byRole;
134
+ }
135
+ if (pool.length > 1 && opts.near) {
136
+ const anchor = els.find((e) => labelMatches(e.label, opts.near));
137
+ const ac = anchor ? center(anchor) : null;
138
+ if (ac) {
139
+ pool = [...pool].sort((a, b) => {
140
+ const ca = center(a);
141
+ const cb = center(b);
142
+ return (ca ? (ca.x - ac.x) ** 2 + (ca.y - ac.y) ** 2 : Infinity) - (cb ? (cb.x - ac.x) ** 2 + (cb.y - ac.y) ** 2 : Infinity);
143
+ });
144
+ return {
145
+ el: pool[0],
146
+ via: `${via}, nearest "${opts.near}"`
147
+ };
148
+ }
149
+ }
150
+ if (pool.length === 1) return {
151
+ el: pool[0],
152
+ via
153
+ };
154
+ return {
155
+ el: null,
156
+ candidates: pool,
157
+ via
158
+ };
159
+ }
160
+ /**
161
+ * One screen's worth of the resolution ladder: id exact → exact label →
162
+ * substring → fuzzy above a strict bar. This is the per-iteration body of
163
+ * resolveElement's scroll loop, extracted so callers holding a fresh cache
164
+ * (item-4 auto-wait) can match without scrolling. Returns `{ el: null }` with
165
+ * no candidates when no rung matched at all.
166
+ */
167
+ function matchInElements(els, query, opts) {
168
+ const q = query.toLowerCase();
169
+ const byId = els.filter((e) => e.id && e.id.toLowerCase() === q);
170
+ if (byId.length) return disambiguate(byId, "id", opts, els);
171
+ const exact = els.filter((e) => e.label.toLowerCase() === q);
172
+ if (exact.length) return disambiguate(exact, "exact label", opts, els);
173
+ const sub = els.filter((e) => e.label.toLowerCase().includes(q));
174
+ if (sub.length) return disambiguate(sub, "label substring", opts, els);
175
+ let best = null;
176
+ let bestScore = 0;
177
+ for (const e of els) {
178
+ const s = fuzzyScore(e.label, query);
179
+ if (s > bestScore) {
180
+ bestScore = s;
181
+ best = e;
182
+ }
183
+ }
184
+ if (best && bestScore >= .75) return {
185
+ el: best,
186
+ via: `fuzzy ${bestScore.toFixed(2)}`
187
+ };
188
+ return { el: null };
189
+ }
190
+ const na = (s) => s.toLowerCase().replace(/[’']/g, "'").trim();
191
+ const ALERT_ACCEPT = [
192
+ "allow while using app",
193
+ "always allow",
194
+ "allow",
195
+ "ok",
196
+ "yes",
197
+ "continue",
198
+ "allow once",
199
+ "turn on",
200
+ "enable",
201
+ "agree",
202
+ "accept",
203
+ "got it",
204
+ "join"
205
+ ];
206
+ const ALERT_DISMISS = [
207
+ "don't allow",
208
+ "not now",
209
+ "cancel",
210
+ "no thanks",
211
+ "no",
212
+ "deny",
213
+ "dismiss",
214
+ "later",
215
+ "skip",
216
+ "don't"
217
+ ];
218
+ function pickAlertButton(buttons, action) {
219
+ const prefs = action === "accept" ? ALERT_ACCEPT : ALERT_DISMISS;
220
+ for (const p of prefs) {
221
+ const hit = buttons.find((b) => na(b.label) === p);
222
+ if (hit) return hit;
223
+ }
224
+ for (const p of prefs) {
225
+ const hit = buttons.find((b) => na(b.label).includes(p));
226
+ if (hit) return hit;
227
+ }
228
+ if (action === "accept") return buttons.find((b) => !ALERT_DISMISS.some((d) => na(b.label).includes(d)));
229
+ return buttons[0];
230
+ }
231
+ function describeAlert(info) {
232
+ return `${info.title}${info.message ? ` ${info.message}` : ""}`.trim() + (info.buttons.length ? ` [buttons: ${info.buttons.map((b) => b.label).join(", ")}]` : "");
233
+ }
234
+ function normRef(ref) {
235
+ return ref.startsWith("@") ? ref : `@${ref}`;
236
+ }
237
+ /** Render any thrown value as a one-line message (appends `details.hint` when present). */
238
+ function describeError(error) {
239
+ if (error instanceof Error && error.message) {
240
+ const hint = error.details?.hint;
241
+ return hint ? `${error.message} (${hint})` : error.message;
242
+ }
243
+ return String(error);
244
+ }
245
+ /**
246
+ * The device core: one instance = one device's observe/resolve/act state,
247
+ * driving one {@link DeviceBackend}. Everything here is portable — no
248
+ * runtime-specific globals and no image libraries — so it runs under Node.
249
+ * The harness's DeviceContext subclasses this and adds the cursor +
250
+ * live-viewer layer via the onCacheUpdated hook.
251
+ */
252
+ var DeviceCore = class {
253
+ /** The backend this core drives. */
254
+ backend;
255
+ cachedNodes = [];
256
+ cachedViewport = {
257
+ width: 390,
258
+ height: 844
259
+ };
260
+ lastApp = {};
261
+ lastRender = null;
262
+ cacheAt = 0;
263
+ constructor(backend) {
264
+ this.backend = backend;
265
+ }
266
+ onCacheUpdated() {}
267
+ /**
268
+ * Canonical post-action report for LLM tool results, shared by every tool
269
+ * surface (agent tools + MCP): verdict from the action's own evidence, then a
270
+ * delta-rendered view of the screen it left behind.
271
+ */
272
+ async renderActionResult(prefix, evidence, refresh = false) {
273
+ if (refresh) await this.observe();
274
+ return `${prefix}${evidence?.detail ? ` (${evidence.detail})` : ""}\n\nCurrent screen (app: ${this.currentApp() ?? "unknown"}):\n${this.renderObservation()}`;
275
+ }
276
+ /**
277
+ * Render the current cached screen for the LLM. full=true (or a structural
278
+ * change since last render) yields the complete compressed tree; otherwise a
279
+ * compact delta. Always updates the baseline.
280
+ */
281
+ renderObservation(full = false) {
282
+ const app = this.lastApp.app;
283
+ const { kept, above, below, suppressFocused, vw } = keptViewportNodes(this.cachedNodes);
284
+ const keys = kept.map(elementKey);
285
+ const lineByKey = /* @__PURE__ */ new Map();
286
+ for (const n of kept) lineByKey.set(elementKey(n), formatNode(n, {
287
+ suppressFocused,
288
+ vw
289
+ }));
290
+ if (!full && this.lastRender != null && this.lastRender.app === app && arraysEqual(this.lastRender.keys, keys) && lineByKey.size === keys.length && this.lastRender.lineByKey.size === this.lastRender.keys.length && this.lastRender) {
291
+ const changed = [];
292
+ for (const key of keys) {
293
+ const now = lineByKey.get(key);
294
+ if (this.lastRender.lineByKey.get(key) !== now) changed.push(`~ ${now}`);
295
+ }
296
+ this.lastRender = {
297
+ app,
298
+ keys,
299
+ lineByKey
300
+ };
301
+ if (changed.length === 0) return `Screen unchanged since last observation (${keys.length} elements).`;
302
+ return `Same screen; ${changed.length} of ${keys.length} element(s) changed:\n${changed.join("\n")}\n(other elements and their @refs unchanged)`;
303
+ }
304
+ this.lastRender = {
305
+ app,
306
+ keys,
307
+ lineByKey
308
+ };
309
+ const lines = kept.map((n) => formatNode(n, {
310
+ suppressFocused,
311
+ vw
312
+ }));
313
+ if (above > 0) lines.unshift(`[${above} elements above the viewport — scroll up to reach them]`);
314
+ if (below > 0) lines.push(`[${below} elements below the viewport — scroll down to reach them]`);
315
+ return lines.join("\n");
316
+ }
317
+ cacheSnapshot(nodes) {
318
+ this.cachedNodes = nodes;
319
+ const root = nodes.find((n) => (n.type === "Application" || n.type === "Window") && n.rect);
320
+ if (root?.rect) this.cachedViewport = {
321
+ width: root.rect.width,
322
+ height: root.rect.height
323
+ };
324
+ this.cacheAt = Date.now();
325
+ this.onCacheUpdated();
326
+ }
327
+ async refreshCache() {
328
+ const snap = await this.backend.snapshot({ interactiveOnly: true });
329
+ this.cacheSnapshot(snap.nodes);
330
+ this.lastApp = {
331
+ app: snap.appName,
332
+ bundleId: snap.appBundleId
333
+ };
334
+ }
335
+ cacheSignature() {
336
+ const head = this.cachedNodes.slice(0, 16).map((n) => `${n.ref ?? ""}:${n.label ?? n.type ?? ""}`).join("|");
337
+ return `${this.cachedNodes.length}#${head}`;
338
+ }
339
+ /** Milliseconds since the cache was last refreshed (Infinity before first). */
340
+ cacheAgeMs() {
341
+ return this.cacheAt === 0 ? Number.POSITIVE_INFINITY : Date.now() - this.cacheAt;
342
+ }
343
+ /** Public fingerprint of the cached tree — the settle/verify signal. */
344
+ stateSignature() {
345
+ return this.cacheSignature();
346
+ }
347
+ /** The cached screen as a compressed {@link Observation} (no new snapshot). */
348
+ currentElements() {
349
+ return {
350
+ app: this.lastApp.app,
351
+ bundleId: this.lastApp.bundleId,
352
+ truncated: false,
353
+ elements: compressNodes(this.cachedNodes)
354
+ };
355
+ }
356
+ /**
357
+ * The frontmost app name from the last snapshot — cheap label without paying
358
+ * the full tree compression (used by the delta renderer's callers).
359
+ */
360
+ currentApp() {
361
+ return this.lastApp.app;
362
+ }
363
+ /**
364
+ * Structured interactive elements from the current cache — the crawler taps
365
+ * these by label (refs are only valid within one snapshot).
366
+ */
367
+ interactiveElements() {
368
+ const out = [];
369
+ const seen = /* @__PURE__ */ new Set();
370
+ for (const n of this.cachedNodes) {
371
+ if (!n.ref || !n.rect) continue;
372
+ const role = n.role ?? n.type ?? "";
373
+ if (!TAPPABLE.has(role)) continue;
374
+ const label = (n.label ?? n.identifier ?? "").trim();
375
+ if (!label) continue;
376
+ if (n.rect.width >= this.cachedViewport.width && n.rect.height >= this.cachedViewport.height) continue;
377
+ const key = `${role}:${label}`;
378
+ if (seen.has(key)) continue;
379
+ seen.add(key);
380
+ out.push({
381
+ ref: normRef(n.ref),
382
+ label,
383
+ role,
384
+ value: n.value,
385
+ rect: n.rect,
386
+ id: n.identifier?.trim() || void 0,
387
+ enabled: n.enabled,
388
+ blocked: n.interactionBlocked
389
+ });
390
+ }
391
+ return out;
392
+ }
393
+ /**
394
+ * Editable text inputs from the current cache. These roles are deliberately
395
+ * excluded from interactiveElements() (they aren't "tap" targets), so the
396
+ * input primitive needs its own accessor to find a search bar / text field to
397
+ * focus. includeMultiline adds TextView bodies for form/compose filling.
398
+ */
399
+ inputFields(includeMultiline = false) {
400
+ const editable = includeMultiline ? EDITABLE_MULTILINE : EDITABLE;
401
+ const out = [];
402
+ for (const n of this.cachedNodes) {
403
+ if (!n.ref || !n.rect) continue;
404
+ const role = n.role ?? n.type ?? "";
405
+ if (!editable.has(role)) continue;
406
+ out.push({
407
+ ref: normRef(n.ref),
408
+ label: (n.label ?? n.identifier ?? "").trim(),
409
+ role,
410
+ value: n.value,
411
+ rect: n.rect,
412
+ enabled: n.enabled,
413
+ blocked: n.interactionBlocked
414
+ });
415
+ }
416
+ return out;
417
+ }
418
+ /**
419
+ * Run the resolution ladder against the current cache only — no scrolling,
420
+ * no fresh snapshot. resolveElement drives this per scroll step.
421
+ */
422
+ resolveInCache(query, opts = {}) {
423
+ return matchInElements(this.interactiveElements(), query, opts);
424
+ }
425
+ /** The full ladder: scroll to top, then match + scroll down until found or stable. */
426
+ async resolveElement(query, opts = {}) {
427
+ await this.scrollToTop();
428
+ for (let i = 0; i < 10; i++) {
429
+ const r = this.resolveInCache(query, opts);
430
+ if (r.el || r.candidates) return r;
431
+ const before = this.screenSignature();
432
+ await this.scroll("down");
433
+ await this.observe();
434
+ if (this.screenSignature() === before) break;
435
+ }
436
+ return { el: null };
437
+ }
438
+ /**
439
+ * Compatibility wrapper: single best element or null (read paths — ask/read a
440
+ * value — where picking the first match is low-risk). Tap paths use
441
+ * resolveElement directly and honor the ambiguity contract.
442
+ */
443
+ async findElement(labelSubstring) {
444
+ const r = await this.resolveElement(labelSubstring);
445
+ return r.el ?? r.candidates?.[0] ?? null;
446
+ }
447
+ /**
448
+ * Read a labeled value. iOS list rows fold the value into the label
449
+ * ("iOS Version, 26.1") or expose it as a Switch value ("1"/"0"); handle both.
450
+ */
451
+ async readField(labelSubstring) {
452
+ const el = await this.findElement(labelSubstring);
453
+ if (!el) return null;
454
+ if (el.value != null && el.value !== "") return el.value;
455
+ const idx = el.label.toLowerCase().indexOf(labelSubstring.toLowerCase());
456
+ if (idx < 0) return el.label;
457
+ return el.label.slice(idx + labelSubstring.length).replace(/^[\s,:]+/, "").trim() || el.label;
458
+ }
459
+ /**
460
+ * A structural fingerprint of the current screen that is stable across
461
+ * dynamic content (times, battery, values) — it keys the crawler's graph
462
+ * nodes so the same screen is recognized regardless of transient text.
463
+ */
464
+ screenSignature() {
465
+ const title = this.cachedNodes.find((n) => (n.type === "NavigationBar" || n.role === "NavigationBar") && n.label)?.label ?? "";
466
+ const labels = this.cachedNodes.filter((n) => TAPPABLE.has(n.role ?? n.type ?? "") && (n.label ?? "").trim()).map((n) => `${n.role ?? n.type}:${(n.label ?? "").trim()}`).sort();
467
+ const uniq = [...new Set(labels)];
468
+ return `${this.lastApp.bundleId ?? ""}|${title}|${uniq.join("~")}`;
469
+ }
470
+ /** Navigation-bar title of the cached screen ('' when absent). */
471
+ screenTitle() {
472
+ return this.cachedNodes.find((n) => (n.type === "NavigationBar" || n.role === "NavigationBar") && n.label)?.label ?? "";
473
+ }
474
+ /** Take one fresh snapshot into the cache and return the compressed observation. */
475
+ async observe() {
476
+ try {
477
+ await this.refreshCache();
478
+ return this.currentElements();
479
+ } catch (error) {
480
+ if (error instanceof SessionNotFoundError || error?.code === "SESSION_NOT_FOUND") return {
481
+ truncated: false,
482
+ elements: "No app session is active yet. Use open_app to launch an app first."
483
+ };
484
+ throw error;
485
+ }
486
+ }
487
+ /**
488
+ * Open an app by name/bundle id. relaunch forces a fresh launch (clean
489
+ * initial screen) instead of just foregrounding — iOS keeps an app's
490
+ * navigation state across foregrounding, so primitives that need a known
491
+ * starting screen pass relaunch=true.
492
+ */
493
+ async openApp(app, relaunch = false) {
494
+ const result = await this.backend.openApp({
495
+ app,
496
+ relaunch
497
+ });
498
+ return `Opened ${result.appName ?? app} (${result.appBundleId ?? "unknown bundle"})`;
499
+ }
500
+ /**
501
+ * Level-2 of the action ladder: deep links beat tap sequences when a URL route
502
+ * exists (maps://, app schemes, https:// universal links). XCTest sessions are
503
+ * app-scoped, so a link that opens a different app must re-scope the session
504
+ * to that app or observations keep tracking the old one.
505
+ */
506
+ async openUrl(url, app) {
507
+ const target = app ?? await this.currentBundleId();
508
+ await this.backend.openApp(target ? {
509
+ app: target,
510
+ url
511
+ } : { url });
512
+ return app ? `Opened ${url} in ${app}` : `Opened ${url}`;
513
+ }
514
+ async currentBundleId() {
515
+ try {
516
+ return (await this.backend.snapshot({
517
+ interactiveOnly: true,
518
+ depth: 1
519
+ })).appBundleId;
520
+ } catch {
521
+ return;
522
+ }
523
+ }
524
+ /** List installed app bundle ids. */
525
+ async listApps() {
526
+ return this.backend.listApps();
527
+ }
528
+ async tapAndDiff(tap) {
529
+ const before = this.cacheSignature();
530
+ await tap();
531
+ await this.refreshCache();
532
+ const changed = before !== this.cacheSignature();
533
+ return {
534
+ changed,
535
+ detail: changed ? "screen changed" : "screen did NOT change — the action may have had no effect"
536
+ };
537
+ }
538
+ /** Tap an element ref, self-diffing the cache to report whether the screen changed. */
539
+ async press(ref) {
540
+ try {
541
+ return await this.tapAndDiff(() => this.backend.press({ ref }));
542
+ } catch (error) {
543
+ if (!/off-?screen/i.test(describeError(error))) throw error;
544
+ const mid = this.visibleMidpoint(this.findNode(ref)?.rect);
545
+ if (!mid) throw error;
546
+ return this.tapAndDiff(() => this.backend.press({
547
+ x: mid.x,
548
+ y: mid.y
549
+ }));
550
+ }
551
+ }
552
+ visibleMidpoint(rect) {
553
+ if (!rect) return null;
554
+ const x1 = Math.max(rect.x, 0);
555
+ const y1 = Math.max(rect.y, 0);
556
+ const x2 = Math.min(rect.x + rect.width, this.cachedViewport.width);
557
+ const y2 = Math.min(rect.y + rect.height, this.cachedViewport.height);
558
+ if (x2 <= x1 || y2 <= y1) return null;
559
+ return {
560
+ x: Math.round((x1 + x2) / 2),
561
+ y: Math.round((y1 + y2) / 2)
562
+ };
563
+ }
564
+ onScreen(rect) {
565
+ if (!rect) return false;
566
+ const cy = rect.y + rect.height / 2;
567
+ return cy > 56 && cy < this.cachedViewport.height - 44 && rect.x < this.cachedViewport.width;
568
+ }
569
+ /**
570
+ * Open an app and walk its nav stack back to the root (dismissing modals), so
571
+ * map-based navigation always starts from a known origin.
572
+ */
573
+ async goToRoot(app) {
574
+ const DISMISS = [
575
+ "Close",
576
+ "Cancel",
577
+ "Done",
578
+ "Not Now",
579
+ "Dismiss"
580
+ ];
581
+ await this.observe().catch(() => void 0);
582
+ if (this.lastApp.bundleId !== app) {
583
+ await this.openApp(app);
584
+ await this.observe();
585
+ }
586
+ await this.clearBlockingAlerts("accept");
587
+ for (let i = 0; i < 12; i++) {
588
+ const els = this.interactiveElements();
589
+ const back = els.find((e) => e.role === "Button" && !!e.rect && e.rect.x < 70 && e.rect.y < 110);
590
+ const dismiss = els.find((e) => e.role === "Button" && DISMISS.includes(e.label));
591
+ const target = back ?? dismiss;
592
+ if (!target) break;
593
+ await this.press(target.ref);
594
+ await this.observe();
595
+ }
596
+ await this.scrollToTop();
597
+ }
598
+ /** Height of the cached viewport in points. */
599
+ viewportHeight() {
600
+ return this.cachedViewport.height;
601
+ }
602
+ /**
603
+ * Vertical span of interactive content in the current cache. Used to decide
604
+ * whether scrolling is even necessary — scroll gestures cost ~2s each, so
605
+ * skipping them on screens that already fit is the single biggest crawl
606
+ * speedup.
607
+ */
608
+ contentBounds() {
609
+ let minY = Infinity;
610
+ let maxY = -Infinity;
611
+ for (const n of this.cachedNodes) {
612
+ if (!n.rect) continue;
613
+ if (!TAPPABLE.has(n.role ?? n.type ?? "")) continue;
614
+ minY = Math.min(minY, n.rect.y);
615
+ maxY = Math.max(maxY, n.rect.y + n.rect.height);
616
+ }
617
+ return {
618
+ minY: minY === Infinity ? 0 : minY,
619
+ maxY: maxY === -Infinity ? 0 : maxY
620
+ };
621
+ }
622
+ /**
623
+ * Tapping the status bar scrolls the active scroll view to the top — native
624
+ * iOS behavior, one fast tap instead of multiple ~2s scroll gestures. Falls
625
+ * back to gesture scrolling if the tap doesn't take.
626
+ */
627
+ async scrollToTop() {
628
+ try {
629
+ await this.backend.press({
630
+ x: Math.round(this.cachedViewport.width / 2),
631
+ y: 6
632
+ });
633
+ await this.observe();
634
+ return;
635
+ } catch {}
636
+ for (let i = 0; i < 6; i++) {
637
+ const before = this.screenSignature();
638
+ await this.scroll("up");
639
+ await this.observe();
640
+ if (this.screenSignature() === before) return;
641
+ }
642
+ }
643
+ /**
644
+ * Tap an element by its label, scrolling it into view first if it's
645
+ * off-screen. The crawler and the map navigator use this so a target below
646
+ * the fold (a long Settings list) is still reachable. Re-resolves the ref
647
+ * after each scroll.
648
+ */
649
+ async tapLabel(label) {
650
+ for (let i = 0; i < 12; i++) {
651
+ const el = this.interactiveElements().find((e) => e.label === label);
652
+ if (el && this.onScreen(el.rect)) try {
653
+ await this.press(el.ref);
654
+ return true;
655
+ } catch (error) {
656
+ if (!/off-?screen/i.test(describeError(error))) throw error;
657
+ }
658
+ const dir = el?.rect ? el.rect.y < 0 ? "up" : "down" : i < 5 ? "up" : "down";
659
+ await this.scroll(dir);
660
+ await this.observe();
661
+ }
662
+ return false;
663
+ }
664
+ /**
665
+ * Vision-path fallback: tap raw coordinates when the accessibility tree is
666
+ * missing or wrong (canvas, games, custom controls). Coordinates are in the
667
+ * same space as observe()'s rects and the screenshot pixels (@1x points).
668
+ */
669
+ async pressAt(x, y) {
670
+ return this.tapAndDiff(() => this.backend.press({
671
+ x,
672
+ y
673
+ }));
674
+ }
675
+ /**
676
+ * Coordinate drag: touch down at (x,y), move by (dx,dy). The primitive for
677
+ * controls a tap can't operate — picker wheels (drag vertically on the wheel
678
+ * column), sliders, and custom carousels. Same coordinate space as rects.
679
+ */
680
+ async pan(x, y, dx, dy, durationMs) {
681
+ await this.backend.pan(x, y, dx, dy, durationMs);
682
+ }
683
+ /**
684
+ * Set-of-Marks visual observation: screenshot with `@ref` labels drawn on the
685
+ * elements, so a vision model can ground itself in pixels and still act by ref.
686
+ */
687
+ async screenshotWithRefs(path) {
688
+ return (await this.backend.screenshot({
689
+ path,
690
+ overlayRefs: true
691
+ })).path;
692
+ }
693
+ async currentViewport() {
694
+ try {
695
+ const root = (await this.backend.snapshot({
696
+ interactiveOnly: true,
697
+ depth: 1
698
+ })).nodes.find((n) => (n.type === "Application" || n.type === "Window") && n.rect);
699
+ return {
700
+ width: root?.rect?.width ?? 390,
701
+ height: root?.rect?.height ?? 844
702
+ };
703
+ } catch {
704
+ return {
705
+ width: 390,
706
+ height: 844
707
+ };
708
+ }
709
+ }
710
+ /** Long-press an element ref. */
711
+ async longPress(ref, durationMs = 800) {
712
+ await this.backend.longPress(ref, durationMs);
713
+ }
714
+ /** Focus a field and replace its text, self-diffing the cache for evidence. */
715
+ async fill(ref, text) {
716
+ return this.tapAndDiff(() => this.backend.fill(ref, text));
717
+ }
718
+ /** Type into whatever currently has keyboard focus. */
719
+ async typeText(text) {
720
+ await this.backend.typeText(text);
721
+ }
722
+ /**
723
+ * Press the keyboard's return/go key. Submits a search bar that acts on
724
+ * Return (Safari's address bar, web forms) rather than filtering results as
725
+ * you type.
726
+ */
727
+ async pressReturn() {
728
+ await this.backend.pressKey("return");
729
+ }
730
+ /** Scroll the active scroll view one step. */
731
+ async scroll(direction) {
732
+ await this.backend.scroll(direction);
733
+ }
734
+ /** Block until `text` appears on screen; returns a confirmation note. */
735
+ async waitForText(text, timeoutMs = 5e3) {
736
+ await this.backend.waitForText(text, timeoutMs);
737
+ return `"${text}" appeared on screen`;
738
+ }
739
+ alertFromCache() {
740
+ const alert = this.cachedNodes.find((n) => (n.type ?? n.role) === "Alert");
741
+ if (!alert) return null;
742
+ const texts = this.cachedNodes.filter((n) => (n.type ?? n.role) === "StaticText" && n.label).map((n) => (n.label ?? "").trim());
743
+ const buttons = [];
744
+ for (const n of this.cachedNodes) {
745
+ if (!n.ref || !n.rect || (n.role ?? n.type) !== "Button") continue;
746
+ const label = (n.label ?? n.identifier ?? "").trim();
747
+ if (label) buttons.push({
748
+ ref: normRef(n.ref),
749
+ label,
750
+ role: "Button",
751
+ value: n.value,
752
+ rect: n.rect,
753
+ enabled: n.enabled,
754
+ blocked: n.interactionBlocked
755
+ });
756
+ }
757
+ const title = (alert.label ?? texts[0] ?? "Alert").trim();
758
+ return {
759
+ title,
760
+ message: texts.find((t) => t !== title),
761
+ buttons
762
+ };
763
+ }
764
+ /**
765
+ * System dialogs (permissions, sign-in prompts) block everything else; the
766
+ * driver exposes them as a first-class action instead of hoping a tap lands.
767
+ */
768
+ async handleAlert(action) {
769
+ await this.observe().catch(() => void 0);
770
+ const info = this.alertFromCache();
771
+ if (info) {
772
+ const description = describeAlert(info);
773
+ if (action === "get") return {
774
+ present: true,
775
+ description
776
+ };
777
+ const btn = pickAlertButton(info.buttons, action);
778
+ if (!btn?.rect) return {
779
+ present: true,
780
+ handled: false,
781
+ description
782
+ };
783
+ await this.pressAt(btn.rect.x + btn.rect.width / 2, btn.rect.y + btn.rect.height / 2);
784
+ await this.observe().catch(() => void 0);
785
+ const still = this.alertFromCache();
786
+ return {
787
+ present: true,
788
+ handled: still == null || still.title !== info.title,
789
+ button: btn.label,
790
+ description
791
+ };
792
+ }
793
+ try {
794
+ const result = await this.backend.systemAlert(action);
795
+ const alert = result.alert;
796
+ return {
797
+ present: alert != null,
798
+ handled: result.handled,
799
+ button: result.button,
800
+ description: alert ? `${alert.title ?? ""} ${alert.message ?? ""}`.trim() + (alert.buttons?.length ? ` [buttons: ${alert.buttons.join(", ")}]` : "") : void 0
801
+ };
802
+ } catch (error) {
803
+ if (/alert not found/i.test(describeError(error))) return { present: false };
804
+ throw error;
805
+ }
806
+ }
807
+ /**
808
+ * Clear the launch permission gauntlet — real apps stack location /
809
+ * notification / tracking prompts on first open, each blocking the app.
810
+ * Grants by default so the crawl sees the most surface. Returns the buttons
811
+ * tapped. Bounded so a non-clearing dialog can't loop forever.
812
+ */
813
+ async clearBlockingAlerts(action = "accept", max = 6) {
814
+ const tapped = [];
815
+ let lastTitle = "";
816
+ for (let i = 0; i < max; i++) {
817
+ if (!this.alertFromCache()) {
818
+ await this.observe().catch(() => void 0);
819
+ if (!this.alertFromCache()) break;
820
+ }
821
+ const r = await this.handleAlert(action);
822
+ if (!r.present || !r.button || !r.handled) break;
823
+ if (r.description === lastTitle) break;
824
+ lastTitle = r.description ?? "";
825
+ tapped.push(r.button);
826
+ }
827
+ return tapped;
828
+ }
829
+ /** Go to the home screen. */
830
+ async goHome() {
831
+ await this.backend.home();
832
+ }
833
+ /** Navigate back (nav-bar back / hardware back). */
834
+ async goBack() {
835
+ await this.backend.back();
836
+ }
837
+ /** Save a screenshot to `path`; returns the written path. */
838
+ async screenshot(path) {
839
+ return (await this.backend.screenshot({ path })).path;
840
+ }
841
+ /** Close the backend's transport session. */
842
+ async closeSession() {
843
+ await this.backend.closeSession();
844
+ }
845
+ findNode(ref) {
846
+ const want = normRef(ref);
847
+ return this.cachedNodes.find((n) => n.ref && normRef(n.ref) === want);
848
+ }
849
+ async ensureCache() {
850
+ if (this.cachedNodes.length === 0) await this.observe();
851
+ }
852
+ };
853
+ //#endregion
854
+ //#region src/secrets.ts
855
+ const MIN_SECRET_LENGTH = 4;
856
+ /**
857
+ * `%variable%` secret substitution (docs/19 §API shape, Stagehand's pattern):
858
+ * the model/caller plans against NAMES; values are injected at the last moment
859
+ * before backend.fill/typeText and never rendered into Actions, results,
860
+ * observations, or traces. Redaction is best-effort belt-and-braces; the hard
861
+ * guarantee is at the substitution point — values never enter stored Actions
862
+ * by construction.
863
+ */
864
+ var SecretStore = class SecretStore {
865
+ values = /* @__PURE__ */ new Map();
866
+ constructor(values) {
867
+ if (values) for (const [k, v] of Object.entries(values)) this.set(k, v);
868
+ }
869
+ /**
870
+ * Store a secret under `name`. Rejects values shorter than 4 chars — a
871
+ * 2-char secret would redact innocent UI text everywhere.
872
+ */
873
+ set(name, value) {
874
+ if (value.length < MIN_SECRET_LENGTH) throw new Error(`secret "${name}" is too short (<${MIN_SECRET_LENGTH} chars) to redact safely`);
875
+ this.values.set(name, value);
876
+ }
877
+ /** The stored secret NAMES (never the values). */
878
+ names() {
879
+ return [...this.values.keys()];
880
+ }
881
+ /** %name% → value. Unknown %x% stays literal. */
882
+ substitute(text) {
883
+ return text.replace(/%([A-Za-z0-9_-]+)%/g, (whole, name) => this.values.get(name) ?? whole);
884
+ }
885
+ /** value → %name% across outbound text (messages, rendered observations). */
886
+ redact(text) {
887
+ let out = text;
888
+ for (const [name, value] of this.values) out = out.split(value).join(`%${name}%`);
889
+ return out;
890
+ }
891
+ /** Per-call vars layered over the store (call-scoped, never persisted). */
892
+ withOverrides(vars) {
893
+ if (!vars || Object.keys(vars).length === 0) return this;
894
+ const merged = new SecretStore();
895
+ for (const [k, v] of this.values) merged.values.set(k, v);
896
+ for (const [k, v] of Object.entries(vars)) merged.set(k, v);
897
+ return merged;
898
+ }
899
+ };
900
+ //#endregion
901
+ //#region src/actions.ts
902
+ function throwIfAborted(signal) {
903
+ if (signal?.aborted) throw new AbortedError();
904
+ }
905
+ /** Race a backend promise against the caller's abort. The abandoned in-flight
906
+ * call may still land on the device — documented abort semantics (state
907
+ * indeterminate); no retry follows an abort. */
908
+ async function raceWithAbort(promise, signal) {
909
+ if (!signal) return promise;
910
+ throwIfAborted(signal);
911
+ let onAbort;
912
+ const aborted = new Promise((_, reject) => {
913
+ onAbort = () => reject(new AbortedError());
914
+ signal.addEventListener("abort", onAbort, { once: true });
915
+ });
916
+ try {
917
+ return await Promise.race([promise, aborted]);
918
+ } finally {
919
+ if (onAbort) signal.removeEventListener("abort", onAbort);
920
+ }
921
+ }
922
+ function sleep(ms, signal) {
923
+ return raceWithAbort(new Promise((r) => setTimeout(r, ms)), signal);
924
+ }
925
+ function queryFor(el) {
926
+ return el.id ? { id: el.id } : { label: el.label };
927
+ }
928
+ function provenance(core, el, via) {
929
+ return {
930
+ ...via === void 0 ? {} : { via },
931
+ label: el.label,
932
+ role: el.role,
933
+ ...el.rect === void 0 ? {} : { rect: el.rect },
934
+ ...core.currentApp() === void 0 ? {} : { app: core.currentApp() },
935
+ ...core.screenTitle() ? { screenTitle: core.screenTitle() } : {}
936
+ };
937
+ }
938
+ /** Synthesize portable actions from the CURRENT cache (observe-time). */
939
+ function toActions(core) {
940
+ const actions = [];
941
+ for (const el of core.interactiveElements()) {
942
+ const kind = el.role;
943
+ if (TAPPABLE.has(kind)) actions.push({
944
+ formatVersion: 0,
945
+ verb: "tap",
946
+ target: queryFor(el),
947
+ observed: provenance(core, el, el.id ? "id" : "exact label")
948
+ });
949
+ }
950
+ for (const el of core.inputFields(true)) actions.push({
951
+ formatVersion: 0,
952
+ verb: "fill",
953
+ target: queryFor(el),
954
+ params: { text: "" },
955
+ observed: provenance(core, el, el.id ? "id" : "exact label")
956
+ });
957
+ return actions;
958
+ }
959
+ const NO_SECRETS = new SecretStore();
960
+ /**
961
+ * Take one fresh snapshot and assemble the full {@link ObserveResult}:
962
+ * deduped element channel, secret-redacted rendered text, and a portable
963
+ * Action per tappable / input field.
964
+ */
965
+ async function buildObserveResult(core, secrets = NO_SECRETS) {
966
+ const obs = await core.observe();
967
+ const redact = (s) => secrets.redact(s);
968
+ const seen = /* @__PURE__ */ new Set();
969
+ const elements = [];
970
+ for (const el of [...core.interactiveElements(), ...core.inputFields(true)]) {
971
+ if (seen.has(el.ref)) continue;
972
+ seen.add(el.ref);
973
+ elements.push({
974
+ ...el,
975
+ label: redact(el.label),
976
+ ...el.value === void 0 || el.value === null ? {} : { value: redact(el.value) }
977
+ });
978
+ }
979
+ return {
980
+ success: true,
981
+ message: elements.length ? `observed ${elements.length} interactive elements` : obs.elements.slice(0, 120),
982
+ ...obs.app === void 0 ? {} : { app: obs.app },
983
+ ...obs.bundleId === void 0 ? {} : { bundleId: obs.bundleId },
984
+ ...core.screenTitle() ? { screenTitle: core.screenTitle() } : {},
985
+ elements,
986
+ rendered: redact(core.renderObservation(true)),
987
+ actions: toActions(core)
988
+ };
989
+ }
990
+ function toResolveOpts(q) {
991
+ return {
992
+ ...q.role === void 0 ? {} : { role: q.role },
993
+ ...q.near === void 0 ? {} : { near: q.near }
994
+ };
995
+ }
996
+ function queryText(q) {
997
+ const t = q.id ?? q.label;
998
+ if (t === void 0 || t === "") throw new ActionFailedError("action target needs an id or label");
999
+ return t;
1000
+ }
1001
+ function isActionable(el) {
1002
+ return el.enabled !== false && !el.blocked;
1003
+ }
1004
+ const CACHE_FRESH_MS = 2e3;
1005
+ const POLL_STEPS_MS = [
1006
+ 150,
1007
+ 300,
1008
+ 600,
1009
+ 800
1010
+ ];
1011
+ /**
1012
+ * Resolve + auto-wait (docs/19: visible+hittable+enabled+settled on every
1013
+ * action, no caller sleep; docs/20 risk 2: must not double latency).
1014
+ * Fast path: a fresh cache resolving to an actionable target executes with
1015
+ * ZERO extra snapshots. Slow path: poll in place (never scroll — scrolling is
1016
+ * the ladder's job, and polling must not dismiss transient menus) until the
1017
+ * target is actionable AND the tree signature is stable between polls.
1018
+ */
1019
+ async function resolveWithWait(core, q, opts, pool) {
1020
+ const signal = opts.signal;
1021
+ const deadline = Date.now() + (opts.timeoutMs ?? 5e3);
1022
+ const text = queryText(q);
1023
+ const rOpts = toResolveOpts(q);
1024
+ const inCache = () => pool === "fields" ? matchInElements(core.inputFields(true), text, rOpts) : core.resolveInCache(text, rOpts);
1025
+ if (core.cacheAgeMs() < CACHE_FRESH_MS) {
1026
+ const r = inCache();
1027
+ if (r.el && isActionable(r.el)) return {
1028
+ ok: true,
1029
+ el: r.el,
1030
+ via: r.via ?? "cache",
1031
+ waited: void 0
1032
+ };
1033
+ if (r.candidates?.length) return {
1034
+ ok: false,
1035
+ result: ambiguityResult(text, r)
1036
+ };
1037
+ }
1038
+ throwIfAborted(signal);
1039
+ let r;
1040
+ if (pool === "tappable") r = await raceWithAbort(core.resolveElement(text, rOpts), signal);
1041
+ else {
1042
+ await raceWithAbort(core.observe(), signal);
1043
+ r = inCache();
1044
+ }
1045
+ if (!r.el && r.candidates?.length) return {
1046
+ ok: false,
1047
+ result: ambiguityResult(text, r)
1048
+ };
1049
+ if (r.el && isActionable(r.el)) return {
1050
+ ok: true,
1051
+ el: r.el,
1052
+ via: r.via ?? "ladder",
1053
+ waited: void 0
1054
+ };
1055
+ const started = Date.now();
1056
+ let polls = 0;
1057
+ let lastSig = core.stateSignature();
1058
+ let step = 0;
1059
+ while (Date.now() < deadline) {
1060
+ await sleep(POLL_STEPS_MS[Math.min(step, POLL_STEPS_MS.length - 1)], signal);
1061
+ step++;
1062
+ polls++;
1063
+ throwIfAborted(signal);
1064
+ await raceWithAbort(core.observe(), signal);
1065
+ const sig = core.stateSignature();
1066
+ const settled = sig === lastSig;
1067
+ lastSig = sig;
1068
+ const rr = inCache();
1069
+ if (rr.el && isActionable(rr.el) && settled) return {
1070
+ ok: true,
1071
+ el: rr.el,
1072
+ via: rr.via ?? "wait",
1073
+ waited: {
1074
+ ms: Date.now() - started,
1075
+ polls
1076
+ }
1077
+ };
1078
+ if (!rr.el && rr.candidates?.length) return {
1079
+ ok: false,
1080
+ result: ambiguityResult(text, rr)
1081
+ };
1082
+ r = rr;
1083
+ }
1084
+ return {
1085
+ ok: false,
1086
+ result: {
1087
+ success: false,
1088
+ code: "TIMEOUT",
1089
+ message: r.el ? `timed out after ${opts.timeoutMs ?? 5e3}ms waiting for "${text}" to become enabled/settled` : `timed out after ${opts.timeoutMs ?? 5e3}ms — no element matching "${text}" on this screen`,
1090
+ waited: {
1091
+ ms: Date.now() - started,
1092
+ polls
1093
+ }
1094
+ }
1095
+ };
1096
+ }
1097
+ function ambiguityResult(text, r) {
1098
+ const list = (r.candidates ?? []).map((c) => `${c.role} "${c.label}"${c.rect ? ` at (${Math.round(c.rect.x)},${Math.round(c.rect.y)})` : ""}`).join("; ");
1099
+ return {
1100
+ success: false,
1101
+ message: `"${text}" is ambiguous — ${r.candidates?.length ?? 0} matches: ${list}. Disambiguate with role or near.`,
1102
+ candidates: r.candidates ?? []
1103
+ };
1104
+ }
1105
+ function resolvedOf(el, via) {
1106
+ return {
1107
+ via,
1108
+ ref: el.ref,
1109
+ label: el.label,
1110
+ role: el.role,
1111
+ ...el.rect === void 0 ? {} : { rect: el.rect }
1112
+ };
1113
+ }
1114
+ /**
1115
+ * THE dispatcher: resolve → auto-wait → execute → diff. One brain — used by
1116
+ * device.tap/type/act, and (items 5-6) by the harness and the skill runner.
1117
+ */
1118
+ async function executeAction(core, action, opts = {}, secrets = NO_SECRETS) {
1119
+ const signal = opts.signal;
1120
+ throwIfAborted(signal);
1121
+ const store = secrets.withOverrides(opts.vars);
1122
+ const redact = (s) => store.redact(s);
1123
+ try {
1124
+ switch (action.verb) {
1125
+ case "tap":
1126
+ case "longPress":
1127
+ case "fill": {
1128
+ if (!action.target) return {
1129
+ success: false,
1130
+ message: `${action.verb} needs a target query`
1131
+ };
1132
+ const wait = await resolveWithWait(core, action.target, opts, action.verb === "fill" ? "fields" : "tappable");
1133
+ if (!wait.ok) return wait.result;
1134
+ const { el, via, waited } = wait;
1135
+ if (action.verb === "longPress") {
1136
+ await raceWithAbort(core.longPress(el.ref, action.params?.durationMs), signal);
1137
+ return {
1138
+ success: true,
1139
+ message: redact(`long-pressed "${el.label}"`),
1140
+ resolved: resolvedOf(el, via),
1141
+ waited
1142
+ };
1143
+ }
1144
+ if (action.verb === "fill") {
1145
+ const text = store.substitute(action.params?.text ?? "");
1146
+ const ev = await raceWithAbort(core.fill(el.ref, text), signal);
1147
+ if (action.params?.submit) await raceWithAbort(core.pressReturn(), signal);
1148
+ return {
1149
+ success: true,
1150
+ message: redact(`filled "${el.label}"${action.params?.submit ? ", pressed Return" : ""}`),
1151
+ ...ev.changed === void 0 ? {} : { changed: ev.changed },
1152
+ resolved: resolvedOf(el, via),
1153
+ waited
1154
+ };
1155
+ }
1156
+ const ev = await raceWithAbort(core.press(el.ref), signal);
1157
+ return {
1158
+ success: true,
1159
+ message: redact(`tapped "${el.label}"${ev.changed === false ? " (no change)" : ""}`),
1160
+ ...ev.changed === void 0 ? {} : { changed: ev.changed },
1161
+ resolved: resolvedOf(el, via),
1162
+ waited
1163
+ };
1164
+ }
1165
+ case "type": {
1166
+ const text = store.substitute(action.params?.text ?? "");
1167
+ await raceWithAbort(core.typeText(text), signal);
1168
+ if (action.params?.submit) await raceWithAbort(core.pressReturn(), signal);
1169
+ return {
1170
+ success: true,
1171
+ message: redact(`typed ${JSON.stringify(action.params?.text ?? "")}`)
1172
+ };
1173
+ }
1174
+ case "pressKey":
1175
+ await raceWithAbort(core.pressReturn(), signal);
1176
+ return {
1177
+ success: true,
1178
+ message: "pressed Return"
1179
+ };
1180
+ case "scroll": {
1181
+ const direction = action.params?.direction ?? "down";
1182
+ await raceWithAbort(core.scroll(direction), signal);
1183
+ await raceWithAbort(core.observe(), signal);
1184
+ return {
1185
+ success: true,
1186
+ message: `scrolled ${direction}`
1187
+ };
1188
+ }
1189
+ case "openApp":
1190
+ if (!action.params?.app) return {
1191
+ success: false,
1192
+ message: "openApp needs params.app"
1193
+ };
1194
+ return {
1195
+ success: true,
1196
+ message: await raceWithAbort(core.openApp(action.params.app, action.params.relaunch ?? false), signal)
1197
+ };
1198
+ case "openUrl":
1199
+ if (!action.params?.url) return {
1200
+ success: false,
1201
+ message: "openUrl needs params.url"
1202
+ };
1203
+ return {
1204
+ success: true,
1205
+ message: await raceWithAbort(core.openUrl(action.params.url, action.params.app), signal)
1206
+ };
1207
+ case "back":
1208
+ await raceWithAbort(core.goBack(), signal);
1209
+ return {
1210
+ success: true,
1211
+ message: "went back"
1212
+ };
1213
+ case "home":
1214
+ await raceWithAbort(core.goHome(), signal);
1215
+ return {
1216
+ success: true,
1217
+ message: "went home"
1218
+ };
1219
+ case "alert": {
1220
+ const outcome = await raceWithAbort(core.handleAlert(action.params?.alertAction ?? "accept"), signal);
1221
+ if (!outcome.present) return {
1222
+ success: false,
1223
+ message: "no system alert is showing"
1224
+ };
1225
+ return {
1226
+ success: outcome.handled !== false,
1227
+ message: `alert ${outcome.handled ? `handled via "${outcome.button}"` : "NOT handled"}: ${outcome.description ?? ""}`
1228
+ };
1229
+ }
1230
+ case "waitForText": {
1231
+ if (!action.params?.text) return {
1232
+ success: false,
1233
+ message: "waitForText needs params.text"
1234
+ };
1235
+ const note = await raceWithAbort(core.waitForText(action.params.text, opts.timeoutMs ?? 5e3), signal);
1236
+ const ok = !/did not appear|not found|timed out/i.test(note);
1237
+ return {
1238
+ success: ok,
1239
+ message: redact(note),
1240
+ ...ok ? {} : { code: "TIMEOUT" }
1241
+ };
1242
+ }
1243
+ default: return {
1244
+ success: false,
1245
+ message: `unknown verb ${String(action.verb)}`
1246
+ };
1247
+ }
1248
+ } catch (error) {
1249
+ if (error instanceof AbortedError) throw error;
1250
+ if (error instanceof PhoneUseError) {
1251
+ if (error instanceof ActionFailedError || error instanceof TimeoutError) return {
1252
+ success: false,
1253
+ message: redact(error.message),
1254
+ code: error.code
1255
+ };
1256
+ throw error;
1257
+ }
1258
+ throw error;
1259
+ }
1260
+ }
1261
+ //#endregion
1262
+ //#region src/backends/agent-device.ts
1263
+ var AgentDeviceBackend = class extends BaseDeviceBackend {
1264
+ client;
1265
+ selection;
1266
+ used = false;
1267
+ constructor(config) {
1268
+ super("agent-device", ALL_CAPABILITIES);
1269
+ this.client = createAgentDeviceClient(config && (config.session !== void 0 || config.daemonBaseUrl !== void 0 || config.daemonAuthToken !== void 0) ? {
1270
+ ...config.session === void 0 ? {} : { session: config.session },
1271
+ ...config.daemonBaseUrl === void 0 ? {} : { daemonBaseUrl: config.daemonBaseUrl },
1272
+ ...config.daemonAuthToken === void 0 ? {} : { daemonAuthToken: config.daemonAuthToken }
1273
+ } : void 0);
1274
+ this.selection = !config ? {} : {
1275
+ platform: config.platform,
1276
+ ...config.device === void 0 ? {} : { device: config.device },
1277
+ ...config.platform === "ios" && config.udid !== void 0 ? { udid: config.udid } : {},
1278
+ ...config.platform === "ios" && config.simulatorDeviceSet !== void 0 ? { iosSimulatorDeviceSet: config.simulatorDeviceSet } : {},
1279
+ ...config.platform === "android" && config.serial !== void 0 ? { serial: config.serial } : {}
1280
+ };
1281
+ }
1282
+ async guard(capability, fn) {
1283
+ if (capability !== "closeSession") this.used = true;
1284
+ try {
1285
+ return await fn();
1286
+ } catch (error) {
1287
+ throw toPhoneUseError(error, {
1288
+ backend: this.backendName,
1289
+ capability
1290
+ });
1291
+ }
1292
+ }
1293
+ snapshot(opts) {
1294
+ return this.guard("snapshot", async () => {
1295
+ const snap = await this.client.capture.snapshot({
1296
+ ...this.selection,
1297
+ ...opts?.interactiveOnly === void 0 ? {} : { interactiveOnly: opts.interactiveOnly },
1298
+ ...opts?.depth === void 0 ? {} : { depth: opts.depth }
1299
+ });
1300
+ return {
1301
+ nodes: snap.nodes,
1302
+ appName: snap.appName,
1303
+ appBundleId: snap.appBundleId
1304
+ };
1305
+ });
1306
+ }
1307
+ screenshot(opts) {
1308
+ return this.guard("screenshot", async () => {
1309
+ return { path: (await this.client.capture.screenshot({
1310
+ ...this.selection,
1311
+ path: opts.path,
1312
+ ...opts.overlayRefs === void 0 ? {} : { overlayRefs: opts.overlayRefs }
1313
+ })).path };
1314
+ });
1315
+ }
1316
+ press(target) {
1317
+ return this.guard("press", async () => {
1318
+ await this.client.interactions.press({
1319
+ ...this.selection,
1320
+ ...target
1321
+ });
1322
+ });
1323
+ }
1324
+ longPress(ref, durationMs) {
1325
+ return this.guard("longPress", async () => {
1326
+ await this.client.interactions.longPress({
1327
+ ...this.selection,
1328
+ ref,
1329
+ ...durationMs === void 0 ? {} : { durationMs },
1330
+ settle: true
1331
+ });
1332
+ });
1333
+ }
1334
+ fill(ref, text) {
1335
+ return this.guard("fill", async () => {
1336
+ await this.client.interactions.fill({
1337
+ ...this.selection,
1338
+ ref,
1339
+ text
1340
+ });
1341
+ });
1342
+ }
1343
+ typeText(text) {
1344
+ return this.guard("type", async () => {
1345
+ await this.client.interactions.type({
1346
+ ...this.selection,
1347
+ text
1348
+ });
1349
+ });
1350
+ }
1351
+ pressKey(_key) {
1352
+ return this.guard("key", async () => {
1353
+ await this.client.command.keyboard({
1354
+ ...this.selection,
1355
+ action: "return"
1356
+ });
1357
+ });
1358
+ }
1359
+ scroll(direction) {
1360
+ return this.guard("scroll", async () => {
1361
+ const args = {
1362
+ ...this.selection,
1363
+ direction
1364
+ };
1365
+ await this.client.interactions.scroll(args);
1366
+ });
1367
+ }
1368
+ pan(x, y, dx, dy, durationMs) {
1369
+ return this.guard("pan", async () => {
1370
+ await this.client.interactions.pan({
1371
+ ...this.selection,
1372
+ x,
1373
+ y,
1374
+ dx,
1375
+ dy,
1376
+ ...durationMs === void 0 ? {} : { durationMs }
1377
+ });
1378
+ });
1379
+ }
1380
+ waitForText(text, timeoutMs) {
1381
+ return this.guard("waitForText", async () => {
1382
+ await this.client.command.wait({
1383
+ ...this.selection,
1384
+ text,
1385
+ ...timeoutMs === void 0 ? {} : { timeoutMs }
1386
+ });
1387
+ });
1388
+ }
1389
+ systemAlert(action) {
1390
+ return this.guard("alert", async () => {
1391
+ const result = await this.client.command.alert({
1392
+ ...this.selection,
1393
+ action
1394
+ });
1395
+ return {
1396
+ alert: result.alert,
1397
+ handled: result.handled,
1398
+ button: result.button
1399
+ };
1400
+ });
1401
+ }
1402
+ home() {
1403
+ return this.guard("home", async () => {
1404
+ await this.client.command.home({ ...this.selection });
1405
+ });
1406
+ }
1407
+ back() {
1408
+ return this.guard("back", async () => {
1409
+ await this.client.command.back({ ...this.selection });
1410
+ });
1411
+ }
1412
+ openApp(opts) {
1413
+ return this.guard("openApp", async () => {
1414
+ const args = opts.url === void 0 ? {
1415
+ app: opts.app,
1416
+ ...opts.relaunch === void 0 ? {} : { relaunch: opts.relaunch }
1417
+ } : opts.app !== void 0 ? {
1418
+ app: opts.app,
1419
+ url: opts.url
1420
+ } : { url: opts.url };
1421
+ const result = await this.client.apps.open({
1422
+ ...this.selection,
1423
+ ...args
1424
+ });
1425
+ return {
1426
+ appName: result.appName,
1427
+ appBundleId: result.appBundleId
1428
+ };
1429
+ });
1430
+ }
1431
+ listApps() {
1432
+ return this.guard("listApps", async () => {
1433
+ return await this.client.apps.list(Object.keys(this.selection).length ? this.selection : void 0);
1434
+ });
1435
+ }
1436
+ closeSession() {
1437
+ if (!this.used) return Promise.resolve();
1438
+ return this.guard("closeSession", async () => {
1439
+ await this.client.sessions.close({ ...this.selection });
1440
+ });
1441
+ }
1442
+ };
1443
+ /**
1444
+ * Build the agent-device backend: the ONE place agent-device is called. Device
1445
+ * pinning is per-request in agent-device, so the backend holds a selection
1446
+ * object and spreads it into every call. With no config, selection is `{}` and
1447
+ * the client is default-constructed — requests are byte-identical to the
1448
+ * pre-seam process-global path (booted-sim auto-detect). Every method
1449
+ * normalizes errors via `toPhoneUseError`; no agent-device type or error ever
1450
+ * escapes this module.
1451
+ */
1452
+ function createAgentDeviceBackend(config) {
1453
+ return new AgentDeviceBackend(config);
1454
+ }
1455
+ //#endregion
1456
+ //#region src/exec.ts
1457
+ const pExecFile = promisify(execFile);
1458
+ const defaultExecRunner = async (file, args, opts) => {
1459
+ const { stdout, stderr } = await pExecFile(file, args, {
1460
+ encoding: "utf8",
1461
+ maxBuffer: 16 * 1024 * 1024,
1462
+ ...opts?.timeoutMs === void 0 ? {} : { timeout: opts.timeoutMs },
1463
+ ...opts?.env === void 0 ? {} : { env: opts.env }
1464
+ });
1465
+ return {
1466
+ stdout,
1467
+ stderr
1468
+ };
1469
+ };
1470
+ function isExecError(err) {
1471
+ return err instanceof Error && ("code" in err || "killed" in err || "stderr" in err);
1472
+ }
1473
+ //#endregion
1474
+ //#region src/lifecycle.ts
1475
+ var IdleLease = class {
1476
+ timer = null;
1477
+ windowMs;
1478
+ onExpire;
1479
+ constructor(windowMs, onExpire) {
1480
+ this.windowMs = windowMs;
1481
+ this.onExpire = onExpire;
1482
+ this.touch();
1483
+ }
1484
+ /** Re-arm with the configured window (no-op when disabled). */
1485
+ touch() {
1486
+ this.arm(this.windowMs);
1487
+ }
1488
+ /** Re-arm with a one-shot override window. */
1489
+ extend(ms) {
1490
+ this.arm(ms ?? this.windowMs);
1491
+ }
1492
+ arm(ms) {
1493
+ if (this.timer) clearTimeout(this.timer);
1494
+ this.timer = null;
1495
+ if (ms === false) return;
1496
+ const t = setTimeout(this.onExpire, ms);
1497
+ t.unref?.();
1498
+ this.timer = t;
1499
+ }
1500
+ dispose() {
1501
+ if (this.timer) clearTimeout(this.timer);
1502
+ this.timer = null;
1503
+ }
1504
+ };
1505
+ /**
1506
+ * Assemble a Device handle over a backend: lease/reaper, verb surface,
1507
+ * close/dispose semantics. Engine authors (ios here, android in item 7b,
1508
+ * phone-backend-* third parties) build on this; tests fabricate devices with
1509
+ * it over a FakeBackend.
1510
+ */
1511
+ function createDeviceHandle(opts) {
1512
+ let status = "running";
1513
+ let closePromise = null;
1514
+ const close = () => {
1515
+ closePromise ??= (async () => {
1516
+ status = "closed";
1517
+ lease.dispose();
1518
+ await opts.backend.closeSession().catch(() => void 0);
1519
+ await opts.doClose();
1520
+ })();
1521
+ return closePromise;
1522
+ };
1523
+ const lease = new IdleLease(opts.idleTimeoutMs ?? 18e4, () => {
1524
+ close().catch(() => void 0).then(() => opts.onIdleClose?.(device));
1525
+ });
1526
+ const touchingBackend = new Proxy(opts.backend, { get(target, prop, receiver) {
1527
+ const value = Reflect.get(target, prop, receiver);
1528
+ if (typeof value !== "function") return value;
1529
+ return (...args) => {
1530
+ if (status === "running") lease.touch();
1531
+ return value.apply(target, args);
1532
+ };
1533
+ } });
1534
+ const core = opts.coreFactory?.(touchingBackend) ?? new DeviceCore(touchingBackend);
1535
+ const secrets = new SecretStore(opts.secrets);
1536
+ const assertOpen = () => {
1537
+ if (status === "closed") throw new SessionNotFoundError(`device ${opts.id} is closed`);
1538
+ };
1539
+ const toQuery = (target) => typeof target === "string" ? { label: target } : target;
1540
+ const device = {
1541
+ id: opts.id,
1542
+ platform: opts.platform,
1543
+ name: opts.name,
1544
+ backendName: opts.backend.backendName,
1545
+ capabilities: opts.backend.capabilities,
1546
+ backend: touchingBackend,
1547
+ createdByUs: opts.createdByUs,
1548
+ get status() {
1549
+ return status;
1550
+ },
1551
+ get isClosed() {
1552
+ return status === "closed";
1553
+ },
1554
+ extendLease(ms) {
1555
+ if (status === "running") lease.extend(ms);
1556
+ },
1557
+ close,
1558
+ [Symbol.asyncDispose]: close,
1559
+ observe() {
1560
+ assertOpen();
1561
+ return buildObserveResult(core, secrets);
1562
+ },
1563
+ tap(target, actOpts = {}) {
1564
+ assertOpen();
1565
+ return executeAction(core, {
1566
+ formatVersion: 0,
1567
+ verb: "tap",
1568
+ target: toQuery(target)
1569
+ }, actOpts, secrets);
1570
+ },
1571
+ type(text, actOpts = {}) {
1572
+ assertOpen();
1573
+ const { field, submit, ...rest } = actOpts;
1574
+ const action = field === void 0 ? {
1575
+ formatVersion: 0,
1576
+ verb: "type",
1577
+ params: {
1578
+ text,
1579
+ submit
1580
+ }
1581
+ } : {
1582
+ formatVersion: 0,
1583
+ verb: "fill",
1584
+ target: toQuery(field),
1585
+ params: {
1586
+ text,
1587
+ submit
1588
+ }
1589
+ };
1590
+ return executeAction(core, action, rest, secrets);
1591
+ },
1592
+ act(action, actOpts = {}) {
1593
+ assertOpen();
1594
+ return executeAction(core, action, actOpts, secrets);
1595
+ },
1596
+ apps: {
1597
+ open(app, o = {}) {
1598
+ assertOpen();
1599
+ const action = o.url === void 0 ? {
1600
+ formatVersion: 0,
1601
+ verb: "openApp",
1602
+ params: {
1603
+ app,
1604
+ relaunch: o.relaunch
1605
+ }
1606
+ } : {
1607
+ formatVersion: 0,
1608
+ verb: "openUrl",
1609
+ params: {
1610
+ app,
1611
+ url: o.url
1612
+ }
1613
+ };
1614
+ return executeAction(core, action, { signal: o.signal }, secrets);
1615
+ },
1616
+ list(o = {}) {
1617
+ assertOpen();
1618
+ return core.listApps();
1619
+ },
1620
+ current() {
1621
+ return core.currentApp();
1622
+ }
1623
+ },
1624
+ screen: {
1625
+ scroll(direction, o = {}) {
1626
+ assertOpen();
1627
+ return executeAction(core, {
1628
+ formatVersion: 0,
1629
+ verb: "scroll",
1630
+ params: { direction }
1631
+ }, { signal: o.signal }, secrets);
1632
+ },
1633
+ async screenshot(o) {
1634
+ assertOpen();
1635
+ return {
1636
+ success: true,
1637
+ message: `screenshot saved`,
1638
+ path: await core.screenshot(o.path)
1639
+ };
1640
+ },
1641
+ waitForText(text, o = {}) {
1642
+ assertOpen();
1643
+ return executeAction(core, {
1644
+ formatVersion: 0,
1645
+ verb: "waitForText",
1646
+ params: { text }
1647
+ }, {
1648
+ signal: o.signal,
1649
+ timeoutMs: o.timeoutMs
1650
+ }, secrets);
1651
+ },
1652
+ alert(action, o = {}) {
1653
+ assertOpen();
1654
+ if (action === "get") return core.handleAlert("get").then((r) => ({
1655
+ success: r.present,
1656
+ message: r.present ? `alert: ${r.description ?? ""}` : "no system alert is showing"
1657
+ }));
1658
+ return executeAction(core, {
1659
+ formatVersion: 0,
1660
+ verb: "alert",
1661
+ params: { alertAction: action }
1662
+ }, { signal: o.signal }, secrets);
1663
+ },
1664
+ back(o = {}) {
1665
+ assertOpen();
1666
+ return executeAction(core, {
1667
+ formatVersion: 0,
1668
+ verb: "back"
1669
+ }, { signal: o.signal }, secrets);
1670
+ },
1671
+ home(o = {}) {
1672
+ assertOpen();
1673
+ return executeAction(core, {
1674
+ formatVersion: 0,
1675
+ verb: "home"
1676
+ }, { signal: o.signal }, secrets);
1677
+ }
1678
+ },
1679
+ secrets
1680
+ };
1681
+ return device;
1682
+ }
1683
+ //#endregion
1684
+ //#region src/backends/ios.ts
1685
+ const UDID_RE = /^[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}$/i;
1686
+ function simctlArgs(setPath, args) {
1687
+ return setPath === void 0 ? ["simctl", ...args] : [
1688
+ "simctl",
1689
+ "--set",
1690
+ setPath,
1691
+ ...args
1692
+ ];
1693
+ }
1694
+ function isAlreadyInState(err) {
1695
+ if (!isExecError(err)) return false;
1696
+ if (err.code === 149) return true;
1697
+ return /current state.*(Booted|Shutdown)/i.test(err.stderr ?? "");
1698
+ }
1699
+ async function runSimctl(exec, setPath, args, opts = {}) {
1700
+ try {
1701
+ return (await exec("xcrun", simctlArgs(setPath, args), opts.timeoutMs === void 0 ? void 0 : { timeoutMs: opts.timeoutMs })).stdout;
1702
+ } catch (err) {
1703
+ if (opts.tolerateState && isAlreadyInState(err)) return "";
1704
+ if (isExecError(err)) {
1705
+ if (err.killed || err.signal) throw new TimeoutError(`simctl ${args[0]} timed out${opts.timeoutMs ? ` after ${opts.timeoutMs}ms` : ""}`, { cause: err });
1706
+ throw new ActionFailedError(`simctl ${args[0]} failed (exit ${String(err.code ?? "?")}): ${(err.stderr ?? err.message).trim()}`, {
1707
+ details: { backendCode: String(err.code ?? "EXEC") },
1708
+ cause: err
1709
+ });
1710
+ }
1711
+ throw toPhoneUseError(err);
1712
+ }
1713
+ }
1714
+ function parseCreatedUdid(stdout) {
1715
+ const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
1716
+ const last = lines[lines.length - 1] ?? "";
1717
+ if (!UDID_RE.test(last)) throw new ActionFailedError(`could not parse udid from simctl create output: ${JSON.stringify(stdout.slice(0, 200))}`);
1718
+ return last;
1719
+ }
1720
+ function parseList(stdout) {
1721
+ let parsed;
1722
+ try {
1723
+ parsed = JSON.parse(stdout);
1724
+ } catch (err) {
1725
+ throw new ActionFailedError("could not parse simctl list output as JSON", { cause: err });
1726
+ }
1727
+ return Object.values(parsed.devices ?? {}).flat();
1728
+ }
1729
+ function makeBackendConfig(udid, opts) {
1730
+ return {
1731
+ platform: "ios",
1732
+ udid,
1733
+ ...opts.simulatorDeviceSet === void 0 ? {} : { simulatorDeviceSet: opts.simulatorDeviceSet },
1734
+ session: opts.session ?? `phone-use-${udid}`,
1735
+ ...opts.daemonBaseUrl === void 0 ? {} : { daemonBaseUrl: opts.daemonBaseUrl },
1736
+ ...opts.daemonAuthToken === void 0 ? {} : { daemonAuthToken: opts.daemonAuthToken }
1737
+ };
1738
+ }
1739
+ async function bootAndWait(exec, setPath, udid, bootTimeoutMs) {
1740
+ await runSimctl(exec, setPath, ["boot", udid], { tolerateState: true });
1741
+ await runSimctl(exec, setPath, [
1742
+ "bootstatus",
1743
+ udid,
1744
+ "-b"
1745
+ ], { timeoutMs: bootTimeoutMs });
1746
+ }
1747
+ async function finishHandle(udid, name, createdByUs, opts, exec) {
1748
+ const backend = createAgentDeviceBackend(makeBackendConfig(udid, opts));
1749
+ if (opts.failFast) await backend.listApps();
1750
+ return createDeviceHandle({
1751
+ id: udid,
1752
+ platform: "ios",
1753
+ name,
1754
+ backend,
1755
+ createdByUs,
1756
+ idleTimeoutMs: opts.idleTimeoutMs,
1757
+ onIdleClose: opts.onIdleClose,
1758
+ secrets: opts.secrets,
1759
+ coreFactory: opts.coreFactory,
1760
+ doClose: async () => {
1761
+ await runSimctl(exec, opts.simulatorDeviceSet, ["shutdown", udid], { tolerateState: true });
1762
+ if (createdByUs) await runSimctl(exec, opts.simulatorDeviceSet, ["delete", udid]);
1763
+ }
1764
+ });
1765
+ }
1766
+ /**
1767
+ * Create and boot a DEDICATED simulator via simctl — no more "whatever is
1768
+ * booted" — and return a {@link Device} pinned to its udid. Created sims are
1769
+ * named `phone-use-<hex>` deliberately: if the process is kill -9'd the
1770
+ * in-process reaper can't run, and the name prefix is how orphans are found.
1771
+ * `close()` shuts the sim down AND deletes it (we created it); a failed boot
1772
+ * best-effort-deletes before rethrowing.
1773
+ */
1774
+ async function launch(options = {}) {
1775
+ const exec = options.exec ?? defaultExecRunner;
1776
+ const deviceType = options.deviceType ?? "iPhone 16";
1777
+ const name = options.name ?? `phone-use-${Math.random().toString(16).slice(2, 10)}`;
1778
+ const bootTimeoutMs = options.bootTimeoutMs ?? 12e4;
1779
+ const createArgs = [
1780
+ "create",
1781
+ name,
1782
+ deviceType,
1783
+ ...options.runtime === void 0 ? [] : [options.runtime]
1784
+ ];
1785
+ const udid = parseCreatedUdid(await runSimctl(exec, options.simulatorDeviceSet, createArgs));
1786
+ try {
1787
+ await bootAndWait(exec, options.simulatorDeviceSet, udid, bootTimeoutMs);
1788
+ } catch (err) {
1789
+ await runSimctl(exec, options.simulatorDeviceSet, ["delete", udid]).catch(() => void 0);
1790
+ throw err;
1791
+ }
1792
+ return finishHandle(udid, name, true, options, exec);
1793
+ }
1794
+ /**
1795
+ * Reattach to an existing simulator by udid (booting it if shut down). The
1796
+ * no-arg form is the sole survivor of the old booted-sim auto-detect: it
1797
+ * attaches to the first booted, available sim. `close()` on a connected
1798
+ * device shuts it down but never deletes it.
1799
+ */
1800
+ async function connect(udid, options = {}) {
1801
+ const exec = options.exec ?? defaultExecRunner;
1802
+ const bootTimeoutMs = options.bootTimeoutMs ?? 12e4;
1803
+ if (udid === void 0) {
1804
+ const booted = parseList(await runSimctl(exec, options.simulatorDeviceSet, [
1805
+ "list",
1806
+ "devices",
1807
+ "booted",
1808
+ "-j"
1809
+ ])).find((d) => d.state === "Booted" && d.isAvailable !== false);
1810
+ if (!booted) throw new DeviceNotFoundError("no booted simulator — use ios.launch() or boot one");
1811
+ return finishHandle(booted.udid, booted.name, false, options, exec);
1812
+ }
1813
+ const row = parseList(await runSimctl(exec, options.simulatorDeviceSet, [
1814
+ "list",
1815
+ "devices",
1816
+ "-j"
1817
+ ])).find((d) => d.udid.toLowerCase() === udid.toLowerCase());
1818
+ if (!row) throw new DeviceNotFoundError(`no simulator with udid ${udid}`);
1819
+ if (row.state !== "Booted") await bootAndWait(exec, options.simulatorDeviceSet, row.udid, bootTimeoutMs);
1820
+ return finishHandle(row.udid, row.name, false, options, exec);
1821
+ }
1822
+ /**
1823
+ * The iOS engine object (Playwright-style): `ios.launch()` for a dedicated
1824
+ * simulator, `ios.connect()` to reattach. Both return the same Device type.
1825
+ */
1826
+ const ios = {
1827
+ /** Create + boot a dedicated simulator and return a Device pinned to it. */
1828
+ launch,
1829
+ /** Reattach to an existing simulator (no-arg: first booted sim). */
1830
+ connect
1831
+ };
1832
+ //#endregion
1833
+ //#region src/index.ts
1834
+ /**
1835
+ * @phone-use/sdk — the device runtime SDK: engine-as-object lifecycle
1836
+ * (ios.launch/connect → Device), Device backends, config, errors, capabilities
1837
+ * (docs/20-runtime-sdk-v1-plan.md items 2-3; action verbs land in item 4).
1838
+ *
1839
+ * The test double (FakeBackend) lives on the "@phone-use/sdk/testing" subpath,
1840
+ * deliberately not re-exported here.
1841
+ */
1842
+ /** The published package version (kept in sync with package.json by the release flow). */
1843
+ const VERSION = "0.1.0";
1844
+ registerBackend("agent-device", createAgentDeviceBackend);
1845
+ //#endregion
1846
+ export { ALL_CAPABILITIES, AbortedError, ActionFailedError, BaseDeviceBackend, DeviceCore, DeviceInUseError, DeviceNotFoundError, PhoneUseError, SecretStore, SessionNotFoundError, TimeoutError, UnsupportedCapabilityError, VERSION, buildObserveResult, createAgentDeviceBackend, createDeviceHandle, describeError, executeAction, getBackendFactory, ios, labelMatches, listBackends, matchInElements, registerBackend, toActions, toPhoneUseError };
1847
+
1848
+ //# sourceMappingURL=index.mjs.map