peachsim 0.1.577 → 0.1.579

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 (178) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +11 -9
  3. package/cli/app-catalog.ts +77 -0
  4. package/cli/app-fonts.ts +4 -20
  5. package/cli/app-splash.ts +2 -18
  6. package/cli/bridge-flow-runner.ts +505 -139
  7. package/cli/cloud-client.ts +1 -4
  8. package/cli/cloud-session.ts +1 -4
  9. package/cli/cloud-test-result-files.ts +3 -0
  10. package/cli/cloud-test-tunnel.ts +2 -5
  11. package/cli/command-registry.ts +2 -0
  12. package/cli/commands/agent.ts +149 -23
  13. package/cli/commands/assert.ts +1 -4
  14. package/cli/commands/box/peach-command.ts +62 -0
  15. package/cli/commands/box/plane-share.ts +0 -70
  16. package/cli/commands/box/rnx-command.ts +1 -60
  17. package/cli/commands/box/search-command.ts +14 -0
  18. package/cli/commands/box.ts +2 -2
  19. package/cli/commands/control.ts +121 -27
  20. package/cli/commands/cpu-profile.ts +23 -10
  21. package/cli/commands/daemon.ts +1 -9
  22. package/cli/commands/detox.ts +26 -18
  23. package/cli/commands/device.ts +129 -1
  24. package/cli/commands/film.ts +25 -16
  25. package/cli/commands/flow.ts +9 -2
  26. package/cli/commands/inspect/actions.ts +75 -1
  27. package/cli/commands/inspect/core.ts +81 -2
  28. package/cli/commands/inspect/describe.ts +2 -6
  29. package/cli/commands/inspect/env.ts +1 -1
  30. package/cli/commands/inspect/settling.ts +20 -3
  31. package/cli/commands/inspect/shared.ts +1 -4
  32. package/cli/commands/inspect/sleep.ts +1 -1
  33. package/cli/commands/inspect.ts +16 -49
  34. package/cli/commands/install-desktop.ts +8 -6
  35. package/cli/commands/login.ts +3 -5
  36. package/cli/commands/maestro-generate.ts +1 -4
  37. package/cli/commands/maestro.ts +38 -21
  38. package/cli/commands/no-bridge-hint.ts +0 -14
  39. package/cli/commands/permissions.ts +2 -2
  40. package/cli/commands/platform.ts +13 -6
  41. package/cli/commands/screenshot.ts +6 -1
  42. package/cli/commands/skills.ts +1 -1
  43. package/cli/commands/test.ts +6 -2
  44. package/cli/commands/three-mode.ts +162 -19
  45. package/cli/commands/upload.ts +3 -18
  46. package/cli/current-sim.ts +0 -1
  47. package/cli/desktop-companion.ts +40 -8
  48. package/cli/drivers/playwright-provisioning.ts +1 -4
  49. package/cli/flow-export.ts +1 -8
  50. package/cli/flow-session.ts +0 -4
  51. package/cli/hints.ts +1 -16
  52. package/cli/inspect-notice-state.ts +0 -4
  53. package/cli/main.ts +2 -4
  54. package/cli/outbound-endpoints.ts +1 -24
  55. package/cli/parse-args.ts +1 -2
  56. package/cli/rnx-in-process.ts +3 -1
  57. package/cli/run-rnx-core.ts +4 -2
  58. package/cli/run-rnx.ts +18 -10
  59. package/cli/send-to-box.ts +1 -4
  60. package/cli/telemetry.ts +1 -1
  61. package/cli/ws-bridge.ts +1 -5
  62. package/detox/gestures.ts +20 -6
  63. package/detox/index.ts +379 -123
  64. package/detox/navigation-lifecycle.ts +22 -13
  65. package/dist-lib/agent-daemon-client.cjs +21 -15
  66. package/dist-lib/agent-events.cjs +1 -1
  67. package/dist-lib/agent-identity.cjs +3 -17
  68. package/dist-lib/agent-sessions.cjs +5 -4
  69. package/dist-lib/attached-projects.cjs +1 -77
  70. package/dist-lib/auth/shared-session.cjs +1 -1
  71. package/dist-lib/backend-origin.cjs +11 -1
  72. package/dist-lib/beta.cjs +1 -1
  73. package/dist-lib/beta.mjs +1 -1
  74. package/dist-lib/bridge-constants.cjs +1 -1
  75. package/dist-lib/bridge-contract-input.cjs +7 -5
  76. package/dist-lib/bridge-contract-input.mjs +7 -5
  77. package/dist-lib/bridge-contract.cjs +1 -1
  78. package/dist-lib/bridge-contract.mjs +1 -1
  79. package/dist-lib/capture-contract.cjs +30 -7
  80. package/dist-lib/capture-contract.mjs +30 -7
  81. package/dist-lib/cli-constants.cjs +1 -1
  82. package/dist-lib/cloud-contract.cjs +1 -1
  83. package/dist-lib/cloud-contract.mjs +1 -1
  84. package/dist-lib/cloud-test-args.cjs +1 -1
  85. package/dist-lib/cloud-test-args.mjs +1 -1
  86. package/dist-lib/cloud.cjs +17 -11
  87. package/dist-lib/cloud.mjs +17 -11
  88. package/dist-lib/config.cjs +1 -1
  89. package/dist-lib/detox/index.cjs +321 -122
  90. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  91. package/dist-lib/home-paths.cjs +1 -1
  92. package/dist-lib/host/bridge-host.cjs +427 -212
  93. package/dist-lib/host/fetch-proxy-handler.cjs +5 -28
  94. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  95. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  96. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  97. package/dist-lib/host/websocket-proxy.cjs +1 -1
  98. package/dist-lib/index.cjs +315 -58
  99. package/dist-lib/jump-to-source-babel.cjs +1 -1
  100. package/dist-lib/jump-to-source-native.cjs +1 -1
  101. package/dist-lib/menu.cjs +67 -31
  102. package/dist-lib/menu.mjs +64 -28
  103. package/dist-lib/metro-fingerprint-registry.cjs +8 -4
  104. package/dist-lib/metro-fingerprint-registry.mjs +6 -4
  105. package/dist-lib/metro-production-bundle.cjs +2 -2
  106. package/dist-lib/metro-production-bundle.mjs +2 -2
  107. package/dist-lib/metro.cjs +8 -4
  108. package/dist-lib/profiles.cjs +1 -1
  109. package/dist-lib/public-brand.cjs +7 -4
  110. package/dist-lib/react-native-host-modules.cjs +1 -1
  111. package/dist-lib/react-native-host-modules.mjs +1 -1
  112. package/dist-lib/render-mode.cjs +3 -13
  113. package/dist-lib/scripts/dev-server-scanner.cjs +5 -17
  114. package/dist-lib/sdk.cjs +129 -10
  115. package/dist-lib/sdk.mjs +127 -10
  116. package/dist-lib/skills.cjs +1635 -1121
  117. package/dist-lib/swift-compile.cjs +277 -70
  118. package/dist-lib/swift-compile.mjs +275 -70
  119. package/dist-lib/swift-package.cjs +17 -5
  120. package/dist-lib/swift-package.mjs +15 -4
  121. package/dist-lib/swift-submit.cjs +32 -8
  122. package/dist-lib/swift-submit.mjs +29 -7
  123. package/dist-lib/swift.cjs +30912 -577
  124. package/dist-lib/vite-kotlin.cjs +356 -0
  125. package/dist-lib/vite-swift.cjs +1743 -0
  126. package/dist-lib/vite.cjs +208 -1039
  127. package/kotlin/README.md +18 -0
  128. package/kotlin/Sources/androidx/annotation/Keep.kt +4 -0
  129. package/kotlin/Sources/dev/onejs/one/OneNativeSourceDispatch.kt +7 -0
  130. package/kotlin/Sources/dev/onejs/one/source/OneModule.kt +4 -0
  131. package/kotlin/Sources/org/json/JSON.kt +244 -0
  132. package/kotlin/toolchain.json +19 -0
  133. package/package.json +14 -2
  134. package/scripts/dev-server-scanner.ts +0 -18
  135. package/scripts/optional-demo-registry.ts +0 -17
  136. package/skills/contrast/SKILL.md +2 -2
  137. package/skills/peach-setup/SKILL.md +16 -0
  138. package/src/agent-daemon-client.ts +36 -22
  139. package/src/agent-identity.ts +0 -15
  140. package/src/agent-prompt.ts +12 -0
  141. package/src/attached-projects.ts +0 -72
  142. package/src/backend-origin.ts +10 -0
  143. package/src/bridge-contract-input.ts +2 -5
  144. package/src/bridge-contract.ts +2 -0
  145. package/src/capture-contract.ts +53 -11
  146. package/src/cloud.ts +4 -9
  147. package/src/dev-server-open.ts +0 -99
  148. package/src/home-paths.ts +2 -3
  149. package/src/host/agent-host.ts +82 -36
  150. package/src/host/bridge-host.ts +5 -11
  151. package/src/host/fetch-proxy-handler.ts +19 -43
  152. package/src/is-record.ts +4 -0
  153. package/src/menu.ts +82 -37
  154. package/src/metro-fingerprint-registry.ts +1 -4
  155. package/src/metro-production-bundle.ts +1 -1
  156. package/src/plist.ts +1 -4
  157. package/src/public-brand.ts +5 -3
  158. package/src/render-mode.ts +0 -12
  159. package/src/runtime-assets.ts +9 -2
  160. package/src/screenshots/compose.ts +7 -1
  161. package/src/screenshots/frame-compose.ts +21 -10
  162. package/src/screenshots/schema.ts +1 -4
  163. package/src/sim-client.ts +2 -4
  164. package/src/skills/builtin/maestro-test.ts +1 -4
  165. package/src/sleep.ts +4 -0
  166. package/src/swift-compile.ts +209 -47
  167. package/src/swift-icu.ts +140 -0
  168. package/src/swift-package.ts +26 -2
  169. package/src/swift-submit.ts +84 -4
  170. package/src/swift.ts +2 -0
  171. package/src/vite-plugin-kotlin.ts +376 -0
  172. package/src/vite-plugin-one.ts +224 -36
  173. package/src/vite-plugin-swift.ts +382 -96
  174. package/src/vite-plugin.ts +121 -134
  175. package/src/vite.d.ts +10 -0
  176. package/cli/resolve-assets.ts +0 -63
  177. package/src/rolldown-plugin-swift.ts +0 -244
  178. package/src/team-machine-prompt.ts +0 -73
@@ -2,9 +2,11 @@ import { spawn, spawnSync } from 'node:child_process'
2
2
  import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
+ import { encodeAppCatalogWire } from 'peach-engine/engine/app-catalog-wire'
5
6
  import {
6
7
  devices,
7
8
  listSelectableDeviceModels,
9
+ resolveDeviceFitBox,
8
10
  type DeviceModel,
9
11
  } from 'peach-engine/settings'
10
12
  import { WebSocket } from 'ws'
@@ -41,6 +43,8 @@ import {
41
43
  runtimeVersionHostname,
42
44
  } from '../../src/runtime-assets'
43
45
  import { rnxRuntime } from '../../src/runtime-delivery'
46
+ import { sleep } from '../../src/sleep'
47
+ import { discoverAppCatalog } from '../app-catalog'
44
48
  import { loadRNXAppConfig } from '../app-config'
45
49
  import {
46
50
  type DiscoveredAppFontSpec,
@@ -108,10 +112,6 @@ const SHELL_APPS: Record<string, string> = {
108
112
  camera: '/app/camera',
109
113
  }
110
114
 
111
- function sleep(ms: number) {
112
- return new Promise((resolve) => setTimeout(resolve, ms))
113
- }
114
-
115
115
  function parsePositiveIntegerEnv(name: string): number | null {
116
116
  const value = process.env[name]?.trim()
117
117
  if (!value) return null
@@ -176,14 +176,19 @@ export async function ensureBridgeForOpen(
176
176
  const lock = readDaemonLockfile()
177
177
  assertExplicitBridgeLock(preferredPort, explicitPort, lock)
178
178
  const preferredWorld = worldForBridgePort(preferredPort)
179
+ // a closed port refuses at once, so this window only matters when something
180
+ // listens: a live bridge on a loaded machine can take longer than 250 ms to
181
+ // finish the handshake, and missing it spawned a second bridge.
179
182
  if (
180
183
  (explicitPort || preferredWorld !== null) &&
181
- (await canConnectToBridge(preferredPort, 250))
184
+ (await canConnectToBridge(preferredPort, 2_000))
182
185
  ) {
183
186
  return preferredPort
184
187
  }
185
188
  if (explicitPort && preferredWorld !== null) {
186
- throw new Error(`peach open: requested bridge port ${preferredPort} is not reachable.`)
189
+ throw new Error(
190
+ `peach open: requested bridge port ${preferredPort} is not reachable.`,
191
+ )
187
192
  }
188
193
  if (
189
194
  !explicitPort &&
@@ -210,7 +215,21 @@ export async function ensureBridgeForOpen(
210
215
  const deadline = Date.now() + OPEN_BRIDGE_SPAWN_TIMEOUT_MS
211
216
  while (Date.now() < deadline) {
212
217
  const nextLock = readDaemonLockfile()
213
- assertExplicitBridgeLock(preferredPort, explicitPort, nextLock)
218
+ if (
219
+ explicitPort &&
220
+ nextLock &&
221
+ isDaemonLockfileFresh(nextLock) &&
222
+ nextLock.bridgePort !== preferredPort
223
+ ) {
224
+ // serve fell back because something already holds the pinned port. the
225
+ // bridge it started is one nobody asked for: stop it, and use the pinned
226
+ // port if what holds it is a bridge that answers given longer.
227
+ child.kill()
228
+ if (await canConnectToBridge(preferredPort, 5_000)) return preferredPort
229
+ throw new Error(
230
+ `peach open: requested bridge port ${preferredPort} is held by a process that does not answer as a peach bridge.`,
231
+ )
232
+ }
214
233
  const selectedPort = explicitPort ? preferredPort : nextLock?.bridgePort
215
234
  if (
216
235
  nextLock &&
@@ -261,6 +280,9 @@ function assertExplicitBridgeLock(
261
280
  }
262
281
 
263
282
  async function ensureRuntimeForOpen() {
283
+ // a dev checkout serves its own build; staging a published runtime into an
284
+ // isolated home would make the daemon serve that instead.
285
+ if (resolveDevCheckoutRuntimeRoot()) return
264
286
  const active = readActiveRuntime()
265
287
  if (active && fs.existsSync(runtimeDir(active))) return
266
288
  console.error(' installing peach engine runtime...')
@@ -351,7 +373,7 @@ function buildShellPathOpenUrl(target: string, baseUrl: string): string {
351
373
  return url.toString()
352
374
  }
353
375
 
354
- // peach share/preview/build links (e.g. https://rnxsim.com/preview/<id>,
376
+ // peach share/preview/build links (e.g. https://peachsim.com/preview/<id>,
355
377
  // https://contrast.dev/build/<owner>/<repo>/<branch>) are full player pages
356
378
  // served by the Contrast app — NOT Metro bundles and NOT driveable shell sims.
357
379
  // passing one to `peach open` used to fall through to the bundle resolver,
@@ -374,12 +396,6 @@ function looksLikeSootSimShareUrl(target: string): boolean {
374
396
  }
375
397
  }
376
398
 
377
- export async function resolveBundleTarget(target: string): Promise<string> {
378
- const normalized = normalizeKnownTarget(target)
379
- const resolved = await resolveConnectionInputForCli(normalized)
380
- return resolved.bundleUrl
381
- }
382
-
383
399
  function localBasePort(target: string): number | null {
384
400
  try {
385
401
  const url = new URL(target.startsWith('http') ? target : `http://${target}`)
@@ -749,6 +765,23 @@ async function stampAppSplash(
749
765
  }
750
766
  }
751
767
 
768
+ // stamp the engine page url with `?appCatalog=` so an app's asset-catalog
769
+ // colors and its declared accent reach the tenant, which applies them to the
770
+ // bundle this url opens. the catalog lives in the native binary, never in
771
+ // the js bundle, so only the app's ios project on disk has it.
772
+ function stampAppCatalog(urlStr: string, project?: ResolvedAppProject | null): string {
773
+ try {
774
+ const url = new URL(urlStr)
775
+ if (!url.searchParams.get('bundle') || !project) return urlStr
776
+ const catalog = discoverAppCatalog(project.appDir)
777
+ if (!catalog) return urlStr
778
+ url.searchParams.set('appCatalog', encodeAppCatalogWire(catalog))
779
+ return url.toString()
780
+ } catch {
781
+ return urlStr
782
+ }
783
+ }
784
+
752
785
  async function splashImageReachable(imageUrl: string): Promise<boolean> {
753
786
  const controller = new AbortController()
754
787
  const timer = setTimeout(() => controller.abort(), 1500)
@@ -775,6 +808,17 @@ export interface BuildShellUrlOptions {
775
808
  // uses the same param. without this, every driver-opened sim silently ran
776
809
  // whatever profile the shell last persisted.
777
810
  device?: string
811
+ // fold posture (open|closed) stamped onto the engine URL as ?posture= next
812
+ // to ?device=. the shell reads it at boot (readUrlSettingOverrides) and
813
+ // keeps it in the URL on every pose change, so it round-trips the way
814
+ // device and orientation do. without this a manual ?posture= appended
815
+ // after the app path lands inside the bundle param and never reaches the
816
+ // shell. non-foldable devices resolve it to closed at boot.
817
+ posture?: string
818
+ // portrayed os version (major.minor) stamped as ?osVersion=, which the
819
+ // shell pins at boot over the device profile's own version. an oracle on a
820
+ // newer os than the profile compares only when the sim runs that os era.
821
+ osVersion?: string
778
822
  resolvedBundle?: ResolvedDevBundle
779
823
  project?: ResolvedAppProject | null
780
824
  // deterministic-capture mode, stamped onto the engine URL as ?proof=1. the
@@ -810,10 +854,15 @@ export async function buildShellUrl(
810
854
  // fallback typeface, which then reads as a conformance diff.
811
855
  const project =
812
856
  opts.project ?? (await resolveAppProjectForBundleUrl(bundleUrlOf(resolved)))
813
- const stamped = await stampAppSplash(await stampAppFonts(resolved, project), project)
814
- if (!opts.device && !opts.proof) return stamped
857
+ const stamped = stampAppCatalog(
858
+ await stampAppSplash(await stampAppFonts(resolved, project), project),
859
+ project,
860
+ )
861
+ if (!opts.device && !opts.proof && !opts.posture && !opts.osVersion) return stamped
815
862
  const url = new URL(stamped)
816
863
  if (opts.device) url.searchParams.set('device', opts.device)
864
+ if (opts.posture) url.searchParams.set('posture', opts.posture)
865
+ if (opts.osVersion) url.searchParams.set('osVersion', opts.osVersion)
817
866
  if (opts.proof) url.searchParams.set('proof', '1')
818
867
  return url.toString()
819
868
  }
@@ -837,9 +886,11 @@ function deriveCurrentSimBaseUrl(
837
886
  // and `stampAppFonts` doesn't get the chance to re-derive against the
838
887
  // current bundle. drop it so the rebuild starts clean.
839
888
  url.searchParams.delete('appFonts')
840
- // appSplash is target-specific in the same way — drop it so the configured
841
- // splash re-derives against the current bundle.
889
+ // appSplash and appCatalog are target-specific in the same way: drop them
890
+ // so the configured splash and catalog re-derive against the current
891
+ // bundle.
842
892
  url.searchParams.delete('appSplash')
893
+ url.searchParams.delete('appCatalog')
843
894
  return url.toString()
844
895
  } catch {
845
896
  return fallback
@@ -1182,6 +1233,12 @@ export function printOpenedSim(
1182
1233
  console.log(JSON.stringify({ simId: sim.id, url: printableSimUrl }, null, 2))
1183
1234
  }
1184
1235
 
1236
+ function printAgentListenHint(quiet: boolean): void {
1237
+ if (!quiet) {
1238
+ console.log(' inspect messages: run `peach agent listen` when you finish your turn')
1239
+ }
1240
+ }
1241
+
1185
1242
  // after a successful open, agents almost always want to know what's on the
1186
1243
  // screen. running describe inline saves a separate roundtrip. tightly
1187
1244
  // bounded — a cold guest bundle takes 5-15s to fully route, so we don't
@@ -1343,6 +1400,8 @@ export async function runOpenCommand(
1343
1400
  '--profile',
1344
1401
  '--cdp-port',
1345
1402
  '--device',
1403
+ '--posture',
1404
+ '--os-version',
1346
1405
  '--viewport',
1347
1406
  ],
1348
1407
  })
@@ -1475,7 +1534,9 @@ export async function runOpenCommand(
1475
1534
  // native desktop surface. neither path opens the user's browser profile.
1476
1535
  const requestedDriverId = args.find((_, i) => args[i - 1] === '--driver') || ''
1477
1536
  if (requestedDriverId && !isDriverId(requestedDriverId)) {
1478
- console.error(` unknown driver "${requestedDriverId}" — run \`peach list --drivers\``)
1537
+ console.error(
1538
+ ` unknown driver "${requestedDriverId}" — run \`peach list --drivers\``,
1539
+ )
1479
1540
  rnxExit(1)
1480
1541
  }
1481
1542
  const driverId: DriverId = isDriverId(requestedDriverId)
@@ -1518,7 +1579,38 @@ export async function runOpenCommand(
1518
1579
  console.error(` known devices: ${listSelectableDeviceModels().join(', ')}`)
1519
1580
  rnxExit(1)
1520
1581
  }
1521
- const deviceSpec = deviceArg ? devices[deviceArg as DeviceModel] : undefined
1582
+
1583
+ // --posture <open|closed> seeds the fold posture for the opened sim,
1584
+ // stamped on the engine URL next to ?device= so it round-trips instead of
1585
+ // being swallowed into the bundle param. only foldable devices (iphone-duo)
1586
+ // have a second posture; anything else resolves to closed at boot.
1587
+ const postureArg = args.find((_, i) => args[i - 1] === '--posture')
1588
+ if (postureArg && postureArg !== 'open' && postureArg !== 'closed') {
1589
+ console.error(` peach open: --posture must be open or closed, got "${postureArg}"`)
1590
+ rnxExit(1)
1591
+ }
1592
+ // the window fits the box the browser stage fits the device into, which for
1593
+ // a foldable spans every posture and orientation, so the device draws at its
1594
+ // natural size in the pose it opens in instead of shrinking into a window
1595
+ // sized for one pose.
1596
+ const deviceFitBox = deviceArg
1597
+ ? resolveDeviceFitBox(
1598
+ deviceArg as DeviceModel,
1599
+ false,
1600
+ undefined,
1601
+ postureArg === 'open' ? 'open' : 'closed',
1602
+ true,
1603
+ )
1604
+ : undefined
1605
+
1606
+ // --os-version <major.minor> pins the portrayed os over the profile's.
1607
+ const osVersionArg = args.find((_, i) => args[i - 1] === '--os-version')
1608
+ if (osVersionArg && !/^\d+\.\d+$/.test(osVersionArg)) {
1609
+ console.error(
1610
+ ` peach open: --os-version must be <major>.<minor>, got "${osVersionArg}"`,
1611
+ )
1612
+ rnxExit(1)
1613
+ }
1522
1614
 
1523
1615
  // --viewport <WxH> sizes the driver-launched page explicitly (film and
1524
1616
  // promo captures frame the 3d stage in landscape). takes precedence over
@@ -1527,7 +1619,9 @@ export async function runOpenCommand(
1527
1619
  const viewportArg = args.find((_, i) => args[i - 1] === '--viewport')
1528
1620
  const viewportMatch = viewportArg ? /^(\d+)x(\d+)$/.exec(viewportArg) : null
1529
1621
  if (viewportArg && !viewportMatch) {
1530
- console.error(` peach open: --viewport must be <width>x<height>, got "${viewportArg}"`)
1622
+ console.error(
1623
+ ` peach open: --viewport must be <width>x<height>, got "${viewportArg}"`,
1624
+ )
1531
1625
  rnxExit(1)
1532
1626
  }
1533
1627
  const explicitViewport = viewportMatch
@@ -1537,6 +1631,8 @@ export async function runOpenCommand(
1537
1631
  const buildShellUrlOpts: BuildShellUrlOptions = {
1538
1632
  hot: hotMode,
1539
1633
  device: deviceArg,
1634
+ posture: postureArg,
1635
+ osVersion: osVersionArg,
1540
1636
  resolvedBundle,
1541
1637
  project,
1542
1638
  proof: args.includes('--proof'),
@@ -1713,6 +1809,7 @@ export async function runOpenCommand(
1713
1809
  quiet,
1714
1810
  )
1715
1811
  await maybeDescribeAfterOpen(parsed.wsPort, targetSim.id, args)
1812
+ printAgentListenHint(quiet)
1716
1813
  return { simId: targetSim.id, launched: false }
1717
1814
  }
1718
1815
  } finally {
@@ -1752,8 +1849,8 @@ export async function runOpenCommand(
1752
1849
  ownerPid,
1753
1850
  viewport:
1754
1851
  explicitViewport ??
1755
- (deviceSpec
1756
- ? { width: deviceSpec.width + 120, height: deviceSpec.height + 60 }
1852
+ (deviceFitBox
1853
+ ? { width: deviceFitBox.width + 120, height: deviceFitBox.height + 60 }
1757
1854
  : undefined),
1758
1855
  connectTimeoutMs: connectWait.hostTimeoutMs,
1759
1856
  })
@@ -1791,6 +1888,7 @@ export async function runOpenCommand(
1791
1888
  signalDriverLaunchConnected(result)
1792
1889
  printOpenedSim(openUrl, match, `${driver.name} driver`, quiet)
1793
1890
  await maybeDescribeAfterOpen(parsed.wsPort, match.id, args)
1891
+ printAgentListenHint(quiet)
1794
1892
  return { simId: match.id, launched: true }
1795
1893
  }
1796
1894
 
@@ -1859,10 +1957,6 @@ export async function runUseCommand(args: string[], opts: ControlCommandOptions
1859
1957
  await runUseLikeCommand(args, opts)
1860
1958
  }
1861
1959
 
1862
- export async function runFocusCommand(args: string[], opts: ControlCommandOptions = {}) {
1863
- await runUseLikeCommand(args, opts)
1864
- }
1865
-
1866
1960
  export async function runClaimCommand(args: string[], opts: ControlCommandOptions = {}) {
1867
1961
  const parsed = parseBridgeCliArgs(args, {
1868
1962
  port: opts.port,
@@ -30,6 +30,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
30
30
  import { dirname, resolve } from 'node:path'
31
31
  import { WebSocket } from 'ws'
32
32
  import { getCliVersion } from '../../src/cli-version'
33
+ import { sleep } from '../../src/sleep'
33
34
  import { IS_STANDALONE } from '../standalone'
34
35
 
35
36
  interface ProfileOptions {
@@ -37,8 +38,6 @@ interface ProfileOptions {
37
38
  verbose?: boolean
38
39
  }
39
40
 
40
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
41
-
42
41
  // compact CDP client with flatten-mode sessionId routing — one ws to the page
43
42
  // target carries the page session plus every auto-attached worker session.
44
43
  class Cdp {
@@ -495,6 +494,7 @@ export async function runCpuProfile(
495
494
  const gate = new Promise((resolve) => {
496
495
  releaseGate = resolve
497
496
  })
497
+ Reflect.set(globalThis, '__sootsimCpuProfileCaptureComplete', false)
498
498
  Reflect.set(globalThis, '__sootsimCpuProfileBarrierState', state)
499
499
  Reflect.set(globalThis, '__sootsimCpuProfileBarrier', async (marker) => {
500
500
  if (state.marker === null) {
@@ -714,14 +714,23 @@ export async function runCpuProfile(
714
714
  )
715
715
  // querying the page while five high-frequency profilers are still active
716
716
  // can itself time out and lengthen the requested sampling interval. stop
717
- // first, then read the already-recorded workload completion evidence.
718
- const workloadEvidence = interactionBarrierEnabled
719
- ? readCpuProfileWorkloadEvidence(
720
- await evaluatePage(
721
- `Reflect.get(globalThis, '__sootsimInteractionPartialResult') ?? null`,
722
- ),
723
- )
724
- : null
717
+ // first, then read the already-recorded workload completion evidence. a
718
+ // failed read must not skip the profile/manifest writes below: the
719
+ // captured profiles are preserved and the manifest records the explicit
720
+ // evidence error, which already forces complete:false downstream.
721
+ let workloadEvidence: CpuProfileWorkloadEvidence | null = null
722
+ let workloadEvidenceError: string | null = null
723
+ try {
724
+ workloadEvidence = interactionBarrierEnabled
725
+ ? readCpuProfileWorkloadEvidence(
726
+ await evaluatePage(
727
+ `Reflect.get(globalThis, '__sootsimInteractionPartialResult') ?? null`,
728
+ ),
729
+ )
730
+ : null
731
+ } catch (error) {
732
+ workloadEvidenceError = error instanceof Error ? error.message : String(error)
733
+ }
725
734
  const workloadActionEpochMs =
726
735
  workloadEvidence?.captures.map((capture) => capture.actionEpochMs) ?? []
727
736
 
@@ -835,6 +844,7 @@ export async function runCpuProfile(
835
844
  captureWindowEndedAt: new Date(captureWindowEndedAtEpochMs).toISOString(),
836
845
  interactionBarrier,
837
846
  workloadEvidence,
847
+ workloadEvidenceError,
838
848
  workloadActionEpochMs,
839
849
  manifest,
840
850
  },
@@ -857,6 +867,9 @@ export async function runCpuProfile(
857
867
  return complete ? 0 : 1
858
868
  } finally {
859
869
  if (interactionBarrierInstalled) {
870
+ await evaluatePage(
871
+ `Reflect.set(globalThis, '__sootsimCpuProfileCaptureComplete', true)`,
872
+ ).catch(() => {})
860
873
  await releaseInteractionBarrier('cleanup').catch(() => {})
861
874
  }
862
875
  cdp.close()
@@ -36,6 +36,7 @@ import {
36
36
  } from '../../src/home-paths'
37
37
  import { rnxPublicBrand } from '../../src/public-brand'
38
38
  import { printCommandHelp } from '../help'
39
+ import { escapeXml } from '../maestro-report'
39
40
  import { rnxExit } from '../run-rnx'
40
41
  import { rnxSelfInvocation, type RnxSelfInvocation } from '../self-invocation'
41
42
  import { ensureDaemonApp } from './daemon-mac-app'
@@ -167,15 +168,6 @@ WantedBy=default.target
167
168
  `
168
169
  }
169
170
 
170
- function escapeXml(value: string): string {
171
- return value
172
- .replace(/&/g, '&amp;')
173
- .replace(/</g, '&lt;')
174
- .replace(/>/g, '&gt;')
175
- .replace(/"/g, '&quot;')
176
- .replace(/'/g, '&apos;')
177
- }
178
-
179
171
  function shellQuote(value: string): string {
180
172
  return /[^\w@%+=:,./-]/.test(value) ? `'${value.replace(/'/g, `'\\''`)}'` : value
181
173
  }
@@ -198,21 +198,28 @@ export async function runDetox(args: string[], opts: RunDetoxOpts = {}): Promise
198
198
  jestArgs.push('--config', tempConfig)
199
199
  }
200
200
 
201
- // hosted run registration (org dashboard pass/fail row). when it applies,
202
- // capture jest's structured results via --json --outputFile so the row can
203
- // carry test counts + the first failure; console output stays untouched.
201
+ // proof runs need the test count too: jest exits zero when a name filter
202
+ // skips every test, which would otherwise report an empty proof as passing.
203
+ const proofCapture =
204
+ process.env.CONFORMANCE_PROOF === '1' ||
205
+ !!process.env.CONFORMANCE_PROOF_DIR ||
206
+ process.env.CONFORMANCE_TARGET === 'sootsim'
207
+
208
+ // hosted run registration (org dashboard pass/fail row) also uses jest's
209
+ // structured results for test counts and the first failure.
204
210
  const auth = resolveCliAuth()
205
211
  const wantsRegistration =
206
212
  !watch &&
207
213
  (process.env.PEACH_REQUIRE_REGISTRATION === 'true' ||
208
214
  shouldRegisterRun({ uploadedShare: false, auth }))
215
+ const needsResults = !watch && (proofCapture || wantsRegistration)
209
216
  let resultsFile: string | null =
210
217
  getFlag('--outputFile') || process.env.SOOTSIM_JEST_OUTPUT_FILE || null
211
- if ((process.env.SOOTSIM_JEST_JSON === '1' || wantsRegistration) && !hasFlag('--json'))
218
+ if ((process.env.SOOTSIM_JEST_JSON === '1' || needsResults) && !hasFlag('--json'))
212
219
  jestArgs.push('--json')
213
220
  if (!getFlag('--outputFile') && process.env.SOOTSIM_JEST_OUTPUT_FILE) {
214
221
  jestArgs.push('--outputFile', process.env.SOOTSIM_JEST_OUTPUT_FILE)
215
- } else if (!resultsFile && wantsRegistration) {
222
+ } else if (!resultsFile && needsResults) {
216
223
  resultsFile = join(tmpdir(), `rnx-detox-results-${process.pid}.json`)
217
224
  jestArgs.push('--outputFile', resultsFile)
218
225
  }
@@ -235,10 +242,6 @@ export async function runDetox(args: string[], opts: RunDetoxOpts = {}): Promise
235
242
  // then visits as tagged heap pointers. disable both Maglev and Sparkplug so
236
243
  // execution stays on the stable Ignition + TurboFan pipeline under Node 24.
237
244
  // normal user detox runs retain their node defaults and resolve jest through npx.
238
- const proofCapture =
239
- process.env.CONFORMANCE_PROOF === '1' ||
240
- !!process.env.CONFORMANCE_PROOF_DIR ||
241
- process.env.CONFORMANCE_TARGET === 'sootsim'
242
245
  const command = proofCapture ? 'node' : 'npx'
243
246
  const commandArgs = proofCapture
244
247
  ? [
@@ -294,16 +297,21 @@ export async function runDetox(args: string[], opts: RunDetoxOpts = {}): Promise
294
297
  if (tempConfig) unlinkSync(tempConfig)
295
298
  }
296
299
 
300
+ const results =
301
+ resultsFile && existsSync(resultsFile) && statSync(resultsFile).mtimeMs >= startedAt
302
+ ? readJestResults(resultsFile)
303
+ : null
304
+ // a selection that skipped every test is not a passing suite. a proof run
305
+ // without structured results has no test-count verdict either.
306
+ if (code === 0 && results?.numPassedTests === 0) {
307
+ console.error(' error: no tests were selected or executed')
308
+ code = 1
309
+ } else if (code === 0 && proofCapture && !watch && !results) {
310
+ console.error(' error: jest did not report test results')
311
+ code = 1
312
+ }
313
+
297
314
  if (wantsRegistration) {
298
- const results =
299
- resultsFile && existsSync(resultsFile) && statSync(resultsFile).mtimeMs >= startedAt
300
- ? readJestResults(resultsFile)
301
- : null
302
- // a selection that skipped every test is not a passing suite.
303
- if (code === 0 && results && results.numPassedTests === 0) {
304
- console.error(' error: no tests were selected or executed')
305
- code = 1
306
- }
307
315
  const origin = await resolveDefaultUploadOrigin()
308
316
  const run = await registerRun({
309
317
  origin,
@@ -1,13 +1,17 @@
1
1
  import {
2
2
  devices,
3
+ DUO_REST_PROGRESS,
3
4
  getDevice,
4
5
  listSelectableDeviceModels,
5
6
  type DeviceModel,
7
+ type DuoFoldRest,
6
8
  } from 'peach-engine/settings'
9
+ import { SOOTSIM_SHELL_COMMAND_NAME } from '../../src/menu'
7
10
  import { rnxExit } from '../run-rnx'
8
11
  import {
9
12
  callInBridge,
10
13
  createBridgeFromParsed,
14
+ evalInBridge,
11
15
  parseBridgeCliArgs,
12
16
  type ParsedBridgeCliArgs,
13
17
  } from '../ws-bridge'
@@ -33,11 +37,14 @@ usage:
33
37
  peach device get
34
38
  peach device set <model> [--sim <id>]
35
39
  peach device list
40
+ peach device posture [closed|book|open|laptop|tent|toggle] [--sim <id>]
36
41
 
37
42
  examples:
38
43
  peach device
39
44
  peach device set iphone-16
40
45
  peach device set iphone-16-pro-max --sim a7
46
+ peach device posture open
47
+ peach device posture laptop
41
48
  `)
42
49
  }
43
50
 
@@ -59,7 +66,16 @@ async function readCurrentDevice(
59
66
  )) ?? { deviceModel: null }
60
67
  const model = typeof settings.deviceModel === 'string' ? settings.deviceModel : null
61
68
  if (!model || !(model in devices)) return null
62
- const spec = getDevice(model as DeviceModel)
69
+ const spec = getDevice(
70
+ model as DeviceModel,
71
+ settings.posture === 'open' || settings.posture === 'closed'
72
+ ? settings.posture
73
+ : undefined,
74
+ settings.orientation === 'portrait' || settings.orientation === 'landscape'
75
+ ? settings.orientation
76
+ : undefined,
77
+ settings.landscapeSide === 'right' ? 'right' : 'left',
78
+ )
63
79
  return {
64
80
  model,
65
81
  width: spec.width,
@@ -72,6 +88,37 @@ async function readCurrentDevice(
72
88
  }
73
89
  }
74
90
 
91
+ function isDuoRest(value: unknown): value is DuoFoldRest {
92
+ return typeof value === 'string' && value in DUO_REST_PROGRESS
93
+ }
94
+
95
+ const DUO_RESTS = Object.keys(DUO_REST_PROGRESS).filter(isDuoRest)
96
+
97
+ type CurrentPostureSnapshot = {
98
+ model: DeviceModel
99
+ rest: DuoFoldRest | null
100
+ } | null
101
+
102
+ // the binary display posture cannot name laptop, book, or tent. only the fold
103
+ // bus names the rest; it is null while a drag holds the hinge between rests.
104
+ async function readCurrentPosture(
105
+ parsed: ParsedBridgeCliArgs,
106
+ ): Promise<CurrentPostureSnapshot> {
107
+ const bridge = createBridgeFromParsed(parsed)
108
+ try {
109
+ const settings = (await callInBridge<Record<string, unknown> | null>(
110
+ bridge,
111
+ 'SootSim.bridges.settings.get',
112
+ )) ?? { deviceModel: null }
113
+ const model = typeof settings.deviceModel === 'string' ? settings.deviceModel : null
114
+ if (!model || !(model in devices)) return null
115
+ const rest = await evalInBridge(bridge, 'globalThis.__sootsimDuoFold?.rest ?? null')
116
+ return { model: model as DeviceModel, rest: isDuoRest(rest) ? rest : null }
117
+ } finally {
118
+ bridge.close()
119
+ }
120
+ }
121
+
75
122
  export async function runDeviceCommand(args: string[], opts: DeviceCommandOptions) {
76
123
  if (args.includes('--help') || args.includes('-h')) {
77
124
  usage()
@@ -128,6 +175,87 @@ export async function runDeviceCommand(args: string[], opts: DeviceCommandOption
128
175
  return
129
176
  }
130
177
 
178
+ if (subcommand === 'posture') {
179
+ const parsed = parseBridgeCliArgs(args, { port: opts.port })
180
+ // the want is optional, so read it past the bridge flags: raw args[1]
181
+ // would take --sim as the want on a bare `posture --sim <id>` read.
182
+ const raw = parsed.positional[1]
183
+ const want = raw === undefined || raw === 'toggle' || isDuoRest(raw) ? raw : null
184
+ if (want === null) {
185
+ console.error(` error: device posture expects ${DUO_RESTS.join(', ')}, or toggle`)
186
+ rnxExit(1)
187
+ }
188
+ const current = await readCurrentPosture(parsed)
189
+ if (!current) {
190
+ console.error(' error: could not read current posture from the target sim')
191
+ rnxExit(1)
192
+ }
193
+ if (!devices[current.model].supportedPostures) {
194
+ console.error(
195
+ ` error: ${current.model} has a single posture — fold posture is only available on iphone-duo`,
196
+ )
197
+ rnxExit(1)
198
+ }
199
+ if (!want) {
200
+ if (!current.rest) {
201
+ console.error(
202
+ ' error: the Duo fold has no current rest; choose a posture explicitly',
203
+ )
204
+ rnxExit(1)
205
+ }
206
+ console.log(` ${current.rest} (${current.model})`)
207
+ return
208
+ }
209
+ if (want === 'toggle' && !current.rest) {
210
+ console.error(
211
+ ' error: the Duo fold has no current rest; choose a posture explicitly',
212
+ )
213
+ rnxExit(1)
214
+ }
215
+ const target: DuoFoldRest =
216
+ want === 'toggle' ? (current.rest === 'closed' ? 'open' : 'closed') : want
217
+ if (target === current.rest) {
218
+ console.log(` kept posture: ${current.rest} (${current.model})`)
219
+ return
220
+ }
221
+ const bridge = createBridgeFromParsed(parsed)
222
+ try {
223
+ // the typed command the duo's pose buttons dispatch: the fold engine
224
+ // sweeps the hinge there, turning the body for laptop and tent, so the
225
+ // app sees the same fold and reserved regions as from the rail. the
226
+ // command resolves once the fold bus reports the hinge resting there.
227
+ const settled = await evalInBridge<boolean>(
228
+ bridge,
229
+ `new Promise((resolve) => {
230
+ const deadline = Date.now() + 10000
231
+ const settled = () => {
232
+ const fold = globalThis.__sootsimDuoFold
233
+ return !!fold && fold.rest === ${JSON.stringify(target)} && !fold.active &&
234
+ Math.abs(fold.progress - ${DUO_REST_PROGRESS[target]}) < 1e-6
235
+ }
236
+ const check = () => {
237
+ if (settled()) resolve(true)
238
+ else if (Date.now() > deadline) resolve(false)
239
+ else setTimeout(check, 50)
240
+ }
241
+ window.dispatchEvent(new CustomEvent(${JSON.stringify(SOOTSIM_SHELL_COMMAND_NAME)}, { detail: { type: 'fold-to-rest', rest: ${JSON.stringify(target)} } }))
242
+ check()
243
+ })`,
244
+ { acquireLock: true },
245
+ )
246
+ if (!settled) {
247
+ console.error(
248
+ ` error: the fold did not come to rest at ${target} within 10s (${current.model})`,
249
+ )
250
+ rnxExit(1)
251
+ }
252
+ console.log(` set posture: ${target} (${current.model})`)
253
+ } finally {
254
+ bridge.close()
255
+ }
256
+ return
257
+ }
258
+
131
259
  usage()
132
260
  console.error(` unknown subcommand: ${subcommand}`)
133
261
  rnxExit(1)