peachsim 0.1.625 → 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
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.625 | (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.625 | (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;
package/dist-lib/vite.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.625 | (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;
@@ -47,6 +47,7 @@ var import_ws = require("ws");
47
47
  // src/bridge-constants.ts
48
48
  var DEFAULT_SOOTSIM_BRIDGE_PORT = 7668;
49
49
  var DEFAULT_SOOTSIM_BRIDGE_URL = `http://localhost:${DEFAULT_SOOTSIM_BRIDGE_PORT}/`;
50
+ var AUTOMATION_SIM_IDLE_REAP_TTL_MS = 5 * 6e4;
50
51
 
51
52
  // src/dev-server-open.ts
52
53
  var import_node_child_process = require("node:child_process");
package/install.mjs ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ // `npx peachsim` runs the same guided install as
3
+ // `curl -fsSL https://peachsim.com/install.sh | sh`: this package ships that
4
+ // script, and it installs the standalone peach executable, then hands off to
5
+ // `peach setup`.
6
+ import { spawnSync } from 'node:child_process'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ const result = spawnSync('sh', [fileURLToPath(new URL('./install.sh', import.meta.url))], {
10
+ stdio: 'inherit',
11
+ })
12
+ if (result.error) {
13
+ process.stderr.write(`error: could not run the peach installer: ${result.error.message}\n`)
14
+ process.exit(1)
15
+ }
16
+ process.exit(result.status ?? 1)
package/install.sh ADDED
@@ -0,0 +1,199 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ # peach installer
5
+ #
6
+ # this installs the standalone peach executable and hands machine and repository
7
+ # decisions to `peach setup`. it does not require node, bun, npm, or sudo.
8
+ #
9
+ # inspect this entire script without running it:
10
+ #
11
+ # curl -fsSL https://peachsim.com/install.sh | less
12
+ #
13
+ # install:
14
+ #
15
+ # curl -fsSL https://peachsim.com/install.sh | sh
16
+ #
17
+ # options:
18
+ # PEACH_VERSION=0.1.377 install a specific cli version
19
+ # PEACH_CLI_PREFIX=... choose the user-writable cli prefix
20
+ # PEACH_NO_MODIFY_PATH=1 do not update your shell profile
21
+
22
+ PEACH_VERSION="${PEACH_VERSION:-latest}"
23
+ CLI_PREFIX="${PEACH_CLI_PREFIX:-$HOME/.local}"
24
+ CLI="$CLI_PREFIX/bin/peach"
25
+ RELEASE_ORIGIN="https://r2.rnxsim.com/rnx-cli"
26
+
27
+ say() {
28
+ printf '%s\n' "$*"
29
+ }
30
+
31
+ fail() {
32
+ printf 'error: %s\n' "$*" >&2
33
+ exit 1
34
+ }
35
+
36
+ command_exists() {
37
+ command -v "$1" >/dev/null 2>&1
38
+ }
39
+
40
+ command_exists curl || fail "curl is required to install peach"
41
+
42
+ case "$(uname -s)" in
43
+ Darwin)
44
+ PLATFORM="darwin"
45
+ PLATFORM_LABEL="macOS"
46
+ ;;
47
+ Linux)
48
+ PLATFORM="linux"
49
+ PLATFORM_LABEL="Linux"
50
+ ;;
51
+ *)
52
+ fail "this installer currently supports macOS and Linux"
53
+ ;;
54
+ esac
55
+
56
+ case "$(uname -m)" in
57
+ arm64 | aarch64)
58
+ ARCH="arm64"
59
+ ;;
60
+ x86_64 | amd64)
61
+ ARCH="x64"
62
+ ;;
63
+ *)
64
+ fail "peach does not support this processor architecture"
65
+ ;;
66
+ esac
67
+
68
+ if [ "$PEACH_VERSION" = "latest" ]; then
69
+ PEACH_VERSION="$(curl -fsSL "$RELEASE_ORIGIN/channels/stable")" ||
70
+ fail "could not resolve the current peach version"
71
+ fi
72
+
73
+ printf '%s' "$PEACH_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' ||
74
+ fail "PEACH_VERSION must be 'latest' or a semantic version"
75
+
76
+ ARTIFACT="peach-$PLATFORM-$ARCH"
77
+ ARTIFACT_URL="$RELEASE_ORIGIN/$PEACH_VERSION/$ARTIFACT"
78
+ TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/peach-install.XXXXXX")"
79
+ TMP_BIN="$TMP_DIR/peach"
80
+ TMP_SHA="$TMP_DIR/peach.sha256"
81
+ cleanup() {
82
+ rm -rf "$TMP_DIR"
83
+ }
84
+ trap cleanup EXIT HUP INT TERM
85
+
86
+ say ""
87
+ say "peach installer"
88
+ say ""
89
+ say " platform: $PLATFORM_LABEL $ARCH"
90
+ say " version: $PEACH_VERSION"
91
+ say " command: $CLI"
92
+ say ""
93
+ say "Downloading the standalone peach executable..."
94
+ curl -fsSL "$ARTIFACT_URL" -o "$TMP_BIN" || fail "could not download peach"
95
+ curl -fsSL "$ARTIFACT_URL.sha256" -o "$TMP_SHA" ||
96
+ fail "could not download the peach checksum"
97
+
98
+ EXPECTED_SHA="$(tr -d '[:space:]' <"$TMP_SHA")"
99
+ printf '%s' "$EXPECTED_SHA" | grep -Eq '^[a-f0-9]{64}$' ||
100
+ fail "the peach checksum is invalid"
101
+ if [ "$PLATFORM" = "darwin" ]; then
102
+ ACTUAL_SHA="$(shasum -a 256 "$TMP_BIN" | awk '{print $1}')"
103
+ else
104
+ ACTUAL_SHA="$(sha256sum "$TMP_BIN" | awk '{print $1}')"
105
+ fi
106
+ [ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] || fail "the peach executable failed verification"
107
+ chmod 755 "$TMP_BIN"
108
+ VERSION_OUTPUT="$("$TMP_BIN" --version 2>/dev/null)" ||
109
+ fail "the downloaded peach executable did not start"
110
+ REPORTED_VERSION="$(
111
+ printf '%s\n' "$VERSION_OUTPUT" |
112
+ sed -n '1s/^peach v\([^[:space:]]*\).*$/\1/p'
113
+ )"
114
+ [ "$REPORTED_VERSION" = "$PEACH_VERSION" ] ||
115
+ fail "the downloaded peach executable reported the wrong version"
116
+
117
+ mkdir -p "$CLI_PREFIX/bin"
118
+ NEXT_CLI="$CLI.next.$$"
119
+ cp "$TMP_BIN" "$NEXT_CLI"
120
+ mv -f "$NEXT_CLI" "$CLI"
121
+
122
+ update_shell_profile() {
123
+ profile="$1"
124
+ shell_name="$2"
125
+ path_line="export PATH=\"$CLI_PREFIX/bin:\$PATH\""
126
+ integration=""
127
+ if [ -n "$shell_name" ]; then
128
+ integration="eval \"\$(command peach __shell-init $shell_name)\""
129
+ fi
130
+
131
+ add_path=0
132
+ if ! { [ -f "$profile" ] && grep -F "$path_line" "$profile" >/dev/null 2>&1; }; then
133
+ add_path=1
134
+ fi
135
+
136
+ add_integration=0
137
+ if [ -n "$integration" ] &&
138
+ ! { [ -f "$profile" ] && grep -F "$integration" "$profile" >/dev/null 2>&1; }; then
139
+ add_integration=1
140
+ fi
141
+
142
+ if [ "$add_path" = 0 ] && [ "$add_integration" = 0 ]; then
143
+ return 0
144
+ fi
145
+
146
+ {
147
+ printf '\n'
148
+ printf '# added by the peach installer; delete this block to remove peach from your shell\n'
149
+ if [ "$add_path" = 1 ]; then
150
+ printf '%s\n' "$path_line"
151
+ fi
152
+ if [ "$add_integration" = 1 ]; then
153
+ printf '%s\n' "$integration"
154
+ fi
155
+ } >>"$profile"
156
+
157
+ if [ "$add_path" = 1 ]; then
158
+ say "Added $CLI_PREFIX/bin to PATH in $profile"
159
+ fi
160
+ if [ "$add_integration" = 1 ]; then
161
+ say "Added Peach shell integration to $profile"
162
+ fi
163
+ }
164
+
165
+ if [ "${PEACH_NO_MODIFY_PATH:-0}" = "1" ]; then
166
+ case ":$PATH:" in
167
+ *":$CLI_PREFIX/bin:"*) ;;
168
+ *)
169
+ say ""
170
+ say "$CLI_PREFIX/bin is not currently on PATH. Add it with:"
171
+ say " export PATH=\"$CLI_PREFIX/bin:\$PATH\""
172
+ ;;
173
+ esac
174
+ case "${SHELL:-}" in
175
+ */zsh) say " eval \"\$(command peach __shell-init zsh)\"" ;;
176
+ */bash) say " eval \"\$(command peach __shell-init bash)\"" ;;
177
+ esac
178
+ else
179
+ case "${SHELL:-}" in
180
+ */zsh) update_shell_profile "$HOME/.zshrc" zsh ;;
181
+ */bash) update_shell_profile "$HOME/.bashrc" bash ;;
182
+ *) update_shell_profile "$HOME/.profile" "" ;;
183
+ esac
184
+ fi
185
+
186
+ PATH="$CLI_PREFIX/bin:$PATH"
187
+ export PATH
188
+
189
+ say ""
190
+ say "peach is installed."
191
+ say ""
192
+ if [ -t 1 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
193
+ say "Continuing with guided setup..."
194
+ "$CLI" setup </dev/tty
195
+ else
196
+ say "Run this deterministic setup command in CI:"
197
+ say ""
198
+ say " peach setup --ci --yes --json"
199
+ fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peachsim",
3
- "version": "0.1.625",
3
+ "version": "0.1.627",
4
4
  "description": "Vite and Metro plugins, testing drivers, and SDK exports for peach.",
5
5
  "author": "Tamagui LLC",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -13,6 +13,9 @@
13
13
  "publishConfig": {
14
14
  "access": "public"
15
15
  },
16
+ "bin": {
17
+ "peachsim": "./install.mjs"
18
+ },
16
19
  "types": "./src/index.ts",
17
20
  "exports": {
18
21
  ".": {
@@ -267,6 +270,8 @@
267
270
  "./package.json": "./package.json"
268
271
  },
269
272
  "files": [
273
+ "install.mjs",
274
+ "install.sh",
270
275
  "dist-lib/",
271
276
  "src/",
272
277
  "cli/",
@@ -13,7 +13,8 @@
13
13
  // zero HTTP requests; probes only fire when a port appears, disappears, or its
14
14
  // pid changes. before any HTTP, a TCP connect gate skips unreachable ports.
15
15
 
16
- import { exec } from 'child_process'
16
+ import { spawn } from 'child_process'
17
+ import { terminateProcessGroup } from '../../contrast/src/runtime/process-group'
17
18
  import { readlink } from 'fs/promises'
18
19
  import http from 'http'
19
20
  import net from 'net'
@@ -37,52 +38,66 @@ import {
37
38
  import { loadOptionalDemoApps, optionalDemoAppsSync } from './optional-demo-registry.ts'
38
39
  import type { AppSettingsSpecifier } from '@peach/globals'
39
40
 
40
- // how long past a command's own budget we wait before deciding its exit event is
41
- // never coming. generous, because this only fires when the child is already
42
- // overdue and we are choosing between a wrong answer and a hang.
43
- const EXEC_EXIT_GRACE_MS = 2_000
44
-
45
41
  /** every subprocess in this file goes through here.
46
42
  *
47
- * `exec`'s own `timeout` option is NOT a backstop. it kills the child and then
48
- * STILL waits for a 'close' event to settle the promise, so when that event is
49
- * lost the promise never settles — the timeout is already spent and nothing is
50
- * left to rescue it. (same failure m10843 pinned in `CheckoutFilePlane.refresh`.)
51
- * `lsof` walks every open fd on the machine, which makes it a realistic victim.
52
- *
53
- * a hung scan is worse than a slow one: `inflightScan` never clears, so
54
- * `/__server-scan` stops answering at all instead of answering "could not
55
- * look". so we race the child against a real wall-clock deadline and reject
56
- * with `killed`, which callers already read as could-not-look. */
57
- async function execGuarded(
43
+ * the command runs under `/bin/sh` as the leader of its own process group, and
44
+ * a timeout kills the whole group. `exec`'s own `timeout` kills only the shell:
45
+ * for `lsof | grep` that orphans `lsof` to launchd, where it keeps walking every
46
+ * open fd on the machine, and a tray rescanning every few seconds on a busy
47
+ * host piled up hundreds of them. the deadline is our own timer, so a lost
48
+ * 'close' event cannot leave the promise unsettled either. a timeout rejects
49
+ * with `killed`, which callers read as could-not-look; a non-zero exit rejects
50
+ * as `exec` did. */
51
+ function execGuarded(
58
52
  command: string,
59
53
  timeout: number,
60
54
  extra: { maxBuffer?: number } = {},
61
55
  ): Promise<{ stdout: string }> {
62
- let child: ReturnType<typeof exec> | undefined
63
- const ran = new Promise<{ stdout: string }>((resolve, reject) => {
64
- child = exec(command, { encoding: 'utf8', timeout, ...extra }, (err, stdout) => {
56
+ const maxBuffer = extra.maxBuffer ?? 1024 * 1024
57
+ return new Promise((resolve, reject) => {
58
+ const child = spawn('/bin/sh', ['-c', command], {
59
+ detached: true,
60
+ stdio: ['ignore', 'pipe', 'ignore'],
61
+ })
62
+ let stdout = ''
63
+ let settled = false
64
+ let killing = false
65
+ const settle = (err: Error | null) => {
66
+ if (settled) return
67
+ settled = true
68
+ clearTimeout(deadline)
65
69
  if (err) reject(err)
66
70
  else resolve({ stdout })
71
+ }
72
+ // a timeout or an overflow settles only once the whole group is gone.
73
+ const killThenSettle = (err: Error) => {
74
+ if (killing) return
75
+ killing = true
76
+ clearTimeout(deadline)
77
+ terminateProcessGroup(child, 'SIGKILL', 0).finally(() => settle(err))
78
+ }
79
+ const deadline = setTimeout(() => {
80
+ killThenSettle(
81
+ Object.assign(new Error(`\`${command}\` did not exit within ${timeout}ms`), {
82
+ killed: true,
83
+ }),
84
+ )
85
+ }, timeout)
86
+ child.stdout.setEncoding('utf8')
87
+ child.stdout.on('data', (chunk: string) => {
88
+ stdout += chunk
89
+ if (stdout.length > maxBuffer) {
90
+ killThenSettle(new Error(`\`${command}\` exceeded maxBuffer ${maxBuffer}`))
91
+ }
92
+ })
93
+ child.on('error', (err) => {
94
+ if (!killing) settle(err)
95
+ })
96
+ child.on('close', (code) => {
97
+ if (killing) return
98
+ settle(code === 0 ? null : new Error(`\`${command}\` exited with ${code}`))
67
99
  })
68
100
  })
69
- let deadlineTimer: ReturnType<typeof setTimeout> | undefined
70
- const deadline = new Promise<never>((_resolve, reject) => {
71
- deadlineTimer = setTimeout(() => {
72
- child?.kill('SIGKILL')
73
- const err = new Error(`\`${command}\` did not exit within ${timeout}ms`)
74
- // matches the shape `wasKilledByTimeout` reads off a real exec timeout, so
75
- // a lost exit event and a genuine timeout reach callers as the same fact.
76
- Object.assign(err, { killed: true })
77
- reject(err)
78
- }, timeout + EXEC_EXIT_GRACE_MS)
79
- })
80
- try {
81
- return await Promise.race([ran, deadline])
82
- } finally {
83
- // the race is settled, so drop the deadline rather than leaving it pending.
84
- clearTimeout(deadlineTimer)
85
- }
86
101
  }
87
102
 
88
103
  export interface DiscoveredServer {
@@ -198,6 +213,7 @@ function httpGet(
198
213
  // first answer wins: the response, the deadline, and the connection closing
199
214
  // all race, and every one of them can arrive after another has decided.
200
215
  let settled = false
216
+ let killing = false
201
217
  const settle = (value: HttpResult | null) => {
202
218
  if (settled) return
203
219
  settled = true
@@ -341,11 +357,32 @@ export async function scanListeningProcesses(
341
357
  excludePorts: number[] = [],
342
358
  ): Promise<ListeningProcessScan> {
343
359
  const excluded = new Set(excludePorts)
360
+ const listing = await listListenersShared()
361
+ if (listing.state === 'could-not-look') return listing
362
+ return {
363
+ state: 'looked',
364
+ processes: listing.processes.filter((p) => acceptPort(p.port, excluded)),
365
+ }
366
+ }
367
+
368
+ // every caller (the tray's interval, the gateway, the dev middleware, `peach
369
+ // open`) shares one listing while it runs. a scan slower than the tray's
370
+ // interval would otherwise start a second `lsof` on top of the first.
371
+ let inflightListing: Promise<ListeningProcessScan> | null = null
372
+
373
+ function listListenersShared(): Promise<ListeningProcessScan> {
374
+ inflightListing ??= listListeners().finally(() => {
375
+ inflightListing = null
376
+ })
377
+ return inflightListing
378
+ }
379
+
380
+ /** every listening node/bun process, one pid per port, before any port filter. */
381
+ async function listListeners(): Promise<ListeningProcessScan> {
344
382
  let timedOut = false
345
383
 
346
384
  // lsof layout: COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
347
385
  // NAME looks like "*:8081" or "127.0.0.1:8081" for a LISTEN line.
348
- // async exec — sync blocked CrBrowserMain for ~600ms/scan on macOS.
349
386
  try {
350
387
  const { stdout } = await execGuarded(
351
388
  `lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | grep -E '^(node|bun)'`,
@@ -356,11 +393,9 @@ export async function scanListeningProcesses(
356
393
  const parts = line.trim().split(/\s+/)
357
394
  if (parts.length < 9) continue
358
395
  const pid = Number(parts[1])
359
- const addr = parts[8]
360
- const m = addr.match(/:(\d+)$/)
396
+ const m = parts[8].match(/:(\d+)$/)
361
397
  if (!m) continue
362
398
  const port = Number(m[1])
363
- if (!acceptPort(port, excluded)) continue
364
399
  if (!seen.has(port)) seen.set(port, pid)
365
400
  }
366
401
  // lsof ran. an empty result is a real answer: nothing is listening.
@@ -385,7 +420,6 @@ export async function scanListeningProcesses(
385
420
  if (!portMatch) continue
386
421
  const port = Number(portMatch[1])
387
422
  const pid = pidMatch ? Number(pidMatch[1]) : 0
388
- if (!acceptPort(port, excluded)) continue
389
423
  if (!seen.has(port)) seen.set(port, pid)
390
424
  }
391
425
  return {
@@ -402,10 +436,7 @@ export async function scanListeningProcesses(
402
436
 
403
437
  // neither tool exists here, so probe the conventional bundler ports directly.
404
438
  // that is a real (if narrow) look, not a substitute for one.
405
- return {
406
- state: 'looked',
407
- processes: FALLBACK_PORTS.filter((p) => acceptPort(p.port, excluded)),
408
- }
439
+ return { state: 'looked', processes: FALLBACK_PORTS }
409
440
  }
410
441
 
411
442
  // kept for backwards compatibility with any external callers that only need
@@ -173,3 +173,14 @@ export function isSootsimBridgeHealth(value: unknown, expectedPort: number): boo
173
173
  // loop; playwright-owned sims use it to close their hosting context.
174
174
  export const SOOTSIM_BRIDGE_SIM_CLOSE_CODE = 4001
175
175
  export const SOOTSIM_BRIDGE_SIM_CLOSE_REASON = 'sootsim close'
176
+
177
+ // automation sims (spawned by the playwright driver: `peach open --driver
178
+ // playwright`, the factory/screenshot capture path, `meta.sootsimHostDriver
179
+ // === 'playwright'`) are unattended browsers, never a human's tab, and an idle
180
+ // one still renders at full rate (the host disables background throttling so
181
+ // captures see real motion). only a cli proves one is in use: attached, which
182
+ // covers a long in-flight command such as a recording, or a command within
183
+ // this window. past it the bridge reaper closes the sim socket with the code
184
+ // above, the page asks its host to close, and the host exits with its chrome
185
+ // tree.
186
+ export const AUTOMATION_SIM_IDLE_REAP_TTL_MS = 5 * 60_000
@@ -164,6 +164,7 @@ export interface RnxRasterRegionTree {
164
164
  nodeId: number
165
165
  parentNodeId: number | null
166
166
  type: string
167
+ props?: Record<string, unknown>
167
168
  text?: string | null
168
169
  style: Record<string, unknown>
169
170
  layout: RnxCaptureRect
@@ -173,14 +174,33 @@ export interface RnxRasterRegionTree {
173
174
  }[]
174
175
  }
175
176
 
177
+ function isLiveRasterSource(node: RnxRasterRegionTree['nodes'][number]): boolean {
178
+ const source = node.props?.source
179
+ return (
180
+ node.type === 'image' &&
181
+ !!source &&
182
+ typeof source === 'object' &&
183
+ 'uri' in source &&
184
+ typeof source.uri === 'string' &&
185
+ source.uri.startsWith('live:')
186
+ )
187
+ }
188
+
176
189
  export function selectRnxRasterNodeIds(tree: RnxRasterRegionTree): Set<number> {
177
190
  const nodesById = new Map(tree.nodes.map((node) => [node.nodeId, node]))
178
191
  const unsupported = new Set<number>()
179
192
  for (const node of tree.nodes) {
180
- const visibleFrame = node.visibleFrame ?? node.frame
193
+ const liveVideo = isLiveRasterSource(node)
194
+ const visibleFrame =
195
+ liveVideo &&
196
+ node.visibleFrame &&
197
+ (node.visibleFrame.width <= 0 || node.visibleFrame.height <= 0)
198
+ ? node.frame
199
+ : (node.visibleFrame ?? node.frame)
181
200
  if (!visibleFrame || visibleFrame.width <= 0 || visibleFrame.height <= 0) continue
182
201
  if (
183
202
  node.type === 'masked-view' ||
203
+ liveVideo ||
184
204
  (node.type === 'text' &&
185
205
  typeof node.text === 'string' &&
186
206
  (/\p{Emoji_Presentation}/u.test(node.text) ||
@@ -208,21 +228,33 @@ export function selectRnxRasterNodeIds(tree: RnxRasterRegionTree): Set<number> {
208
228
 
209
229
  const screenArea = tree.width * tree.height
210
230
  const maxLayerArea = screenArea * 0.25
211
- const maxAtlasArea = screenArea * 0.5
212
231
  const candidates = [...unsupported]
213
232
  .map((nodeId) => {
214
233
  const node = nodesById.get(nodeId)
215
- const frame = node?.visibleFrame ?? node?.frame
216
- return frame ? { nodeId, area: frame.width * frame.height } : null
234
+ if (!node) return null
235
+ const liveVideo = isLiveRasterSource(node)
236
+ const frame =
237
+ liveVideo &&
238
+ node.visibleFrame &&
239
+ (node.visibleFrame.width <= 0 || node.visibleFrame.height <= 0)
240
+ ? node.frame
241
+ : (node.visibleFrame ?? node.frame)
242
+ return frame ? { nodeId, area: frame.width * frame.height, liveVideo } : null
217
243
  })
218
244
  .filter(
219
- (candidate): candidate is { nodeId: number; area: number } =>
220
- candidate !== null && candidate.area <= maxLayerArea,
245
+ (candidate): candidate is { nodeId: number; area: number; liveVideo: boolean } =>
246
+ candidate !== null && (candidate.liveVideo || candidate.area <= maxLayerArea),
247
+ )
248
+ .sort(
249
+ (left, right) =>
250
+ Number(right.liveVideo) - Number(left.liveVideo) ||
251
+ left.area - right.area ||
252
+ left.nodeId - right.nodeId,
221
253
  )
222
- .sort((left, right) => left.area - right.area || left.nodeId - right.nodeId)
223
254
  const selected = new Set<number>()
224
255
  let atlasArea = 0
225
256
  for (const candidate of candidates) {
257
+ const maxAtlasArea = screenArea * (candidate.liveVideo ? 1 : 0.5)
226
258
  if (atlasArea + candidate.area > maxAtlasArea) continue
227
259
  selected.add(candidate.nodeId)
228
260
  atlasArea += candidate.area
@@ -345,6 +377,7 @@ export function rnxCaptureRasterRegions(
345
377
  nodeId: node.nodeId,
346
378
  parentNodeId: node.parentNodeId,
347
379
  type: node.type,
380
+ props: node.props,
348
381
  text: node.text,
349
382
  style: node.style.resolved,
350
383
  layout: node.layout.local,