@agimon-ai/po-ios 0.0.1-alpha.0 → 0.0.1-alpha.1

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/README.md CHANGED
@@ -11,7 +11,7 @@ This package is a composable [Po](https://www.npmjs.com/package/@agimon-ai/po) s
11
11
 
12
12
  ## Install
13
13
 
14
- The layer is opt-in: select the `ios` layer in your Po modes. The package declares poServer metadata for native server discovery.
14
+ The package is opt-in and intentionally absent from init templates. Define an `ios` layer with `packages: ['@agimon-ai/po-ios']` in your modes file, then select that layer in a major mode. The package declares poServer metadata for native server discovery.
15
15
 
16
16
  ## Use
17
17
 
@@ -41,9 +41,11 @@ ios:
41
41
 
42
42
  Release uninstalls the apps a session installed and keeps the device warm. Devices of a dead server are erased before reuse.
43
43
 
44
- Accessibility and touch (`observe`, `tap`, `type`, `swipe`, `pressButton`, `waitFor`, `settle`) go through a resident Swift helper in `native/sim-helper`. The server builds it on first use with the person's Xcode (`xcrun swift build`) into `<po home>/ios/native/<hash>/`; set `ios.helperPath` to use a prebuilt one instead. Its private framework calls are not recorded yet (see `native/sim-helper/PRIVATE_APIS.md`), so until then those calls return `IOS_HELPER_UNAVAILABLE`.
44
+ Accessibility and touch (`observe`, `tap`, `type`, `swipe`, `pressButton`, `waitFor`, `settle`) go through a resident Swift helper in `native/sim-helper`. The server builds it on first use with the person's Xcode (`xcrun swift build`) into `<po home>/ios/native/<hash>/`; set `ios.helperPath` to use a prebuilt one instead. On Xcode 27.1 (27A9275) and iOS 26.5, direct native-protocol trials verified observation, taps, timed swipes, US-layout ASCII typing (including shifted punctuation and return), home and lock. Arbitrary Unicode text and Siri/volume buttons explicitly return `IOS_HELPER_UNAVAILABLE`, with no clipboard or AX-value fallback. Unsupported text is rejected before any keys are emitted. Missing or changed private API shapes also fail explicitly. See `native/sim-helper/PRIVATE_APIS.md` for evidence and limits.
45
45
 
46
- Physical devices: `ios.devices()` lists what CoreDevice sees (`xcrun devicectl list devices`), and `ios.acquireDevice({ udid })` leases a paired, connected phone after the person approves it. A phone lease shares the `ios_lease` table (`kind = 'device'`) so two sessions never hold one phone, but it has no `ios_device` row: the pool never boots, erases or counts it against `maxBooted`. On a phone only `install` (a device-signed `.app`), `uninstall`, `launch` and `release` work; release only drops the lease. Accessibility, screenshots and logs on phones need the XCUITest runner, which is not built yet.
46
+ This is a documented partial implementation, not completed acceptance of the full iOS plan. Rotation, two-device isolation and built-layer smoke remain unverified. Input acknowledgment does not mean the UI transition has finished; a lock transition took about three seconds to appear in AX observations.
47
+
48
+ Physical devices: `ios.devices()` lists what CoreDevice sees (`xcrun devicectl list devices`), and `ios.acquireDevice({ udid })` leases a paired, reachable phone after the person approves it. An idle disconnected tunnel is checked with a bounded device-info request rather than rejected solely by its listed state. A phone lease shares the `ios_lease` table (`kind = 'device'`) so two sessions never hold one phone, but it has no `ios_device` row: the pool never boots, erases or counts it against `maxBooted`. On a phone only `install` (a device-signed `.app`), `uninstall`, `launch` and `release` work; release only drops the lease. Phone console capture and the XCUITest runner are not implemented. Other phone calls explicitly return `IOS_UNSUPPORTED_HOST`. Live signed-app and runner acceptance is still pending.
47
49
 
48
50
  ## Help
49
51
 
@@ -53,14 +53,12 @@ var IosError = class extends Error {
53
53
  };
54
54
  //#endregion
55
55
  //#region src/services/devicectl/index.ts
56
+ const CONNECTION_TIMEOUT_MS = 2e4;
56
57
  function record(value) {
57
58
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
58
59
  }
59
60
  const text$1 = (value) => typeof value === "string" ? value : "";
60
- /**
61
- * Reads `devicectl list devices --json-output`. The shape is hand-written from Apple's documented output and is
62
- * not yet checked against a real run; unknown fields read as empty rather than failing the whole list.
63
- */
61
+ /** Reads physical devices from Xcode 27.1 output, with legacy-field fallback for older Xcode versions. */
64
62
  function parsePhysicalDevices(output) {
65
63
  let parsed;
66
64
  try {
@@ -69,21 +67,30 @@ function parsePhysicalDevices(output) {
69
67
  throw new IosError("IOS_COMMAND_FAILED", "devicectl returned unreadable device JSON.");
70
68
  }
71
69
  const devices = record(record(parsed).result).devices;
72
- return (Array.isArray(devices) ? devices : []).map((entry) => {
70
+ return (Array.isArray(devices) ? devices : []).flatMap((entry) => {
73
71
  const device = record(entry);
74
- const properties = record(device.deviceProperties);
75
- const hardware = record(device.hardwareProperties);
76
- const connection = record(device.connectionProperties);
77
- return {
78
- udid: text$1(hardware.udid),
72
+ const properties = record(device.properties);
73
+ const hardware = record(properties.hardware);
74
+ const state = record(properties.state);
75
+ const software = record(properties.software);
76
+ const connection = record(properties.connection);
77
+ const legacyHardware = record(device.hardwareProperties);
78
+ const legacyProperties = record(device.deviceProperties);
79
+ const legacyConnection = record(device.connectionProperties);
80
+ const realities = [hardware.reality, legacyHardware.reality].filter((value) => value !== void 0);
81
+ if (realities.length === 0 || realities.some((value) => value !== "physical") || state.visibilityClass === "simulators" || device.visibilityClass === "simulators") return [];
82
+ const udid = text$1(hardware.udid ?? legacyHardware.udid);
83
+ if (udid === "") return [];
84
+ return [{
85
+ udid,
79
86
  identifier: text$1(device.identifier),
80
- name: text$1(properties.name),
81
- model: text$1(hardware.marketingName),
82
- osVersion: text$1(properties.osVersionNumber),
83
- platform: text$1(hardware.platform),
84
- paired: connection.pairingState === "paired",
85
- connected: connection.tunnelState === "connected"
86
- };
87
+ name: text$1(state.name ?? legacyProperties.name),
88
+ model: text$1(hardware.marketingName ?? legacyHardware.marketingName),
89
+ osVersion: text$1(record(software.osVersionNumber).stringValue ?? legacyProperties.osVersionNumber),
90
+ platform: text$1(hardware.platform ?? legacyHardware.platform),
91
+ paired: (connection.pairingState ?? legacyConnection.pairingState) === "paired",
92
+ connected: (connection.state ?? legacyConnection.tunnelState) === "connected"
93
+ }];
87
94
  });
88
95
  }
89
96
  const firstLine$1 = (output) => output.split("\n").map((line) => line.trim()).find((line) => line !== "") ?? "";
@@ -116,6 +123,17 @@ function createDeviceCtl(options) {
116
123
  await options.remove(file);
117
124
  }
118
125
  },
126
+ async ensureReachable(identifier, signal) {
127
+ await step([
128
+ "device",
129
+ "info",
130
+ "details",
131
+ "--device",
132
+ identifier,
133
+ "--timeout",
134
+ String(CONNECTION_TIMEOUT_MS / 1e3)
135
+ ], signal, 25e3);
136
+ },
119
137
  async install(identifier, appPath, signal) {
120
138
  await step([
121
139
  "device",
@@ -144,6 +162,7 @@ function createDeviceCtl(options) {
144
162
  "--device",
145
163
  identifier,
146
164
  bundleId,
165
+ "--",
147
166
  ...args
148
167
  ], signal);
149
168
  }
@@ -484,7 +503,7 @@ function compareVersions(left, right) {
484
503
  }
485
504
  /**
486
505
  * The runtime and device type new pool devices use. `runtime` may be `latest`, an identifier, a version or a
487
- * name; `deviceType` an identifier or a name. Unset means the newest available iOS runtime and its newest iPhone.
506
+ * name; `deviceType` an identifier or a name. Unset means the newest available iOS runtime and its first listed iPhone.
488
507
  */
489
508
  function resolveDeviceSpec(runtimes, wanted) {
490
509
  const ios = runtimes.filter((runtime) => runtime.isAvailable && (runtime.platform === "iOS" || runtime.name.startsWith("iOS"))).sort((left, right) => compareVersions(right.version, left.version));
@@ -499,7 +518,7 @@ function resolveDeviceSpec(runtimes, wanted) {
499
518
  throw new IosError("IOS_NO_RUNTIME", `No available iOS simulator runtime matches "${query ?? "latest"}" (available: ${available}). Install one in Xcode settings, Components.`);
500
519
  }
501
520
  const types = runtime.supportedDeviceTypes;
502
- const deviceType = wanted.deviceType === void 0 ? types.filter((type) => type.productFamily === "iPhone").at(-1) : types.find((type) => type.identifier === wanted.deviceType || type.name === wanted.deviceType);
521
+ const deviceType = wanted.deviceType === void 0 ? types.find((type) => type.productFamily === "iPhone") : types.find((type) => type.identifier === wanted.deviceType || type.name === wanted.deviceType);
503
522
  if (deviceType === void 0) throw new IosError("IOS_NO_RUNTIME", `${runtime.name} supports no device type matching "${wanted.deviceType ?? "iPhone"}".`);
504
523
  return {
505
524
  runtime: runtime.identifier,
@@ -701,6 +720,7 @@ function createDevicePool(options) {
701
720
  const supported = probe.platform === "darwin";
702
721
  const wakers = /* @__PURE__ */ new Set();
703
722
  const orphans = /* @__PURE__ */ new Map();
723
+ const queues = /* @__PURE__ */ new Map();
704
724
  let spec;
705
725
  let timer;
706
726
  let ticking;
@@ -922,18 +942,31 @@ function createDevicePool(options) {
922
942
  if (leases.length > 1) throw new IosError("IOS_INVALID_ARGUMENT", `This session holds ${String(leases.length)} leases; pass leaseId (${leases.map((lease) => lease.lease_id).join(", ")}).`);
923
943
  return leases[0];
924
944
  };
925
- const release = async (owner, leaseId, reset = "apps") => {
945
+ /** Calls and release share one queue, including session teardown outside the namespace. */
946
+ const queueLease = async (owner, leaseId, work) => {
926
947
  const lease = await resolveLease(owner, leaseId);
948
+ const next = (queues.get(lease.lease_id) ?? Promise.resolve()).catch(() => void 0).then(async () => {
949
+ if (stopped) throw new IosError("IOS_LEASE_LOST", "The iOS pool is stopping.");
950
+ return work(await resolveLease(owner, lease.lease_id));
951
+ });
952
+ queues.set(lease.lease_id, next);
953
+ try {
954
+ return await next;
955
+ } finally {
956
+ if (queues.get(lease.lease_id) === next) queues.delete(lease.lease_id);
957
+ }
958
+ };
959
+ const release = (owner, leaseId, reset = "apps") => queueLease(owner, leaseId, async (lease) => {
927
960
  const result = await view(lease);
961
+ if (lease.kind === "simulator") await options.helper?.forget(lease.udid);
928
962
  const udid = await store.release(lease.lease_id, reset === "erase", now());
929
963
  if (udid !== void 0 && lease.kind === "simulator") {
930
- options.helper?.forget(udid);
931
964
  const device = await store.claimDirty(serverId, udid);
932
965
  if (device !== void 0) await clean(device);
933
966
  }
934
967
  wake();
935
968
  return result;
936
- };
969
+ });
937
970
  const reconcile = async () => {
938
971
  const rows = await store.listDevices();
939
972
  const listed = await simctl.listDevices();
@@ -1090,11 +1123,16 @@ function createDevicePool(options) {
1090
1123
  };
1091
1124
  });
1092
1125
  },
1093
- async acquireDevice(owner, udid, label) {
1126
+ async acquireDevice(owner, udid, label, signal) {
1094
1127
  requireSupported();
1095
- const device = (await requireDevicectl().listDevices()).find((candidate) => candidate.udid === udid);
1128
+ if (stopped) throw new IosError("IOS_POOL_BUSY", "The iOS pool is stopping.");
1129
+ signal?.throwIfAborted();
1130
+ const devicectl = requireDevicectl();
1131
+ const device = (await devicectl.listDevices(signal)).find((candidate) => candidate.udid === udid);
1096
1132
  if (device === void 0) throw new IosError("IOS_INVALID_ARGUMENT", `No physical device ${udid} is visible. Call ios.devices() to list them.`);
1097
- if (!device.paired || !device.connected) throw new IosError("IOS_INVALID_ARGUMENT", `${device.name || udid} is not ready: pair it with this Mac, unlock it, and enable Developer Mode.`);
1133
+ if (!device.paired) throw new IosError("IOS_INVALID_ARGUMENT", `${device.name || udid} is not ready: pair it with this Mac, unlock it, and enable Developer Mode.`);
1134
+ if (!device.connected) await devicectl.ensureReachable(udid, signal);
1135
+ signal?.throwIfAborted();
1098
1136
  const lease = await store.leasePhone(udid, {
1099
1137
  leaseId: crypto.randomUUID(),
1100
1138
  owner,
@@ -1102,6 +1140,10 @@ function createDevicePool(options) {
1102
1140
  label: label ?? null,
1103
1141
  now: now()
1104
1142
  });
1143
+ if (signal?.aborted) {
1144
+ if (lease !== void 0) await store.release(lease.lease_id, false, now());
1145
+ signal.throwIfAborted();
1146
+ }
1105
1147
  if (lease !== void 0) return view(lease);
1106
1148
  const held = (await store.allLeases()).find((candidate) => candidate.udid === udid);
1107
1149
  if (held !== void 0 && held.owner_root === owner) return view(held);
@@ -1112,6 +1154,10 @@ function createDevicePool(options) {
1112
1154
  await store.touchLease(lease.lease_id, now());
1113
1155
  return view(lease);
1114
1156
  },
1157
+ withLease: (owner, leaseId, work) => queueLease(owner, leaseId, async (lease) => {
1158
+ await store.touchLease(lease.lease_id, now());
1159
+ return work(await view(lease));
1160
+ }),
1115
1161
  async leases(owner) {
1116
1162
  return Promise.all((await store.leasesOf(owner)).map(view));
1117
1163
  },
@@ -1126,7 +1172,8 @@ function createDevicePool(options) {
1126
1172
  if (stopped) return;
1127
1173
  stopped = true;
1128
1174
  if (timer !== void 0) clearInterval(timer);
1129
- options.helper?.dispose();
1175
+ await Promise.allSettled(queues.values());
1176
+ await options.helper?.dispose();
1130
1177
  wake();
1131
1178
  await ticking;
1132
1179
  if (!supported) return;
@@ -1261,9 +1308,18 @@ function spawnLineProcess(binary, env) {
1261
1308
  const exitListeners = [];
1262
1309
  let buffer = "";
1263
1310
  let exited = false;
1311
+ let resolveExit;
1312
+ const closed = new Promise((resolve) => {
1313
+ resolveExit = resolve;
1314
+ });
1315
+ const kill = () => {
1316
+ if (!exited) child.kill("SIGKILL");
1317
+ return closed;
1318
+ };
1264
1319
  const exit = (code) => {
1265
1320
  if (exited) return;
1266
1321
  exited = true;
1322
+ resolveExit();
1267
1323
  for (const listener of exitListeners) listener(code);
1268
1324
  };
1269
1325
  child.stdout.setEncoding("utf8");
@@ -1272,9 +1328,13 @@ function spawnLineProcess(binary, env) {
1272
1328
  for (let index = buffer.indexOf("\n"); index >= 0; index = buffer.indexOf("\n")) {
1273
1329
  const line = buffer.slice(0, index);
1274
1330
  buffer = buffer.slice(index + 1);
1331
+ if (line.length > 8388608) {
1332
+ kill();
1333
+ return;
1334
+ }
1275
1335
  if (line.trim() !== "") for (const listener of lineListeners) listener(line);
1276
1336
  }
1277
- if (buffer.length > 8388608) child.kill();
1337
+ if (buffer.length > 8388608) kill();
1278
1338
  });
1279
1339
  child.stderr.resume();
1280
1340
  child.stdin.on("error", () => void 0);
@@ -1286,9 +1346,7 @@ function spawnLineProcess(binary, env) {
1286
1346
  },
1287
1347
  onLine: (listener) => lineListeners.push(listener),
1288
1348
  onExit: (listener) => exitListeners.push(listener),
1289
- kill: () => {
1290
- child.kill();
1291
- }
1349
+ kill
1292
1350
  };
1293
1351
  }
1294
1352
  //#endregion
@@ -1300,8 +1358,8 @@ function replyError(error) {
1300
1358
  return require_contracts.IOS_ERROR_CODES.includes(code) ? new IosError(code, message) : unavailable(`${code ? `${code}: ` : ""}${message}`);
1301
1359
  }
1302
1360
  /**
1303
- * Client for the resident Swift helper. One request per line, matched by id; a timeout kills the process and the
1304
- * next call starts a fresh one, at most three times in five minutes. A protocol mismatch triggers one rebuild.
1361
+ * Client for the resident Swift helper. Requests dispatch in order, matched by id. A timeout stops the process;
1362
+ * restart waits for its observed exit, at most three starts in five minutes. A protocol mismatch rebuilds once.
1305
1363
  */
1306
1364
  function createSimHelper(options) {
1307
1365
  const now = options.now ?? Date.now;
@@ -1310,90 +1368,113 @@ function createSimHelper(options) {
1310
1368
  const starts = [];
1311
1369
  let current;
1312
1370
  let starting;
1371
+ let stopping = Promise.resolve();
1372
+ let dispatch = Promise.resolve();
1313
1373
  let nextId = 1;
1314
1374
  let disposed = false;
1315
- const failAll = (error) => {
1316
- for (const [id, entry] of pending) {
1317
- clearTimeout(entry.timer);
1318
- entry.reject(error);
1375
+ const take = (id) => {
1376
+ const entry = pending.get(id);
1377
+ if (entry !== void 0) {
1319
1378
  pending.delete(id);
1379
+ entry.cleanup();
1320
1380
  }
1381
+ return entry;
1382
+ };
1383
+ const failAll = (error, target) => {
1384
+ for (const [id, entry] of pending) if (target === void 0 || entry.target === target) take(id)?.reject(error);
1321
1385
  };
1322
- const stop = (target) => {
1323
- if (current === target) current = void 0;
1324
- starting = void 0;
1325
- target.kill();
1386
+ const stop = (target, error = unavailable("The iOS helper stopped; it restarts on the next call.")) => {
1387
+ if (current === target) {
1388
+ current = void 0;
1389
+ dispatch = Promise.resolve();
1390
+ }
1391
+ stopping = Promise.all([stopping, target.kill()]).then(() => void 0);
1392
+ failAll(error, target);
1393
+ return stopping;
1326
1394
  };
1327
1395
  const send = (target, op, udid, params, signal) => new Promise((resolve, reject) => {
1396
+ if (disposed || current !== target) {
1397
+ reject(unavailable("The iOS helper is not running."));
1398
+ return;
1399
+ }
1328
1400
  if (signal?.aborted) {
1329
1401
  reject(signal.reason);
1330
1402
  return;
1331
1403
  }
1332
1404
  const id = nextId++;
1405
+ const aborted = () => take(id)?.reject(signal?.reason);
1333
1406
  const timer = setTimeout(() => {
1334
- pending.delete(id);
1335
- reject(unavailable(`The iOS helper did not answer ${op} within ${String(timeoutMs / 1e3)}s; it restarts on the next call.`));
1336
- stop(target);
1407
+ if (current === target) starting = void 0;
1408
+ stop(target, unavailable(`The iOS helper did not answer ${op} within ${String(timeoutMs)} ms. It restarts on the next call.`));
1337
1409
  }, timeoutMs);
1338
1410
  pending.set(id, {
1411
+ target,
1339
1412
  resolve,
1340
1413
  reject,
1341
- timer
1342
- });
1343
- signal?.addEventListener("abort", () => {
1344
- const entry = pending.get(id);
1345
- if (entry === void 0) return;
1346
- clearTimeout(entry.timer);
1347
- pending.delete(id);
1348
- reject(signal.reason);
1349
- }, { once: true });
1350
- options.developerDir().then((developerDir) => target.write(JSON.stringify({
1351
- v: 1,
1352
- id,
1353
- op,
1354
- ...udid === void 0 ? {} : { udid },
1355
- deviceSet: options.deviceSet,
1356
- developerDir,
1357
- params
1358
- }))).catch((error) => {
1359
- const entry = pending.get(id);
1360
- if (entry === void 0) return;
1361
- clearTimeout(entry.timer);
1362
- pending.delete(id);
1363
- reject(error);
1414
+ cleanup: () => {
1415
+ clearTimeout(timer);
1416
+ signal?.removeEventListener("abort", aborted);
1417
+ }
1364
1418
  });
1419
+ signal?.addEventListener("abort", aborted, { once: true });
1420
+ dispatch = dispatch.then(async () => {
1421
+ if (!pending.has(id)) return;
1422
+ const developerDir = await options.developerDir();
1423
+ if (!pending.has(id)) return;
1424
+ target.write(JSON.stringify({
1425
+ v: 1,
1426
+ id,
1427
+ op,
1428
+ ...udid === void 0 ? {} : { udid },
1429
+ deviceSet: options.deviceSet,
1430
+ developerDir,
1431
+ params
1432
+ }));
1433
+ }).catch((error) => take(id)?.reject(error));
1365
1434
  });
1366
1435
  const launch = async (rebuild) => {
1367
1436
  const recent = starts.filter((started) => now() - started < require_contracts.HELPER_RESTART_WINDOW_MS);
1368
1437
  starts.splice(0, starts.length, ...recent);
1369
1438
  if (starts.length >= 3) throw unavailable("The iOS helper keeps failing; it restarted three times in five minutes. Try again later.");
1370
1439
  starts.push(now());
1371
- const spawned = options.spawn(await options.binary(rebuild));
1440
+ const binary = await options.binary(rebuild);
1441
+ if (disposed) throw unavailable("The iOS helper is shut down.");
1442
+ const spawned = options.spawn(binary);
1372
1443
  current = spawned;
1373
1444
  spawned.onLine((line) => {
1374
1445
  let reply;
1375
1446
  try {
1376
- reply = JSON.parse(line);
1447
+ const parsed = JSON.parse(line);
1448
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return;
1449
+ reply = parsed;
1377
1450
  } catch {
1378
1451
  return;
1379
1452
  }
1380
- const entry = typeof reply.id === "number" ? pending.get(reply.id) : void 0;
1381
- if (entry === void 0) return;
1382
- clearTimeout(entry.timer);
1383
- pending.delete(reply.id);
1384
- if (reply.ok === true) entry.resolve(reply.result);
1385
- else entry.reject(replyError(reply.error));
1453
+ const id = typeof reply.id === "number" ? reply.id : void 0;
1454
+ if (id === void 0 || pending.get(id)?.target !== spawned) return;
1455
+ const entry = take(id);
1456
+ if (reply.ok === true) entry?.resolve(reply.result);
1457
+ else entry?.reject(replyError(reply.error));
1386
1458
  });
1387
1459
  spawned.onExit((code) => {
1388
1460
  if (current === spawned) {
1389
1461
  current = void 0;
1390
1462
  starting = void 0;
1463
+ dispatch = Promise.resolve();
1391
1464
  }
1392
- failAll(unavailable(`The iOS helper exited (code ${String(code)}); it restarts on the next call.`));
1465
+ failAll(unavailable(`The iOS helper exited (code ${String(code)}); it restarts on the next call.`), spawned);
1393
1466
  });
1394
- const probe = await send(spawned, "probe", void 0, {});
1467
+ let probe;
1468
+ try {
1469
+ const result = await send(spawned, "probe", void 0, {});
1470
+ if (result === null || typeof result !== "object" || !("protocolVersion" in result) || !Number.isInteger(result.protocolVersion) || !("missing" in result) || !Array.isArray(result.missing) || !result.missing.every((capability) => typeof capability === "string")) throw unavailable("The iOS helper returned an invalid probe.");
1471
+ probe = result;
1472
+ } catch (error) {
1473
+ await stop(spawned);
1474
+ throw error;
1475
+ }
1395
1476
  if (probe.protocolVersion !== 1) {
1396
- stop(spawned);
1477
+ await stop(spawned);
1397
1478
  if (rebuild) throw unavailable(`The iOS helper speaks protocol ${String(probe.protocolVersion)}, not ${String(1)}, even after a rebuild.`);
1398
1479
  return launch(true);
1399
1480
  }
@@ -1402,12 +1483,16 @@ function createSimHelper(options) {
1402
1483
  probe
1403
1484
  };
1404
1485
  };
1405
- const ready = () => {
1406
- if (disposed) return Promise.reject(unavailable("The iOS helper is shut down."));
1407
- starting ??= launch(false).catch((error) => {
1408
- starting = void 0;
1409
- throw error;
1410
- });
1486
+ const ready = async () => {
1487
+ await stopping;
1488
+ if (disposed) throw unavailable("The iOS helper is shut down.");
1489
+ if (starting === void 0) {
1490
+ const attempt = launch(false).catch((error) => {
1491
+ if (starting === attempt) starting = void 0;
1492
+ throw error;
1493
+ });
1494
+ starting = attempt;
1495
+ }
1411
1496
  return starting;
1412
1497
  };
1413
1498
  return {
@@ -1422,22 +1507,15 @@ function createSimHelper(options) {
1422
1507
  const { child: target } = await ready();
1423
1508
  await send(target, "act", udid, action, signal);
1424
1509
  },
1425
- forget(udid) {
1426
- if (current === void 0) return;
1427
- send(current, "forget", udid, {}).catch((error) => options.onNotice?.(`iOS helper: forget ${udid} failed: ${error instanceof Error ? error.message : String(error)}`));
1510
+ async forget(udid) {
1511
+ await stopping;
1512
+ if (starting !== void 0) await starting;
1513
+ if (current !== void 0) await send(current, "forget", udid, {});
1428
1514
  },
1429
- dispose() {
1515
+ async dispose() {
1430
1516
  disposed = true;
1431
- const target = current;
1432
- failAll(unavailable("The iOS helper is shut down."));
1433
- if (target === void 0) return;
1434
- target.write(JSON.stringify({
1435
- v: 1,
1436
- id: 0,
1437
- op: "shutdown",
1438
- params: {}
1439
- }));
1440
- stop(target);
1517
+ if (current !== void 0) await stop(current, unavailable("The iOS helper is shut down."));
1518
+ else await stopping;
1441
1519
  }
1442
1520
  };
1443
1521
  }
@@ -1970,7 +2048,7 @@ declare const ios: {
1970
2048
 
1971
2049
  /** Physical devices this Mac sees over CoreDevice, and whether a session holds them. */
1972
2050
  devices(): Promise<{ udid: string; name: string; model: string; osVersion: string; paired: boolean; connected: boolean; leased: boolean; leaseId?: string }[]>;
1973
- /** Leases a connected physical device by UDID. Asks the person first, every time. */
2051
+ /** Leases a paired, reachable physical device by UDID, reconnecting an idle tunnel. Asks the person first. */
1974
2052
  acquireDevice(options: { udid: string; label?: string }): Promise<IosLease>;
1975
2053
  };`;
1976
2054
  /** A physical device is the person's own hardware, so leasing one always asks them first. */
@@ -2003,8 +2081,6 @@ function base64(bytes) {
2003
2081
  }
2004
2082
  /** The `ios` code-mode namespace: lease a pool simulator, then install, launch, look and read logs on it. */
2005
2083
  function createIosNamespace(options) {
2006
- /** Calls on one lease run one at a time, so two scripts never interleave on one device. */
2007
- const queues = /* @__PURE__ */ new Map();
2008
2084
  return {
2009
2085
  name: "ios",
2010
2086
  summary: "Leased iOS simulators from a machine-wide pool shared by every Po session: ios.acquire() a device, install and launch an app, observe the accessibility tree, tap, type and swipe, take screenshots and read logs, then ios.release().",
@@ -2025,17 +2101,11 @@ function createIosNamespace(options) {
2025
2101
  };
2026
2102
  const onLease = async (leaseId, work) => {
2027
2103
  const live = pool();
2028
- const lease = await live.resolveLease(options.owner, leaseId);
2029
- const next = (queues.get(lease.leaseId) ?? Promise.resolve()).catch(() => void 0).then(() => {
2104
+ return live.withLease(options.owner, leaseId, (lease) => {
2030
2105
  run.signal.throwIfAborted();
2106
+ if (disposed) throw new Error("iOS namespace run is disposed.");
2031
2107
  return work(lease, live);
2032
2108
  });
2033
- queues.set(lease.leaseId, next);
2034
- try {
2035
- return await next;
2036
- } finally {
2037
- if (queues.get(lease.leaseId) === next) queues.delete(lease.leaseId);
2038
- }
2039
2109
  };
2040
2110
  /** As onLease, refusing physical devices for calls only simulators support so far. */
2041
2111
  const onSimulator = (leaseId, call, work) => onLease(leaseId, (lease, live) => {
@@ -2308,7 +2378,7 @@ function createIosNamespace(options) {
2308
2378
  members.devices = async () => pool().devices(options.owner);
2309
2379
  members.acquireDevice = async (input) => {
2310
2380
  const params = parse(AcquireDeviceSchema, input, "acquireDevice");
2311
- return pool().acquireDevice(options.owner, params.udid, params.label);
2381
+ return pool().acquireDevice(options.owner, params.udid, params.label, run.signal);
2312
2382
  };
2313
2383
  return members;
2314
2384
  }