Skip to content

feat: add wearable pairing command - #3002

Open
csark0812 wants to merge 16 commits into
callstack:mainfrom
csark0812:chris/agent/wearable-pairing
Open

csark0812 wants to merge 16 commits into
callstack:mainfrom
csark0812:chris/agent/wearable-pairing

Conversation

@csark0812

@csark0812 csark0812 commented Sep 27, 2026 •

Copy link
Copy Markdown

Summary

  • add a public devices.pairWearable() typed client command with matching CLI, MCP, daemon registry, capability facts, and structured unsupported-operation behavior
  • implement Apple Simulator pairing, active-pair selection, optional wearable boot/readiness polling, and rollback of pairs or boots created by a failed request
  • implement Android Wear discovery, optional emulator boot/readiness polling, and honest human-step-required reporting when ADB transport exists but companion pairing is not yet proven
  • discover watchOS simulators in Apple inventory so callers can select a concrete wearable without leaking raw simctl or ADB commands

Contract

client.devices.pairWearable({
  phone: { platform: "ios" | "android", deviceId },
  wearable: { deviceId },
  boot: true,
});

The result distinguishes connected, paired, and human-step-required. Android does not claim pairing success from ADB connectivity alone.

Verification

  • pnpm check:affected --run
  • 574 related Vitest files / 4,244 tests passed
  • provider-backed integration: 57/57 public commands and 70/70 device-observable workflow flags
  • replay compatibility, daemon wire compatibility, affected-selector, and mutation-model checks passed

The repository gate skipped GitHub-authoritative jobs locally as designed. No live hardware or simulator pairing green is claimed here; live device proof remains an operator acceptance step.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 41 files

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread packages/platform-android/src/wearable-pairing.ts Outdated
Comment thread packages/platform-apple/src/wearable-pairing.ts Outdated
Comment thread packages/platform-apple/src/wearable-pairing.ts Outdated
Comment thread scripts/integration-progress-model.ts
Comment thread packages/provider-limrun/src/facts-runtime.ts Outdated
Comment thread packages/contracts/src/client-device-view.ts
Comment thread src/daemon/handlers/__tests__/session-state.test.ts Outdated
Comment thread packages/platform-apple/src/wearable-pairing.test.ts
Comment thread packages/platform-apple/src/wearable-pairing.test.ts
Comment thread test/integration/provider-scenarios/apple-platform-output-guard.test.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 1 file (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/__tests__/cli-client-commands.test.ts Outdated
@thymikee

Copy link
Copy Markdown
Member

Findings only, at 426941e.

packages/platform-apple/src/inventory-classification.ts:38 (https://github.com/callstack/agent-device/blob/426941e/packages/platform-apple/src/inventory-classification.ts#L38): isSupportedAppleRuntime now accepts watchOS runtimes, so parseSimctlAppleDevices emits watch rows with target 'mobile', and appleDeviceSelectionRank gives them phoneRank 0 (packages/kernel/src/device.ts#L588-L603), so matchesPlatformSelector('ios') keeps them. After pair-wearable --boot there is one booted iPhone and one booted watch, so preferredDeviceCandidates returns two booted rank-0 candidates and any sessionless --platform ios command without --udid throws AMBIGUOUS_MATCH where it used to resolve the iPhone; the name-based /\b(watch|watchos)\b/ check at line 61 also misclassifies an iPhone simulator whose name contains "Watch". Existing iOS users with a booted watch simulator, the exact state this command produces, lose default device selection, and devices output changes with no docs entry. Default Apple selection should only offer devices that can run the requested command family: either revert the inventory change, since pairing already lists watches itself, or give watchOS a rank that loses to phones and iPads, and classify watchOS from the runtime or device-type identifier only, never from the device name.

src/daemon/handlers/session-state.ts:259 (https://github.com/callstack/agent-device/blob/426941e/src/daemon/handlers/session-state.ts#L259): the pair-wearable branch builds resolveCommandDevice flags only from input.phone and drops req.flags, so iosSimulatorDeviceSet and androidDeviceAllowlist never apply; the phone is looked up in the default simulator set, scopeSimctlArgsForDevice(phone) then scopes watch listing, pair, boot and unpair to that default set, and on Android discover() (packages/platform-android/src/wearable-pairing.ts#L77) lists every AVD and serial with no allowlist. A tenant- or lab-scoped caller can boot, pair, or terminate devices outside its declared scope, and with a custom simulator set the phone lookup fails with DEVICE_NOT_FOUND. Every device the command touches, phone and wearable, needs to resolve inside the request's isolation scope: merge req.flags with the phone udid/serial into resolveCommandDevice, and pass the allowlist and simulator set into wearable discovery.

packages/platform-apple/src/wearable-pairing.ts:32 (https://github.com/callstack/agent-device/blob/426941e/packages/platform-apple/src/wearable-pairing.ts#L32): bootedHere = input.boot && wearable.booted !== true treats a watch already in the 'Booting' state, or one booted by another caller meanwhile, as booted by this request, so simctl boot fails and the catch block (line 104) shuts down a device this request never booted. Separately, createdPairId at line 62 is set only from stdout of a pair call that returned, so an abort during simctl pair (runRequired forwards the signal) or a zero-stdout success leaves CoreSimulator with a pair the catch block never removes, and pair_activate on an existing inactive pair can leave a different pair active after a later failure with no restore. Pre-existing devices get shut down, and pairs created by the request survive cancellation or partial failure, which is the exact invariant this PR claims to hold. Rollback should undo exactly the effects this request caused, measured against a snapshot taken before any change: record the pre-request pair list and watch state, shut down only if the pre-state was exactly 'Shutdown' and this request issued boot, and in the catch block list pairs again without the signal and unpair every (phone, watch) pair missing from the snapshot. Tests should cover an abort during pair, a Booting watch, and an unrelated pre-existing pair that must survive.

packages/platform-apple/src/wearable-pairing.ts:89 (https://github.com/callstack/agent-device/blob/426941e/packages/platform-apple/src/wearable-pairing.ts#L89): /connected/i matches CoreSimulator's '(active, disconnected)' state, so a shut-down watch (for example with boot:false) is reported as status 'connected', and /active|connected/ at line 75 would also skip activation for any state containing 'inactive'; the test fixtures use invented states ('paired', 'active, connected') so neither test can reach this. The client is told the watch is connected when it is disconnected, and connected versus paired is the main distinction the result is meant to convey. Parse the state into exact tokens, active/inactive and connected/disconnected, and use real simctl list pairs -j state strings in the fixtures.

packages/platform-android/src/wearable-pairing.ts:48 (https://github.com/callstack/agent-device/blob/426941e/packages/platform-android/src/wearable-pairing.ts#L48): with boot:false and a stopped Wear AVD row (discover includes stopped devices), if (wearable.booted) skips get-state and still returns human-step-required, which pairWearableCliOutput (src/commands/management/output.ts#L188) prints as "Wearable transport ready"; the Android fact (runtime.ts#L429) is available for every Android target with no check of the phone's own readiness, and an explicit selector accepts any non-phone device as the wearable. The CLI claims the ADB transport is ready when nothing was probed, and the returned pairId is synthetic. human-step-required should be returned only after the wearable transport has been proven: refuse, or require --boot, when the wearable is not booted, gate the fact to mobile Android phones, and align the CLI text with what was actually checked.

Not blocking: the rollback test seeds no pre-existing pair and doesn't cover boot/shutdown, cancellation, or the disconnected state (packages/platform-apple/src/wearable-pairing.test.ts#L57), parseWatchDevices duplicates parsing that listAppleSimulators already does (packages/platform-apple/src/wearable-pairing.ts#L122), the Proxy spread in the CLI test silently drops methods instead of throwing (src/tests/cli-client-commands.test.ts#L1171), the daemon's manual input validation silently drops non-string fields instead of rejecting them (src/daemon/handlers/session-state.ts#L460), and the change is +1155/-19 against the 1,000-line budget in docs/agents/pull-requests.md, so any of these can be taken or left.

Is an Apple-only pair-wearable, selecting the watch from the existing listAppleSimulators inventory without a --boot path, simpler than what's here: drop the boot/rollback machinery entirely (unpair against a pre-request snapshot is the only undo needed), drop the Android half since it only returns a fixed string and a synthetic pairId today, and would that roughly halve the production diff?

The PR body says no live run was done. To validate: run pair-wearable <iPhone-udid> <shutdown-unpaired-watch-udid> --platform ios --boot --json on a local simulator and show the returned status alongside xcrun simctl list pairs -j before and after, proving exactly one new active pair; force a failure or Ctrl-C during that run and show simctl list pairs and watch state back at their pre-request values with an unrelated pre-existing pair still present; run snapshot --platform ios with no --udid afterward and show default selection still resolves the iPhone; and on an Android emulator with a Wear AVD, run pair-wearable <phone-serial> --platform android --boot --json and show human-step-required appearing only after adb -s <wear> get-state reports device.

The packet reports one check and it is green, but on this cross-repo head the Integration Tests and Coverage jobs that cover the new provider scenarios and the daemon route are not reported, and no failing job overlaps the diff. I did not run simctl locally to confirm the exact list pairs -j state strings, so the disconnected-state finding is a likely rather than confirmed read of known CoreSimulator output, and I did not confirm whether simctl pair commits the pair before an aborted subprocess is killed. This needs the isolation-scope fix, the snapshot-based rollback, the default-selection regression fix, and the connected-state parsing and Android readiness fixes, followed by the live simulator and emulator runs above, before this is ready to merge.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread packages/kernel/src/device.ts
Comment thread packages/platform-android/src/wearable-pairing.test.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/daemon/handlers/__tests__/session-state.test.ts Outdated
Comment thread packages/platform-apple/src/inventory-classification.test.ts
Comment thread src/commands/management/device.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 18 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread packages/platform-android/src/wearable-pairing.ts
Comment thread packages/kernel/src/device.ts
Comment thread packages/platform-android/src/wearable-pairing.ts
Comment thread src/commands/management/device.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 18 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread packages/platform-android/src/wearable-pairing.ts Outdated
Comment thread packages/platform-android/src/wearable-pairing.ts Outdated
@csark0812

csark0812 commented Sep 29, 2026 •

Copy link
Copy Markdown
Author

Wearable pairing follow-up pushed in f57809b (including a sync with current upstream main). This addresses the review points in the current patch: iOS phone selection preserves simulator-set/request isolation flags; Android Wear discovery is constrained by the daemon-derived serial allowlist on both initial and refreshed inventory; a watch already in Booting is only waited on and is not later claimed/shut down as request-owned; and failure/cancellation rollback snapshots existing CoreSimulator pairs, removes every newly created pair, and restores the pre-request active pair. Added regression coverage for disconnected-but-active status, Booting ownership, cancellation rollback, and Android discovery scoping. Also split Android wearable selection/probing to pass Fallow and fixed the existing PID-exit race in the Node integration suite. Verification: focused wearable/handler tests passed (30 tests); full check:affected --run passed all locally runnable checks, including typecheck, build/package, provider integration, unit/related tests (1,569 files; 12,666 passed, 1 skipped), integration, Fallow, and daemon wire compatibility. GitHub-authoritative host/device lanes are not represented by that local run. Live paired phone/watch validation and human completion of Android companion setup remain unverified.

@thymikee

Copy link
Copy Markdown
Member

Thanks for the update. I reviewed f57809b against the earlier findings (#3002 (comment)). Some are fixed, but the Apple rollback still touches devices this request does not own, and the live runs are still missing.

The rollback in wearable-pairing.ts lists every pair in the shared CoreSimulator set. It unpairs any pair missing from the snapshot, whichever phone and watch it joins (lines 136-148). It then re-activates every pair that was active before the request, for all phones (lines 149-160). Other daemons, Xcode, and manual simctl calls use the same set. So a pair that another actor creates or switches for a different phone between the snapshot and the failure can be unpaired or reverted. The new test 'cancellation after simctl pair' asserts that pair_activate runs on 'unrelated-active-pair', which locks in this over-reach. The same ownership rule is half-applied at line 41: bootedHere is set before simctl boot runs. If another caller boots the watch first, boot exits non-zero, and the catch block shuts down a watch this request never booted. Could rollback follow one rule: undo only effects this request caused on (phone.id, wearable.id)? That means unpair only a new pair for this phone and watch, re-activate only the earlier active pair that involves this phone or this watch, and mark the watch as booted by this request only when simctl boot exits 0 or is aborted. Please flip the test assertion for the unrelated pair to false, and add a case where boot exits non-zero on a shutdown watch and no shutdown call follows.

The new path drives real devices through simctl and adb (boot, pair, pair_activate, unpair, emulator launch and terminate), but the only proof is stubbed-host unit tests with hand-written simctl list pairs -j strings (wearable-pairing.ts#L19). If the real JSON differs from the accepted key names (udid, identifier, deviceIdentifier; watch or wearable), listPairs returns nothing. Every pair attempt then fails with 'did not report the newly created wearable pair'. Please run these on a local simulator and share the output:

  • pair-wearable <iPhone-udid> <shutdown-unpaired-watch-udid> --platform ios --boot --json. Show the returned status and xcrun simctl list pairs -j before and after, with exactly one new active pair for that phone.
  • A forced failure or Ctrl-C during that run. Show the pairs and watch state back at their earlier values, while an unrelated pair for a different phone keeps its state.
  • snapshot --platform ios with no --udid afterward, still resolving the iPhone.
  • On Android, pair-wearable <phone-serial> --platform android --boot --json against a stopped Wear AVD. It should return human-step-required only after adb -s <wear> get-state reports device.

The earlier question is still open: would it be simpler to ship Apple-only pairing with a snapshot-scoped unpair and drop the Android half? That half grew by about 214 lines in this update, and its result is still a fixed human-step string with a synthetic pairId. Could you say why it must ship in this PR, or split it into a follow-up?

Not blocking, and you can take or leave it: androidWearablePairingFact refuses only TV targets, so a Wear emulator (target mobile) is still accepted as the phone. You could refuse a phone whose probe shows the watch feature.

The one reported check passes. The Integration Tests and Coverage jobs, which would run the provider scenarios and the daemon pair-wearable route, are not reported on this cross-repo head. No failing job overlaps the diff, and there are no conflicts. I did not run simctl, so the real list pairs -j key names and state strings are unconfirmed. I also did not check every consumer of the widened Apple inventory. watchOS rows now appear under --platform apple for devices, test shard allocation, and device-claim-inspection, and I confirmed only selectDefaultDevice and the ios selector filter them. The rollback race needs another actor on the same CoreSimulator set. I did not confirm whether the daemon serializes requests for different phones, so inside one daemon the window may open only through external tools.

Before merge, please limit the Apple rollback (unpair, re-activate, shutdown) to effects this request caused on its own phone and watch, then share the live simulator and emulator runs.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants