rnxsim 0.1.500 → 0.1.502

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/cli/cloud-client.ts +90 -7
  3. package/cli/cloud-dispatch.ts +27 -27
  4. package/cli/cloud-session.ts +61 -6
  5. package/cli/commands/agent-wrapper.ts +17 -2
  6. package/cli/commands/control.ts +33 -31
  7. package/cli/commands/detox.ts +157 -159
  8. package/cli/commands/platform.ts +170 -48
  9. package/cli/commands/preview.ts +1 -1
  10. package/cli/commands/test.ts +403 -95
  11. package/cli/main.ts +11 -0
  12. package/cli/outbound-endpoints.ts +10 -0
  13. package/detox/jest-preset.cjs +1 -6
  14. package/dist-lib/agent-daemon-client.cjs +1 -1
  15. package/dist-lib/agent-events.cjs +1 -1
  16. package/dist-lib/agent-identity.cjs +1 -1
  17. package/dist-lib/agent-sessions.cjs +1 -1
  18. package/dist-lib/attached-projects.cjs +1 -1
  19. package/dist-lib/auth/shared-session.cjs +1 -1
  20. package/dist-lib/backend-origin.cjs +1 -1
  21. package/dist-lib/beta.cjs +1 -1
  22. package/dist-lib/beta.mjs +1 -1
  23. package/dist-lib/bridge-constants.cjs +1 -1
  24. package/dist-lib/bridge-contract-input.cjs +1 -1
  25. package/dist-lib/bridge-contract-input.mjs +1 -1
  26. package/dist-lib/bridge-contract.cjs +1 -1
  27. package/dist-lib/bridge-contract.mjs +1 -1
  28. package/dist-lib/capture-contract.cjs +1 -1
  29. package/dist-lib/capture-contract.mjs +1 -1
  30. package/dist-lib/cli-constants.cjs +1 -1
  31. package/dist-lib/cloud-contract.cjs +7 -1
  32. package/dist-lib/cloud-contract.mjs +5 -1
  33. package/dist-lib/cloud.cjs +1 -1
  34. package/dist-lib/cloud.mjs +1 -1
  35. package/dist-lib/config.cjs +1 -1
  36. package/dist-lib/detox/index.cjs +1 -1
  37. package/dist-lib/detox/jest-preset.cjs +1 -6
  38. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  39. package/dist-lib/home-paths.cjs +1 -1
  40. package/dist-lib/host/bridge-host.cjs +54 -29
  41. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  42. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  43. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  44. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  45. package/dist-lib/host/websocket-proxy.cjs +1 -1
  46. package/dist-lib/index.cjs +39 -12
  47. package/dist-lib/jump-to-source-babel.cjs +1 -1
  48. package/dist-lib/jump-to-source-native.cjs +1 -1
  49. package/dist-lib/menu.cjs +1 -1
  50. package/dist-lib/menu.mjs +1 -1
  51. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  52. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  53. package/dist-lib/metro-production-bundle.cjs +1 -1
  54. package/dist-lib/metro-production-bundle.mjs +1 -1
  55. package/dist-lib/metro.cjs +1 -1
  56. package/dist-lib/profiles.cjs +1 -1
  57. package/dist-lib/public-brand.cjs +1 -1
  58. package/dist-lib/react-native-host-modules.cjs +1 -1
  59. package/dist-lib/react-native-host-modules.mjs +1 -1
  60. package/dist-lib/render-mode.cjs +1 -1
  61. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  62. package/dist-lib/sdk.cjs +6 -8
  63. package/dist-lib/sdk.mjs +6 -8
  64. package/dist-lib/skills.cjs +109 -51
  65. package/dist-lib/vite.cjs +1 -1
  66. package/package.json +1 -1
  67. package/skills/rnx-test/SKILL.md +2 -2
  68. package/src/cloud-contract.ts +2 -0
  69. package/src/sim-client.ts +8 -10
package/README.md CHANGED
@@ -445,7 +445,7 @@ no code changes — just add one line to your jest config:
445
445
 
446
446
  ```js
447
447
  // jest config
448
- { preset: 'rnxsim/detox/jest-preset' }
448
+ { preset: 'rnxsim/detox' }
449
449
  ```
450
450
 
451
451
  Or use the CLI directly:
@@ -36,7 +36,7 @@ import {
36
36
  type RNXMetroModuleIdentityMetadata,
37
37
  } from '../src/metro-production-bundle.ts'
38
38
  import { rnxPublicBrand } from '../src/public-brand.ts'
39
- import { CHOOSE_ACCOUNT_HINT } from './auth.ts'
39
+ import { CHOOSE_ACCOUNT_HINT, resolveCliAuth } from './auth.ts'
40
40
  import {
41
41
  createCloudSession,
42
42
  normalizeCloudOrigin,
@@ -44,8 +44,25 @@ import {
44
44
  type CloudBoxSession,
45
45
  type CloudSession,
46
46
  type CloudSimSession,
47
+ type RemotePlatform,
47
48
  } from './cloud-session.ts'
48
- import { detectProject, findProjectRoot } from './commands/detect.ts'
49
+
50
+ export class UnsupportedRemoteHostError extends Error {
51
+ readonly code = 'unsupported_host' as const
52
+ readonly platform: string
53
+ readonly host: string
54
+
55
+ constructor(platform: string, host: string, message?: string) {
56
+ super(
57
+ message ??
58
+ `rnx remote ${platform} does not support --${host}: rnxsim/cloud only creates iOS simulators`,
59
+ )
60
+ this.name = 'UnsupportedRemoteHostError'
61
+ this.platform = platform
62
+ this.host = host
63
+ }
64
+ }
65
+ import { detectProject } from './commands/detect.ts'
49
66
  import type { StorageSnapshot } from '../../sootsim-engine/src/preview/storage-snapshot-contract.ts'
50
67
  import type {
51
68
  BridgeClaimResult,
@@ -58,7 +75,7 @@ export const DEFAULT_RNX_CLOUD_ORIGIN = rnxPublicBrand.origin
58
75
  export const MAX_RNX_CLOUD_BUNDLE_BYTES = RNX_CLOUD_MAX_ARTIFACT_BYTES
59
76
 
60
77
  // every message this module writes names the command through this constant.
61
- const REMOTE_COMMAND = 'rnx ios --remote'
78
+ const REMOTE_COMMAND = 'rnx remote'
62
79
  const PREVIEW_COMMAND = 'rnx preview'
63
80
 
64
81
  // a Metro asset dest is a shallow tree of images. a walk that runs past this
@@ -72,6 +89,7 @@ interface CreateCloudBoxOptions {
72
89
  authorization: string
73
90
  /** the account to bill when the authorization is a session token; an api key names its own */
74
91
  accountId: string | null
92
+ platform?: RemotePlatform
75
93
  apiOrigin?: string
76
94
  /** the name this box carries in the account's box list. */
77
95
  boxName?: string | null
@@ -502,9 +520,28 @@ function searchMetroAssetDest(
502
520
  resolve(bundleDirectory, '..', 'assets'),
503
521
  ]
504
522
  const cwd = process.cwd()
505
- if (detectProject(cwd)?.hasMetroConfig) {
506
- const root = findProjectRoot(cwd)
507
- candidates.push(join(root, 'dist'), join(root, 'build'))
523
+ let projectDirectory: string | null = null
524
+ for (const start of [bundleDirectory, cwd]) {
525
+ let directory = resolve(start)
526
+ while (true) {
527
+ if (detectProject(directory)?.hasMetroConfig) {
528
+ projectDirectory = directory
529
+ break
530
+ }
531
+ const parent = dirname(directory)
532
+ if (parent === directory) break
533
+ directory = parent
534
+ }
535
+ if (projectDirectory) break
536
+ }
537
+ if (projectDirectory) {
538
+ candidates.push(
539
+ join(projectDirectory, 'assets'),
540
+ join(projectDirectory, 'dist'),
541
+ join(projectDirectory, 'dist', 'assets'),
542
+ join(projectDirectory, 'build'),
543
+ join(projectDirectory, 'build', 'assets'),
544
+ )
508
545
  }
509
546
  const searched: string[] = []
510
547
  const holders: { dest: MetroAssetDest; verified: number }[] = []
@@ -833,6 +870,10 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
833
870
  assetsDirectory: string | null
834
871
  warnings: string[]
835
872
  }> {
873
+ const platform = options.platform ?? 'ios'
874
+ if (platform === 'android') {
875
+ throw new UnsupportedRemoteHostError('android', 'nano')
876
+ }
836
877
  const apiOrigin = resolveCloudOrigin(options.apiOrigin)
837
878
  const artifact = await produceCloudArtifact(options.bundlePath)
838
879
  const boxName = options.boxName?.trim()
@@ -840,7 +881,7 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
840
881
  authorization: options.authorization,
841
882
  'x-rnx-box-size': 'nano',
842
883
  'x-rnx-artifact-sha256': artifact.sha256,
843
- 'x-rnx-platform': 'ios',
884
+ 'x-rnx-platform': platform,
844
885
  'x-rnx-device': options.device,
845
886
  ...(options.accountId ? { 'x-rnx-account-id': options.accountId } : null),
846
887
  ...(boxName ? { 'x-rnx-box-name': boxName } : null),
@@ -889,6 +930,12 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
889
930
  apiOrigin,
890
931
  claimId: created.receipt.simulator.claim.id,
891
932
  token: created.simulatorToken,
933
+ platform,
934
+ device: options.device,
935
+ artifact: {
936
+ sha256: artifact.sha256,
937
+ bytes: artifact.bytes.length,
938
+ },
892
939
  })
893
940
  return {
894
941
  receipt: created.receipt,
@@ -1185,6 +1232,36 @@ export async function confirmCloudSimulator(session: CloudSimSession): Promise<v
1185
1232
 
1186
1233
  // deletes the box, which closes every simulator it holds
1187
1234
  export async function closeCloudBox(session: CloudSession): Promise<void> {
1235
+ if (session.service === 'box') {
1236
+ const auth = resolveCliAuth()
1237
+ let authHeader = `Bearer ${session.boxToken}`
1238
+ if (auth?.kind === 'api-key') {
1239
+ authHeader = `Bearer ${auth.secret}`
1240
+ } else if (auth?.kind === 'session') {
1241
+ authHeader = `Bearer ${auth.token}`
1242
+ }
1243
+ let response: Response
1244
+ try {
1245
+ response = await fetch(
1246
+ `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}/stop`,
1247
+ {
1248
+ method: 'POST',
1249
+ headers: { authorization: authHeader },
1250
+ },
1251
+ )
1252
+ } catch (error) {
1253
+ throw new Error(
1254
+ `could not reach box ${session.boxId} at ${session.apiOrigin}: ${
1255
+ error instanceof Error ? error.message : String(error)
1256
+ }`,
1257
+ )
1258
+ }
1259
+ const text = await response.text()
1260
+ if (!response.ok && response.status !== 404) {
1261
+ throw responseError(response.status, text)
1262
+ }
1263
+ return
1264
+ }
1188
1265
  const response = await fetch(
1189
1266
  `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}`,
1190
1267
  {
@@ -1300,6 +1377,12 @@ export async function addCloudSimulator(
1300
1377
  simId,
1301
1378
  claimId: claim.id,
1302
1379
  token,
1380
+ platform: 'ios',
1381
+ device: options.device,
1382
+ artifact: {
1383
+ sha256: artifact.id.replace(/^sha256:/, ''),
1384
+ bytes: artifact.bytes,
1385
+ },
1303
1386
  })
1304
1387
  return { receipt, session: updatedSession }
1305
1388
  }
@@ -126,12 +126,8 @@ export async function dispatchCloudBoundary({
126
126
  command,
127
127
  args,
128
128
  }: CloudBoundaryInvocation): Promise<CloudBoundaryResult> {
129
- if (command === 'ios' && args.includes('--remote')) return { handled: false }
130
- if (command === 'android' && args.includes('--remote')) {
131
- console.error(
132
- ' rnx android --remote is not available; remote simulators are iOS only',
133
- )
134
- return { handled: true, code: 1 }
129
+ if ((command === 'ios' || command === 'android') && args.includes('--remote')) {
130
+ return { handled: false }
135
131
  }
136
132
  if (!CLOUD_TARGET_COMMANDS.has(command)) return { handled: false }
137
133
 
@@ -155,13 +151,35 @@ export async function dispatchCloudBoundary({
155
151
  }
156
152
  if (!session) return { handled: false }
157
153
 
154
+ if (command === 'close') {
155
+ if (args.includes('--all') || args.includes('--others')) {
156
+ return refuseCloudInvocation(command, parsed.positional)
157
+ }
158
+ try {
159
+ const descriptor = cloudSession.requireCloudSessionShellUpdate()
160
+ await cloudClient.closeCloudBox(session)
161
+ cloudSession.publishCloudSessionToShell(null, descriptor)
162
+ console.log(
163
+ session.service === 'box'
164
+ ? ` closed: remote box ${session.boxId}`
165
+ : ` closed: remote simulator ${session.simId}`,
166
+ )
167
+ return { handled: true, code: 0 }
168
+ } catch (error) {
169
+ console.error(
170
+ ` close failed: ${error instanceof Error ? error.message : String(error)}`,
171
+ )
172
+ return { handled: true, code: 1 }
173
+ }
174
+ }
175
+
158
176
  // a box relays the whole bridge vocabulary to the page hosting its
159
177
  // simulator, which runs the same shell the local CLI drives. so there is no
160
178
  // subset to police: every command below reaches it through the ordinary
161
- // bridge, which createBridgeFromParsed points at the box. the two exceptions
162
- // are the two commands that are about a nano claim, which a box has none of.
179
+ // bridge, which createBridgeFromParsed points at the box. claim is specific
180
+ // to nano.
163
181
  if (session.service === 'box') {
164
- if (command === 'claim' || command === 'close') {
182
+ if (command === 'claim') {
165
183
  console.error(
166
184
  ` rnx ${command} is not available for a box; its simulator is held by the Box page. use rnx box stop to end the box.`,
167
185
  )
@@ -188,24 +206,6 @@ export async function dispatchCloudBoundary({
188
206
  }
189
207
  }
190
208
 
191
- if (command === 'close') {
192
- if (args.includes('--all') || args.includes('--others')) {
193
- return refuseCloudInvocation(command, parsed.positional)
194
- }
195
- try {
196
- const descriptor = cloudSession.requireCloudSessionShellUpdate()
197
- await cloudClient.closeCloudBox(session)
198
- cloudSession.publishCloudSessionToShell(null, descriptor)
199
- console.log(` closed: remote simulator ${session.simId}`)
200
- return { handled: true, code: 0 }
201
- } catch (error) {
202
- console.error(
203
- ` close failed: ${error instanceof Error ? error.message : String(error)}`,
204
- )
205
- return { handled: true, code: 1 }
206
- }
207
- }
208
-
209
209
  // these inputs use the same perform executor as an inline chain. a CPU
210
210
  // simulator has focused text inputs but no browser keyboard to inspect.
211
211
  if (
@@ -15,6 +15,13 @@ export const RNX_SHELL_UPDATE_UNSET = `${RNX_SHELL_UPDATE_PREFIX}unset`
15
15
  // leaving a reader to infer it from which fields happen to be filled in.
16
16
  export type CloudSession = CloudSimSession | CloudBoxSession
17
17
 
18
+ export type RemotePlatform = 'ios' | 'android'
19
+
20
+ export interface CloudSessionArtifactIdentity {
21
+ sha256: string
22
+ bytes?: number
23
+ }
24
+
18
25
  /**
19
26
  * one remote host, addressed by the box token, and the simulator inside it
20
27
  * that ordinary commands reach with the simulator token. this is what
@@ -29,6 +36,9 @@ export interface CloudSimSession {
29
36
  apiOrigin: string
30
37
  claimId: string
31
38
  token: string
39
+ platform?: RemotePlatform
40
+ device?: string
41
+ artifact?: CloudSessionArtifactIdentity
32
42
  }
33
43
 
34
44
  /**
@@ -43,6 +53,9 @@ export interface CloudBoxSession {
43
53
  boxId: string
44
54
  boxToken: string
45
55
  apiOrigin: string
56
+ platform?: RemotePlatform
57
+ device?: string
58
+ artifact?: CloudSessionArtifactIdentity
46
59
  }
47
60
 
48
61
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -79,8 +92,11 @@ export function createCloudSession(input: {
79
92
  apiOrigin: string
80
93
  claimId: string
81
94
  token: string
95
+ platform?: RemotePlatform
96
+ device?: string
97
+ artifact?: CloudSessionArtifactIdentity
82
98
  }): CloudSimSession {
83
- const session = {
99
+ const session: CloudSimSession = {
84
100
  version: CLOUD_SESSION_VERSION,
85
101
  service: 'sim',
86
102
  boxId: input.boxId.trim(),
@@ -89,7 +105,10 @@ export function createCloudSession(input: {
89
105
  apiOrigin: normalizeCloudOrigin(input.apiOrigin.trim()),
90
106
  claimId: input.claimId.trim(),
91
107
  token: input.token,
92
- } satisfies CloudSimSession
108
+ ...(input.platform ? { platform: input.platform } : {}),
109
+ ...(input.device?.trim() ? { device: input.device.trim() } : {}),
110
+ ...(input.artifact ? { artifact: input.artifact } : {}),
111
+ }
93
112
  if (
94
113
  !session.boxId ||
95
114
  !session.boxToken ||
@@ -106,14 +125,20 @@ export function createCloudBoxSession(input: {
106
125
  boxId: string
107
126
  boxToken: string
108
127
  apiOrigin: string
128
+ platform?: RemotePlatform
129
+ device?: string
130
+ artifact?: CloudSessionArtifactIdentity
109
131
  }): CloudBoxSession {
110
- const session = {
132
+ const session: CloudBoxSession = {
111
133
  version: CLOUD_SESSION_VERSION,
112
134
  service: 'box',
113
135
  boxId: input.boxId.trim(),
114
136
  boxToken: input.boxToken,
115
137
  apiOrigin: normalizeCloudOrigin(input.apiOrigin.trim()),
116
- } satisfies CloudBoxSession
138
+ ...(input.platform ? { platform: input.platform } : {}),
139
+ ...(input.device?.trim() ? { device: input.device.trim() } : {}),
140
+ ...(input.artifact ? { artifact: input.artifact } : {}),
141
+ }
117
142
  if (!session.boxId || !session.boxToken) {
118
143
  throw new Error('cloud box session requires a box, an origin, and a box token')
119
144
  }
@@ -160,9 +185,29 @@ export function parseCloudSession(encoded: string): CloudSession {
160
185
  ) {
161
186
  throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`)
162
187
  }
188
+ const platform =
189
+ parsed.platform === 'ios' || parsed.platform === 'android'
190
+ ? parsed.platform
191
+ : undefined
192
+ const device = typeof parsed.device === 'string' ? parsed.device : undefined
193
+ const artifact =
194
+ isRecord(parsed.artifact) && typeof parsed.artifact.sha256 === 'string'
195
+ ? {
196
+ sha256: parsed.artifact.sha256,
197
+ bytes:
198
+ typeof parsed.artifact.bytes === 'number' ? parsed.artifact.bytes : undefined,
199
+ }
200
+ : undefined
163
201
  try {
164
202
  if (parsed.service === 'box') {
165
- return createCloudBoxSession({ boxId, boxToken, apiOrigin })
203
+ return createCloudBoxSession({
204
+ boxId,
205
+ boxToken,
206
+ apiOrigin,
207
+ platform,
208
+ device,
209
+ artifact,
210
+ })
166
211
  }
167
212
  const simId = parsed.simId
168
213
  const claimId = parsed.claimId
@@ -175,7 +220,17 @@ export function parseCloudSession(encoded: string): CloudSession {
175
220
  ) {
176
221
  throw new Error('unreadable cloud session')
177
222
  }
178
- return createCloudSession({ boxId, boxToken, simId, apiOrigin, claimId, token })
223
+ return createCloudSession({
224
+ boxId,
225
+ boxToken,
226
+ simId,
227
+ apiOrigin,
228
+ claimId,
229
+ token,
230
+ platform,
231
+ device,
232
+ artifact,
233
+ })
179
234
  } catch {
180
235
  throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`)
181
236
  }
@@ -33,6 +33,7 @@ import {
33
33
  openSync,
34
34
  } from 'node:fs'
35
35
  import fs from 'node:fs/promises'
36
+ import os from 'node:os'
36
37
  import path from 'node:path'
37
38
  import readline from 'node:readline'
38
39
  import { promisify } from 'node:util'
@@ -848,6 +849,20 @@ function buildClaudeTurnRunner(deps: ClaudeTurnDeps): TurnRunner {
848
849
  } = deps
849
850
  const startedAt = Date.now()
850
851
  emit({ type: 'turn-started' } as AgentEvent)
852
+ // the agent runs as the user, on the user's claude login. the host app's
853
+ // own api key must not become the agent's credential.
854
+ const { ANTHROPIC_API_KEY: _hostApiKey, ...claudeEnv } = process.env
855
+ // `--session-id` creates the transcript and refuses a uuid that already
856
+ // has one, so every turn after the first (including the first turn of a
857
+ // restarted wrapper) resumes it instead.
858
+ const transcriptPath = path.join(
859
+ os.homedir(),
860
+ '.claude',
861
+ 'projects',
862
+ cwd.replace(/[/.]/g, '-'),
863
+ `${claudeSessionUuid}.jsonl`,
864
+ )
865
+ const sessionFlag = existsSync(transcriptPath) ? '--resume' : '--session-id'
851
866
 
852
867
  const child = spawn(
853
868
  resolvedBin,
@@ -858,7 +873,7 @@ function buildClaudeTurnRunner(deps: ClaudeTurnDeps): TurnRunner {
858
873
  '--verbose',
859
874
  '--permission-mode',
860
875
  'bypassPermissions',
861
- '--session-id',
876
+ sessionFlag,
862
877
  claudeSessionUuid,
863
878
  text,
864
879
  ],
@@ -866,7 +881,7 @@ function buildClaudeTurnRunner(deps: ClaudeTurnDeps): TurnRunner {
866
881
  cwd,
867
882
  stdio: ['ignore', 'pipe', 'pipe'],
868
883
  env: {
869
- ...process.env,
884
+ ...claudeEnv,
870
885
  RNX_CLI_IDENTITY: sessionId,
871
886
  SOOTSIM_PROJECT_ID: projectId,
872
887
  },
@@ -1520,12 +1520,18 @@ export async function runOpenCommand(
1520
1520
  printBridgeWorldNotice(parsed.wsPort, baseUrl)
1521
1521
  const savedSimId = readCurrentSimId()
1522
1522
  const shouldReuseCurrentSim = !openInNewSim
1523
+ // every open stamps its page URL with a one-off token and matches the sim by
1524
+ // it. a sim id alone cannot tell the page this open loaded from the page it
1525
+ // replaced, which stays registered until the browser commits the navigation.
1526
+ const token = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
1527
+ const simMatchesToken = (sim: BridgeSimInfo) =>
1528
+ sim.url?.includes(`inspectOpen=${token}`) ?? false
1523
1529
 
1524
1530
  if (shouldReuseCurrentSim) {
1525
1531
  const bridge = createBridgeFromParsed(parsed)
1526
1532
  const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
1527
1533
  let fallBackToNewSim = false
1528
- let preNavSimIds = new Set<string>()
1534
+ let navigatedShellUrl: string | null = null
1529
1535
 
1530
1536
  try {
1531
1537
  let targetSim: BridgeSimInfo | null = null
@@ -1585,27 +1591,24 @@ export async function runOpenCommand(
1585
1591
  }
1586
1592
  }
1587
1593
  if (!fallBackToNewSim && targetSim) {
1588
- // a navigation that crosses origins tears down the page context, so
1589
- // the guest reconnects as a NEW sim and the id we are holding stops
1590
- // existing. record what was connected first, so the wait below can
1591
- // follow the reload instead of polling an id the browser retired.
1592
- preNavSimIds = new Set(sims.map((sim) => sim.id))
1593
1594
  const resolvedBaseUrl =
1594
1595
  hasExplicitBaseUrl || looksLikeSootsimUrl(target)
1595
1596
  ? baseUrl
1596
1597
  : deriveCurrentSimBaseUrl(targetSim, baseUrl)
1597
- const shellUrl =
1598
+ navigatedShellUrl =
1598
1599
  targetShellUrl ??
1599
1600
  (await buildShellUrl(
1600
1601
  target,
1601
1602
  applyRNXConfigToUrl(resolvedBaseUrl, runtimeConfig),
1602
1603
  buildShellUrlOpts,
1603
1604
  ))
1605
+ const navigationUrl = new URL(navigatedShellUrl)
1606
+ navigationUrl.searchParams.set('inspectOpen', token)
1604
1607
  bridge
1605
1608
  .send({
1606
1609
  type: 'evaluate',
1607
1610
  simId: targetSim.id,
1608
- code: `window.location.href = ${JSON.stringify(shellUrl)}`,
1611
+ code: `window.location.href = ${JSON.stringify(navigationUrl.toString())}`,
1609
1612
  })
1610
1613
  .catch(() => {})
1611
1614
  }
@@ -1621,21 +1624,30 @@ export async function runOpenCommand(
1621
1624
  rnxExit(1)
1622
1625
  }
1623
1626
 
1624
- if (!fallBackToNewSim && targetSim) {
1625
- await sleep(1500)
1626
- const navSim = targetSim
1627
- const reloadedSim = await waitForSimMatch(
1627
+ if (!fallBackToNewSim && targetSim && navigatedShellUrl) {
1628
+ // the navigated page may keep the sim id (restored from session
1629
+ // storage) or register a new one, so match by token, never by id.
1630
+ const navigatedSim = await waitForSimMatch(
1628
1631
  parsed.wsPort,
1629
1632
  parsed.commandTimeoutMs,
1630
1633
  (sim) =>
1631
1634
  sim.readyState === 'open' &&
1632
1635
  simUsesDriver(sim, driverId) &&
1633
- (sim.id === navSim.id || (!preNavSimIds.has(sim.id) && sim.isPrimary)),
1636
+ simMatchesToken(sim),
1634
1637
  { attempts: Math.round(30 * flowTimeoutScale()) },
1635
1638
  )
1636
- if (reloadedSim && reloadedSim.id !== navSim.id) {
1637
- targetSim = reloadedSim
1639
+ if (!navigatedSim) {
1640
+ console.error(
1641
+ ` timed out waiting for sim ${targetSim.id} to load ${navigatedShellUrl}`,
1642
+ )
1643
+ await printBridgeFailureDiagnostics(bridge, {
1644
+ errorsCommand: `rnx get errors 5${simHint}`,
1645
+ warningsCommand: `rnx get warnings 5${simHint}`,
1646
+ requestsCommand: `rnx get requests 5${simHint}`,
1647
+ })
1648
+ rnxExit(1)
1638
1649
  }
1650
+ targetSim = navigatedSim
1639
1651
  const ready = await waitForSimReady(
1640
1652
  parsed.wsPort,
1641
1653
  parsed.commandTimeoutMs,
@@ -1659,18 +1671,12 @@ export async function runOpenCommand(
1659
1671
  }
1660
1672
  ready.bridge.close()
1661
1673
  saveCurrentSimId(targetSim.id)
1662
- const resolvedBaseUrl =
1663
- hasExplicitBaseUrl || looksLikeSootsimUrl(target)
1664
- ? baseUrl
1665
- : deriveCurrentSimBaseUrl(targetSim, baseUrl)
1666
- const shellUrl =
1667
- targetShellUrl ??
1668
- (await buildShellUrl(
1669
- target,
1670
- applyRNXConfigToUrl(resolvedBaseUrl, runtimeConfig),
1671
- buildShellUrlOpts,
1672
- ))
1673
- printOpenedSim(shellUrl, { ...targetSim, url: shellUrl }, 'current sim', quiet)
1674
+ printOpenedSim(
1675
+ navigatedShellUrl,
1676
+ { ...targetSim, url: navigatedShellUrl },
1677
+ 'current sim',
1678
+ quiet,
1679
+ )
1674
1680
  await maybeDescribeAfterOpen(parsed.wsPort, targetSim.id, args)
1675
1681
  return parsed.wsPort
1676
1682
  }
@@ -1680,7 +1686,6 @@ export async function runOpenCommand(
1680
1686
  }
1681
1687
 
1682
1688
  const configuredBaseUrl = applyRNXConfigToUrl(baseUrl, runtimeConfig)
1683
- const token = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
1684
1689
  const openUrl = await buildOpenUrl(target, configuredBaseUrl, token, buildShellUrlOpts)
1685
1690
  const ownerPid = getStableOwnerPid()
1686
1691
  if (!ownerPid) {
@@ -1689,9 +1694,6 @@ export async function runOpenCommand(
1689
1694
  )
1690
1695
  rnxExit(1)
1691
1696
  }
1692
- const simMatchesToken = (sim: BridgeSimInfo) =>
1693
- sim.url?.includes(`inspectOpen=${token}`) ?? false
1694
-
1695
1697
  const driver = getDriver(driverId)
1696
1698
  if (!driver) {
1697
1699
  console.error(` unknown driver "${driverId}" — run \`rnx list --drivers\``)