peachsim 0.1.626 → 0.1.627

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 (82) hide show
  1. package/README.md +3 -2
  2. package/cli/commands/box/bundle-plane.ts +7 -2
  3. package/cli/commands/box.ts +9 -2
  4. package/cli/commands/control.ts +32 -1
  5. package/cli/commands/flow.ts +17 -6
  6. package/cli/commands/maestro-generate.ts +5 -12
  7. package/cli/commands/maestro.ts +16 -6
  8. package/cli/commands/platform.ts +1 -0
  9. package/cli/commands/test.ts +14 -0
  10. package/cli/commands/upload.ts +18 -8
  11. package/cli/drivers/playwright-sim-host.ts +10 -3
  12. package/cli/drivers/playwright.ts +8 -5
  13. package/cli/run-rnx.ts +8 -4
  14. package/dist-lib/agent-daemon-client.cjs +2 -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 +4 -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 +18 -8
  29. package/dist-lib/capture-contract.mjs +18 -8
  30. package/dist-lib/cli-constants.cjs +1 -1
  31. package/dist-lib/cloud-contract.cjs +1 -1
  32. package/dist-lib/cloud-contract.mjs +1 -1
  33. package/dist-lib/cloud-test-args.cjs +1 -1
  34. package/dist-lib/cloud-test-args.mjs +1 -1
  35. package/dist-lib/cloud.cjs +1 -1
  36. package/dist-lib/cloud.mjs +1 -1
  37. package/dist-lib/config.cjs +1 -1
  38. package/dist-lib/detox/index.cjs +1 -1
  39. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  40. package/dist-lib/home-paths.cjs +1 -1
  41. package/dist-lib/host/bridge-host.cjs +239 -102
  42. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  43. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  44. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  45. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  46. package/dist-lib/host/websocket-proxy.cjs +1 -1
  47. package/dist-lib/index.cjs +19 -8
  48. package/dist-lib/jump-to-source-babel.cjs +1 -1
  49. package/dist-lib/jump-to-source-native.cjs +1 -1
  50. package/dist-lib/menu.cjs +1 -1
  51. package/dist-lib/menu.mjs +1 -1
  52. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  53. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  54. package/dist-lib/metro-production-bundle.cjs +1 -1
  55. package/dist-lib/metro-production-bundle.mjs +1 -1
  56. package/dist-lib/metro.cjs +1 -1
  57. package/dist-lib/profiles.cjs +1 -1
  58. package/dist-lib/public-brand.cjs +1 -1
  59. package/dist-lib/react-native-host-modules.cjs +1 -1
  60. package/dist-lib/react-native-host-modules.mjs +1 -1
  61. package/dist-lib/render-mode.cjs +1 -1
  62. package/dist-lib/scripts/dev-server-scanner.cjs +215 -28
  63. package/dist-lib/sdk.cjs +1 -1
  64. package/dist-lib/sdk.mjs +1 -1
  65. package/dist-lib/skills.cjs +292 -128
  66. package/dist-lib/swift-compile.cjs +1 -1
  67. package/dist-lib/swift-compile.mjs +1 -1
  68. package/dist-lib/swift-package.cjs +1 -1
  69. package/dist-lib/swift-package.mjs +1 -1
  70. package/dist-lib/swift-submit.cjs +1 -1
  71. package/dist-lib/swift-submit.mjs +1 -1
  72. package/dist-lib/swift.cjs +14488 -14349
  73. package/dist-lib/vite-kotlin.cjs +1 -1
  74. package/dist-lib/vite-swift.cjs +1 -1
  75. package/dist-lib/vite.cjs +2 -1
  76. package/install.mjs +16 -0
  77. package/install.sh +199 -0
  78. package/package.json +6 -1
  79. package/scripts/dev-server-scanner.ts +77 -46
  80. package/src/bridge-constants.ts +11 -0
  81. package/src/capture-contract.ts +40 -7
  82. package/src/host/bridge-host.ts +24 -103
package/README.md CHANGED
@@ -8,7 +8,8 @@ integration library used to drive a browser-native React Native simulator.
8
8
  The CLI and library are separate public artifacts. The recommended installer
9
9
  downloads one Bun-compiled `peach` executable that needs no Node.js, npm, or Bun
10
10
  installation. The `peachsim` npm package contains bundler plugins, testing
11
- drivers, and library exports, with no command-line binary.
11
+ drivers, and library exports; its only command, `npx peachsim`, runs that same
12
+ installer from the copy of `install.sh` it ships.
12
13
 
13
14
  The executable updates itself with `peach upgrade`. Successful interactive human
14
15
  commands refresh a cached stable CLI version in a detached process at most once
@@ -220,7 +221,7 @@ bridge always addresses the **shell worker**, never an undifferentiated sim.
220
221
  ## Getting started
221
222
 
222
223
  ```sh
223
- curl -fsSL https://peachsim.com/install.sh | sh
224
+ curl -fsSL https://peachsim.com/install.sh | sh # or: npx peachsim
224
225
  peach # open ConnectRN and choose a running app in the simulator
225
226
  peach open /settings # dispatch a React Native deep link into that app
226
227
  ```
@@ -8,15 +8,20 @@
8
8
  // the bundle is split here rather than by the caller, so the one place that
9
9
  // decides where the parts go is the one place that decides how big they are.
10
10
 
11
- import { PREBUILT_BUNDLE_PART_BYTES, prebuiltBundlePartPath } from '@peach/box/template'
11
+ import {
12
+ PREBUILT_BUNDLE_DEVICE_PATH,
13
+ PREBUILT_BUNDLE_PART_BYTES,
14
+ prebuiltBundlePartPath,
15
+ } from '@peach/box/template'
12
16
  import type { ProjectFileMeta, ProjectFilePlane } from '@peach/box/plane'
13
17
 
14
18
  export class BundleFilePlane implements ProjectFilePlane {
15
19
  private readonly parts = new Map<string, Uint8Array>()
16
20
  private readonly entries = new Map<string, ProjectFileMeta>()
17
21
 
18
- constructor(bundle: Uint8Array) {
22
+ constructor(bundle: Uint8Array, device: string | null) {
19
23
  if (bundle.byteLength === 0) throw new Error('a Box bundle cannot be empty')
24
+ if (device) this.record(PREBUILT_BUNDLE_DEVICE_PATH, new TextEncoder().encode(device))
20
25
  for (
21
26
  let offset = 0, index = 0;
22
27
  offset < bundle.byteLength;
@@ -527,6 +527,8 @@ function cloudBoxClient(options: {
527
527
  */
528
528
  export async function createPrebuiltBundleBox(options: {
529
529
  bundle: Uint8Array
530
+ // the simulator's device model, or null for the Box page's default
531
+ device: string | null
530
532
  name: string
531
533
  endpoint: string
532
534
  apiOrigin: string
@@ -537,7 +539,7 @@ export async function createPrebuiltBundleBox(options: {
537
539
  name: options.name,
538
540
  stack: prebuiltBundleBoxTemplate.stack,
539
541
  template: BOX_TEMPLATES.resolve({ name: prebuiltBundleBoxTemplate.name }).provenance,
540
- source: new BundleFilePlane(options.bundle),
542
+ source: new BundleFilePlane(options.bundle, options.device),
541
543
  })
542
544
  }
543
545
 
@@ -653,7 +655,12 @@ async function runCloudBoxCreate(
653
655
  try {
654
656
  const box =
655
657
  'bundle' in source
656
- ? await createPrebuiltBundleBox({ ...connection, bundle: source.bundle, name })
658
+ ? await createPrebuiltBundleBox({
659
+ ...connection,
660
+ bundle: source.bundle,
661
+ device: null,
662
+ name,
663
+ })
657
664
  : await cloudBoxClient(connection).create({
658
665
  name,
659
666
  stack: template.stack,
@@ -840,6 +840,9 @@ export interface BuildShellUrlOptions {
840
840
  // screen holding a focused text field never reaches three identical idle
841
841
  // frames, so the screenshot guard burns all 12 attempts and then fails.
842
842
  proof?: boolean
843
+ // launch-time environment variables, stamped as repeated ?env=KEY%3DVALUE.
844
+ // see the --env flag above.
845
+ env?: string[]
843
846
  }
844
847
 
845
848
  function bundleUrlOf(urlStr: string): string | undefined {
@@ -867,12 +870,20 @@ export async function buildShellUrl(
867
870
  await stampAppSplash(await stampAppFonts(resolved, project), project),
868
871
  project,
869
872
  )
870
- if (!opts.device && !opts.proof && !opts.posture && !opts.osVersion) return stamped
873
+ if (
874
+ !opts.device &&
875
+ !opts.proof &&
876
+ !opts.posture &&
877
+ !opts.osVersion &&
878
+ !opts.env?.length
879
+ )
880
+ return stamped
871
881
  const url = new URL(stamped)
872
882
  if (opts.device) url.searchParams.set('device', opts.device)
873
883
  if (opts.posture) url.searchParams.set('posture', opts.posture)
874
884
  if (opts.osVersion) url.searchParams.set('osVersion', opts.osVersion)
875
885
  if (opts.proof) url.searchParams.set('proof', '1')
886
+ for (const entry of opts.env ?? []) url.searchParams.append('env', entry)
876
887
  return url.toString()
877
888
  }
878
889
 
@@ -1412,6 +1423,7 @@ export async function runOpenCommand(
1412
1423
  '--posture',
1413
1424
  '--os-version',
1414
1425
  '--viewport',
1426
+ '--env',
1415
1427
  ],
1416
1428
  })
1417
1429
  const requestedProfileId = args.find((_, i) => args[i - 1] === '--profile')
@@ -1621,6 +1633,24 @@ export async function runOpenCommand(
1621
1633
  rnxExit(1)
1622
1634
  }
1623
1635
 
1636
+ // --env <KEY=VALUE> (repeatable) sets a launch-time environment variable,
1637
+ // the peach analogue of an Xcode scheme env var or SIMCTL_CHILD_<VAR>:
1638
+ // stamped as repeated ?env=, carried on AppLoadRequest, and read into a
1639
+ // Swift guest's ProcessInfo.environment (WASI environ) exactly as it would
1640
+ // read a real launch env. the guest app opts in by reading the key itself;
1641
+ // no other surface is affected.
1642
+ const envArgs: string[] = []
1643
+ for (let i = 0; i < args.length; i++) {
1644
+ if (args[i] !== '--env') continue
1645
+ const raw = args[i + 1]
1646
+ const eqIndex = raw ? raw.indexOf('=') : -1
1647
+ if (!raw || eqIndex <= 0) {
1648
+ console.error(` peach open: --env expects KEY=VALUE, got "${raw ?? ''}"`)
1649
+ rnxExit(1)
1650
+ }
1651
+ envArgs.push(raw)
1652
+ }
1653
+
1624
1654
  // --viewport <WxH> sizes the driver-launched page explicitly (film and
1625
1655
  // promo captures frame the 3d stage in landscape). takes precedence over
1626
1656
  // the device-derived window; without either, the driver keeps its own
@@ -1645,6 +1675,7 @@ export async function runOpenCommand(
1645
1675
  resolvedBundle,
1646
1676
  project,
1647
1677
  proof: args.includes('--proof'),
1678
+ env: envArgs.length ? envArgs : undefined,
1648
1679
  }
1649
1680
 
1650
1681
  // resolve the shell base URL from the bridge port we settled on, so the page
@@ -21,7 +21,7 @@ import { parseFlowFile, validateFlowFile } from '../flow-file'
21
21
  import { FlowLiveStatusReporter } from '../flow-live-status'
22
22
  import { finalizeFlowSession, keepFlowCandidate, startFlowSession } from '../flow-session'
23
23
  import { GLOBAL_FLAG_TAKES_VALUE } from '../parse-args'
24
- import { rnxExit } from '../run-rnx'
24
+ import { PeachExit, rnxExit } from '../run-rnx'
25
25
  import {
26
26
  BRIDGE_VALUE_FLAGS,
27
27
  createBridge,
@@ -155,6 +155,13 @@ export function getLastFlowPreviewUploadResult(): UploadResult | null {
155
155
  return lastPreviewUploadResult
156
156
  }
157
157
 
158
+ // why the last flow's preview upload failed, as the upload printed it
159
+ let lastPreviewUploadError: string | null = null
160
+
161
+ export function getLastFlowPreviewUploadError(): string | null {
162
+ return lastPreviewUploadError
163
+ }
164
+
158
165
  // per-step trace of the most recent playback, preview or not — callers
159
166
  // Maestro callers fold this into their run registration.
160
167
  let lastFlowTraceSteps: SootSimFlowTraceStep[] = []
@@ -211,6 +218,7 @@ export async function runFlowPlayback(
211
218
  hooks: { beforeSteps?: () => Promise<void> } = {},
212
219
  ): Promise<number> {
213
220
  lastPreviewUploadResult = null
221
+ lastPreviewUploadError = null
214
222
  lastFlowTraceSteps = []
215
223
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
216
224
  console.log(`
@@ -278,7 +286,8 @@ options:
278
286
  public link origin for --preview (default: peachsim.com
279
287
  for prod uploads)
280
288
  --billing-kind test_run
281
- meter the chained preview upload as a test run
289
+ the chained upload is a hosted test job's recording,
290
+ covered by the job's minutes (set by the hosted recorder)
282
291
  --owner <org> associate preview upload with a linked org repo
283
292
  --repo <repo> repo name for --owner; user must belong to the team
284
293
 
@@ -631,9 +640,9 @@ flow extension:
631
640
  // pr-preview run, so allow it for preview uploads. local flow recording
632
641
  // writes a file and matches `peach record --mode video`.
633
642
  allowGitHubRecording: preview,
634
- // a test_run bills its recording from balance at finalize (preflighted
635
- // at upload init), not from a plan entitlement: the plan gate would
636
- // refuse every pay-as-you-go hosted test.
643
+ // a test_run recording is covered by its hosted job's minutes, not by a
644
+ // plan entitlement: the plan gate would refuse every pay-as-you-go
645
+ // hosted test.
637
646
  requireRecordingEntitlement: preview && previewBillingKind !== 'test_run',
638
647
  // re-arm the event recorder after every launchApp-with-reload. the
639
648
  // recorder lives in-page state and gets wiped by window.location.reload.
@@ -947,7 +956,9 @@ flow extension:
947
956
  const { runUpload } = await import('./upload')
948
957
  lastPreviewUploadResult = await runUpload(uploadArgs, {})
949
958
  } catch (err: any) {
950
- console.error(` preview upload failed: ${err?.message || err}`)
959
+ lastPreviewUploadError =
960
+ err instanceof PeachExit && err.reason ? err.reason : String(err?.message || err)
961
+ console.error(` preview upload failed: ${lastPreviewUploadError}`)
951
962
  exitCode = 1
952
963
  }
953
964
  }
@@ -4,6 +4,7 @@ import { mkdtempSync, writeFileSync } from 'fs'
4
4
  import { tmpdir } from 'os'
5
5
  import { join } from 'path'
6
6
  import yaml from 'yaml'
7
+ import { isRecord } from '../../src/is-record'
7
8
  import { authHeaderValue, resolveCliAuth } from '../auth'
8
9
  import { registerRun, stepSummaryFromTrace } from '../run-registry'
9
10
  import { createBridgeFromParsed, parseBridgeCliArgs } from '../ws-bridge'
@@ -18,7 +19,6 @@ import {
18
19
  } from './flow'
19
20
  import { inspectDescribe } from './inspect/core'
20
21
  import { resolveDefaultUploadOrigin } from './upload'
21
- import { isRecord } from '../../src/is-record'
22
22
 
23
23
  const DEFAULT_TEST_MODEL = process.env.PEACH_TEST_MODEL || 'deepseek-v4-flash'
24
24
  const DESCRIBE_CONTEXT_LIMIT = 18_000
@@ -239,14 +239,7 @@ export function buildFlowArgs(
239
239
  flowPath: string,
240
240
  origin: string,
241
241
  ) {
242
- const out = [
243
- flowPath,
244
- '--preview',
245
- '--preview-origin',
246
- origin,
247
- '--billing-kind',
248
- 'test_run',
249
- ]
242
+ const out = [flowPath, '--preview', '--preview-origin', origin]
250
243
  for (let i = 0; i < args.length; i++) {
251
244
  if (i === promptIndex) continue
252
245
  const arg = args[i]
@@ -285,10 +278,10 @@ export function findUnexpectedGenerateArgs(
285
278
  if (i === promptIndex) continue
286
279
  const arg = args[i]
287
280
  if (!arg) continue
288
- // generate writes --billing-kind onto the flow argv itself and getFlag
289
- // takes the first occurrence, so a caller's value loses to it in silence.
281
+ // a generated flow's recording is an ordinary preview upload; the only
282
+ // billing kind is a hosted test job's, which the recorder sets.
290
283
  if (arg === '--billing-kind') {
291
- problems.push({ arg, message: `${arg} is set by peach maestro generate` })
284
+ problems.push({ arg, message: `${arg} is only for hosted test jobs` })
292
285
  i += 1
293
286
  continue
294
287
  }
@@ -26,6 +26,7 @@ import { RnxExit } from '../run-rnx'
26
26
  import {
27
27
  findUnexpectedFlowArgs,
28
28
  FLOW_ARG_VALUE_FLAGS,
29
+ getLastFlowPreviewUploadError,
29
30
  getLastFlowPreviewUploadResult,
30
31
  getLastFlowTraceSteps,
31
32
  hoistLeadingSimFlag,
@@ -728,6 +729,10 @@ export async function runMaestro(
728
729
  console.error(` error: ${target} not found`)
729
730
  return 1
730
731
  }
732
+ // cwd is the real path, so name every flow from the target's real path too:
733
+ // a target under a symlink (macOS /tmp, /var/folders) otherwise reports as
734
+ // ../../../var/folders/... instead of flows/login.yaml.
735
+ target = fs.realpathSync(target)
731
736
 
732
737
  let files: string[]
733
738
  let workspaceConfig: MaestroWorkspaceConfig = {}
@@ -886,24 +891,29 @@ export async function runMaestro(
886
891
  // the upload failed, so the flow never disappears from the collected
887
892
  // entries.
888
893
  if (args.includes('--preview')) {
894
+ const uploaded = Boolean(upload?.shareId || upload?.previewUrl)
889
895
  const previewPayload = {
890
896
  flow: path.relative(cwd, file) || file,
891
897
  previewId: upload?.shareId ?? null,
892
898
  previewUrl: upload?.previewUrl ?? null,
893
- // the failing step as the junit report names it, on one line
899
+ // the failing step as the junit report names it, on one line, else
900
+ // why the required recording upload failed
894
901
  failure:
895
- exit === 0
902
+ exit === 0 && uploaded
896
903
  ? null
897
- : failureFromTraceSteps(traceSteps, 'flow playback failed').body.split(
898
- '\n',
899
- )[0],
904
+ : failureFromTraceSteps(
905
+ traceSteps,
906
+ uploaded
907
+ ? 'flow playback failed'
908
+ : `recording upload failed: ${getLastFlowPreviewUploadError() ?? 'no preview returned'}`,
909
+ ).body.split('\n')[0],
900
910
  }
901
911
  try {
902
912
  for (const name of cloudTestResultFileNames('preview', flowIndex, files.length)) {
903
913
  fs.writeFileSync(path.join(cwd, name), JSON.stringify(previewPayload))
904
914
  }
905
915
  } catch {}
906
- if (!upload?.shareId && !upload?.previewUrl) {
916
+ if (!uploaded) {
907
917
  console.error(' error: required hosted recording upload failed')
908
918
  if (exit === 0) exit = 1
909
919
  if (worstExit === 0) worstExit = 1
@@ -454,6 +454,7 @@ async function runRemoteBox(
454
454
  }
455
455
  const created = await box.createPrebuiltBundleBox({
456
456
  bundle: new Uint8Array(artifact.bytes),
457
+ device: selectedDevice,
457
458
  name: input.name ?? boxNameForBundle(input.bundlePath),
458
459
  endpoint,
459
460
  apiOrigin: box.cloudBoxApiOrigin(args, auth),
@@ -873,6 +873,20 @@ export async function runCloudTest(
873
873
 
874
874
  const { id } = (await response.json()) as { id: string }
875
875
  console.log(` cloud test job submitted: ${id}`)
876
+ // a job whose app this process serves over its tunnel cannot finish once
877
+ // the process exits, so an interrupt cancels it rather than leaving it to
878
+ // fail flow by flow while it bills.
879
+ if (closeTunnel) {
880
+ const cancelOnSignal = () => {
881
+ console.error(`\n interrupted: cancelling ${id}`)
882
+ void cancelCloudTest(id, origin).finally(() => {
883
+ closeTunnel?.()
884
+ process.exit(130)
885
+ })
886
+ }
887
+ process.once('SIGINT', cancelOnSignal)
888
+ process.once('SIGTERM', cancelOnSignal)
889
+ }
876
890
 
877
891
  const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
878
892
  const startedAt = Date.now()
@@ -194,7 +194,8 @@ options:
194
194
  --repo <repo> repo name for --owner; the signed-in user must belong
195
195
  to the local/org team that owns the link
196
196
  --billing-kind test_run
197
- meter this upload as a natural-language test run
197
+ the recording of a hosted test job, covered by the
198
+ job's minutes (set by the hosted recorder)
198
199
  --sim <sim> target a specific sim (see: peach list)
199
200
  --open open the resulting /preview/<id> url in the browser
200
201
  --assets-only drop API/JSON/HTML records before upload; keep images,
@@ -476,6 +477,12 @@ export async function runUpload(
476
477
  console.error(` invalid --billing-kind: ${billingKind}`)
477
478
  rnxExit(1)
478
479
  }
480
+ // the hosted recorder names the job whose minutes cover this recording
481
+ const runnerJobId = process.env.CONTRAST_RUNNER_JOB_ID
482
+ if (billingKind === 'test_run' && !runnerJobId) {
483
+ console.error(' --billing-kind test_run is only for hosted test jobs')
484
+ rnxExit(1)
485
+ }
479
486
  if (!eventsPath && !videoPath) {
480
487
  console.error(
481
488
  ' preview uploads require playback data: pass --events or --video.\n' +
@@ -1045,6 +1052,7 @@ export async function runUpload(
1045
1052
  pullRequestNumber: provenance.pullRequestNumber ?? undefined,
1046
1053
  pullRequestTitle: provenance.pullRequestTitle ?? undefined,
1047
1054
  billingKind: billingKind === 'test_run' ? 'test_run' : undefined,
1055
+ runnerJobId: billingKind === 'test_run' ? runnerJobId : undefined,
1048
1056
  runScope: runScope || undefined,
1049
1057
  files: prepared.map((f) => ({
1050
1058
  url: f.url,
@@ -1161,19 +1169,21 @@ export async function runUpload(
1161
1169
  if (e.phase === 'init' && e.status === 401) {
1162
1170
  console.error(' preview upload requires a valid login.')
1163
1171
  console.error(' run `peach login` and retry.')
1164
- rnxExit(1)
1172
+ rnxExit(1, 'preview upload requires a valid login')
1165
1173
  }
1166
1174
  if (e.status === 402) {
1167
1175
  // billing gate — surface the server's own `message`, not raw JSON.
1168
- console.error(
1169
- ` ${parseServerMessage(e.responseText) ?? 'preview uploads require Personal, Team, or an active trial — upgrade from the billing dialog.'}`,
1170
- )
1171
- rnxExit(1)
1176
+ const message =
1177
+ parseServerMessage(e.responseText) ??
1178
+ 'preview uploads require Personal, Team, or an active trial — upgrade from the billing dialog.'
1179
+ console.error(` ${message}`)
1180
+ rnxExit(1, message)
1172
1181
  }
1173
1182
  // PresignedUploadError.message already carries the "<phase> failed:"
1174
1183
  // prefix — don't re-prefix it (that produced "init failed: init failed:").
1175
- console.error(` ${e.message ?? `${e.phase ?? 'upload'} failed: ${String(err)}`}`)
1176
- rnxExit(1)
1184
+ const message = e.message ?? `${e.phase ?? 'upload'} failed: ${String(err)}`
1185
+ console.error(` ${message}`)
1186
+ rnxExit(1, message)
1177
1187
  }
1178
1188
 
1179
1189
  const previewUrl = `${publicOrigin}${finalize.url}`
@@ -7,8 +7,10 @@ import { createBrowserPointerDispatcher } from '../../../peach-engine/src/render
7
7
  //
8
8
  // it resolves playwright from the absolute path the driver hands it, launches
9
9
  // Chrome for Testing, opens the target url, then keeps itself (and therefore
10
- // the browser) alive until the browser disconnects. all inputs arrive through
11
- // the environment.
10
+ // the browser) alive until the page asks it to close (`peach close`, or the
11
+ // bridge reaping the sim after AUTOMATION_SIM_IDLE_REAP_TTL_MS with no cli),
12
+ // the browser disconnects, or its owning session exits. all inputs arrive
13
+ // through the environment.
12
14
  //
13
15
  // it stays a source string rather than a module of its own: playwright is
14
16
  // resolved from the user's project at runtime and is not a dependency here, so
@@ -515,7 +517,12 @@ const cleanupProfile = () => {
515
517
  process.stderr.write('[console.error] ' + m.text() + '\\n');
516
518
  }
517
519
  });
518
- page.on('crash', () => process.stderr.write('[page] renderer crashed\\n'));
520
+ // a crashed renderer never runs the page's bridge close path, so nothing
521
+ // would ever close this host.
522
+ page.on('crash', () => {
523
+ process.stderr.write('[page] renderer crashed; closing playwright host\\n');
524
+ void shutdown(1);
525
+ });
519
526
  try {
520
527
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
521
528
  } catch (err) {
@@ -16,6 +16,7 @@ import { spawn } from 'child_process'
16
16
  import { closeSync, mkdtempSync, openSync, readFileSync } from 'fs'
17
17
  import { tmpdir } from 'os'
18
18
  import { join } from 'path'
19
+ import { AUTOMATION_SIM_IDLE_REAP_TTL_MS } from '../../src/bridge-constants'
19
20
  import { playwrightHostLogName } from '../../src/playwright-host-log'
20
21
  import { ensureProfile, playwrightProfileUserDataDir } from '../../src/profiles'
21
22
  import { RNX_INTERNAL_CHILDREN, RNX_INTERNAL_COMMAND } from '../internal-child'
@@ -172,12 +173,14 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
172
173
  }${hint ? `\n${hint}` : ''}`,
173
174
  }
174
175
  }
175
- // name the lifetime tie: the host watchdog closes chrome ~5s after the
176
- // owning session pid exits, which surprises one-shot/detached shells
177
- // (their topmost non-init ancestor is the shell itself, so the sim dies
178
- // the moment the command finishes).
176
+ // name both lifetime bounds: the bridge closes an automation sim no cli
177
+ // has touched for the idle window, and the host watchdog closes chrome ~5s
178
+ // after the owning session pid exits, which surprises one-shot/detached
179
+ // shells (their topmost non-init ancestor is the shell itself, so the sim
180
+ // dies the moment the command finishes).
179
181
  const ownerComm = getProcessComm(ownerPid)
180
- const ownerNote = ` — browser closes when pid ${ownerPid}${ownerComm ? ` (${ownerComm})` : ''} exits`
182
+ const idleMinutes = AUTOMATION_SIM_IDLE_REAP_TTL_MS / 60_000
183
+ const ownerNote = ` — browser closes after ${idleMinutes} min with no peach command, or when pid ${ownerPid}${ownerComm ? ` (${ownerComm})` : ''} exits`
181
184
  return {
182
185
  launched: true,
183
186
  message: `playwright chrome launched → ${opts.url}${ownerNote}`,
package/cli/run-rnx.ts CHANGED
@@ -38,13 +38,17 @@ interface RnxSinks {
38
38
  // thrown by `peachExit` in place of process.exit. every catch that reports an
39
39
  // error must re-throw this before reporting, or it turns a deliberate exit
40
40
  // into a spurious "command failed" line with the sentinel's own text.
41
+ // `reason` carries the error the command already printed, so an in-process
42
+ // caller can report why instead of only the exit code.
41
43
  export class PeachExit extends Error {
42
44
  readonly code: number
45
+ readonly reason: string | null
43
46
 
44
- constructor(code: number) {
47
+ constructor(code: number, reason: string | null = null) {
45
48
  super(`peach exited with code ${code}`)
46
49
  this.name = 'PeachExit'
47
50
  this.code = code
51
+ this.reason = reason
48
52
  }
49
53
  }
50
54
 
@@ -61,11 +65,11 @@ export function rethrowIfExit(error: unknown): void {
61
65
 
62
66
  // the in-process replacement for process.exit. returns `never` exactly as
63
67
  // process.exit does, so it substitutes without changing any call site's types.
64
- export function peachExit(code: number): never {
65
- throw new PeachExit(code)
68
+ export function peachExit(code: number, reason?: string): never {
69
+ throw new PeachExit(code, reason)
66
70
  }
67
71
 
68
- export const rnxExit: (code: number) => never = peachExit
72
+ export const rnxExit: (code: number, reason?: string) => never = peachExit
69
73
 
70
74
  function write(stream: 'stdout' | 'stderr', text: string): boolean {
71
75
  const current = sinks.getStore()
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -218,6 +218,7 @@ function fileFromImportMeta() {
218
218
  // src/bridge-constants.ts
219
219
  var DEFAULT_SOOTSIM_BRIDGE_PORT = 7668;
220
220
  var DEFAULT_SOOTSIM_BRIDGE_URL = `http://localhost:${DEFAULT_SOOTSIM_BRIDGE_PORT}/`;
221
+ var AUTOMATION_SIM_IDLE_REAP_TTL_MS = 5 * 6e4;
221
222
 
222
223
  // src/agent-daemon-client.ts
223
224
  var AgentDaemonError = class extends Error {
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/beta.ts
4
4
  var IS_BETA = true;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -22,6 +22,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
22
22
  // src/bridge-constants.ts
23
23
  var bridge_constants_exports = {};
24
24
  __export(bridge_constants_exports, {
25
+ AUTOMATION_SIM_IDLE_REAP_TTL_MS: () => AUTOMATION_SIM_IDLE_REAP_TTL_MS,
25
26
  DEFAULT_SOOTSIM_BRIDGE_PORT: () => DEFAULT_SOOTSIM_BRIDGE_PORT,
26
27
  DEFAULT_SOOTSIM_BRIDGE_URL: () => DEFAULT_SOOTSIM_BRIDGE_URL,
27
28
  SOOTSIM_BRIDGE_SIM_CLOSE_CODE: () => SOOTSIM_BRIDGE_SIM_CLOSE_CODE,
@@ -145,8 +146,10 @@ function isSootsimBridgeHealth(value, expectedPort) {
145
146
  }
146
147
  var SOOTSIM_BRIDGE_SIM_CLOSE_CODE = 4001;
147
148
  var SOOTSIM_BRIDGE_SIM_CLOSE_REASON = "sootsim close";
149
+ var AUTOMATION_SIM_IDLE_REAP_TTL_MS = 5 * 6e4;
148
150
  // Annotate the CommonJS export names for ESM import in node:
149
151
  0 && (module.exports = {
152
+ AUTOMATION_SIM_IDLE_REAP_TTL_MS,
150
153
  DEFAULT_SOOTSIM_BRIDGE_PORT,
151
154
  DEFAULT_SOOTSIM_BRIDGE_URL,
152
155
  SOOTSIM_BRIDGE_SIM_CLOSE_CODE,
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/bridge-contract.ts
4
4
  var SIM_LONG_PRESS_MAX_MS = 5e3;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.626 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/bridge-contract.ts
4
4
  var REFUSED_PERFORM_STEP_TYPES = [