peachsim 0.1.578 → 0.1.580

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
@@ -8,6 +8,7 @@ import {
8
8
  writeSharedDesktopAuthSession,
9
9
  type SharedDesktopAuthSession,
10
10
  } from '../../src/auth/shared-session'
11
+ import { isLoopbackHost } from '../../src/backend-origin'
11
12
  import { rnxPublicBrand } from '../../src/public-brand'
12
13
  import { rnxExit } from '../run-rnx'
13
14
 
@@ -176,11 +177,8 @@ async function runDevLogin(origin: string): Promise<LoginResult> {
176
177
  function isLocalOrigin(origin: string): boolean {
177
178
  try {
178
179
  const hostname = new URL(origin).hostname.replace(/^\[|\]$/g, '').toLowerCase()
179
- if (hostname === 'localhost') return true
180
- if (/^127(?:\.\d{1,3}){3}$/.test(hostname)) return true
181
- if (hostname === '::1' || hostname === '0.0.0.0') return true
180
+ if (isLoopbackHost(hostname)) return true
182
181
  if (hostname.endsWith('.local')) return true
183
- if (hostname.endsWith('.localhost')) return true
184
182
  // VM↔host bridge aliases — orb, docker, lima, multipass all forward the
185
183
  // host on a well-known name (IPv4 or IPv6) so a VM-internal `peach login`
186
184
  // can still take the dev-login fast path against the host's Contrast dev
@@ -321,7 +319,7 @@ export async function runLogin(args: string[]) {
321
319
  return
322
320
  }
323
321
 
324
- const url = new URL(`${origin.replace(/\/$/, '')}/auth/rnx-cli`)
322
+ const url = new URL(`${origin.replace(/\/$/, '')}/auth/peach-cli`)
325
323
  url.searchParams.set('state', state)
326
324
  url.searchParams.set('port', String(address.port))
327
325
  url.searchParams.set('contrast', origin)
@@ -18,6 +18,7 @@ import {
18
18
  } from './flow'
19
19
  import { inspectDescribe } from './inspect/core'
20
20
  import { resolveDefaultUploadOrigin } from './upload'
21
+ import { isRecord } from '../../src/is-record'
21
22
 
22
23
  const DEFAULT_TEST_MODEL = process.env.PEACH_TEST_MODEL || 'deepseek-v4-flash'
23
24
  const DESCRIBE_CONTEXT_LIMIT = 18_000
@@ -210,10 +211,6 @@ function extractJson(text: string): unknown {
210
211
  return JSON.parse(candidate)
211
212
  }
212
213
 
213
- function isRecord(value: unknown): value is Record<string, unknown> {
214
- return Boolean(value && typeof value === 'object' && !Array.isArray(value))
215
- }
216
-
217
214
  function parseGeneratedFlow(text: string): {
218
215
  summary: string
219
216
  steps: Record<string, unknown>[]
@@ -111,9 +111,12 @@ peach maestro — compatibility matrix
111
111
 
112
112
  supported verbs:
113
113
  launchApp (including arguments), stopApp, clearState, clearKeychain (warn-only),
114
- tapOn, tapAtCoords, longPressOn,
114
+ selectors: text, id, index (negative from the end), childOf, below, above,
115
+ leftOf, rightOf, width / height / tolerance, traits, enabled, selected, checked,
116
+ tapOn (including repeat / delay), doubleTapOn, tapAtCoords, longPressOn,
115
117
  inputText, pressKey, dispatchKey, hideKeyboard, eraseText,
116
- assertVisible, assertNotVisible, assertTreeContains,
118
+ setClipboard, pasteText,
119
+ assertVisible, assertNotVisible, assertTrue, assertTreeContains,
117
120
  waitFor, extendedWaitUntil, waitForAnimationToEnd,
118
121
  scroll, scrollUntilVisible, scrollTo, swipe, pinch,
119
122
  takeScreenshot, dumpTree, back,
@@ -158,10 +161,12 @@ partial:
158
161
  MAESTRO_* (so \${SOOTSIM_*} credentials resolve); a bare \${NAME} that is
159
162
  undefined fails the step loudly instead of typing "undefined".
160
163
 
161
- not yet implemented (will throw "unsupported maestro verb"):
162
- travel, setLocation, setAirplaneMode, killApp, faker,
164
+ not yet implemented (the step fails with "unsupported flow step"):
165
+ travel, setLocation, setAirplaneMode, killApp, faker, inputRandom*,
163
166
  addMedia, startRecording, stopRecording (use --record flag),
164
167
  repeat.while (only repeat.times is supported).
168
+ selector keys that fail the step: containsChild, containsDescendants,
169
+ focused, css.
165
170
  `)
166
171
  }
167
172
 
@@ -303,7 +308,9 @@ export function extractMaestroCompatFlags(args: string[]): {
303
308
  break
304
309
  case '--platform':
305
310
  if (value.toLowerCase() !== 'ios') {
306
- throw new Error(`peach emulates iOS only; --platform ${value} is not supported`)
311
+ throw new Error(
312
+ `peach emulates iOS only; --platform ${value} is not supported`,
313
+ )
307
314
  }
308
315
  compat.platform = value
309
316
  break
@@ -874,23 +881,33 @@ export async function runMaestro(
874
881
  }
875
882
 
876
883
  const upload = getLastFlowPreviewUploadResult()
877
- // the receipt files are the per-flow manifest: always written, with
878
- // null ids when the upload failed, so the flow never disappears from
879
- // the collected entries.
880
- const previewPayload = {
881
- flow: path.relative(cwd, file) || file,
882
- previewId: upload?.shareId ?? null,
883
- previewUrl: upload?.previewUrl ?? null,
884
- }
885
- try {
886
- for (const name of cloudTestResultFileNames('preview', flowIndex, files.length)) {
887
- fs.writeFileSync(path.join(cwd, name), JSON.stringify(previewPayload))
884
+ // only --preview uploads, so only it leaves receipts in the working
885
+ // directory. they are the per-flow manifest: written with null ids when
886
+ // the upload failed, so the flow never disappears from the collected
887
+ // entries.
888
+ if (args.includes('--preview')) {
889
+ const previewPayload = {
890
+ flow: path.relative(cwd, file) || file,
891
+ previewId: upload?.shareId ?? null,
892
+ previewUrl: upload?.previewUrl ?? null,
893
+ // the failing step as the junit report names it, on one line
894
+ failure:
895
+ exit === 0
896
+ ? null
897
+ : failureFromTraceSteps(traceSteps, 'flow playback failed').body.split(
898
+ '\n',
899
+ )[0],
900
+ }
901
+ try {
902
+ for (const name of cloudTestResultFileNames('preview', flowIndex, files.length)) {
903
+ fs.writeFileSync(path.join(cwd, name), JSON.stringify(previewPayload))
904
+ }
905
+ } catch {}
906
+ if (!upload?.shareId && !upload?.previewUrl) {
907
+ console.error(' error: required hosted recording upload failed')
908
+ if (exit === 0) exit = 1
909
+ if (worstExit === 0) worstExit = 1
888
910
  }
889
- } catch {}
890
- if (!upload?.shareId && !upload?.previewUrl && args.includes('--preview')) {
891
- console.error(' error: required hosted recording upload failed')
892
- if (exit === 0) exit = 1
893
- if (worstExit === 0) worstExit = 1
894
911
  }
895
912
 
896
913
  if (shouldRegisterRun({ uploadedShare: Boolean(upload), auth })) {
@@ -1,6 +1,5 @@
1
1
  // shared hint printed when the CLI has no bridge or no desktop companion.
2
2
 
3
- import { findDesktopCompanion } from '../desktop-companion'
4
3
  import type { BridgeSimInfo } from '../ws-bridge'
5
4
 
6
5
  export function printMissingCompanionHint() {
@@ -14,19 +13,6 @@ export function printMissingCompanionHint() {
14
13
  console.error('')
15
14
  }
16
15
 
17
- export function printMissingBridgeHint(port: number) {
18
- const companion = findDesktopCompanion()
19
- console.log(` note: no peach bridge detected on port ${port}`)
20
- if (companion) {
21
- console.log(` launch the installed companion with:`)
22
- console.log(` peach desktop`)
23
- return
24
- }
25
- console.log('')
26
- console.log(' run `peach daemon install` to enable the faster background service,')
27
- console.log(' or run `peach serve` in another shell for a foreground bridge.')
28
- }
29
-
30
16
  export function printMissingSimHint(port: number) {
31
17
  console.error('')
32
18
  console.error(` no sim is connected to the peach bridge on port ${port}.`)
@@ -28,7 +28,7 @@ const STATUSES: readonly SystemPermissionStatus[] = [
28
28
  'unavailable',
29
29
  ]
30
30
 
31
- const KINDS: readonly SystemPermissionKind[] = [
31
+ export const PERMISSION_KINDS: readonly SystemPermissionKind[] = [
32
32
  'app-tracking-transparency',
33
33
  'bluetooth',
34
34
  'calendars',
@@ -71,7 +71,7 @@ function flagValue(args: string[], flag: string): string | undefined {
71
71
  }
72
72
 
73
73
  function permissionKind(value: string): SystemPermissionKind | null {
74
- return KINDS.find((candidate) => candidate === value) ?? null
74
+ return PERMISSION_KINDS.find((candidate) => candidate === value) ?? null
75
75
  }
76
76
 
77
77
  function permissionStatus(value: string): SystemPermissionStatus | null {
@@ -460,12 +460,6 @@ async function runRemoteBox(
460
460
  apiKey: header.replace(/^Bearer /, ''),
461
461
  accountId,
462
462
  })
463
- // creating a box starts no browser, and only its Browser Run tab hosts the
464
- // simulator, so without this the viewer opens onto a box nobody is running.
465
- await ensureCloudBoxBrowser({
466
- connectUrl: cloudBoxConnectUrl(endpoint, created.id),
467
- accessToken: created.accessToken ?? '',
468
- })
469
463
  const session = createCloudBoxSession({
470
464
  boxId: created.id,
471
465
  boxToken: created.accessToken ?? '',
@@ -477,6 +471,19 @@ async function runRemoteBox(
477
471
  bytes: artifact.bytes.length,
478
472
  },
479
473
  })
474
+ // creating a box starts no browser, and only its Browser Run tab hosts the
475
+ // simulator, so without this the viewer opens onto a box nobody is running.
476
+ // a box whose browser never started is one nobody can use, and it would
477
+ // bill until someone found it, so it closes with the failure.
478
+ try {
479
+ await ensureCloudBoxBrowser({
480
+ connectUrl: cloudBoxConnectUrl(endpoint, created.id),
481
+ accessToken: created.accessToken ?? '',
482
+ })
483
+ } catch (error) {
484
+ await closeCloudBox(session)
485
+ throw error
486
+ }
480
487
  try {
481
488
  publishCloudSessionToShell(session)
482
489
  } catch {
@@ -474,7 +474,12 @@ async function runPlaywrightScreenshot(args: string[], opts: ScreenshotOptions)
474
474
  })
475
475
 
476
476
  try {
477
- const page = await browser.newPage({ viewport: { width: 500, height: 900 } })
477
+ const page = await browser.newPage({
478
+ viewport: { width: 500, height: 900 },
479
+ // this fallback screenshots a page a person opens; state display density
480
+ // so the PNG is retina rather than half of what the display shows.
481
+ deviceScaleFactor: 2,
482
+ })
478
483
  await page.goto(url, { waitUntil: 'networkidle' })
479
484
  await page.waitForTimeout(2000)
480
485
 
@@ -306,7 +306,7 @@ const LEGACY_BUNDLED_SKILL_DIRS = [
306
306
  'sootsim-debug',
307
307
  'sootsim-perf',
308
308
  'sootsim-test',
309
- 'sootsim-visual',
309
+ 'peach-visual',
310
310
  'rnx-setup',
311
311
  'rnx-debug',
312
312
  'rnx-perf',
@@ -572,7 +572,9 @@ async function readJobStatus(
572
572
  export async function cancelCloudTest(jobId: string, origin: string): Promise<number> {
573
573
  const auth = resolveCliAuth()
574
574
  if (!auth) {
575
- console.error(' error: cancelling a cloud test requires `peach login` or PEACH_API_KEY')
575
+ console.error(
576
+ ' error: cancelling a cloud test requires `peach login` or PEACH_API_KEY',
577
+ )
576
578
  return 1
577
579
  }
578
580
  const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(jobId)}`
@@ -947,7 +949,9 @@ export async function runCloudTest(
947
949
  }
948
950
  }
949
951
 
950
- console.error(` error: cloud test timed out after ${Math.round(timeoutMs / 1000)}s`)
952
+ console.error(
953
+ ` error: cloud test timed out after ${timeoutMs < 1000 ? `${timeoutMs}ms` : `${timeoutMs / 1000}s`}`,
954
+ )
951
955
  return 1
952
956
  } finally {
953
957
  closeTunnel?.()
@@ -18,6 +18,7 @@ import {
18
18
  type WsBridge,
19
19
  } from '../ws-bridge'
20
20
  import { runShellBooleanMode, type ShellBooleanModeOptions } from './shell-boolean-mode'
21
+ import { isRecord } from '../../src/is-record'
21
22
 
22
23
  type ThreeModeConfig = Record<string, unknown>
23
24
  type ThreeModeState = Record<string, unknown> & {
@@ -97,6 +98,12 @@ measure options (projected bounds only — no pixels rendered):
97
98
  --at <ms[,ms...]> sample exact timeline times (overrides from/to/count)
98
99
  --output <file.json> write the measurement JSON here instead of stdout
99
100
 
101
+ asset delivery:
102
+ local recording/screenshot files referenced by the timeline upload once per
103
+ distinct file in ~1mb chunks; the page serves them back as blob: urls.
104
+ a later command against the same page reuses them without re-uploading.
105
+ uploads need a development build of the page; production drops the bridge.
106
+
100
107
  examples:
101
108
  peach mode three
102
109
  peach mode three on
@@ -328,10 +335,6 @@ function readNumberFlag(args: string[], flag: string, fallback: number, minimum
328
335
  return value
329
336
  }
330
337
 
331
- function isRecord(value: unknown): value is Record<string, unknown> {
332
- return typeof value === 'object' && value !== null && !Array.isArray(value)
333
- }
334
-
335
338
  function mimeTypeForAsset(path: string, contentType: unknown) {
336
339
  const extension = extname(path).toLowerCase()
337
340
  if (contentType === 'recording') {
@@ -343,36 +346,171 @@ function mimeTypeForAsset(path: string, contentType: unknown) {
343
346
  return 'image/png'
344
347
  }
345
348
 
346
- function inlineTimelineAssets(value: unknown, baseDirectory: string): unknown {
349
+ // the ws bridge server caps inbound messages at its 100 MiB maxPayload, so a
350
+ // timeline with base64-inlined assets closes the socket (code 1009) once the
351
+ // clips grow past it. local assets travel in 1 MiB raw chunks instead — each
352
+ // bridge message stays near 1.4 MB — and the page serves them back to the
353
+ // timeline as blob: URLs.
354
+ const TIMELINE_ASSET_CHUNK_BYTES = 1_048_576
355
+
356
+ type TimelineAssetRef = {
357
+ src: string
358
+ recording: boolean
359
+ }
360
+
361
+ function timelineAssetRefOf(value: Record<string, unknown>): TimelineAssetRef | null {
362
+ if (
363
+ (value.type === 'recording' ||
364
+ value.type === 'screenshot' ||
365
+ value.kind === 'screen') &&
366
+ typeof value.src === 'string' &&
367
+ !/^(?:blob:|data:|https?:)/i.test(value.src)
368
+ ) {
369
+ return { src: value.src, recording: value.type === 'recording' }
370
+ }
371
+ return null
372
+ }
373
+
374
+ function collectTimelineAssetRefs(value: unknown): TimelineAssetRef[] {
375
+ const refs: TimelineAssetRef[] = []
376
+ const seen = new Set<string>()
377
+ const visit = (node: unknown): void => {
378
+ if (Array.isArray(node)) {
379
+ for (const entry of node) visit(entry)
380
+ return
381
+ }
382
+ if (!isRecord(node)) return
383
+ for (const entry of Object.values(node)) visit(entry)
384
+ const ref = timelineAssetRefOf(node)
385
+ if (ref && !seen.has(ref.src)) {
386
+ seen.add(ref.src)
387
+ refs.push(ref)
388
+ }
389
+ }
390
+ visit(value)
391
+ return refs
392
+ }
393
+
394
+ type DistinctTimelineAsset = {
395
+ hash: string
396
+ mime: string
397
+ bytes: Buffer
398
+ srcs: string[]
399
+ }
400
+
401
+ function readDistinctTimelineAssets(
402
+ value: unknown,
403
+ baseDirectory: string,
404
+ ): DistinctTimelineAsset[] {
405
+ const byHash = new Map<string, DistinctTimelineAsset>()
406
+ for (const ref of collectTimelineAssetRefs(value)) {
407
+ const assetPath = resolve(baseDirectory, ref.src)
408
+ const bytes = readFileSync(assetPath)
409
+ const hash = createHash('sha256').update(bytes).digest('hex')
410
+ const existing = byHash.get(hash)
411
+ if (existing) {
412
+ existing.srcs.push(ref.src)
413
+ continue
414
+ }
415
+ byHash.set(hash, {
416
+ hash,
417
+ mime: mimeTypeForAsset(assetPath, ref.recording ? 'recording' : 'screenshot'),
418
+ bytes,
419
+ srcs: [ref.src],
420
+ })
421
+ }
422
+ return [...byHash.values()]
423
+ }
424
+
425
+ function rewriteTimelineAssetSrcs(
426
+ value: unknown,
427
+ urlBySrc: Map<string, string>,
428
+ ): unknown {
347
429
  if (Array.isArray(value)) {
348
- return value.map((entry) => inlineTimelineAssets(entry, baseDirectory))
430
+ return value.map((entry) => rewriteTimelineAssetSrcs(entry, urlBySrc))
349
431
  }
350
432
  if (!isRecord(value)) return value
351
433
 
352
434
  const result: Record<string, unknown> = {}
353
435
  for (const [key, entry] of Object.entries(value)) {
354
- result[key] = inlineTimelineAssets(entry, baseDirectory)
436
+ result[key] = rewriteTimelineAssetSrcs(entry, urlBySrc)
355
437
  }
356
- if (
357
- (result.type === 'recording' ||
358
- result.type === 'screenshot' ||
359
- result.kind === 'screen') &&
360
- typeof result.src === 'string' &&
361
- !/^(?:blob:|data:|https?:)/i.test(result.src)
362
- ) {
363
- const assetPath = resolve(baseDirectory, result.src)
364
- const bytes = readFileSync(assetPath)
365
- result.src = `data:${mimeTypeForAsset(assetPath, result.type === 'recording' ? 'recording' : 'screenshot')};base64,${bytes.toString('base64')}`
438
+ const ref = timelineAssetRefOf(result)
439
+ if (ref) {
440
+ const url = urlBySrc.get(ref.src)
441
+ if (url) result.src = url
366
442
  }
367
443
  return result
368
444
  }
369
445
 
446
+ type TimelineAssetUploadResult = {
447
+ hash: string
448
+ url: string | null
449
+ }
450
+
451
+ async function uploadTimelineAssets(
452
+ bridge: WsBridge,
453
+ timeline: unknown,
454
+ baseDirectory: string,
455
+ ): Promise<unknown> {
456
+ const assets = readDistinctTimelineAssets(timeline, baseDirectory)
457
+ const urlBySrc = new Map<string, string>()
458
+ for (const asset of assets) {
459
+ const shortHash = asset.hash.slice(0, 12)
460
+ const known = await callInBridgeWrite<TimelineAssetUploadResult>(
461
+ bridge,
462
+ 'SootSim.bridges.threeMode.uploadAsset',
463
+ { op: 'has', hash: asset.hash },
464
+ )
465
+ if (known?.url) {
466
+ console.log(
467
+ ` three-mode: reusing uploaded asset ${shortHash} (${asset.bytes.length} bytes, upload skipped)`,
468
+ )
469
+ for (const src of asset.srcs) urlBySrc.set(src, known.url)
470
+ continue
471
+ }
472
+ await callInBridgeWrite(bridge, 'SootSim.bridges.threeMode.uploadAsset', {
473
+ op: 'begin',
474
+ hash: asset.hash,
475
+ mime: asset.mime,
476
+ bytes: asset.bytes.length,
477
+ })
478
+ const chunks = Math.max(1, Math.ceil(asset.bytes.length / TIMELINE_ASSET_CHUNK_BYTES))
479
+ for (let index = 0; index < chunks; index += 1) {
480
+ const slice = asset.bytes.subarray(
481
+ index * TIMELINE_ASSET_CHUNK_BYTES,
482
+ (index + 1) * TIMELINE_ASSET_CHUNK_BYTES,
483
+ )
484
+ await callInBridgeWrite(bridge, 'SootSim.bridges.threeMode.uploadAsset', {
485
+ op: 'chunk',
486
+ hash: asset.hash,
487
+ index,
488
+ base64: slice.toString('base64'),
489
+ })
490
+ }
491
+ const finished = await callInBridgeWrite<TimelineAssetUploadResult>(
492
+ bridge,
493
+ 'SootSim.bridges.threeMode.uploadAsset',
494
+ { op: 'finish', hash: asset.hash },
495
+ )
496
+ if (!finished?.url) {
497
+ throw new Error(`three-mode asset upload finished without a URL for ${shortHash}`)
498
+ }
499
+ console.log(
500
+ ` three-mode: uploaded asset ${shortHash} (${asset.bytes.length} bytes in ${chunks} chunks)`,
501
+ )
502
+ for (const src of asset.srcs) urlBySrc.set(src, finished.url)
503
+ }
504
+ return rewriteTimelineAssetSrcs(timeline, urlBySrc)
505
+ }
506
+
370
507
  function readTimelineFile(path: string) {
371
508
  const absolutePath = resolve(path)
372
509
  const parsed: unknown = JSON.parse(readFileSync(absolutePath, 'utf8'))
373
510
  return {
374
511
  absolutePath,
375
- timeline: inlineTimelineAssets(parsed, dirname(absolutePath)),
512
+ timeline: parsed,
513
+ baseDirectory: dirname(absolutePath),
376
514
  }
377
515
  }
378
516
 
@@ -434,10 +572,15 @@ async function runThreeModeTimelineAction(
434
572
  if (!timelinePath)
435
573
  throw new Error(`three-mode ${action} expects a timeline JSON path`)
436
574
  const loadedFile = readTimelineFile(timelinePath)
575
+ const timeline = await uploadTimelineAssets(
576
+ bridge,
577
+ loadedFile.timeline,
578
+ loadedFile.baseDirectory,
579
+ )
437
580
  const loaded = await callInBridgeWrite<TimelineSampleResult>(
438
581
  bridge,
439
582
  'SootSim.bridges.threeMode.loadTimeline',
440
- loadedFile.timeline,
583
+ timeline,
441
584
  )
442
585
  if (action === 'load') {
443
586
  console.log(
@@ -21,6 +21,7 @@ import {
21
21
  isTraceTimelineEvent,
22
22
  parseTraceTimelineJsonl,
23
23
  } from 'peach-engine/preview/trace'
24
+ import { loopbackOriginOf } from '../../src/backend-origin'
24
25
  import { rnxPublicBrand } from '../../src/public-brand'
25
26
  import { fetchAppFontFiles } from '../app-fonts'
26
27
  import { fetchAppSplashImages } from '../app-splash'
@@ -115,7 +116,7 @@ function readSnapshotManifest(manifestPath: string): AttachedInteractionSnapshot
115
116
 
116
117
  const REMOTE_DEFAULT_ORIGIN = process.env.PEACH_UPLOAD_ORIGIN || 'https://contrast.dev'
117
118
  // the canonical public preview link. Uploads go to the contrast backend, but
118
- // reviewers/users open rnx preview pages on rnxsim.com.
119
+ // reviewers/users open rnx preview pages on peachsim.com.
119
120
  const REMOTE_DEFAULT_PUBLIC_PREVIEW_ORIGIN =
120
121
  process.env.PEACH_PREVIEW_ORIGIN || rnxPublicBrand.origin
121
122
  export async function resolveDefaultUploadOrigin(
@@ -130,22 +131,6 @@ function normalizeOrigin(origin: string): string {
130
131
  return origin.replace(/\/$/, '')
131
132
  }
132
133
 
133
- function isLoopbackOrigin(origin: string): boolean {
134
- try {
135
- const parsed = new URL(origin)
136
- const hostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase()
137
- return (
138
- hostname === 'localhost' ||
139
- hostname.endsWith('.localhost') ||
140
- hostname === '0.0.0.0' ||
141
- hostname === '::1' ||
142
- /^127(?:\.\d{1,3}){3}$/.test(hostname)
143
- )
144
- } catch {
145
- return false
146
- }
147
- }
148
-
149
134
  function isContrastOrigin(origin: string): boolean {
150
135
  try {
151
136
  const hostname = new URL(origin).hostname.toLowerCase()
@@ -163,7 +148,7 @@ export function resolvePublicPreviewOrigin(
163
148
  if (process.env.PEACH_PREVIEW_ORIGIN) {
164
149
  return normalizeOrigin(process.env.PEACH_PREVIEW_ORIGIN)
165
150
  }
166
- if (isLoopbackOrigin(uploadOrigin)) return normalizeOrigin(uploadOrigin)
151
+ if (loopbackOriginOf(uploadOrigin) !== null) return normalizeOrigin(uploadOrigin)
167
152
  if (isContrastOrigin(uploadOrigin)) {
168
153
  return normalizeOrigin(REMOTE_DEFAULT_PUBLIC_PREVIEW_ORIGIN)
169
154
  }
@@ -19,7 +19,6 @@ export interface LocalCurrentTarget {
19
19
  // progressively weaker signal of "same caller across invocations".
20
20
  const IDENTITY_ENV_VARS = [
21
21
  'PEACH_CLI_IDENTITY', // explicit override — fastest path, skips all pid lookups
22
- 'TM_SESSION', // Team Machine session id; routes prompt-bar followups back to this agent
23
22
  'CLAUDE_CODE_SESSION_ID', // claude code, if ever set
24
23
  'CODEX_THREAD_ID', // codex / openai agent thread, stable across tool invocations
25
24
  'TERM_SESSION_ID', // macOS Terminal.app + iTerm2
@@ -89,12 +89,10 @@ function findElectronBin(startDir: string): string | null {
89
89
  return null
90
90
  }
91
91
 
92
- function findMacApp(): DesktopCompanionInstall | null {
93
- const root = getSootsimRoot()
92
+ function findInstalledMacApp(): DesktopCompanionInstall | null {
94
93
  const candidates = [
95
94
  '/Applications/peach.app',
96
95
  resolve(process.env.HOME || '', 'Applications/peach.app'),
97
- ...(root ? [resolve(root, 'app/peach.app')] : []),
98
96
  ]
99
97
 
100
98
  const found = candidates.find((candidate) => existsSync(candidate))
@@ -106,6 +104,18 @@ function findMacApp(): DesktopCompanionInstall | null {
106
104
  }
107
105
  }
108
106
 
107
+ return null
108
+ }
109
+
110
+ function findMacApp(): DesktopCompanionInstall | null {
111
+ const installed = findInstalledMacApp()
112
+ if (installed) return installed
113
+ const root = getSootsimRoot()
114
+ const builtApp = root ? resolve(root, 'app/peach.app') : null
115
+ if (builtApp && existsSync(builtApp)) {
116
+ return { path: builtApp, platform: 'darwin', kind: 'mac-app' }
117
+ }
118
+
109
119
  try {
110
120
  const result = execFileSync(
111
121
  'mdfind',
@@ -124,6 +134,13 @@ function findMacApp(): DesktopCompanionInstall | null {
124
134
  return null
125
135
  }
126
136
 
137
+ export function findInstalledDesktopCompanion(): DesktopCompanionInstall | null {
138
+ if (process.platform === 'darwin') return findInstalledMacApp()
139
+ if (process.platform === 'linux') return findInstalledLinuxApp()
140
+ if (process.platform === 'win32') return findWindowsInstall()
141
+ return null
142
+ }
143
+
127
144
  function findFirstAppImage(dir: string): string | null {
128
145
  if (!existsSync(dir)) return null
129
146
  const match = readdirSync(dir)
@@ -133,16 +150,12 @@ function findFirstAppImage(dir: string): string | null {
133
150
  return match ? join(dir, match) : null
134
151
  }
135
152
 
136
- function findLinuxInstall(): DesktopCompanionInstall | null {
137
- const root = getSootsimRoot()
153
+ function findInstalledLinuxApp(): DesktopCompanionInstall | null {
138
154
  const directCandidates = [
139
155
  resolve(process.env.HOME || '', 'Applications/peach.AppImage'),
140
156
  resolve(process.env.HOME || '', '.local/bin/peach.AppImage'),
141
157
  '/opt/peach/peach.AppImage',
142
158
  '/opt/peach/peach',
143
- ...(root
144
- ? [resolve(root, 'app/peach.AppImage'), resolve(root, 'release/linux-unpacked/peach')]
145
- : []),
146
159
  ]
147
160
 
148
161
  const directMatch = directCandidates.find((candidate) => existsSync(candidate))
@@ -154,6 +167,25 @@ function findLinuxInstall(): DesktopCompanionInstall | null {
154
167
  }
155
168
  }
156
169
 
170
+ return null
171
+ }
172
+
173
+ function findLinuxInstall(): DesktopCompanionInstall | null {
174
+ const installed = findInstalledLinuxApp()
175
+ if (installed) return installed
176
+ const root = getSootsimRoot()
177
+ const directCandidates = root
178
+ ? [resolve(root, 'app/peach.AppImage'), resolve(root, 'release/linux-unpacked/peach')]
179
+ : []
180
+ const directMatch = directCandidates.find((candidate) => existsSync(candidate))
181
+ if (directMatch) {
182
+ return {
183
+ path: directMatch,
184
+ platform: 'linux',
185
+ kind: directMatch.endsWith('.AppImage') ? 'linux-appimage' : 'linux-binary',
186
+ }
187
+ }
188
+
157
189
  const scannedMatch = root
158
190
  ? findFirstAppImage(resolve(root, 'app')) ||
159
191
  findFirstAppImage(resolve(root, 'release'))
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { RNX_INTERNAL_CHILDREN, RNX_INTERNAL_COMMAND } from '../internal-child'
6
6
  import { rnxSelfInvocation } from '../self-invocation'
7
+ import { isRecord } from '../../src/is-record'
7
8
 
8
9
  export interface ResolvedPlaywright {
9
10
  // package name that resolved (for human-facing messaging)
@@ -27,10 +28,6 @@ export type PlaywrightBrowserProvisioningResult =
27
28
  message: string
28
29
  }
29
30
 
30
- function isRecord(value: unknown): value is Record<string, unknown> {
31
- return typeof value === 'object' && value !== null && !Array.isArray(value)
32
- }
33
-
34
31
  export function resolvePlaywright(): ResolvedPlaywright | null {
35
32
  // resolve from several roots, not just cwd. a user can invoke a global CLI
36
33
  // from an arbitrary directory while Playwright is installed beside the CLI.