@tapflowio/ios-agent 0.17.0 → 0.19.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/IOSAgent.js CHANGED
@@ -5,10 +5,26 @@ import { tmpdir } from 'os';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { spawnSync } from 'child_process';
7
7
  import { WebSocket } from 'ws';
8
- import { createLogger, PlatformError, ValidationError } from '@tapflowio/agent-core';
8
+ import { createLogger, PlatformError, ValidationError, bootAbandonMessage, BOOT_NO_SESSION_STATE } from '@tapflowio/agent-core';
9
9
  const logger = createLogger('ios-agent');
10
10
  // Typed so a typo cannot ship silently — the viewer gates the whole clipboard bridge on this.
11
11
  const AGENT_CAPABILITIES = ['clipboard'];
12
+ // Human prose for each reason. Not in `@tapflowio/protocol`: that package's main entry must stay
13
+ // runtime-free so it erases under `import type` and never reaches the dashboard bundle — a lookup
14
+ // table is a runtime value. The `reason` on the wire is the machine-readable half; this is only what
15
+ // a human reads, and each agent owns its own wording.
16
+ const INPUT_ERROR_MESSAGES = {
17
+ 'not-booted': 'device not booted',
18
+ 'channel-unavailable': 'input channel not ready',
19
+ 'channel-starting': 'the input channel is still starting — retry in a moment',
20
+ 'dispatch-failed': 'the device rejected the input',
21
+ unsupported: 'this input is not supported on the active connection to the device',
22
+ malformed: 'this input does not fit what the device is doing',
23
+ 'no-gesture': 'no gesture is in progress to complete — start a new one',
24
+ // The relay is the only producer of this one — an agent has no way to know which socket asked. Present
25
+ // because the map is exhaustive, which is what makes a new reason a decision rather than an omission.
26
+ 'not-session-owner': 'this input was addressed to a session the sender does not hold',
27
+ };
12
28
  // Cross-platform button name → iOS device-chrome button name. Chrome uses
13
29
  // hyphens and "power" (not "lock"); MCP's vocabulary uses underscores. Names
14
30
  // not listed here (incl. the raw chrome names the dashboard sends) pass through.
@@ -18,7 +34,7 @@ export const IOS_BUTTON_ALIASES = {
18
34
  volume_down: 'volume-down',
19
35
  };
20
36
  import { createResourceSampler, registerStreamWs, disableNagle, createKeyframeAwareSender, pickMaxSize, createRateLimitedDropWarn, createThroughputSampler, createSleepBlocker, getMachineId, isLocalhostWss, DEFAULT_BACKPRESSURE_BYTES, writeEnvelopeHeader, rewriteLowLatencySpsInFrame, CODEC_JPEG, CODEC_H264, CODEC_AUDIO, sendAudioYieldingToVideo, } from '@tapflowio/agent-core/utils';
21
- import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError } from './SimctlWrapper.js';
37
+ import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError, firstLine } from './SimctlWrapper.js';
22
38
  import { MAX_CLIPBOARD_BYTES, clipboardByteLength, CLIPBOARD_SENTINEL_PREFIX as SENTINEL_PREFIX, isClipboardSentinel as isSentinel, CLIPBOARD_COPY_DEADLINE_MS, CLIPBOARD_WRITE_DEADLINE_MS, CLIPBOARD_RESTORE_DEADLINE_MS, CLIPBOARD_POLL_MS, createKeyedSerialQueue, } from '@tapflowio/agent-core';
23
39
  import { ScreenCaptureStreamer } from './ScreenCaptureStreamer.js';
24
40
  import { AudioCaptureStreamer, readSimVolume, applyGain } from './AudioCaptureStreamer.js';
@@ -45,6 +61,34 @@ export class IOSAgent {
45
61
  token;
46
62
  handshakeTimeoutMs;
47
63
  ws = null;
64
+ /** Send on the control socket, if there is one. The `?.` is the point: 66 call sites relied on a send
65
+ * being a no-op between reconnects, and this preserves that exactly.
66
+ *
67
+ * Typed with `AgentControlOutbound`, which is why this exists — an agent's literal used to reach `ws.send`
68
+ * with nothing checking it, and #489/#490 are what that cost. *
69
+ * **The guard is not defensive tidying.** `ws.send` on anything other than OPEN takes the `sendAfterClose`
70
+ * path, which adds the payload to a buffer nobody will flush and neither throws nor emits — so a reply
71
+ * sent to a closing socket is indistinguishable from a delivered one at the call site. `reportResources`
72
+ * has always checked; this did not, and the boot answers this file now owes a caller are exactly the
73
+ * messages whose purpose is to end someone's wait.
74
+ *
75
+ * The body is held character for character by `scripts/__tests__/agentSendTyped.test.mjs`, so the comment
76
+ * lives out here: that check strips comments and matches the body exactly, having watched three earlier
77
+ * drafts get bypassed by a renamed socket. */
78
+ sendMsg(msg) {
79
+ if (this.ws?.readyState !== WebSocket.OPEN)
80
+ return;
81
+ this.ws.send(JSON.stringify(msg));
82
+ }
83
+ /** Send on a socket the caller has already established. Takes the socket **as an argument** rather than
84
+ * reading `this.ws`, and that is deliberate: the call sites that use it sit behind an entry guard
85
+ * (`if (!this.ws) return`), and today deleting one of those guards is a compile error. Reading
86
+ * `this.ws` here — or asserting it with `!` — would make the guard optional to the compiler and turn
87
+ * its removal into a runtime `TypeError` instead. It also serves the `agent:register` send, which runs
88
+ * inside `onopen` on a local socket. */
89
+ sendOn(ws, msg) {
90
+ ws.send(JSON.stringify(msg));
91
+ }
48
92
  deviceStates = new Map();
49
93
  // Last app launched per device (deviceId → bundleId). The XCUITest tree backend
50
94
  // queries by bundleId; kept outside DeviceState so it survives a relay reconnect
@@ -103,7 +147,7 @@ export class IOSAgent {
103
147
  }, this.handshakeTimeoutMs);
104
148
  ws.once('open', () => {
105
149
  disableNagle(ws);
106
- ws.send(JSON.stringify({
150
+ this.sendOn(ws, {
107
151
  type: 'agent:register',
108
152
  platform: 'ios',
109
153
  // Lets a viewer tell a clipboard-capable agent from one that predates the
@@ -118,7 +162,7 @@ export class IOSAgent {
118
162
  status: d.status,
119
163
  osVersion: d.osVersion,
120
164
  })),
121
- }));
165
+ });
122
166
  });
123
167
  ws.once('message', (data) => {
124
168
  let msg;
@@ -140,7 +184,14 @@ export class IOSAgent {
140
184
  this.initDeviceStates(msg.registeredSessions);
141
185
  ws.on('message', (d) => {
142
186
  try {
143
- this.handleRelayMessage(JSON.parse(d.toString()));
187
+ const m = JSON.parse(d.toString());
188
+ // Every type `handleRelayMessage` dispatches is session-scoped, and the relay resolves the
189
+ // session before forwarding — so a message without one did not come from that path. Rejecting
190
+ // it here is what lets the dispatcher declare `sessionId: string` instead of threading an
191
+ // optional through 30 sends and asserting it with `!` at each one.
192
+ if (typeof m.type !== 'string' || typeof m.sessionId !== 'string')
193
+ return;
194
+ this.handleRelayMessage(m);
144
195
  }
145
196
  catch { /* ignore malformed */ }
146
197
  });
@@ -183,6 +234,8 @@ export class IOSAgent {
183
234
  streamReader: null,
184
235
  captureStreamer: null,
185
236
  bootSeq: 0,
237
+ bootAbandon: new Map(),
238
+ bootsInFlight: new Set(),
186
239
  orientation: 'portrait',
187
240
  loadedChrome: null,
188
241
  softKeyboardVisible: false,
@@ -208,6 +261,7 @@ export class IOSAgent {
208
261
  this.resourcesTimer = null;
209
262
  }
210
263
  for (const state of this.deviceStates.values()) {
264
+ this.bumpBootSeq(state, 'relay-lost');
211
265
  this.cleanupDeviceState(state);
212
266
  }
213
267
  this.deviceStates.clear();
@@ -226,6 +280,13 @@ export class IOSAgent {
226
280
  this.resourcesTimer = null;
227
281
  }
228
282
  for (const state of this.deviceStates.values()) {
283
+ // Invalidate any boot still in flight against this state. The map is dropped just below, but a
284
+ // `handleDeviceBoot` awaiting simctl holds its own reference and its seq would still match, so it
285
+ // would go on to build a TouchHelper on a state nobody owns. That used to leak one child process;
286
+ // a self-reviving helper would respawn for the life of the agent with no reference left to stop it.
287
+ // Bumped here rather than inside `cleanupDeviceState` so that all three retirements name a reason —
288
+ // and so Android, whose boot path calls its own cleanup mid-boot, can hold the same shape.
289
+ this.bumpBootSeq(state, 'relay-lost');
229
290
  this.cleanupDeviceState(state);
230
291
  }
231
292
  this.deviceStates.clear();
@@ -254,7 +315,7 @@ export class IOSAgent {
254
315
  const bootedCount = Array.from(this.deviceStates.values()).filter((s) => s.streamReader !== null).length;
255
316
  const slotsTotal = this.deviceStates.size;
256
317
  const { memUsedMB, memTotalMB } = this.resources.getMemoryUsage();
257
- this.ws.send(JSON.stringify({
318
+ this.sendOn(this.ws, {
258
319
  type: 'agent:resources',
259
320
  resources: {
260
321
  cpuPercent: this.resources.getCpuPercent(),
@@ -264,7 +325,7 @@ export class IOSAgent {
264
325
  slotsTotal,
265
326
  reportedAt: Date.now(),
266
327
  },
267
- }));
328
+ });
268
329
  }
269
330
  cleanupDeviceState(state) {
270
331
  void state.streamReader?.cancel();
@@ -285,38 +346,46 @@ export class IOSAgent {
285
346
  state.streamWs = null;
286
347
  }
287
348
  sendChromeData(state, device) {
288
- if (!this.ws)
349
+ // `readyState`, not presence: this runs mid-boot, and a socket that closed since the entry guard
350
+ // takes the payload into a buffer nobody flushes while `device:ready` is dropped by `sendMsg`'s own
351
+ // check — leaving the caller with neither the data nor an answer.
352
+ if (this.ws?.readyState !== WebSocket.OPEN)
289
353
  return;
354
+ // Stop the outgoing helper before dropping the reference. This used to leak a child process;
355
+ // now that a helper revives itself on death, an orphan would also keep respawning with
356
+ // nobody left holding a reference to stop it.
357
+ state.touchHelper?.stop();
290
358
  state.touchHelper = new TouchHelper(device.id);
291
359
  state.touchHelper.start();
292
- this.ws.send(JSON.stringify({
360
+ this.sendOn(this.ws, {
293
361
  type: 'session:deviceInfo',
294
362
  sessionId: state.sessionId,
295
363
  payload: {
296
364
  deviceName: device.name,
297
365
  osVersion: device.osVersion ?? '',
298
366
  },
299
- }));
367
+ });
300
368
  state.loadedChrome = this.chromeLoader.load(device.typeId ?? device.name);
301
369
  if (!state.loadedChrome)
302
370
  return;
303
- this.ws.send(JSON.stringify({
371
+ this.sendOn(this.ws, {
304
372
  type: 'session:chrome',
305
373
  sessionId: state.sessionId,
306
374
  payload: state.loadedChrome,
307
- }));
375
+ });
308
376
  }
309
377
  startBinaryStream(state, streamWs) {
310
378
  // H.264 is the default; opt out per-agent with TAPFLOW_IOS_CODEC=jpeg. It also needs
311
379
  // a browser that reported it can decode it (device:boot acceptH264) — otherwise JPEG.
312
- // Only on the ScreenCaptureStreamer path — the MjpegStreamer fallback is always JPEG.
380
+ // Only on the ScreenCaptureStreamer path — the MjpegStreamer fallback is always JPEG, and now
381
+ // genuinely produces it rather than only being stamped that way.
313
382
  const envAllowsH264 = process.env.TAPFLOW_IOS_CODEC !== 'jpeg';
314
383
  const useH264 = this.intervalMs === undefined && envAllowsH264 && state.acceptH264;
315
384
  const codec = useH264 ? CODEC_H264 : CODEC_JPEG;
316
385
  let stream;
317
386
  if (this.intervalMs !== undefined) {
318
387
  state.captureStreamer = null;
319
- stream = new MjpegStreamer(this.simctl, this.intervalMs).start();
388
+ stream = new MjpegStreamer(this.simctl, state.deviceId, this.intervalMs).start();
320
389
  }
321
390
  else {
322
391
  const maxSize = pickMaxSize({
@@ -324,7 +393,7 @@ export class IOSAgent {
324
393
  external: state.external,
325
394
  override: process.env.TAPFLOW_IOS_MAX_SIZE ?? process.env.TAPFLOW_MAX_SIZE,
326
395
  });
327
- const capture = new ScreenCaptureStreamer(this.fps, state.deviceId, useH264 ? 'h264' : 'jpeg', maxSize);
396
+ const capture = new ScreenCaptureStreamer(state.deviceId, this.fps, useH264 ? 'h264' : 'jpeg', maxSize);
328
397
  state.captureStreamer = capture;
329
398
  stream = capture.start();
330
399
  }
@@ -398,50 +467,174 @@ export class IOSAgent {
398
467
  await registerStreamWs(streamWs, state.sessionId);
399
468
  return streamWs;
400
469
  }
401
- async handleDeviceBoot(sessionId, deviceId, fullErase = false, acceptH264 = false, tier) {
470
+ /** Retire the boot that holds the current seq, recording what retired it, and return the new seq.
471
+ *
472
+ * **Three callers, and the third is why this is a method.** A new boot supersedes the one in flight, a
473
+ * shutdown abandons it, and losing the relay invalidates it — that last one lives in the reconnect path
474
+ * (`cleanupDeviceState`'s callers) and is easy to miss when reading `handleDeviceBoot` alone. Spreading
475
+ * the reason across three `state.bootSeq++` lines makes forgetting one silent; here it is a parameter. */
476
+ bumpBootSeq(state, reason) {
477
+ if (state.bootsInFlight.has(state.bootSeq))
478
+ state.bootAbandon.set(state.bootSeq, reason);
479
+ return ++state.bootSeq;
480
+ }
481
+ /** Answer a boot this agent has stopped running. Called at every point that abandons one.
482
+ *
483
+ * **Only when a correlator exists**, which is the same rule the input path settled on (#489): a reply
484
+ * nobody is waiting for is not an answer, and the dashboard reports every *uncorrelated*
485
+ * `device:boot-error` as a failure — deliberately, since Android's dead-stream report has no id it could
486
+ * carry (#426). So sending one for a boot with no correlator would put an error toast on a tester's
487
+ * screen for a device that is booting normally. */
488
+ abandonBoot(state, seq, sessionId, requestId) {
489
+ const reason = state.bootAbandon.get(seq) ?? 'superseded';
490
+ state.bootAbandon.delete(seq);
491
+ // **A state the agent has stopped holding cannot be answered, and trying is worse than silence.**
492
+ // `relay-lost` retires a boot by dropping the whole map, so by the time the parked `await` resumes this
493
+ // `state` is an object nobody is registered against — and whether the socket is back yet decides
494
+ // between dropping the reply and sending it to a *new* relay naming a session id it has never heard
495
+ // of. Neither is an answer, and which one happens is a race between a poll interval and a backoff.
496
+ if (this.deviceStates.get(sessionId) !== state) {
497
+ logger.warn(`boot for ${sessionId} abandoned (${reason}) after the session was dropped — nothing to answer`);
498
+ return;
499
+ }
500
+ if (!requestId) {
501
+ logger.warn(`boot for ${sessionId} abandoned (${reason}) with no requestId — nothing to answer`);
502
+ return;
503
+ }
504
+ this.sendMsg({ type: 'device:boot-error', sessionId, requestId, message: bootAbandonMessage(reason) });
505
+ }
506
+ // `requestId` is a parameter, never a field on `state`: `bootSeq` exists because two boots overlap,
507
+ // and a correlator hoisted onto shared state would answer the first request with the second's id.
508
+ // Optional because the relay's idle timer boots nothing — but every *browser* boot carries one.
509
+ async handleDeviceBoot(sessionId, deviceId, fullErase = false, acceptH264 = false, tier, requestId) {
402
510
  const state = this.deviceStates.get(sessionId);
403
- if (!state || !this.ws)
511
+ // Split, because the two halves are not the same kind of nothing. No open control channel means the
512
+ // answer itself has nowhere to go, so this is the one abandonment that stays silent — and the caller
513
+ // learns from the relay instead, which declares the agent away and terminates the session. A missing
514
+ // `state` is answerable and now answered (#489's reasoning, on the boot path).
515
+ const ws = this.ws;
516
+ if (!ws || ws.readyState !== WebSocket.OPEN)
404
517
  return;
518
+ if (!state) {
519
+ if (requestId)
520
+ this.sendMsg({ type: 'device:boot-error', sessionId, requestId, message: BOOT_NO_SESSION_STATE });
521
+ return;
522
+ }
405
523
  state.acceptH264 = acceptH264;
406
524
  if (tier) {
407
525
  state.secureContext = tier.secureContext;
408
526
  state.external = tier.external;
409
527
  }
410
- const seq = ++state.bootSeq;
411
- void state.streamReader?.cancel();
412
- state.streamReader = null;
413
- state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
414
- state.touchHelper?.stop();
415
- state.touchHelper = null;
416
- state.booted = false;
417
- state.streamWs?.close();
418
- state.streamWs = null;
419
- this.ws.send(JSON.stringify({ type: 'device:booting', sessionId }));
528
+ const seq = this.bumpBootSeq(state, 'superseded');
529
+ state.bootsInFlight.add(seq);
530
+ // **The teardown runs inside the try, and that is a fix rather than tidying.** It ran before it, so a
531
+ // throw there rejected this handler into the bare `.catch(logger.error)` at its dispatch site: no
532
+ // `device:booting`, no ready, no error — and the seq was already bumped, so the boot this one
533
+ // superseded was gone as well. The caller then waited out the very deadline this change exists to
534
+ // end. `finally` is what stops `bootAbandon` outliving the boot that would have read it.
420
535
  try {
536
+ void state.streamReader?.cancel();
537
+ state.streamReader = null;
538
+ state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
539
+ state.touchHelper?.stop();
540
+ state.touchHelper = null;
541
+ state.booted = false;
542
+ state.streamWs?.close();
543
+ state.streamWs = null;
544
+ this.sendOn(ws, { type: 'device:booting', sessionId });
421
545
  const devices = await this.simctl.listDevices();
422
- if (seq !== state.bootSeq)
546
+ if (seq !== state.bootSeq) {
547
+ this.abandonBoot(state, seq, sessionId, requestId);
423
548
  return;
549
+ }
424
550
  const target = devices.find((d) => d.id === deviceId);
425
551
  if (!target)
426
552
  throw new PlatformError(`Device not found: ${deviceId}`);
427
553
  if (fullErase) {
428
- await this.simctl.erase(deviceId);
554
+ // `simctl erase` only accepts a shut-down device, and a device is often already running —
555
+ // it survives agent restarts and sessions that ended without a clean shutdown. Without this
556
+ // the boot fails outright (#439).
557
+ //
558
+ // Anything that is not `shutdown` gets the shutdown, not just `booted`: `toDeviceStatus`
559
+ // collapses `Booting` / `Shutting Down` / `Creating` into `unknown`, and `erase` refuses
560
+ // every one of them. Re-picking a device while its `device:shutdown` is still draining
561
+ // lands exactly there. `SimctlWrapper.shutdown` already tolerates an off device, so
562
+ // widening the condition costs nothing.
563
+ //
564
+ // This makes an unwanted reset *succeed* rather than fail, so it is only safe alongside the
565
+ // dashboard change that consumes the toggle after one use. The erase failing is what used
566
+ // to stand between a stale toggle and a wiped device.
567
+ if (target.status !== 'shutdown')
568
+ await this.simctl.shutdown(deviceId);
569
+ // The shutdown is an await, so a newer boot for this device may have overtaken us while it
570
+ // ran. Without this check the superseded boot goes on to erase a device the tester has
571
+ // since re-picked with the toggle off — the exact wipe-with-no-click this issue is about.
572
+ if (seq !== state.bootSeq) {
573
+ this.abandonBoot(state, seq, sessionId, requestId);
574
+ return;
575
+ }
576
+ try {
577
+ await this.simctl.erase(deviceId);
578
+ }
579
+ catch (err) {
580
+ // We powered the device off to make the erase possible. If the erase then fails, leaving
581
+ // it off strands the tester with a dead device on top of the error message, so put it
582
+ // back the way we found it and report the original failure.
583
+ //
584
+ // `=== 'booted'`, deliberately narrower than the `!== 'shutdown'` above: that one asks
585
+ // "does this need shutting down", this one asks "did we take it down". `unknown` covers
586
+ // `Shutting Down` (someone else was already stopping it) and `Creating` (never ran) —
587
+ // booting either would not be putting it back.
588
+ //
589
+ // The seq check matters as much: erase takes seconds, and a shutdown arriving mid-flight
590
+ // bumps bootSeq. Recovering then would power a device back up right after the tester
591
+ // asked for it to stop — and the throw below is swallowed by the outer catch on the same
592
+ // seq mismatch, so nothing would say why.
593
+ if (seq === state.bootSeq && target.status === 'booted') {
594
+ await this.simctl.boot(deviceId).catch(() => { });
595
+ }
596
+ throw err;
597
+ }
429
598
  await this.simctl.boot(deviceId);
430
599
  }
431
- else if (target.status !== 'booted') {
600
+ else {
601
+ // Unconditionally, including when the list above said `booted`. That reading is already
602
+ // stale by the time we act on it — a tester can ⌘Q the simulator, or run `simctl shutdown`,
603
+ // in the width of one `xcrun` round trip — and skipping the boot on it is the only way to
604
+ // reach the wait below with *nothing bringing the device up*, which then costs the full
605
+ // deadline to discover. `SimctlWrapper.boot` swallows `Unable to boot device in current
606
+ // state: Booted`, so re-issuing it for a device that really is up costs one no-op
607
+ // subprocess and makes "the wait only ever runs after a boot was accepted" true.
432
608
  await this.bootWithZombieRecovery(deviceId);
433
609
  }
434
- if (seq !== state.bootSeq)
610
+ if (seq !== state.bootSeq) {
611
+ this.abandonBoot(state, seq, sessionId, requestId);
435
612
  return;
436
- const refreshed = await this.simctl.listDevices();
437
- const refreshedDevice = refreshed.find((d) => d.id === deviceId) ?? target;
438
- this.sendChromeData(state, {
439
- ...refreshedDevice,
440
- status: 'booted',
441
- });
613
+ }
614
+ // `simctl boot` returns on initiation, so everything below used to run while the device was
615
+ // still coming up — measured 7.6s early (#486). The status sent with the chrome data was
616
+ // hardcoded `'booted'` over a value that had just been fetched and discarded, and
617
+ // `device:ready` went out a few lines later. A human is slower than the gap and rarely
618
+ // notices; `mcp-server` installs and taps the moment it sees ready, which is what #440's
619
+ // "No devices are booted" was.
620
+ // `isStale` rather than a check on the far side alone: this handler is fire-and-forget, so a
621
+ // shutdown arriving mid-wait bumps `bootSeq` and leaves the poll spawning `xcrun simctl list`
622
+ // twice a second against a device that is now deliberately off — for the rest of the deadline,
623
+ // with nothing left that wants the answer.
624
+ const bootedDevice = await this.simctl.waitUntilBooted(deviceId, { isStale: () => seq !== state.bootSeq });
625
+ // Another await — a multi-second one — and `sendChromeData` below starts a helper process. A
626
+ // shutdown or a newer boot arriving in this gap would otherwise get a helper installed for the
627
+ // device it is taking down — and one that revives itself, so the stale reference outlives the
628
+ // boot that returns just below.
629
+ if (seq !== state.bootSeq) {
630
+ this.abandonBoot(state, seq, sessionId, requestId);
631
+ return;
632
+ }
633
+ this.sendChromeData(state, bootedDevice);
442
634
  const streamWs = await this.openStreamWs(state);
443
635
  if (seq !== state.bootSeq) {
444
636
  streamWs.close();
637
+ this.abandonBoot(state, seq, sessionId, requestId);
445
638
  return;
446
639
  }
447
640
  this.startBinaryStream(state, streamWs);
@@ -450,7 +643,7 @@ export class IOSAgent {
450
643
  if (this.audioEnabled())
451
644
  this.startAudioCapture(state, streamWs, deviceId);
452
645
  state.booted = true;
453
- this.ws?.send(JSON.stringify({ type: 'device:ready', sessionId, payload: { deviceId } }));
646
+ this.sendMsg({ type: 'device:ready', sessionId, requestId, payload: { deviceId } });
454
647
  // Sync AppleKeyboards after ready — fire-and-forget so streaming isn't delayed.
455
648
  // hw=Automatic lets the hardware layout follow the active input source on LANG1/CapsLock.
456
649
  // By the time the user navigates to a text field the sync has already completed.
@@ -459,10 +652,22 @@ export class IOSAgent {
459
652
  });
460
653
  }
461
654
  catch (e) {
462
- if (seq !== state.bootSeq)
655
+ if (seq !== state.bootSeq) {
656
+ this.abandonBoot(state, seq, sessionId, requestId);
463
657
  return;
464
- const message = e instanceof Error ? e.message : String(e);
465
- this.ws?.send(JSON.stringify({ type: 'device:boot-error', sessionId, message }));
658
+ }
659
+ // `firstLine`, not `e.message`: this reaches a toast, and node's first line is
660
+ // `Command failed: xcrun simctl boot <UDID>`, which says nothing and echoes the udid. That
661
+ // matters more now that the boot is issued unconditionally — a device caught mid `Shutting
662
+ // Down` refuses the boot, and refusing is correct (swallowing it would put us back to waiting
663
+ // on a device nobody is bringing up), so the refusal is what the tester reads.
664
+ this.sendMsg({ type: 'device:boot-error', sessionId, requestId, message: firstLine(e) });
665
+ }
666
+ finally {
667
+ state.bootsInFlight.delete(seq);
668
+ // Its own key, in the one case nothing reads it: a newer boot arriving after this boot's last
669
+ // checkpoint but before it returns.
670
+ state.bootAbandon.delete(seq);
466
671
  }
467
672
  }
468
673
  // Boot a device, auto-recovering from a vanished data dir. simctl lists the device
@@ -480,11 +685,11 @@ export class IOSAgent {
480
685
  await this.simctl.boot(deviceId);
481
686
  }
482
687
  }
483
- async handleDeviceShutdown(sessionId, deviceId) {
688
+ async handleDeviceShutdown(sessionId, deviceId, requestId) {
484
689
  const state = this.deviceStates.get(sessionId);
485
690
  if (!state)
486
691
  return;
487
- state.bootSeq++;
692
+ this.bumpBootSeq(state, 'shut-down');
488
693
  void state.streamReader?.cancel();
489
694
  state.streamReader = null;
490
695
  state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
@@ -499,11 +704,12 @@ export class IOSAgent {
499
704
  this.uiTreeReader.stopIfDevice(deviceId);
500
705
  try {
501
706
  await this.simctl.shutdown(deviceId);
502
- this.ws?.send(JSON.stringify({
707
+ this.sendMsg({
503
708
  type: 'device:shutdown-done',
504
709
  sessionId,
710
+ requestId,
505
711
  payload: { deviceId },
506
- }));
712
+ });
507
713
  }
508
714
  catch (e) {
509
715
  const message = e instanceof Error ? e.message : String(e);
@@ -537,14 +743,89 @@ export class IOSAgent {
537
743
  sentinelParked(deviceId) {
538
744
  return (this.parkedSentinels.get(deviceId) ?? 0) > 0;
539
745
  }
540
- // Ack a terminal input: input:done = dispatched to a booted device (not a landing guarantee — HID is fire-and-forget); input:error = no live channel / not booted. Off the sync inject path, so start/end pairing is unaffected.
541
- async ackInput(state, dispatched) {
542
- const booted = dispatched && (state.booted || (await this.isBooted(state.deviceId)));
543
- if (booted)
746
+ // A terminal input naming a session this agent holds no state for. Reachable in a way that is
747
+ // genuinely disputed: `sessionRebind.test.ts` records that a restarted agent is re-seeded from
748
+ // `agent:registered`, so `!state` should never fire — but `registeredSessions` carries one entry
749
+ // per *device* (`RelayServer.ts`, `byDeviceId`), and the relay's own comment there notes that one
750
+ // device can now sit behind two sessions, which leaves the second unseeded. Answering costs four
751
+ // lines; staying silent costs a terminal input swallowed and the caller's own fallback reporting
752
+ // success. Android already answers, and `channel-unavailable` is what it maps this to.
753
+ /** The correlator on an input an ack answers, or `null` if the frame cannot be attributed.
754
+ *
755
+ * A local capture rather than a guard at the top of the dispatcher, and that is the point: a guard there
756
+ * would not narrow `msg.requestId` inside the case, so the shortest way to satisfy the reply's required
757
+ * field would be `msg.requestId!` — the assertion removed from `open-url` and then from clipboard. This
758
+ * hands back a `string` the case can close over.
759
+ *
760
+ * It is unvalidated JSON, so the check is real work rather than ceremony: the declaration is required and
761
+ * every in-repo sender is typed against it, and #444 made the relay refuse an empty one — but this agent may
762
+ * be talking to a relay older than that, and `mcp-server`'s tool
763
+ * schemas are bare `z.string()` so a model can produce `''`. */
764
+ correlatorOf(msg) {
765
+ if (typeof msg.requestId === 'string' && msg.requestId !== '')
766
+ return msg.requestId;
767
+ logger.warn(`${msg.type} without a usable requestId — dropped, its ack could not be attributed`);
768
+ return null;
769
+ }
770
+ ackNoSession(sessionId, requestId) {
771
+ // The `if (!sessionId) return` this used to open with is gone: the dispatcher now declares
772
+ // `sessionId: string`, so there is no undefined to guard against and the guard would have been a
773
+ // silent drop with nothing left that could reach it.
774
+ this.sendMsg({
775
+ type: 'input:error', sessionId, requestId,
776
+ message: INPUT_ERROR_MESSAGES['channel-unavailable'],
777
+ reason: 'channel-unavailable',
778
+ });
779
+ }
780
+ /**
781
+ * Why a write was refused. The helper's boolean says only "no".
782
+ *
783
+ * `kind` matters because the two questions were decided at different times. Readiness is about now;
784
+ * a continuation frame's fate was decided when the gesture opened. A gesture whose opening frame
785
+ * was refused inside the start-up window owns nothing, so its terminal frame can never land no
786
+ * matter how ready the helper has since become — and reading readiness first answered `malformed`
787
+ * ("never retry") for precisely the case `channel-starting` exists to serve. Ownership is therefore
788
+ * checked first for continuations, which is also the right answer when the helper died mid-gesture
789
+ * and its replacement is still starting: waiting would not help either.
790
+ *
791
+ * Safe to read at the ack site rather than inside the write, because iOS writes are synchronous —
792
+ * but only for the question readiness answers. That is why ownership is asked separately.
793
+ */
794
+ refusalReason(helper, kind) {
795
+ const state = helper?.inputState() ?? 'unavailable';
796
+ // No channel at all dominates: the caller must reconnect, and telling it to re-open a gesture
797
+ // would send it round a loop that cannot succeed.
798
+ if (state === 'unavailable')
799
+ return 'channel-unavailable';
800
+ // Then ownership, and only for a continuation. A gesture whose opening frame never landed owns
801
+ // nothing, so its terminal frame can never be delivered however ready the helper has become —
802
+ // which is why readiness must not be consulted first here.
803
+ //
804
+ // A consequence worth stating: `channel-starting` is unreachable for a continuation. Owning a
805
+ // gesture requires an opening frame to have landed, which requires the helper to have been ready,
806
+ // so a continuation is never refused *merely* because the channel is still coming up. Standalone
807
+ // inputs — a key, a button — are the ones that get that answer.
808
+ if (kind === 'continuation' && !helper?.ownsGesture())
809
+ return 'no-gesture';
810
+ return state === 'starting' ? 'channel-starting' : 'no-gesture';
811
+ }
812
+ // Ack a terminal input. `input:done` = dispatched to a booted device (not a landing guarantee —
813
+ // HID is fire-and-forget). Otherwise `input:error` carries a machine-readable `reason` so a caller
814
+ // can tell "retry in a moment" from "reconnect" from "never retry"; `message` stays human prose.
815
+ // Off the sync inject path, so start/end pairing is unaffected.
816
+ //
817
+ // `requestId` is a parameter, never read from shared state: a gesture is dozens of frames and two can
818
+ // overlap, so a correlator hoisted out of per-request scope would answer one input with another's id —
819
+ // which is #499 rebuilt inside the agent. Same reason `seq` is passed in on the Android side.
820
+ async ackInput(state, outcome, requestId) {
821
+ const reason = outcome !== 'delivered'
822
+ ? outcome
823
+ : (state.booted || (await this.isBooted(state.deviceId))) ? null : 'not-booted';
824
+ if (reason === null)
544
825
  state.booted = true; // cache the post-reconnect verify so later inputs skip simctl
545
- this.ws?.send(JSON.stringify(booted
546
- ? { type: 'input:done', sessionId: state.sessionId }
547
- : { type: 'input:error', sessionId: state.sessionId, message: dispatched ? 'device not booted' : 'input channel not ready' }));
826
+ this.sendMsg(reason === null
827
+ ? { type: 'input:done', sessionId: state.sessionId, requestId }
828
+ : { type: 'input:error', sessionId: state.sessionId, requestId, message: INPUT_ERROR_MESSAGES[reason], reason });
548
829
  }
549
830
  async isBooted(deviceId) {
550
831
  try {
@@ -560,44 +841,68 @@ export class IOSAgent {
560
841
  case 'device:boot': {
561
842
  const { deviceId, resetMode, acceptH264, secureContext, external } = msg.payload;
562
843
  const sessionId = msg.sessionId;
563
- this.handleDeviceBoot(sessionId, deviceId, resetMode === 'full-erase', acceptH264 === true, { secureContext: !!secureContext, external: !!external })
844
+ this.handleDeviceBoot(sessionId, deviceId, resetMode === 'full-erase', acceptH264 === true, { secureContext: !!secureContext, external: !!external }, msg.requestId)
564
845
  .catch((e) => logger.error('handleDeviceBoot failed:', e));
565
846
  break;
566
847
  }
567
848
  case 'device:shutdown': {
568
849
  const { deviceId } = msg.payload;
569
850
  const sessionId = msg.sessionId;
570
- this.handleDeviceShutdown(sessionId, deviceId)
851
+ this.handleDeviceShutdown(sessionId, deviceId, msg.requestId)
571
852
  .catch((e) => logger.error('handleDeviceShutdown failed:', e));
572
853
  break;
573
854
  }
574
855
  case 'app:install': {
575
856
  const { filePath, bundleId } = msg.payload;
576
857
  const sessionId = msg.sessionId;
577
- this.installBuild(filePath, bundleId)
578
- .then(() => this.ws?.send(JSON.stringify({ type: 'app:install-done', sessionId })))
858
+ const { requestId } = msg;
859
+ if (typeof requestId !== 'string' || requestId === '') {
860
+ console.warn('[tapflow] app:install without a requestId — dropped, cannot correlate a reply');
861
+ break;
862
+ }
863
+ // `...body` first: with the ids last, a body *variable* carrying one would override the real
864
+ // correlator, because excess-property checking does not fire on variables. See `open-url`.
865
+ const respond = (body) => this.sendMsg({ ...body, sessionId, requestId });
866
+ const installState = this.deviceStates.get(sessionId);
867
+ if (!installState) {
868
+ respond({ type: 'app:install-error', message: 'No booted device' });
869
+ break;
870
+ }
871
+ this.installBuild(installState.deviceId, filePath, bundleId)
872
+ .then(() => respond({ type: 'app:install-done' }))
579
873
  .catch((e) => {
580
874
  const message = e instanceof Error ? e.message : String(e);
581
- this.ws?.send(JSON.stringify({ type: 'app:install-error', sessionId, message }));
875
+ respond({ type: 'app:install-error', message });
582
876
  });
583
877
  break;
584
878
  }
585
879
  case 'app:launch': {
586
880
  const { bundleId } = msg.payload;
587
881
  const sessionId = msg.sessionId;
882
+ const { requestId } = msg;
883
+ if (typeof requestId !== 'string' || requestId === '') {
884
+ console.warn('[tapflow] app:launch without a requestId — dropped, cannot correlate a reply');
885
+ break;
886
+ }
887
+ // `...body` first: with the ids last, a body *variable* carrying one would override the real
888
+ // correlator, because excess-property checking does not fire on variables. See `open-url`.
889
+ const respond = (body) => this.sendMsg({ ...body, sessionId, requestId });
588
890
  const launchState = this.deviceStates.get(sessionId);
589
- this.simctl.launchApp(bundleId)
891
+ if (!launchState) {
892
+ respond({ type: 'app:launch-error', message: 'No booted device' });
893
+ break;
894
+ }
895
+ this.simctl.launchApp(launchState.deviceId, bundleId)
590
896
  .then(() => {
591
897
  // Track the foreground app so ui:tree:request can query it via XCUITest.
592
- if (launchState)
593
- this.lastBundleIds.set(launchState.deviceId, bundleId);
898
+ this.lastBundleIds.set(launchState.deviceId, bundleId);
594
899
  // Audio: the whole-sim tap's poll picks up the launched app process within one interval;
595
900
  // no per-launch helper needed.
596
- this.ws?.send(JSON.stringify({ type: 'app:launch-done', sessionId }));
901
+ respond({ type: 'app:launch-done' });
597
902
  })
598
903
  .catch((e) => {
599
904
  const message = e instanceof Error ? e.message : String(e);
600
- this.ws?.send(JSON.stringify({ type: 'app:launch-error', sessionId, message }));
905
+ respond({ type: 'app:launch-error', message });
601
906
  });
602
907
  break;
603
908
  }
@@ -619,11 +924,19 @@ export class IOSAgent {
619
924
  break;
620
925
  }
621
926
  case 'input:touch:end': {
927
+ const requestId = this.correlatorOf(msg);
928
+ if (requestId === null)
929
+ break;
622
930
  const state = this.deviceStates.get(msg.sessionId);
623
- if (!state)
931
+ if (!state) {
932
+ this.ackNoSession(msg.sessionId, requestId);
624
933
  break;
625
- state.touchHelper?.touchEnd();
626
- void this.ackInput(state, state.touchHelper !== null); // terminal of a tap/swipe → ack the gesture
934
+ }
935
+ // The helper's answer, not its existence: a helper whose process has died reports every
936
+ // write as dropped, and that is what the caller needs to hear (#482).
937
+ const helper = state.touchHelper;
938
+ // terminal of a tap/swipe → ack the gesture
939
+ void this.ackInput(state, helper?.touchEnd() ? 'delivered' : this.refusalReason(helper, 'continuation'), requestId);
627
940
  break;
628
941
  }
629
942
  case 'input:pinch:start': {
@@ -644,11 +957,16 @@ export class IOSAgent {
644
957
  break;
645
958
  }
646
959
  case 'input:pinch:end': {
960
+ const requestId = this.correlatorOf(msg);
961
+ if (requestId === null)
962
+ break;
647
963
  const state = this.deviceStates.get(msg.sessionId);
648
- if (!state)
964
+ if (!state) {
965
+ this.ackNoSession(msg.sessionId, requestId);
649
966
  break;
650
- state.touchHelper?.pinchEnd();
651
- void this.ackInput(state, state.touchHelper !== null);
967
+ }
968
+ const pinchHelper = state.touchHelper;
969
+ void this.ackInput(state, pinchHelper?.pinchEnd() ? 'delivered' : this.refusalReason(pinchHelper, 'continuation'), requestId);
652
970
  break;
653
971
  }
654
972
  case 'input:rotate': {
@@ -675,24 +993,27 @@ export class IOSAgent {
675
993
  : this.simctl.hideSoftwareKeyboard(state.deviceId);
676
994
  op.then(() => {
677
995
  state.softKeyboardVisible = showing;
678
- this.ws?.send(JSON.stringify({
996
+ this.sendMsg({
679
997
  type: 'keyboard:toggled',
680
998
  sessionId: state.sessionId,
681
999
  payload: { visible: showing },
682
- }));
1000
+ });
683
1001
  }).catch((e) => {
684
1002
  logger.error('keyboard toggle failed:', e);
685
1003
  });
686
1004
  break;
687
1005
  }
688
1006
  case 'input:type': {
1007
+ const requestId = this.correlatorOf(msg);
1008
+ if (requestId === null)
1009
+ break;
689
1010
  const sessionId = msg.sessionId;
690
1011
  const state = this.deviceStates.get(sessionId);
691
1012
  if (state)
692
1013
  this.ensureTouchHelper(state);
693
1014
  const { text } = (msg.payload ?? {});
694
1015
  if (!state?.touchHelper) {
695
- this.ws?.send(JSON.stringify({ type: 'input:type-error', sessionId, message: 'No booted device' }));
1016
+ this.sendMsg({ type: 'input:type-error', sessionId, requestId, message: 'No booted device' });
696
1017
  break;
697
1018
  }
698
1019
  // simctl pbcopy → Cmd+V paste. Works for arbitrary Unicode (unlike a
@@ -709,92 +1030,155 @@ export class IOSAgent {
709
1030
  state.softKeyboardVisible = false;
710
1031
  await this.simctl.hideSoftwareKeyboard(state.deviceId).catch(() => { });
711
1032
  }
712
- state.touchHelper?.sendKey(KEY_CODE_MAP['KeyV'], MODIFIER_BITS['MetaLeft']);
1033
+ // Throwing routes into the input:type-error below. Without it a dropped chord still
1034
+ // answers input:type-done: the text sits on the device pasteboard, nothing was pasted
1035
+ // into the app, and the caller moves on. Unlike the copy path in clipboard:read there
1036
+ // is no pasteboard deadline here to fail loudly in its place.
1037
+ if (!state.touchHelper?.sendKey(KEY_CODE_MAP['KeyV'], MODIFIER_BITS['MetaLeft'])) {
1038
+ throw new PlatformError('Cannot paste the text — no input channel to the device');
1039
+ }
713
1040
  };
714
1041
  // Ack on completion so a following input step (e.g. pressKey Enter) is
715
1042
  // only sent after the paste has actually landed.
716
1043
  // Shares the clipboard queue: this writes the pasteboard, so running it alongside a
717
1044
  // clipboard:read would overwrite that read's sentinel and be returned as "copied".
718
1045
  this.clipboardQueue(state.deviceId, doType)
719
- .then(() => this.ws?.send(JSON.stringify({ type: 'input:type-done', sessionId })))
1046
+ .then(() => this.sendMsg({ type: 'input:type-done', sessionId, requestId }))
720
1047
  .catch((e) => {
721
1048
  const message = e instanceof Error ? e.message : String(e);
722
1049
  logger.error('input:type (pbcopy+paste) failed:', e);
723
- this.ws?.send(JSON.stringify({ type: 'input:type-error', sessionId, message }));
1050
+ this.sendMsg({ type: 'input:type-error', sessionId, requestId, message });
724
1051
  });
725
1052
  break;
726
1053
  }
727
1054
  case 'input:key': {
1055
+ const requestId = this.correlatorOf(msg);
1056
+ if (requestId === null)
1057
+ break;
728
1058
  const state = this.deviceStates.get(msg.sessionId);
729
- if (!state)
1059
+ if (!state) {
1060
+ this.ackNoSession(msg.sessionId, requestId);
730
1061
  break;
1062
+ }
731
1063
  this.ensureTouchHelper(state);
732
1064
  const { code, modifiers } = msg.payload;
733
1065
  const usage = KEY_CODE_MAP[code];
734
1066
  if (usage === undefined) {
735
- this.ws?.send(JSON.stringify({ type: 'input:error', sessionId: msg.sessionId, message: `unknown key code: ${code}` }));
1067
+ // Prose preserved (it names the code), reason added: `unsupported` tells a caller never to
1068
+ // retry, which the message alone could not.
1069
+ this.sendMsg({
1070
+ type: 'input:error', sessionId: msg.sessionId, requestId,
1071
+ message: `unknown key code: ${code}`, reason: 'unsupported',
1072
+ });
736
1073
  break;
737
1074
  }
738
1075
  if (state.softKeyboardVisible) {
739
1076
  // Hide the SW keyboard first so iOS re-initialises the HW keyboard
740
1077
  // context. Skipping this causes input-source desync (qks / ㅂㅏㄴ symptoms).
741
1078
  state.softKeyboardVisible = false;
742
- this.simctl.hideSoftwareKeyboard(state.deviceId)
743
- .then(() => state.touchHelper?.sendKey(usage, modifiers ?? 0))
744
- .catch((e) => {
745
- logger.error('hideSoftwareKeyboard (on key) failed:', e);
746
- state.touchHelper?.sendKey(usage, modifiers ?? 0);
1079
+ // The ack belongs after the deferred chord, not beside it. Acking out here ran before
1080
+ // the key was ever written, so the answer could only be a guess — and the guess was
1081
+ // "delivered". The hide failing is not fatal (the key still goes out), so the catch
1082
+ // resolves and the send happens exactly once either way.
1083
+ void this.simctl.hideSoftwareKeyboard(state.deviceId)
1084
+ .catch((e) => { logger.error('hideSoftwareKeyboard (on key) failed:', e); })
1085
+ .then(() => {
1086
+ const h = state.touchHelper;
1087
+ return this.ackInput(state, h?.sendKey(usage, modifiers ?? 0) ? 'delivered' : this.refusalReason(h, 'standalone'), requestId);
747
1088
  });
748
1089
  }
749
1090
  else {
750
- state.touchHelper?.sendKey(usage, modifiers ?? 0);
1091
+ const h = state.touchHelper;
1092
+ void this.ackInput(state, h?.sendKey(usage, modifiers ?? 0) ? 'delivered' : this.refusalReason(h, 'standalone'), requestId);
751
1093
  }
752
- void this.ackInput(state, state.touchHelper !== null);
753
1094
  break;
754
1095
  }
755
1096
  case 'input:button': {
1097
+ const requestId = this.correlatorOf(msg);
1098
+ if (requestId === null)
1099
+ break;
756
1100
  const state = this.deviceStates.get(msg.sessionId);
757
- if (!state)
1101
+ if (!state) {
1102
+ this.ackNoSession(msg.sessionId, requestId);
758
1103
  break;
1104
+ }
759
1105
  this.ensureTouchHelper(state);
760
1106
  const { name, phase } = msg.payload;
761
1107
  // Map the cross-platform button vocabulary (used by MCP) onto this
762
1108
  // device's actual chrome button names. Dashboard already sends the raw
763
1109
  // chrome names (e.g. "volume-up"), which pass through unchanged.
764
1110
  const chromeName = IOS_BUTTON_ALIASES[name] ?? name;
1111
+ // Two branches below deliberately write nothing — a home press-down, and a button this
1112
+ // device's chrome has no HID mapping for. Neither is a dropped input, so they answer
1113
+ // with the channel's health instead of a write result; reporting an error there would
1114
+ // trade this issue's false success for a false failure.
1115
+ const btnHelper = state.touchHelper;
1116
+ const wrote = (ok) => ok ? 'delivered' : this.refusalReason(btnHelper, 'standalone');
1117
+ // The two branches that write nothing answer from the channel's state, which is `delivered`
1118
+ // when it is ready. A button this device's chrome lacks stays a *success* — the device
1119
+ // genuinely has no such button, so an error would be a false failure (#484). That is a
1120
+ // decision, and it is why iOS never sends `unsupported` for a button while Android does.
1121
+ let outcome = btnHelper?.inputState() === 'ready'
1122
+ ? 'delivered'
1123
+ : this.refusalReason(btnHelper, 'standalone');
765
1124
  if (chromeName === 'home') {
766
1125
  // Home has no HID down/up split — always a single legacy press. Send once on release
767
1126
  // (or on a phase-less legacy message) so a down+up pair doesn't fire it twice.
768
1127
  if (phase !== 'down')
769
- state.touchHelper?.pressLegacyButton(0);
1128
+ outcome = wrote(btnHelper?.pressLegacyButton(0) ?? false);
770
1129
  }
771
1130
  else {
772
1131
  const btn = state.loadedChrome?.buttons.find((b) => b.name === chromeName);
773
1132
  if (btn && btn.usagePage > 0 && btn.usage > 0) {
774
1133
  if (phase === 'down')
775
- state.touchHelper?.pressButtonDown(btn.usagePage, btn.usage);
1134
+ outcome = wrote(btnHelper?.pressButtonDown(btn.usagePage, btn.usage) ?? false);
776
1135
  else if (phase === 'up')
777
- state.touchHelper?.pressButtonUp(btn.usagePage, btn.usage);
1136
+ outcome = wrote(btnHelper?.pressButtonUp(btn.usagePage, btn.usage) ?? false);
778
1137
  else
779
- state.touchHelper?.pressButton(btn.usagePage, btn.usage);
1138
+ outcome = wrote(btnHelper?.pressButton(btn.usagePage, btn.usage) ?? false);
780
1139
  }
781
1140
  }
782
- void this.ackInput(state, state.touchHelper !== null);
1141
+ void this.ackInput(state, outcome, requestId);
783
1142
  break;
784
1143
  }
785
1144
  case 'open-url': {
786
1145
  const { url } = msg.payload;
787
1146
  const sessionId = msg.sessionId;
1147
+ const { requestId } = msg;
1148
+ // No fallback by design (see the note above `OpenUrl`), so an uncorrelatable request cannot be
1149
+ // answered correlatably either — and inventing an id would make this agent's reply look like a
1150
+ // response to a request nobody made. Every in-repo sender supplies one, and the `fixed` version
1151
+ // group means there is no in-repo skew window; validating third-party frames at the relay's door
1152
+ // is #444, which will take this over. Until then a drop with a log beats a reply that lies.
1153
+ if (typeof requestId !== 'string' || requestId === '') {
1154
+ console.warn('[tapflow] open-url without a requestId — dropped, cannot correlate a reply');
1155
+ break;
1156
+ }
1157
+ // Every exit merges the correlation ids here. What that buys, precisely — review measured 13
1158
+ // attacks and the type caught 3:
1159
+ //
1160
+ // - **Omitting the correlator is a compile error.** That comes from `requestId: string` being
1161
+ // required on `OpenUrlDone`/`OpenUrlError`, reached through `sendMsg`'s `AgentControlOutbound`
1162
+ // — not from `OpenUrlReplyBody`, whose one contribution is rejecting a fresh id written as a
1163
+ // literal at the `respond(...)` call.
1164
+ // - **`...body` goes first on purpose.** With the ids last, a body *variable* carrying a
1165
+ // `requestId` cannot override them — excess-property checking does not fire on variables, so
1166
+ // the earlier `{ sessionId, requestId, ...body }` let a wrong id win.
1167
+ //
1168
+ // What it does **not** buy: `sendMsg` accepts any `string` here, so a site that bypasses
1169
+ // `respond` type-checks. The echo tests in both agents' suites are what catch that, and each
1170
+ // remaining correlation pair needs its own — this helper does not remove that work.
1171
+ const respond = (body) => this.sendMsg({ ...body, sessionId, requestId });
788
1172
  const state = this.deviceStates.get(sessionId);
789
1173
  if (!state) {
790
- this.ws?.send(JSON.stringify({ type: 'open-url:error', sessionId, message: 'no booted device' }));
1174
+ respond({ type: 'open-url:error', message: 'no booted device' });
791
1175
  break;
792
1176
  }
793
1177
  this.simctl.openUrl(state.deviceId, url)
794
- .then(() => this.ws?.send(JSON.stringify({ type: 'open-url:done', sessionId })))
1178
+ .then(() => respond({ type: 'open-url:done' }))
795
1179
  .catch((e) => {
796
1180
  const message = e instanceof Error ? e.message : String(e);
797
- this.ws?.send(JSON.stringify({ type: 'open-url:error', sessionId, message }));
1181
+ respond({ type: 'open-url:error', message });
798
1182
  });
799
1183
  break;
800
1184
  }
@@ -802,33 +1186,48 @@ export class IOSAgent {
802
1186
  const raw = msg;
803
1187
  const { requestId, format } = raw;
804
1188
  const sessionId = msg.sessionId;
805
- this.simctl.screenshot(format ?? 'png')
806
- .then((buf) => this.ws?.send(JSON.stringify({
1189
+ const shotState = this.deviceStates.get(sessionId);
1190
+ if (!shotState) {
1191
+ this.sendMsg({ type: 'screenshot:error', sessionId, requestId, message: 'No booted device' });
1192
+ break;
1193
+ }
1194
+ // Note for anyone widening this signature again: before the udid parameter existed this
1195
+ // read `screenshot(format ?? 'png')`, and adding a leading `udid: string` did NOT make it a
1196
+ // type error — the format string simply became the device id. The compiler cannot help
1197
+ // here; the test that scans exec arguments is what does.
1198
+ this.simctl.screenshot(shotState.deviceId, format ?? 'png')
1199
+ .then((buf) => this.sendMsg({
807
1200
  type: 'screenshot:done',
808
1201
  sessionId,
809
1202
  requestId,
810
1203
  format: format ?? 'png',
811
1204
  data: buf.toString('base64'),
812
- })))
1205
+ }))
813
1206
  .catch((e) => {
814
1207
  const message = e instanceof Error ? e.message : String(e);
815
- this.ws?.send(JSON.stringify({ type: 'screenshot:error', sessionId, requestId, message }));
1208
+ this.sendMsg({ type: 'screenshot:error', sessionId, requestId, message });
816
1209
  });
817
1210
  break;
818
1211
  }
819
1212
  case 'app:clear-state': {
820
1213
  const { bundleId } = (msg.payload ?? {});
821
1214
  const sessionId = msg.sessionId;
1215
+ const { requestId } = msg;
1216
+ if (typeof requestId !== 'string' || requestId === '') {
1217
+ console.warn('[tapflow] app:clear-state without a requestId — dropped, cannot correlate a reply');
1218
+ break;
1219
+ }
1220
+ const respond = (body) => this.sendMsg({ ...body, sessionId, requestId });
822
1221
  const state = this.deviceStates.get(sessionId);
823
1222
  if (!state || !bundleId) {
824
- this.ws?.send(JSON.stringify({ type: 'app:clear-state-error', sessionId, message: !state ? 'No booted device' : 'bundleId missing' }));
1223
+ respond({ type: 'app:clear-state-error', message: !state ? 'No booted device' : 'bundleId missing' });
825
1224
  break;
826
1225
  }
827
- this.simctl.clearAppData(bundleId)
828
- .then(() => this.ws?.send(JSON.stringify({ type: 'app:clear-state-done', sessionId })))
1226
+ this.simctl.clearAppData(state.deviceId, bundleId)
1227
+ .then(() => respond({ type: 'app:clear-state-done' }))
829
1228
  .catch((e) => {
830
1229
  const message = e instanceof Error ? e.message : String(e);
831
- this.ws?.send(JSON.stringify({ type: 'app:clear-state-error', sessionId, message }));
1230
+ respond({ type: 'app:clear-state-error', message });
832
1231
  });
833
1232
  break;
834
1233
  }
@@ -837,14 +1236,22 @@ export class IOSAgent {
837
1236
  // await hideSoftwareKeyboard first), and reading too early returns the PREVIOUS
838
1237
  // pasteboard — a stale value the user would never notice.
839
1238
  case 'clipboard:read': {
1239
+ // `requestId: string`, matching the screenshot and ui:tree casts a few cases below — clipboard was
1240
+ // the only one reading it as optional, for the same wire guarantee. `ClipboardRequest.requestId` is
1241
+ // required and the only requester goes through a typed `send()`, so nothing in-repo omits it.
1242
+ //
1243
+ // It is still an assertion about unvalidated JSON, as those other two are: a third-party client
1244
+ // could omit it, and then the reply would carry `undefined` and be uncorrelatable. That is inbound
1245
+ // validation (#444), not producer typing, and making it optional here instead would just move the
1246
+ // same hole into every reply this case sends.
840
1247
  const { requestId } = msg;
841
1248
  const sessionId = msg.sessionId;
842
1249
  const state = this.deviceStates.get(sessionId);
843
1250
  if (!state) {
844
- this.ws?.send(JSON.stringify({
1251
+ this.sendMsg({
845
1252
  type: 'clipboard:error', sessionId, requestId, message: 'No booted device',
846
1253
  payload: { sentinelParked: false },
847
- }));
1254
+ });
848
1255
  break;
849
1256
  }
850
1257
  const { press } = (msg.payload ?? {});
@@ -852,7 +1259,7 @@ export class IOSAgent {
852
1259
  // executes AFTER this — so the viewer hears back as soon as the outcome is known and
853
1260
  // does not wait out the restore window. The queue is still held until the restore
854
1261
  // finishes, which is what actually matters: the next operation must not see a sentinel.
855
- const respond = (body) => this.ws?.send(JSON.stringify({ sessionId, requestId, ...body }));
1262
+ const respond = (body) => this.sendMsg({ sessionId, requestId, ...body });
856
1263
  const read = async () => {
857
1264
  if (!press) {
858
1265
  respond({ type: 'clipboard:data', payload: { text: await this.simctl.getPasteboard(state.deviceId) } });
@@ -965,23 +1372,31 @@ export class IOSAgent {
965
1372
  break;
966
1373
  }
967
1374
  case 'clipboard:write': {
1375
+ // `requestId: string`, matching the screenshot and ui:tree casts a few cases below — clipboard was
1376
+ // the only one reading it as optional, for the same wire guarantee. `ClipboardRequest.requestId` is
1377
+ // required and the only requester goes through a typed `send()`, so nothing in-repo omits it.
1378
+ //
1379
+ // It is still an assertion about unvalidated JSON, as those other two are: a third-party client
1380
+ // could omit it, and then the reply would carry `undefined` and be uncorrelatable. That is inbound
1381
+ // validation (#444), not producer typing, and making it optional here instead would just move the
1382
+ // same hole into every reply this case sends.
968
1383
  const { requestId } = msg;
969
1384
  const sessionId = msg.sessionId;
970
1385
  const state = this.deviceStates.get(sessionId);
971
1386
  if (!state) {
972
- this.ws?.send(JSON.stringify({
1387
+ this.sendMsg({
973
1388
  type: 'clipboard:error', sessionId, requestId, message: 'No booted device',
974
1389
  payload: { sentinelParked: false },
975
- }));
1390
+ });
976
1391
  break;
977
1392
  }
978
1393
  const { text, pasteAfter } = (msg.payload ?? {});
979
1394
  if (clipboardByteLength(text ?? '') > MAX_CLIPBOARD_BYTES) {
980
- this.ws?.send(JSON.stringify({
1395
+ this.sendMsg({
981
1396
  type: 'clipboard:error', sessionId, requestId,
982
1397
  message: `Clipboard is too large (max ${Math.floor(MAX_CLIPBOARD_BYTES / 1024)} KB)`,
983
1398
  payload: { sentinelParked: this.sentinelParked(state.deviceId) },
984
- }));
1399
+ });
985
1400
  break;
986
1401
  }
987
1402
  const write = async () => {
@@ -1006,17 +1421,22 @@ export class IOSAgent {
1006
1421
  state.softKeyboardVisible = false;
1007
1422
  await this.simctl.hideSoftwareKeyboard(state.deviceId).catch(() => { });
1008
1423
  }
1009
- state.touchHelper.sendKey(KEY_CODE_MAP['KeyV'], MODIFIER_BITS['MetaLeft']);
1424
+ // Same reason as input:type: the deadline above proves the pasteboard took the text,
1425
+ // not that the chord reached the device, so a dead channel here would answer
1426
+ // clipboard:write-done having pasted nothing.
1427
+ if (!state.touchHelper?.sendKey(KEY_CODE_MAP['KeyV'], MODIFIER_BITS['MetaLeft'])) {
1428
+ throw new PlatformError('Cannot press paste — no input channel to the device');
1429
+ }
1010
1430
  };
1011
1431
  // Ack only once the write (and the paste, when asked for) actually landed.
1012
1432
  this.clipboardQueue(state.deviceId, write)
1013
- .then(() => this.ws?.send(JSON.stringify({ type: 'clipboard:write-done', sessionId, requestId })))
1433
+ .then(() => this.sendMsg({ type: 'clipboard:write-done', sessionId, requestId }))
1014
1434
  .catch((e) => {
1015
1435
  const message = e instanceof Error ? e.message : String(e);
1016
- this.ws?.send(JSON.stringify({
1436
+ this.sendMsg({
1017
1437
  type: 'clipboard:error', sessionId, requestId, message,
1018
1438
  payload: { sentinelParked: this.sentinelParked(state.deviceId) },
1019
- }));
1439
+ });
1020
1440
  });
1021
1441
  break;
1022
1442
  }
@@ -1026,14 +1446,14 @@ export class IOSAgent {
1026
1446
  const sessionId = msg.sessionId;
1027
1447
  const state = this.deviceStates.get(sessionId);
1028
1448
  if (!state) {
1029
- this.ws?.send(JSON.stringify({ type: 'ui:tree:error', sessionId, requestId, message: 'No booted device' }));
1449
+ this.sendMsg({ type: 'ui:tree:error', sessionId, requestId, message: 'No booted device' });
1030
1450
  break;
1031
1451
  }
1032
1452
  this.readUITree(state)
1033
- .then((elements) => this.ws?.send(JSON.stringify({ type: 'ui:tree:response', sessionId, requestId, elements })))
1453
+ .then((elements) => this.sendMsg({ type: 'ui:tree:response', sessionId, requestId, elements }))
1034
1454
  .catch((e) => {
1035
1455
  const message = e instanceof Error ? e.message : String(e);
1036
- this.ws?.send(JSON.stringify({ type: 'ui:tree:error', sessionId, requestId, message }));
1456
+ this.sendMsg({ type: 'ui:tree:error', sessionId, requestId, message });
1037
1457
  });
1038
1458
  break;
1039
1459
  }
@@ -1054,15 +1474,15 @@ export class IOSAgent {
1054
1474
  * 직접 설치. tar 추출은 실행 비트·심볼릭 링크를 보존하고(재압축이 아니라 네이티브 보관),
1055
1475
  * macOS tar(libarchive)가 path traversal·symlink 탈출을 기본 차단한다. 완료 후 임시 정리.
1056
1476
  */
1057
- async installBuild(filePath, bundleId) {
1477
+ async installBuild(udid, filePath, bundleId) {
1058
1478
  if (bundleId) {
1059
- await this.simctl.uninstallApp(bundleId).catch(() => { });
1479
+ await this.simctl.uninstallApp(udid, bundleId).catch(() => { });
1060
1480
  }
1061
1481
  const lower = filePath.toLowerCase();
1062
1482
  const isTar = lower.endsWith('.tar.gz') || lower.endsWith('.tgz');
1063
1483
  const isZip = lower.endsWith('.zip');
1064
1484
  if (!isTar && !isZip) {
1065
- return this.simctl.installApp(filePath);
1485
+ return this.simctl.installApp(udid, filePath);
1066
1486
  }
1067
1487
  const tmpDir = path.join(tmpdir(), `tapflow-install-${randomUUID()}`);
1068
1488
  fs.mkdirSync(tmpDir, { recursive: true });
@@ -1087,7 +1507,7 @@ export class IOSAgent {
1087
1507
  if (!appDir) {
1088
1508
  throw new ValidationError('.app 디렉토리를 찾을 수 없습니다. iphonesimulator 로 빌드한 .app 을 .app.zip 또는 .tar.gz 로 업로드하세요.');
1089
1509
  }
1090
- await this.simctl.installApp(path.join(tmpDir, appDir));
1510
+ await this.simctl.installApp(udid, path.join(tmpDir, appDir));
1091
1511
  }
1092
1512
  finally {
1093
1513
  fs.rmSync(tmpDir, { recursive: true, force: true });
@@ -1097,12 +1517,45 @@ export class IOSAgent {
1097
1517
  listDevices() { return this.simctl.listDevices(); }
1098
1518
  boot(deviceId) { return this.simctl.boot(deviceId); }
1099
1519
  shutdown(deviceId) { return this.simctl.shutdown(deviceId); }
1100
- installApp(appPath) { return this.simctl.installApp(appPath); }
1101
- async launchApp(bundleId) { await this.simctl.launchApp(bundleId); }
1102
- async queryUITree() {
1103
- const state = this.deviceStates.values().next().value;
1104
- if (!state)
1520
+ /** The `DeviceAgent` interface has no device parameter — it predates multi-session agents and is
1521
+ * shared with Android, so widening it would be a breaking change for every implementation and
1522
+ * caller. `AndroidAgent` resolves the single live session internally instead
1523
+ * (`AndroidAgent.ts:1429`); these do the same. Throwing beats falling back to simctl's `booted`
1524
+ * alias, which would quietly act on whichever simulator happened to be up. */
1525
+ async installApp(appPath) {
1526
+ await this.simctl.installApp(this.soleDeviceId(), appPath);
1527
+ }
1528
+ async launchApp(bundleId) {
1529
+ await this.simctl.launchApp(this.soleDeviceId(), bundleId);
1530
+ }
1531
+ /** The one booted session, or undefined when there is no unambiguous answer. Callers that can
1532
+ * reasonably do nothing (input) use this; callers that owe the user a result use
1533
+ * `soleDeviceState`, which throws instead. */
1534
+ liveDeviceState() {
1535
+ const live = [...this.deviceStates.values()].filter((s) => s.booted);
1536
+ return live.length === 1 ? live[0] : undefined;
1537
+ }
1538
+ soleDeviceState() {
1539
+ // `deviceStates` holds one entry per *registered* simulator, not per running one — the relay
1540
+ // opens a session for every device in `agent:register` and this Mac reports dozens. Taking the
1541
+ // first entry would pick whichever simulator simctl listed first, almost always a shut-down
1542
+ // one, which is worse than the `booted` alias it replaced: the alias at least found the device
1543
+ // that was actually up. `booted` is set by handleDeviceBoot, so filtering on it is the liveness
1544
+ // check `AndroidAgent` gets for free from `getSerial` (its serial map is only populated on
1545
+ // launch).
1546
+ const live = [...this.deviceStates.values()].filter((s) => s.booted);
1547
+ if (live.length === 0)
1105
1548
  throw new ValidationError('no booted device — call connect() first');
1549
+ // Refusing beats guessing: this interface has no way to say which device is meant, and picking
1550
+ // one silently is the whole defect being fixed here.
1551
+ if (live.length > 1) {
1552
+ throw new ValidationError(`${live.length} booted devices — this entry point cannot choose between them`);
1553
+ }
1554
+ return live[0];
1555
+ }
1556
+ soleDeviceId() { return this.soleDeviceState().deviceId; }
1557
+ async queryUITree() {
1558
+ const state = this.soleDeviceState();
1106
1559
  return this.readUITree(state);
1107
1560
  }
1108
1561
  // ── Audio output (opt-in, macOS 14.2+ Core Audio process taps) ───────────────────────────────
@@ -1196,35 +1649,33 @@ export class IOSAgent {
1196
1649
  // stream cancelled or ws closed — expected on teardown/restart
1197
1650
  }
1198
1651
  }
1199
- screenshot() { return this.simctl.screenshot(); }
1652
+ // async, not a bare `return`: `soleDeviceId` throws, and a synchronous throw out of a method
1653
+ // typed `Promise<T>` skips every caller's `.catch`.
1654
+ async screenshot() { return this.simctl.screenshot(this.soleDeviceId()); }
1200
1655
  stream() {
1201
- const first = this.deviceStates.values().next().value;
1202
- if (!first)
1203
- throw new ValidationError('no booted device — call connect() first');
1656
+ const first = this.soleDeviceState();
1204
1657
  // DeviceAgent.stream() is the platform-neutral Buffer contract; unwrap StreamFrame payloads.
1205
- return new ScreenCaptureStreamer(this.fps, first.deviceId).start()
1658
+ return new ScreenCaptureStreamer(first.deviceId, this.fps).start()
1206
1659
  .pipeThrough(new TransformStream({
1207
1660
  transform(frame, controller) { controller.enqueue(frame.payload); },
1208
1661
  }));
1209
1662
  }
1210
1663
  touchStart(x, y) {
1211
- const first = this.deviceStates.values().next().value;
1664
+ const first = this.liveDeviceState();
1212
1665
  first?.touchHelper?.touchStart(x, y);
1213
1666
  }
1214
1667
  touchMove(x, y) {
1215
- const first = this.deviceStates.values().next().value;
1668
+ const first = this.liveDeviceState();
1216
1669
  first?.touchHelper?.touchMove(x, y);
1217
1670
  return Promise.resolve();
1218
1671
  }
1219
1672
  touchEnd() {
1220
- const first = this.deviceStates.values().next().value;
1673
+ const first = this.liveDeviceState();
1221
1674
  first?.touchHelper?.touchEnd();
1222
1675
  return Promise.resolve();
1223
1676
  }
1224
1677
  openUrl(url) {
1225
- const first = this.deviceStates.values().next().value;
1226
- if (!first)
1227
- throw new ValidationError('no booted device — call connect() first');
1678
+ const first = this.soleDeviceState();
1228
1679
  return this.simctl.openUrl(first.deviceId, url);
1229
1680
  }
1230
1681
  }