peachsim 0.1.527
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.
- package/LICENSE +22 -0
- package/README.md +640 -0
- package/cli/app-config.ts +82 -0
- package/cli/app-fonts.ts +388 -0
- package/cli/app-project.ts +59 -0
- package/cli/app-splash.ts +171 -0
- package/cli/app-state-reset.ts +26 -0
- package/cli/auth.ts +184 -0
- package/cli/bin.ts +59 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2806 -0
- package/cli/browser-backend.ts +44 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +1530 -0
- package/cli/cloud-dispatch.ts +206 -0
- package/cli/cloud-session.ts +227 -0
- package/cli/command-contract.ts +221 -0
- package/cli/command-registry.ts +304 -0
- package/cli/commands/agent-wrapper.ts +1048 -0
- package/cli/commands/agent.ts +439 -0
- package/cli/commands/app-fonts.ts +99 -0
- package/cli/commands/assert.ts +542 -0
- package/cli/commands/auth.ts +71 -0
- package/cli/commands/box/bundle-plane.ts +71 -0
- package/cli/commands/box/checkout-plane.ts +256 -0
- package/cli/commands/box/plane-share.ts +208 -0
- package/cli/commands/box/rnx-command.ts +60 -0
- package/cli/commands/box/search-command.ts +157 -0
- package/cli/commands/box.ts +925 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +176 -0
- package/cli/commands/config.ts +34 -0
- package/cli/commands/control.ts +2154 -0
- package/cli/commands/cpu-profile.ts +941 -0
- package/cli/commands/daemon-mac-app.ts +165 -0
- package/cli/commands/daemon.ts +800 -0
- package/cli/commands/debug.ts +751 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +201 -0
- package/cli/commands/detox.ts +481 -0
- package/cli/commands/device.ts +134 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/do-chain.ts +408 -0
- package/cli/commands/electron.ts +94 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1240 -0
- package/cli/commands/inspect/actions.ts +516 -0
- package/cli/commands/inspect/core.ts +2086 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +119 -0
- package/cli/commands/inspect/env.ts +25 -0
- package/cli/commands/inspect/find.ts +169 -0
- package/cli/commands/inspect/get-layout.ts +53 -0
- package/cli/commands/inspect/keyboard.ts +54 -0
- package/cli/commands/inspect/list.ts +175 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +77 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +32 -0
- package/cli/commands/inspect/settling.ts +197 -0
- package/cli/commands/inspect/shared.ts +259 -0
- package/cli/commands/inspect/sleep.ts +15 -0
- package/cli/commands/inspect/tree.ts +23 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +211 -0
- package/cli/commands/inspect/wait-idle.ts +25 -0
- package/cli/commands/inspect/wait-ready.ts +87 -0
- package/cli/commands/inspect/wait-selector.ts +59 -0
- package/cli/commands/inspect/wait.ts +32 -0
- package/cli/commands/inspect.ts +4738 -0
- package/cli/commands/install-cli.ts +275 -0
- package/cli/commands/install-desktop.ts +385 -0
- package/cli/commands/login.ts +357 -0
- package/cli/commands/logout.ts +24 -0
- package/cli/commands/maestro-generate.ts +375 -0
- package/cli/commands/maestro.ts +536 -0
- package/cli/commands/mode.ts +58 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +67 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/platform.ts +410 -0
- package/cli/commands/preview.ts +103 -0
- package/cli/commands/profile.ts +106 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1435 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +89 -0
- package/cli/commands/runtime.ts +348 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +144 -0
- package/cli/commands/screenshot-mode.ts +38 -0
- package/cli/commands/screenshot.ts +492 -0
- package/cli/commands/screenshots-capture.ts +608 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +139 -0
- package/cli/commands/setup.ts +416 -0
- package/cli/commands/shell-boolean-mode.ts +82 -0
- package/cli/commands/skills.ts +468 -0
- package/cli/commands/slides.ts +357 -0
- package/cli/commands/state.ts +94 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +55 -0
- package/cli/commands/test.ts +535 -0
- package/cli/commands/three-mode.ts +771 -0
- package/cli/commands/timeline.ts +121 -0
- package/cli/commands/upgrade.ts +167 -0
- package/cli/commands/upload.ts +1203 -0
- package/cli/commands/version.ts +63 -0
- package/cli/commands/welcome.ts +17 -0
- package/cli/commands/what-happened.ts +328 -0
- package/cli/current-sim.ts +217 -0
- package/cli/desktop-companion.ts +315 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +184 -0
- package/cli/drivers/playwright-sim-host.ts +537 -0
- package/cli/drivers/playwright.ts +209 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-export.ts +55 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +126 -0
- package/cli/flow-session.ts +140 -0
- package/cli/help-core.ts +143 -0
- package/cli/help.ts +44 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +214 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/internal-child.ts +176 -0
- package/cli/maestro-js.ts +323 -0
- package/cli/main.ts +704 -0
- package/cli/open-url.ts +8 -0
- package/cli/outbound-endpoints.ts +830 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +189 -0
- package/cli/privacy.ts +53 -0
- package/cli/prompt.ts +56 -0
- package/cli/recording-access.ts +108 -0
- package/cli/registry.ts +2 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/rnx-in-process.ts +98 -0
- package/cli/run-registry.ts +226 -0
- package/cli/run-rnx-core.ts +148 -0
- package/cli/run-rnx.ts +137 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/secure-file.ts +31 -0
- package/cli/self-invocation.ts +58 -0
- package/cli/send-to-box.ts +109 -0
- package/cli/setup-repository.ts +191 -0
- package/cli/shell-init.ts +98 -0
- package/cli/standalone.ts +4 -0
- package/cli/telemetry.ts +84 -0
- package/cli/ui.ts +68 -0
- package/cli/ws-bridge.ts +1020 -0
- package/detox/attempt-events.cjs +23 -0
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +48 -0
- package/detox/expectations.ts +534 -0
- package/detox/gestures.ts +501 -0
- package/detox/index.ts +2694 -0
- package/detox/jest-preset.cjs +29 -0
- package/detox/jest-setup-after-env.cjs +22 -0
- package/detox/matchers.ts +30 -0
- package/detox/motion-change.cjs +109 -0
- package/detox/motion-change.d.cts +35 -0
- package/detox/navigation-lifecycle.ts +290 -0
- package/detox/navigation.ts +43 -0
- package/detox/proof-frame.cjs +114 -0
- package/detox/record-block-execution.cjs +78 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-lib/agent-daemon-client.cjs +502 -0
- package/dist-lib/agent-events.cjs +48 -0
- package/dist-lib/agent-identity.cjs +53 -0
- package/dist-lib/agent-sessions.cjs +759 -0
- package/dist-lib/attached-projects.cjs +526 -0
- package/dist-lib/auth/shared-session.cjs +208 -0
- package/dist-lib/backend-origin.cjs +88 -0
- package/dist-lib/beta.cjs +41 -0
- package/dist-lib/beta.mjs +13 -0
- package/dist-lib/bridge-constants.cjs +161 -0
- package/dist-lib/bridge-contract-input.cjs +451 -0
- package/dist-lib/bridge-contract-input.mjs +414 -0
- package/dist-lib/bridge-contract.cjs +130 -0
- package/dist-lib/bridge-contract.mjs +94 -0
- package/dist-lib/capture-contract.cjs +220 -0
- package/dist-lib/capture-contract.mjs +190 -0
- package/dist-lib/cli-constants.cjs +32 -0
- package/dist-lib/cloud-contract.cjs +194 -0
- package/dist-lib/cloud-contract.mjs +145 -0
- package/dist-lib/cloud.cjs +670 -0
- package/dist-lib/cloud.mjs +639 -0
- package/dist-lib/config.cjs +96 -0
- package/dist-lib/detox/index.cjs +3789 -0
- package/dist-lib/detox/jest-preset.cjs +29 -0
- package/dist-lib/dev-bundle-resolution.cjs +290 -0
- package/dist-lib/home-paths.cjs +638 -0
- package/dist-lib/host/bridge-host.cjs +10903 -0
- package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
- package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
- package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
- package/dist-lib/host/replacement-module-handler.cjs +469 -0
- package/dist-lib/host/websocket-proxy.cjs +298 -0
- package/dist-lib/index.cjs +4268 -0
- package/dist-lib/jump-to-source-babel.cjs +93 -0
- package/dist-lib/jump-to-source-native.cjs +112 -0
- package/dist-lib/menu.cjs +646 -0
- package/dist-lib/menu.mjs +604 -0
- package/dist-lib/metro-fingerprint-registry.cjs +186 -0
- package/dist-lib/metro-fingerprint-registry.mjs +156 -0
- package/dist-lib/metro-production-bundle.cjs +329 -0
- package/dist-lib/metro-production-bundle.mjs +295 -0
- package/dist-lib/metro.cjs +904 -0
- package/dist-lib/profiles.cjs +246 -0
- package/dist-lib/public-brand.cjs +39 -0
- package/dist-lib/react-native-host-modules.cjs +51 -0
- package/dist-lib/react-native-host-modules.mjs +25 -0
- package/dist-lib/render-mode.cjs +54 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
- package/dist-lib/sdk.cjs +2433 -0
- package/dist-lib/sdk.mjs +2336 -0
- package/dist-lib/skills.cjs +39132 -0
- package/dist-lib/swift-submit.cjs +92 -0
- package/dist-lib/swift-submit.mjs +65 -0
- package/dist-lib/swift.cjs +1646 -0
- package/dist-lib/vite.cjs +1591 -0
- package/package.json +290 -0
- package/scripts/dev-server-scanner.ts +1291 -0
- package/scripts/optional-demo-registry.ts +113 -0
- package/skills/contrast/SKILL.md +165 -0
- package/skills/rnx-debug/SKILL.md +433 -0
- package/skills/rnx-perf/SKILL.md +399 -0
- package/skills/rnx-setup/SKILL.md +172 -0
- package/skills/rnx-test/SKILL.md +321 -0
- package/skills/rnx-visual/SKILL.md +126 -0
- package/src/agent-daemon-client.ts +410 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-identity.ts +23 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +580 -0
- package/src/app-url.ts +39 -0
- package/src/attached-projects.ts +539 -0
- package/src/auth/shared-session.ts +235 -0
- package/src/backend-origin.ts +70 -0
- package/src/beta.ts +19 -0
- package/src/bridge-constants.ts +175 -0
- package/src/bridge-contract-input.ts +502 -0
- package/src/bridge-contract.ts +558 -0
- package/src/browser-cache.ts +16 -0
- package/src/capture-contract.ts +587 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +40 -0
- package/src/cloud-contract.ts +274 -0
- package/src/cloud.ts +762 -0
- package/src/codex-client.ts +234 -0
- package/src/config.ts +132 -0
- package/src/connect.ts +101 -0
- package/src/dev-bundle-resolution.ts +346 -0
- package/src/dev-server-open.ts +198 -0
- package/src/disk-cleanup.ts +540 -0
- package/src/flow-timeout-scale.ts +15 -0
- package/src/home-paths.ts +806 -0
- package/src/host/agent-host.ts +666 -0
- package/src/host/bridge-host.ts +2788 -0
- package/src/host/fetch-proxy-handler.ts +449 -0
- package/src/host/fetch-proxy-overrides.ts +51 -0
- package/src/host/open-url.ts +89 -0
- package/src/host/plane-host.ts +203 -0
- package/src/host/replacement-module-handler.ts +158 -0
- package/src/host/websocket-proxy.ts +315 -0
- package/src/index.ts +71 -0
- package/src/jump-to-source-babel.ts +135 -0
- package/src/jump-to-source-native.ts +102 -0
- package/src/menu.ts +777 -0
- package/src/metro-fingerprint-registry.ts +302 -0
- package/src/metro-plugin.ts +341 -0
- package/src/metro-production-bundle.ts +471 -0
- package/src/native-dev-bundle-url.ts +118 -0
- package/src/native-seam-manifest.ts +341 -0
- package/src/playwright-host-log.ts +16 -0
- package/src/plist.ts +27 -0
- package/src/poll-until.ts +123 -0
- package/src/profiles.ts +179 -0
- package/src/public-brand.ts +9 -0
- package/src/react-native-host-modules.ts +26 -0
- package/src/render-mode.ts +27 -0
- package/src/rolldown-plugin-swift.ts +240 -0
- package/src/runtime-assets.ts +233 -0
- package/src/runtime-delivery.ts +54 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +514 -0
- package/src/screenshots/orchestrate.ts +256 -0
- package/src/screenshots/org-deck.ts +91 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/sdk.ts +15 -0
- package/src/settings-bundle.ts +418 -0
- package/src/shell-bridge-identity.ts +15 -0
- package/src/sim-client.ts +697 -0
- package/src/skills/builtin/a11y-review.ts +132 -0
- package/src/skills/builtin/compat-check.ts +71 -0
- package/src/skills/builtin/maestro-test.ts +121 -0
- package/src/skills/builtin/perf-profile.ts +90 -0
- package/src/skills/builtin/screenshot-all.ts +52 -0
- package/src/skills/builtin/visual-diff.ts +100 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/swift-submit.ts +185 -0
- package/src/swift.ts +7 -0
- package/src/team-machine-prompt.ts +73 -0
- package/src/vite-plugin-one.ts +264 -0
- package/src/vite-plugin-swift.ts +620 -0
- package/src/vite-plugin.ts +1946 -0
- package/src/worklets-babel.ts +250 -0
package/cli/ws-bridge.ts
ADDED
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
// rnx CLI ↔ sim WS bridge
|
|
2
|
+
//
|
|
3
|
+
// the rnx runtime opens a WebSocket server (default port 7668) that
|
|
4
|
+
// accepts JSON commands like `{ type: 'evaluate', code: 'window.__sootsimTest.getNodeCount()' }`
|
|
5
|
+
// or `{ type: 'call', path: 'SootSim.bridges.mainShell.launchApp', args: ['photos'] }`
|
|
6
|
+
// and responds with `{ id, result }` or `{ id, error }`. CLI commands use
|
|
7
|
+
// this to drive a running rnx instance from the terminal.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { WebSocket } from 'ws'
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_SOOTSIM_BRIDGE_PORT,
|
|
14
|
+
resolveSootsimBridgePort,
|
|
15
|
+
} from '../src/bridge-constants'
|
|
16
|
+
import { DEFAULT_SOOTSIM_SHELL_URL } from '../src/cli-constants'
|
|
17
|
+
import {
|
|
18
|
+
isDaemonLockfileFresh,
|
|
19
|
+
isDevBridgeLockfileFresh,
|
|
20
|
+
readDaemonLockfile,
|
|
21
|
+
readDevBridgeLockfiles,
|
|
22
|
+
rnxHomeDir,
|
|
23
|
+
type DevBridgeLockfile,
|
|
24
|
+
} from '../src/home-paths'
|
|
25
|
+
import { createCloudBridgeForParsed } from './cloud-client'
|
|
26
|
+
import {
|
|
27
|
+
clearCurrentSimId,
|
|
28
|
+
getCliIdentity,
|
|
29
|
+
readCurrentSim,
|
|
30
|
+
readCurrentSimId,
|
|
31
|
+
saveCurrentSimId,
|
|
32
|
+
} from './current-sim'
|
|
33
|
+
|
|
34
|
+
// --- bridge world resolution ---------------------------------------------
|
|
35
|
+
//
|
|
36
|
+
// two bridge kinds can coexist on one machine: Vite dev-shell bridges
|
|
37
|
+
// (~/.rnx/dev-bridges/<port>.json, shells served by Vite) and the installed
|
|
38
|
+
// daemon runtime (~/.rnx/daemon.json, shell served by the daemon's own
|
|
39
|
+
// http server). every default — the ws port a command connects to AND the
|
|
40
|
+
// shell base URL `open` builds its page from — must come from the SAME
|
|
41
|
+
// world, or `open` polls one bridge while the sim registers on the other
|
|
42
|
+
// (the 125s-timeout split-brain bug). resolveBridgeWorld is the single
|
|
43
|
+
// source of that decision: an invocation's explicit offset wins, then its
|
|
44
|
+
// checkout's dev bridge, then the canonical dev bridge, a fresh daemon, and
|
|
45
|
+
// finally hardcoded defaults.
|
|
46
|
+
|
|
47
|
+
export interface BridgeWorld {
|
|
48
|
+
source: 'dev-bridge' | 'daemon' | 'default'
|
|
49
|
+
bridgePort: number
|
|
50
|
+
shellBaseUrl: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// where this invocation is running. the same rnx commands work in four
|
|
54
|
+
// places, and the placement is what a command consults instead of growing a
|
|
55
|
+
// second spelling per place. host and local-box share one transport today —
|
|
56
|
+
// a local box mounts this machine's checkout and drives this machine's
|
|
57
|
+
// bridge — so they differ in what is allowed, not in where packets go. a
|
|
58
|
+
// cloud box is the one that moves the endpoint, and it does not exist yet.
|
|
59
|
+
export type RnxPlacement = 'host' | 'local-box' | 'cloud-box'
|
|
60
|
+
|
|
61
|
+
// set by `rnx box` on the shell's child processes. the box is the only thing
|
|
62
|
+
// that may set it; nothing infers a placement from cwd or heuristics.
|
|
63
|
+
export const RNX_PLACEMENT_ENV = 'RNX_PLACEMENT'
|
|
64
|
+
|
|
65
|
+
export function resolvePlacement(): RnxPlacement {
|
|
66
|
+
return process.env[RNX_PLACEMENT_ENV] === 'local-box' ? 'local-box' : 'host'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** the ws endpoint a command connects to. the single place a bridge URL is
|
|
70
|
+
* formed, so a cloud box changes this function rather than every call site.
|
|
71
|
+
* host and local-box both drive this machine's bridge.
|
|
72
|
+
*
|
|
73
|
+
* changing what this returns changes real egress, and the outbound inventory
|
|
74
|
+
* declares the CALL EXPRESSION rather than the URL, so it cannot see through
|
|
75
|
+
* this function. a cloud endpoint added here must get its own declaration in
|
|
76
|
+
* `outbound-endpoints.ts` instead of inheriting the local_development one. */
|
|
77
|
+
export function bridgeWsUrl(wsPort: number): string {
|
|
78
|
+
return `ws://127.0.0.1:${wsPort}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// resolved once per CLI process so a multi-step command keeps one world for
|
|
82
|
+
// its entire invocation. separate CLI calls make the same deterministic choice
|
|
83
|
+
// from the owner-scoped dev records instead of following whichever heartbeat
|
|
84
|
+
// happened to run last.
|
|
85
|
+
let resolvedWorld: { homeDir: string; world: BridgeWorld } | null = null
|
|
86
|
+
|
|
87
|
+
function liveDevBridgeLockfiles(): DevBridgeLockfile[] {
|
|
88
|
+
return readDevBridgeLockfiles().filter((lockfile) => isDevBridgeLockfileFresh(lockfile))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function checkoutRoot(cwd: string): string | null {
|
|
92
|
+
let current = path.resolve(cwd)
|
|
93
|
+
while (true) {
|
|
94
|
+
if (fs.existsSync(path.join(current, '.git'))) return current
|
|
95
|
+
const parent = path.dirname(current)
|
|
96
|
+
if (parent === current) return null
|
|
97
|
+
current = parent
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pathContains(parent: string, child: string): boolean {
|
|
102
|
+
const relative = path.relative(parent, child)
|
|
103
|
+
return (
|
|
104
|
+
relative === '' ||
|
|
105
|
+
(relative !== '..' &&
|
|
106
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
107
|
+
!path.isAbsolute(relative))
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolveDefaultDevBridgeLockfile(): DevBridgeLockfile | null {
|
|
112
|
+
const lockfiles = liveDevBridgeLockfiles()
|
|
113
|
+
if (lockfiles.length === 0) return null
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
process.env.SOOTSIM_BRIDGE_PORT !== undefined ||
|
|
117
|
+
process.env.PORT_OFFSET !== undefined
|
|
118
|
+
) {
|
|
119
|
+
const requestedPort = resolveSootsimBridgePort({
|
|
120
|
+
explicitPort: process.env.SOOTSIM_BRIDGE_PORT,
|
|
121
|
+
portOffset: process.env.PORT_OFFSET,
|
|
122
|
+
})
|
|
123
|
+
const requested = lockfiles.find((lockfile) => lockfile.bridgePort === requestedPort)
|
|
124
|
+
if (requested) return requested
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let cwd: string | null = null
|
|
128
|
+
try {
|
|
129
|
+
cwd = process.cwd()
|
|
130
|
+
} catch {}
|
|
131
|
+
if (cwd) {
|
|
132
|
+
const currentCheckout = checkoutRoot(cwd)
|
|
133
|
+
const sameCheckout = lockfiles.filter((lockfile) => {
|
|
134
|
+
const bridgeCheckout = checkoutRoot(lockfile.cwd)
|
|
135
|
+
if (currentCheckout && bridgeCheckout) return currentCheckout === bridgeCheckout
|
|
136
|
+
return pathContains(cwd, lockfile.cwd) || pathContains(lockfile.cwd, cwd)
|
|
137
|
+
})
|
|
138
|
+
if (sameCheckout.length > 0) {
|
|
139
|
+
return (
|
|
140
|
+
sameCheckout.find(
|
|
141
|
+
(lockfile) => lockfile.bridgePort === DEFAULT_SOOTSIM_BRIDGE_PORT,
|
|
142
|
+
) ?? sameCheckout[0]
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
lockfiles.find((lockfile) => lockfile.bridgePort === DEFAULT_SOOTSIM_BRIDGE_PORT) ??
|
|
149
|
+
lockfiles[0]
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function resolveBridgeWorld(): BridgeWorld {
|
|
154
|
+
const homeDir = rnxHomeDir()
|
|
155
|
+
if (resolvedWorld?.homeDir === homeDir) return resolvedWorld.world
|
|
156
|
+
const world = resolveBridgeWorldFresh()
|
|
157
|
+
resolvedWorld = { homeDir, world }
|
|
158
|
+
return world
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resolveBridgeWorldFresh(): BridgeWorld {
|
|
162
|
+
const dev = resolveDefaultDevBridgeLockfile()
|
|
163
|
+
if (dev) {
|
|
164
|
+
return {
|
|
165
|
+
source: 'dev-bridge',
|
|
166
|
+
bridgePort: dev.bridgePort,
|
|
167
|
+
// older lockfile writers don't record the vite http port; the default
|
|
168
|
+
// shell URL is the dev shell anyway, so the fallback stays coherent.
|
|
169
|
+
shellBaseUrl: dev.shellPort
|
|
170
|
+
? `http://localhost:${dev.shellPort}/`
|
|
171
|
+
: DEFAULT_SOOTSIM_SHELL_URL,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const lock = readDaemonLockfile()
|
|
175
|
+
if (lock && isDaemonLockfileFresh(lock)) {
|
|
176
|
+
return {
|
|
177
|
+
source: 'daemon',
|
|
178
|
+
bridgePort: lock.bridgePort,
|
|
179
|
+
shellBaseUrl:
|
|
180
|
+
lock.runtimePort > 0
|
|
181
|
+
? `http://localhost:${lock.runtimePort}/`
|
|
182
|
+
: DEFAULT_SOOTSIM_SHELL_URL,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
source: 'default',
|
|
187
|
+
bridgePort: DEFAULT_SOOTSIM_BRIDGE_PORT,
|
|
188
|
+
shellBaseUrl: DEFAULT_SOOTSIM_SHELL_URL,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** the world a specific bridge port belongs to, or null when no fresh
|
|
193
|
+
* lockfile claims it. lets `open --port 7669` derive the daemon's shell
|
|
194
|
+
* base URL instead of mixing worlds. */
|
|
195
|
+
export function worldForBridgePort(
|
|
196
|
+
port: number,
|
|
197
|
+
): { source: 'dev-bridge' | 'daemon'; shellBaseUrl: string } | null {
|
|
198
|
+
const dev = liveDevBridgeLockfiles().find((lockfile) => lockfile.bridgePort === port)
|
|
199
|
+
if (dev) {
|
|
200
|
+
return {
|
|
201
|
+
source: 'dev-bridge',
|
|
202
|
+
shellBaseUrl: dev.shellPort
|
|
203
|
+
? `http://localhost:${dev.shellPort}/`
|
|
204
|
+
: DEFAULT_SOOTSIM_SHELL_URL,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const lock = readDaemonLockfile()
|
|
208
|
+
if (lock && isDaemonLockfileFresh(lock) && lock.bridgePort === port) {
|
|
209
|
+
return {
|
|
210
|
+
source: 'daemon',
|
|
211
|
+
shellBaseUrl:
|
|
212
|
+
lock.runtimePort > 0
|
|
213
|
+
? `http://localhost:${lock.runtimePort}/`
|
|
214
|
+
: DEFAULT_SOOTSIM_SHELL_URL,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** the bridge port of the world that serves a given shell base URL, or null
|
|
221
|
+
* when no fresh lockfile claims it. lets `open --base-url` (without --port)
|
|
222
|
+
* poll the bridge that actually belongs to that shell. */
|
|
223
|
+
export function bridgePortForShellBaseUrl(baseUrl: string): number | null {
|
|
224
|
+
let port: number
|
|
225
|
+
try {
|
|
226
|
+
const url = new URL(baseUrl)
|
|
227
|
+
port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80
|
|
228
|
+
} catch {
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
if (!Number.isFinite(port) || port <= 0) return null
|
|
232
|
+
const dev = liveDevBridgeLockfiles().find(
|
|
233
|
+
(lockfile) =>
|
|
234
|
+
lockfile.shellPort === port ||
|
|
235
|
+
lockfile.runtimePort === port ||
|
|
236
|
+
lockfile.bridgePort === port,
|
|
237
|
+
)
|
|
238
|
+
if (dev) {
|
|
239
|
+
return dev.bridgePort
|
|
240
|
+
}
|
|
241
|
+
const lock = readDaemonLockfile()
|
|
242
|
+
if (
|
|
243
|
+
lock &&
|
|
244
|
+
isDaemonLockfileFresh(lock) &&
|
|
245
|
+
(lock.runtimePort === port || lock.bridgePort === port)
|
|
246
|
+
) {
|
|
247
|
+
return lock.bridgePort
|
|
248
|
+
}
|
|
249
|
+
return null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** the bridge a `--base-url` pin implies. one rule for every command that takes
|
|
253
|
+
* the flag, because they used to disagree: flow refused an unserved pin while
|
|
254
|
+
* open kept the discovered default and then started a bridge of its own, so
|
|
255
|
+
* the page rendered in the pinned shell and the polling waited on another
|
|
256
|
+
* world's bridge. that reads as "saved sim <id> is gone" and gets retried
|
|
257
|
+
* rather than fixed. an explicit --port states the world outright and wins;
|
|
258
|
+
* otherwise the pin has to name a world something serves. */
|
|
259
|
+
export function resolveBridgePortForPin(pin: {
|
|
260
|
+
baseUrl: string
|
|
261
|
+
wsPort: number
|
|
262
|
+
explicitPort: boolean
|
|
263
|
+
}): { port: number; error?: undefined } | { port?: undefined; error: string } {
|
|
264
|
+
const served = bridgePortForShellBaseUrl(pin.baseUrl)
|
|
265
|
+
if (served === null) {
|
|
266
|
+
if (pin.explicitPort) return { port: pin.wsPort }
|
|
267
|
+
return {
|
|
268
|
+
error:
|
|
269
|
+
`no live rnx world serves ${pin.baseUrl} — start that shell first, ` +
|
|
270
|
+
'or name its bridge with --port <port>',
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (pin.explicitPort && pin.wsPort !== served) {
|
|
274
|
+
return {
|
|
275
|
+
error: `--base-url ${pin.baseUrl} belongs to bridge port ${served}, but --port ${pin.wsPort} names another world`,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { port: served }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** default ws bridge port for CLI commands: the fresh dev bridge when a vite
|
|
282
|
+
* shell is live, else the fresh daemon, else the hardcoded default. one
|
|
283
|
+
* deterministic answer regardless of how the CLI was invoked (global binary
|
|
284
|
+
* vs repo checkout) — invocation-path heuristics made `rnx list` flap
|
|
285
|
+
* between worlds. */
|
|
286
|
+
export function resolveDefaultBridgePort(): number {
|
|
287
|
+
return resolveBridgeWorld().bridgePort
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** one-line stderr notice naming the bridge world a command resolved, so the
|
|
291
|
+
* dev-bridge vs daemon pick is never silent (the split-brain bug hid behind
|
|
292
|
+
* exactly this silence). pass the shell base URL actually used when the
|
|
293
|
+
* caller resolved one (open); otherwise the port's own world URL prints. */
|
|
294
|
+
export function printBridgeWorldNotice(
|
|
295
|
+
bridgePort: number,
|
|
296
|
+
shellBaseUrl?: string | null,
|
|
297
|
+
): void {
|
|
298
|
+
const world = worldForBridgePort(bridgePort)
|
|
299
|
+
const kind =
|
|
300
|
+
world?.source === 'dev-bridge'
|
|
301
|
+
? 'dev bridge'
|
|
302
|
+
: world?.source === 'daemon'
|
|
303
|
+
? 'daemon bridge'
|
|
304
|
+
: 'bridge'
|
|
305
|
+
const origin =
|
|
306
|
+
world?.source === 'dev-bridge'
|
|
307
|
+
? `dev-bridges/${bridgePort}.json`
|
|
308
|
+
: world?.source === 'daemon'
|
|
309
|
+
? 'daemon.json'
|
|
310
|
+
: 'no lockfile'
|
|
311
|
+
const shell = shellBaseUrl ?? world?.shellBaseUrl
|
|
312
|
+
process.stderr.write(
|
|
313
|
+
` → ${kind} :${bridgePort} (${shell ? `shell ${shell}, ` : ''}${origin})\n`,
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface WsBridge {
|
|
318
|
+
readonly plane?: 'local' | 'cloud'
|
|
319
|
+
// `opts.timeoutMs` overrides the bridge-wide commandTimeoutMs for a single
|
|
320
|
+
// call — used for fast readiness probes so a dead/unresponsive sim fails in
|
|
321
|
+
// seconds instead of stalling the full command timeout.
|
|
322
|
+
send(
|
|
323
|
+
cmd: { type: string; [k: string]: any },
|
|
324
|
+
opts?: { timeoutMs?: number },
|
|
325
|
+
): Promise<any>
|
|
326
|
+
listSims(): Promise<BridgeSimInfo[]>
|
|
327
|
+
// a guest reload retires the socket and the same page reconnects under a new
|
|
328
|
+
// or restored id. a caller that pins an id resolves that target here; null
|
|
329
|
+
// means nothing proves what replaced it.
|
|
330
|
+
resolveReloadedSim(retiredSimId: string): Promise<string | null>
|
|
331
|
+
focusSim(simId?: string): Promise<any>
|
|
332
|
+
closeSim(simId?: string): Promise<any>
|
|
333
|
+
claim(simId?: string, opts?: { force?: boolean }): Promise<BridgeClaimResult>
|
|
334
|
+
close(): void
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface BridgeClaimResult {
|
|
338
|
+
simId: string
|
|
339
|
+
lockedBy: string
|
|
340
|
+
lockExpiresAt: number
|
|
341
|
+
bootedCount: number
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export interface BridgeLockInfo {
|
|
345
|
+
by: string
|
|
346
|
+
expiresInMs: number
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export class BridgeSimLockedError extends Error {
|
|
350
|
+
lock: BridgeLockInfo
|
|
351
|
+
constructor(message: string, lock: BridgeLockInfo) {
|
|
352
|
+
super(message)
|
|
353
|
+
this.name = 'BridgeSimLockedError'
|
|
354
|
+
this.lock = lock
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
interface WsResponse {
|
|
359
|
+
id: number
|
|
360
|
+
result?: any
|
|
361
|
+
error?: string
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
interface WsBridgeOptions {
|
|
365
|
+
commandTimeoutMs?: number
|
|
366
|
+
simId?: string
|
|
367
|
+
cliLabel?: string
|
|
368
|
+
simIdSource?: ParsedBridgeCliArgs['simIdSource']
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// label shown in the target-sim notice. explains where the simId came
|
|
372
|
+
// from so an agent can tell "I asked for a7" vs "I got default primary".
|
|
373
|
+
function describeSimIdSource(
|
|
374
|
+
source: ParsedBridgeCliArgs['simIdSource'] | undefined,
|
|
375
|
+
): string {
|
|
376
|
+
if (source === 'flag') return 'via --sim'
|
|
377
|
+
if (source === 'saved') return 'saved via `rnx use`'
|
|
378
|
+
return 'primary fallback — no sim pinned'
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export interface BridgeSimInfo {
|
|
382
|
+
id: string
|
|
383
|
+
origin?: string
|
|
384
|
+
url?: string
|
|
385
|
+
title?: string
|
|
386
|
+
userAgent?: string
|
|
387
|
+
connectedAt: number
|
|
388
|
+
lastSeenAt: number
|
|
389
|
+
lastActiveAt?: number
|
|
390
|
+
isPrimary: boolean
|
|
391
|
+
readyState: 'open' | 'closing' | 'closed'
|
|
392
|
+
attachedCliCount?: number
|
|
393
|
+
lockedBy?: string
|
|
394
|
+
lockedByKind?: 'cli' | 'user-active'
|
|
395
|
+
lockExpiresAt?: number
|
|
396
|
+
/** true when this sim's lease belongs to us. computed server-side. */
|
|
397
|
+
lockedByMe?: boolean
|
|
398
|
+
userFocused?: boolean
|
|
399
|
+
userVisible?: boolean
|
|
400
|
+
visibilityState?: string
|
|
401
|
+
documentFocused?: boolean
|
|
402
|
+
/** registration kind — 'sootsim' (default) or 'contrast'. used by the
|
|
403
|
+
* `contrast` CLI to filter, and by tooling that wants to distinguish a
|
|
404
|
+
* Contrast IDE tab from a regular rnx runtime. */
|
|
405
|
+
kind?: string
|
|
406
|
+
/** opaque metadata supplied at register time (projectId, route, the
|
|
407
|
+
* attached iOS sim id, etc). free-form by design. */
|
|
408
|
+
meta?: Record<string, unknown>
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// a lock owner is either a friendly cli label ("active user") or a raw cli
|
|
412
|
+
// identity key ("CLAUDE_CODE_SESSION_ID:<uuid>", "gppid-1234"). keep the list
|
|
413
|
+
// readable by collapsing a "<source>:<value>" identity to a short form that
|
|
414
|
+
// still says which agent holds it.
|
|
415
|
+
export function formatLockOwner(owner: string): string {
|
|
416
|
+
const colon = owner.indexOf(':')
|
|
417
|
+
if (colon <= 0) return owner.length > 24 ? `${owner.slice(0, 21)}…` : owner
|
|
418
|
+
const source = owner.slice(0, colon)
|
|
419
|
+
const value = owner.slice(colon + 1)
|
|
420
|
+
const short = value.length > 12 ? `${value.slice(0, 8)}…` : value
|
|
421
|
+
if (
|
|
422
|
+
source === 'TM_SESSION' ||
|
|
423
|
+
source === 'CLAUDE_CODE_SESSION_ID' ||
|
|
424
|
+
source === 'CODEX_THREAD_ID'
|
|
425
|
+
) {
|
|
426
|
+
return `agent ${short}`
|
|
427
|
+
}
|
|
428
|
+
return `${source} ${short}`
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
interface ParseBridgeCliArgsOptions {
|
|
432
|
+
port?: number
|
|
433
|
+
commandTimeoutMs?: number
|
|
434
|
+
stripBooleanFlags?: string[]
|
|
435
|
+
stripValueFlags?: string[]
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export interface ParsedBridgeCliArgs {
|
|
439
|
+
positional: string[]
|
|
440
|
+
wsPort: number
|
|
441
|
+
explicitPort: boolean
|
|
442
|
+
simId?: string
|
|
443
|
+
simIdSource: 'flag' | 'saved' | 'none'
|
|
444
|
+
commandTimeoutMs: number
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
interface BridgeFlagState {
|
|
448
|
+
wsPort: number
|
|
449
|
+
explicitPort: boolean
|
|
450
|
+
commandTimeoutMs: number
|
|
451
|
+
simId: string | undefined
|
|
452
|
+
simIdSource: ParsedBridgeCliArgs['simIdSource']
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// every flag parseBridgeCliArgs consumes a value for on its own, without being
|
|
456
|
+
// listed in a caller's stripValueFlags. the parser iterates this table, so the
|
|
457
|
+
// exported name list below cannot drift from what is actually parsed. a name
|
|
458
|
+
// missing from that list is not a parse failure, it is a command that hunts a
|
|
459
|
+
// positional in raw argv and takes the flag's value instead: `maestro test
|
|
460
|
+
// --newflag value login.yaml` would run `value` as the flow.
|
|
461
|
+
const BRIDGE_VALUE_FLAGS_TABLE: readonly {
|
|
462
|
+
names: readonly string[]
|
|
463
|
+
apply: (state: BridgeFlagState, value: string | undefined) => void
|
|
464
|
+
}[] = [
|
|
465
|
+
{
|
|
466
|
+
names: ['--port', '-p'],
|
|
467
|
+
// naming the flag pins the world even when the value is missing or junk,
|
|
468
|
+
// so a later --base-url cannot quietly retarget the run to another shell.
|
|
469
|
+
apply: (state, value) => {
|
|
470
|
+
state.explicitPort = true
|
|
471
|
+
if (value !== undefined) state.wsPort = Number(value)
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
names: ['--timeout'],
|
|
476
|
+
apply: (state, value) => {
|
|
477
|
+
if (value !== undefined) state.commandTimeoutMs = Number(value)
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
// `--sim` is the canonical target flag; `--session` / `--tab` are
|
|
482
|
+
// documented aliases (CLAUDE.md, the rnx agent skills, and engine docs all
|
|
483
|
+
// say `--session <id>`). without these aliases an agent following the docs
|
|
484
|
+
// would have `--session ef` silently ignored and get the *currently
|
|
485
|
+
// pinned* sim's tree instead — wrong-app data with no error.
|
|
486
|
+
names: ['--sim', '--session', '--tab'],
|
|
487
|
+
apply: (state, value) => {
|
|
488
|
+
if (value === undefined) return
|
|
489
|
+
state.simId = value.trim() || undefined
|
|
490
|
+
state.simIdSource = 'flag'
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
]
|
|
494
|
+
|
|
495
|
+
export const BRIDGE_VALUE_FLAGS: readonly string[] = BRIDGE_VALUE_FLAGS_TABLE.flatMap(
|
|
496
|
+
(flag) => [...flag.names],
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
export function parseBridgeCliArgs(
|
|
500
|
+
args: string[],
|
|
501
|
+
opts: ParseBridgeCliArgsOptions = {},
|
|
502
|
+
): ParsedBridgeCliArgs {
|
|
503
|
+
const flagIndices = new Set<number>()
|
|
504
|
+
const state: BridgeFlagState = {
|
|
505
|
+
wsPort: opts.port ?? resolveDefaultBridgePort(),
|
|
506
|
+
explicitPort: opts.port !== undefined,
|
|
507
|
+
commandTimeoutMs: opts.commandTimeoutMs ?? 15000,
|
|
508
|
+
simId: undefined,
|
|
509
|
+
simIdSource: 'none',
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const stripBooleanFlags = new Set(opts.stripBooleanFlags ?? [])
|
|
513
|
+
const stripValueFlags = new Set(opts.stripValueFlags ?? [])
|
|
514
|
+
|
|
515
|
+
for (let i = 0; i < args.length; i++) {
|
|
516
|
+
const arg = args[i]
|
|
517
|
+
// both spellings for every flag in the table: `--flag value` and
|
|
518
|
+
// `--flag=value`.
|
|
519
|
+
const valueFlag = BRIDGE_VALUE_FLAGS_TABLE.find((flag) =>
|
|
520
|
+
flag.names.some((name) => arg === name || arg.startsWith(`${name}=`)),
|
|
521
|
+
)
|
|
522
|
+
if (valueFlag) {
|
|
523
|
+
flagIndices.add(i)
|
|
524
|
+
const inline = valueFlag.names.find((name) => arg.startsWith(`${name}=`))
|
|
525
|
+
if (inline) {
|
|
526
|
+
valueFlag.apply(state, arg.slice(inline.length + 1))
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
const value = i + 1 < args.length ? args[i + 1] : undefined
|
|
530
|
+
if (value !== undefined) flagIndices.add(i + 1)
|
|
531
|
+
valueFlag.apply(state, value)
|
|
532
|
+
i++
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
if (stripBooleanFlags.has(arg)) {
|
|
536
|
+
flagIndices.add(i)
|
|
537
|
+
continue
|
|
538
|
+
}
|
|
539
|
+
if (stripValueFlags.has(arg)) {
|
|
540
|
+
flagIndices.add(i)
|
|
541
|
+
if (i + 1 < args.length) {
|
|
542
|
+
flagIndices.add(i + 1)
|
|
543
|
+
}
|
|
544
|
+
i++
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (!state.simId) {
|
|
549
|
+
const savedSim = readCurrentSim()
|
|
550
|
+
if (savedSim) {
|
|
551
|
+
state.simId = savedSim.simId.trim() || undefined
|
|
552
|
+
state.simIdSource = 'saved'
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
positional: args.filter((_, i) => !flagIndices.has(i)),
|
|
558
|
+
wsPort: state.wsPort,
|
|
559
|
+
explicitPort: state.explicitPort,
|
|
560
|
+
simId: state.simId,
|
|
561
|
+
simIdSource: state.simIdSource,
|
|
562
|
+
commandTimeoutMs: state.commandTimeoutMs,
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBridge {
|
|
567
|
+
let nextId = 1
|
|
568
|
+
const commandTimeoutMs = opts.commandTimeoutMs ?? 15000
|
|
569
|
+
const pending = new Map<
|
|
570
|
+
number,
|
|
571
|
+
{ resolve: (v: any) => void; reject: (e: Error) => void }
|
|
572
|
+
>()
|
|
573
|
+
|
|
574
|
+
const ws = new WebSocket(bridgeWsUrl(wsPort))
|
|
575
|
+
// always key the lease on the stable agent/terminal identity, never on the
|
|
576
|
+
// target simId. keying off `sim:<id>` conflated "which sim" with "who is
|
|
577
|
+
// driving it": a single agent's identity then flipped between its stable key
|
|
578
|
+
// (untargeted commands) and `sim:<id>` (targeted commands), so the agent
|
|
579
|
+
// could take a lease under one key and get refused under the other — locked
|
|
580
|
+
// out of a sim it owns. it also made two different agents driving the same
|
|
581
|
+
// sim collide into one identity and trample each other's lease. getCliIdentity()
|
|
582
|
+
// is already stable per agent run (CLAUDE_CODE_SESSION_ID / per-terminal) and
|
|
583
|
+
// distinct between agents — exactly what the lease wants.
|
|
584
|
+
const identity = getCliIdentity()
|
|
585
|
+
|
|
586
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
587
|
+
ws.on('open', () => {
|
|
588
|
+
// identify ourselves so the server keys leases on the agent run,
|
|
589
|
+
// not the ephemeral ws connection (CLI invocations are short-lived).
|
|
590
|
+
// when identity is unstable the server falls back to a per-socket key,
|
|
591
|
+
// which is still safer than letting multiple agents share a sim.
|
|
592
|
+
try {
|
|
593
|
+
ws.send(
|
|
594
|
+
JSON.stringify({
|
|
595
|
+
type: 'bridge:hello',
|
|
596
|
+
id: 0,
|
|
597
|
+
cliIdentityKey: identity.key,
|
|
598
|
+
cliIdentitySource: identity.source,
|
|
599
|
+
cliLabel: opts.cliLabel,
|
|
600
|
+
}),
|
|
601
|
+
)
|
|
602
|
+
} catch {}
|
|
603
|
+
resolve()
|
|
604
|
+
})
|
|
605
|
+
ws.on('error', (err) => {
|
|
606
|
+
// err.message is often empty (ECONNREFUSED surfaces with no message on
|
|
607
|
+
// some node builds) — don't emit a dangling "...7668: " with no cause.
|
|
608
|
+
const cause = err.message ? `: ${err.message}` : ''
|
|
609
|
+
reject(new Error(`could not connect to ${bridgeWsUrl(wsPort)}${cause}`))
|
|
610
|
+
})
|
|
611
|
+
})
|
|
612
|
+
// a bridge closed before its handshake finished rejects `ready` with nothing
|
|
613
|
+
// awaiting it (every send awaits ready and still sees the rejection); mark it
|
|
614
|
+
// handled so that path never surfaces as an unhandled rejection.
|
|
615
|
+
ready.catch(() => {})
|
|
616
|
+
|
|
617
|
+
let warnedAboutContention = false
|
|
618
|
+
let noticedTarget = false
|
|
619
|
+
// the browser host and connection behind each target, learned while that
|
|
620
|
+
// sim is still connected. a reload changes the connection, and sometimes the
|
|
621
|
+
// sim id, but not the host.
|
|
622
|
+
const lineageBySimId = new Map<string, { hostPid: number; connectedAt: number }>()
|
|
623
|
+
const lineageLookupBySimId = new Map<string, Promise<void>>()
|
|
624
|
+
|
|
625
|
+
// a reload retires the socket but keeps the browser host. the replacement
|
|
626
|
+
// can receive a new sim id or restore the prior one from session storage.
|
|
627
|
+
// the unloading page's socket stays listed as open until its close lands, so
|
|
628
|
+
// only a connection newer than the known one is a replacement. the restored
|
|
629
|
+
// id is definitive; a new id must be the only newer same-host match.
|
|
630
|
+
function pickReloadedTarget(sims: unknown, retiredSimId: string): string | null {
|
|
631
|
+
const lineage = lineageBySimId.get(retiredSimId)
|
|
632
|
+
if (!lineage) return null
|
|
633
|
+
const matches = (Array.isArray(sims) ? (sims as BridgeSimInfo[]) : []).filter(
|
|
634
|
+
(sim) =>
|
|
635
|
+
sim.readyState === 'open' &&
|
|
636
|
+
sim.meta?.sootsimHostPid === lineage.hostPid &&
|
|
637
|
+
sim.connectedAt > lineage.connectedAt,
|
|
638
|
+
)
|
|
639
|
+
const successor =
|
|
640
|
+
matches.find((sim) => sim.id === retiredSimId) ??
|
|
641
|
+
(matches.length === 1 ? matches[0] : null)
|
|
642
|
+
if (!successor) return null
|
|
643
|
+
lineageBySimId.set(successor.id, {
|
|
644
|
+
hostPid: lineage.hostPid,
|
|
645
|
+
connectedAt: successor.connectedAt,
|
|
646
|
+
})
|
|
647
|
+
return successor.id
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// print a one-line notice on first sim-scoped command so agents/users see
|
|
651
|
+
// which sim the command actually hit. suppressed for bridge:*
|
|
652
|
+
// control commands (list/open/focus/close/claim) that don't target a
|
|
653
|
+
// single sim, and for the hidden SOOTSIM_QUIET_TARGET_NOTICE escape hatch
|
|
654
|
+
// used by scripts that prefer clean stdout.
|
|
655
|
+
function maybeNoticeTarget(cmdType: string, simId: string | undefined) {
|
|
656
|
+
if (noticedTarget) return
|
|
657
|
+
if (process.env.SOOTSIM_QUIET_TARGET_NOTICE === '1') return
|
|
658
|
+
if (cmdType.startsWith('bridge:') || cmdType === 'focus' || cmdType === 'close')
|
|
659
|
+
return
|
|
660
|
+
noticedTarget = true
|
|
661
|
+
const label = simId ?? 'primary'
|
|
662
|
+
const source = describeSimIdSource(simId ? opts.simIdSource : 'none')
|
|
663
|
+
process.stderr.write(` → ${label} (${source})\n`)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
ws.on('message', (data) => {
|
|
667
|
+
let msg: any
|
|
668
|
+
try {
|
|
669
|
+
msg = JSON.parse(data.toString())
|
|
670
|
+
} catch {
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
if (msg.id === 0) return // bridge:hello ack
|
|
674
|
+
const p = pending.get(msg.id)
|
|
675
|
+
if (!p) return
|
|
676
|
+
pending.delete(msg.id)
|
|
677
|
+
|
|
678
|
+
// warn once if another CLI identity is also targeting this sim. this is
|
|
679
|
+
// NOT a bridge-throughput / congestion warning — the bridge is a thin ws
|
|
680
|
+
// relay and handles many messages/sec. it's a *semantic* warning: two
|
|
681
|
+
// agents driving the same screen will collide on taps/scroll/keyboard
|
|
682
|
+
// state. writes already serialize via the cli lease; reads pass through.
|
|
683
|
+
if (msg._otherCliCount > 0 && !warnedAboutContention) {
|
|
684
|
+
warnedAboutContention = true
|
|
685
|
+
process.stderr.write(
|
|
686
|
+
`\n ⚠ ${msg._otherCliCount} other CLI identity/identities are driving this sim\n` +
|
|
687
|
+
' taps, scrolls, and keyboard input from multiple agents will collide on\n' +
|
|
688
|
+
' the same screen state (this is not a bridge throughput limit).\n' +
|
|
689
|
+
' use `rnx open --new` for an isolated sim per agent.\n\n',
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (msg.error) {
|
|
694
|
+
if (msg._locked) {
|
|
695
|
+
p.reject(new BridgeSimLockedError(msg.error, msg._locked as BridgeLockInfo))
|
|
696
|
+
} else {
|
|
697
|
+
p.reject(new Error(msg.error))
|
|
698
|
+
}
|
|
699
|
+
} else {
|
|
700
|
+
p.resolve(msg.result)
|
|
701
|
+
}
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
// when the bridge host or target sim disconnects, reject all pending commands
|
|
705
|
+
// so the CLI gets a clear error instead of silently timing out
|
|
706
|
+
ws.on('close', (code, reason) => {
|
|
707
|
+
const msg = reason?.toString() || 'connection closed'
|
|
708
|
+
for (const [id, p] of pending) {
|
|
709
|
+
pending.delete(id)
|
|
710
|
+
p.reject(new Error(`sim disconnected: ${msg} (code ${code})`))
|
|
711
|
+
}
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
plane: 'local',
|
|
716
|
+
async send(cmd, callOpts) {
|
|
717
|
+
// NOTE: `opts` (the createBridge closure arg) carries simId; do not
|
|
718
|
+
// shadow it with the per-call arg.
|
|
719
|
+
const effectiveTimeoutMs = callOpts?.timeoutMs ?? commandTimeoutMs
|
|
720
|
+
const sendOnce = async (
|
|
721
|
+
defaultSimId?: string,
|
|
722
|
+
cmdOverride?: Record<string, any>,
|
|
723
|
+
) => {
|
|
724
|
+
await ready
|
|
725
|
+
const id = nextId++
|
|
726
|
+
return new Promise((resolve, reject) => {
|
|
727
|
+
const timeout = setTimeout(() => {
|
|
728
|
+
pending.delete(id)
|
|
729
|
+
reject(
|
|
730
|
+
new Error(
|
|
731
|
+
`command timed out after ${Math.round(effectiveTimeoutMs / 1000)}s`,
|
|
732
|
+
),
|
|
733
|
+
)
|
|
734
|
+
}, effectiveTimeoutMs)
|
|
735
|
+
|
|
736
|
+
pending.set(id, {
|
|
737
|
+
resolve: (v) => {
|
|
738
|
+
clearTimeout(timeout)
|
|
739
|
+
resolve(v)
|
|
740
|
+
},
|
|
741
|
+
reject: (e) => {
|
|
742
|
+
clearTimeout(timeout)
|
|
743
|
+
reject(e)
|
|
744
|
+
},
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
const payload = { ...(cmdOverride ?? cmd), id } as Record<string, any>
|
|
748
|
+
if (payload.simId === undefined && defaultSimId) {
|
|
749
|
+
payload.simId = defaultSimId
|
|
750
|
+
}
|
|
751
|
+
maybeNoticeTarget(payload.type ?? '', payload.simId)
|
|
752
|
+
ws.send(JSON.stringify(payload))
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// re-resolve the saved pin per send for bridges that were not
|
|
757
|
+
// explicitly `--sim`-targeted. a poll loop (`wait ready`) started
|
|
758
|
+
// before a concurrent `rnx open` finished connecting would
|
|
759
|
+
// otherwise stay on the primary fallback for its whole budget, probing
|
|
760
|
+
// an unrelated (often empty) sim while the intended one connects and
|
|
761
|
+
// pins itself mid-wait. falling back to the startup pin keeps the
|
|
762
|
+
// fail-closed saved-sim-gone path below when the pin file is cleared.
|
|
763
|
+
const sentSimId =
|
|
764
|
+
opts.simIdSource === 'flag' ? opts.simId : (readCurrentSimId() ?? opts.simId)
|
|
765
|
+
const targetedSimId =
|
|
766
|
+
typeof cmd.simId === 'string' && cmd.simId ? cmd.simId : sentSimId
|
|
767
|
+
try {
|
|
768
|
+
// a command can itself reload the page and retire its sim id. capture
|
|
769
|
+
// the browser-host lineage before dispatch, while the target still
|
|
770
|
+
// exists, so the caller can follow the replacement without guessing.
|
|
771
|
+
if (
|
|
772
|
+
targetedSimId &&
|
|
773
|
+
cmd.type !== 'bridge:list-sims' &&
|
|
774
|
+
!lineageBySimId.has(targetedSimId)
|
|
775
|
+
) {
|
|
776
|
+
let lookup = lineageLookupBySimId.get(targetedSimId)
|
|
777
|
+
if (!lookup) {
|
|
778
|
+
lookup = (async () => {
|
|
779
|
+
try {
|
|
780
|
+
const sims = await sendOnce(undefined, { type: 'bridge:list-sims' })
|
|
781
|
+
if (!Array.isArray(sims)) return
|
|
782
|
+
const targeted = (sims as BridgeSimInfo[]).find(
|
|
783
|
+
(sim) => sim.id === targetedSimId,
|
|
784
|
+
)
|
|
785
|
+
const hostPid = targeted?.meta?.sootsimHostPid
|
|
786
|
+
if (targeted && typeof hostPid === 'number') {
|
|
787
|
+
lineageBySimId.set(targetedSimId, {
|
|
788
|
+
hostPid,
|
|
789
|
+
connectedAt: targeted.connectedAt,
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
} catch {
|
|
793
|
+
// lineage enables fail-closed reload following, but a failed
|
|
794
|
+
// observation must not replace the caller's command failure.
|
|
795
|
+
} finally {
|
|
796
|
+
lineageLookupBySimId.delete(targetedSimId)
|
|
797
|
+
}
|
|
798
|
+
})()
|
|
799
|
+
lineageLookupBySimId.set(targetedSimId, lookup)
|
|
800
|
+
}
|
|
801
|
+
await lookup
|
|
802
|
+
}
|
|
803
|
+
return await sendOnce(sentSimId)
|
|
804
|
+
} catch (error) {
|
|
805
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
806
|
+
const missingSimMessage = `no sim connected with id ${sentSimId}`
|
|
807
|
+
if (
|
|
808
|
+
opts.simIdSource !== 'flag' &&
|
|
809
|
+
sentSimId &&
|
|
810
|
+
cmd.simId === undefined &&
|
|
811
|
+
(message === missingSimMessage || message.startsWith(`${missingSimMessage};`))
|
|
812
|
+
) {
|
|
813
|
+
// a reload retires the sim id but keeps the browser host, so the
|
|
814
|
+
// successor with the same host pid IS the same page lineage — not a
|
|
815
|
+
// guess. adopt only that, and only when it is unambiguous.
|
|
816
|
+
const successor = pickReloadedTarget(
|
|
817
|
+
await sendOnce(undefined, { type: 'bridge:list-sims' }),
|
|
818
|
+
sentSimId,
|
|
819
|
+
)
|
|
820
|
+
if (successor) {
|
|
821
|
+
saveCurrentSimId(successor)
|
|
822
|
+
process.stderr.write(
|
|
823
|
+
` note: sim ${sentSimId} reloaded and reconnected as ${successor}; following it\n`,
|
|
824
|
+
)
|
|
825
|
+
return sendOnce(successor)
|
|
826
|
+
}
|
|
827
|
+
clearCurrentSimId()
|
|
828
|
+
// the saved sim is gone and nothing proves what replaced it. fail
|
|
829
|
+
// closed instead of driving whichever sim is primary; that is how
|
|
830
|
+
// agents end up recording the wrong app.
|
|
831
|
+
throw new Error(
|
|
832
|
+
`saved sim ${sentSimId} is gone${message.slice(missingSimMessage.length)}; ` +
|
|
833
|
+
'run `rnx list` and ' +
|
|
834
|
+
`\`rnx use <sim>\`, or open a fresh sim`,
|
|
835
|
+
)
|
|
836
|
+
}
|
|
837
|
+
throw error
|
|
838
|
+
}
|
|
839
|
+
},
|
|
840
|
+
async listSims() {
|
|
841
|
+
const sims = await this.send({ type: 'bridge:list-sims' })
|
|
842
|
+
return Array.isArray(sims) ? (sims as BridgeSimInfo[]) : []
|
|
843
|
+
},
|
|
844
|
+
// a caller holding its own pinned sim id (a flow runner sets simId on every
|
|
845
|
+
// command, so it never reaches the pin-following path in send) asks here
|
|
846
|
+
// after a reload retires its socket. the page can reconnect under a new id
|
|
847
|
+
// or restore the old id; null means no unique same-host target exists.
|
|
848
|
+
async resolveReloadedSim(retiredSimId: string) {
|
|
849
|
+
return pickReloadedTarget(await this.listSims(), retiredSimId)
|
|
850
|
+
},
|
|
851
|
+
async focusSim(simId?: string) {
|
|
852
|
+
return this.send({ type: 'focus', simId })
|
|
853
|
+
},
|
|
854
|
+
async closeSim(simId?: string) {
|
|
855
|
+
return this.send({ type: 'close', simId })
|
|
856
|
+
},
|
|
857
|
+
async claim(simId?: string, claimOpts: { force?: boolean } = {}) {
|
|
858
|
+
const result = await this.send({
|
|
859
|
+
type: 'bridge:claim',
|
|
860
|
+
simId,
|
|
861
|
+
force: claimOpts.force === true,
|
|
862
|
+
})
|
|
863
|
+
return result as BridgeClaimResult
|
|
864
|
+
},
|
|
865
|
+
close() {
|
|
866
|
+
// send an explicit goodbye so the server deregisters this socket
|
|
867
|
+
// synchronously instead of waiting on the tcp close event, which
|
|
868
|
+
// can lag the next cli invocation and produce phantom-peer warnings.
|
|
869
|
+
try {
|
|
870
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
871
|
+
ws.send(JSON.stringify({ type: 'bridge:bye', id: 0 }))
|
|
872
|
+
}
|
|
873
|
+
} catch {}
|
|
874
|
+
ws.close()
|
|
875
|
+
// clear the grace timer on a real close, or a one-shot `rnx` command is
|
|
876
|
+
// held on the event loop for the full 250ms after its work is done. that
|
|
877
|
+
// delay lands directly in in-box prompt latency, since the shell spawns
|
|
878
|
+
// rnx and waits on child close.
|
|
879
|
+
const terminateGrace = setTimeout(() => {
|
|
880
|
+
if (ws.readyState !== WebSocket.CLOSED) {
|
|
881
|
+
ws.terminate()
|
|
882
|
+
}
|
|
883
|
+
}, 250)
|
|
884
|
+
ws.once('close', () => clearTimeout(terminateGrace))
|
|
885
|
+
},
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* how a command reaches a simulator. left out, it resolves the way the host
|
|
891
|
+
* CLI always has: the cloud box route when a box session is set, otherwise the
|
|
892
|
+
* local ws daemon. a box shell passes its own bridge so the same command runs
|
|
893
|
+
* in the calling process against the box's own simulator.
|
|
894
|
+
*/
|
|
895
|
+
export interface BridgeTransportOption {
|
|
896
|
+
createBridge?: (parsed: ParsedBridgeCliArgs) => WsBridge
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
export function createBridgeFromParsed(parsed: ParsedBridgeCliArgs): WsBridge {
|
|
900
|
+
const cloudBridge = createCloudBridgeForParsed(parsed)
|
|
901
|
+
if (cloudBridge) return cloudBridge
|
|
902
|
+
return createBridge(parsed.wsPort, {
|
|
903
|
+
commandTimeoutMs: parsed.commandTimeoutMs,
|
|
904
|
+
simId: parsed.simId,
|
|
905
|
+
simIdSource: parsed.simIdSource,
|
|
906
|
+
})
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
interface SimHealthProbe {
|
|
910
|
+
hidden?: boolean
|
|
911
|
+
visibilityState?: string
|
|
912
|
+
hasFocus?: boolean | null
|
|
913
|
+
simId?: string | null
|
|
914
|
+
url?: string
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function describeVisibleSimCandidate(sims: BridgeSimInfo[], currentId?: string | null) {
|
|
918
|
+
const open = sims.filter((sim) => sim.readyState === 'open' && sim.id !== currentId)
|
|
919
|
+
const candidate =
|
|
920
|
+
open.find((sim) => sim.userFocused) ??
|
|
921
|
+
open.find((sim) => sim.userVisible === true && sim.isPrimary) ??
|
|
922
|
+
open.find((sim) => sim.userVisible === true) ??
|
|
923
|
+
open.find((sim) => sim.isPrimary)
|
|
924
|
+
if (!candidate) return null
|
|
925
|
+
const tags = [
|
|
926
|
+
candidate.isPrimary ? 'primary' : null,
|
|
927
|
+
candidate.userVisible === false
|
|
928
|
+
? 'hidden'
|
|
929
|
+
: candidate.userVisible === true
|
|
930
|
+
? 'visible'
|
|
931
|
+
: null,
|
|
932
|
+
candidate.userFocused ? 'focused' : null,
|
|
933
|
+
].filter(Boolean)
|
|
934
|
+
return `${candidate.id}${tags.length ? ` [${tags.join(', ')}]` : ''}`
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Check if the target sim is healthy for interaction. Warns on stderr if:
|
|
939
|
+
* - document.hidden is true (animations won't run, coordinates may be wrong)
|
|
940
|
+
* Returns the health state so callers can decide whether to proceed.
|
|
941
|
+
*/
|
|
942
|
+
export async function checkSimHealth(
|
|
943
|
+
bridge: WsBridge,
|
|
944
|
+
): Promise<{ hidden: boolean; warned: boolean; simId?: string | null }> {
|
|
945
|
+
// a CPU simulator has no document to be hidden, and the probe below is an
|
|
946
|
+
// `evaluate` the cloud runtime refuses by name.
|
|
947
|
+
if (bridge.plane === 'cloud') return { hidden: false, warned: false }
|
|
948
|
+
try {
|
|
949
|
+
const probe = (await bridge.send({
|
|
950
|
+
type: 'evaluate',
|
|
951
|
+
code: `(() => ({
|
|
952
|
+
hidden: document.hidden,
|
|
953
|
+
visibilityState: document.visibilityState,
|
|
954
|
+
hasFocus: typeof document.hasFocus === 'function' ? document.hasFocus() : null,
|
|
955
|
+
simId: window.__sootsimBridge?.id ?? window.SootSim?.state?.simId ?? null,
|
|
956
|
+
url: location.href
|
|
957
|
+
}))()`,
|
|
958
|
+
})) as SimHealthProbe | boolean | null
|
|
959
|
+
const health =
|
|
960
|
+
typeof probe === 'object' && probe !== null ? probe : { hidden: probe === true }
|
|
961
|
+
const hidden = health.hidden === true
|
|
962
|
+
if (hidden === true) {
|
|
963
|
+
let candidate: string | null = null
|
|
964
|
+
try {
|
|
965
|
+
candidate = describeVisibleSimCandidate(await bridge.listSims(), health.simId)
|
|
966
|
+
} catch {}
|
|
967
|
+
process.stderr.write(
|
|
968
|
+
`\n ⚠ target sim${health.simId ? ` ${health.simId}` : ''} is hidden (document.hidden = true)\n` +
|
|
969
|
+
(health.visibilityState ? ` visibility: ${health.visibilityState}\n` : '') +
|
|
970
|
+
' animations and rAF callbacks are throttled — coordinates may be wrong\n' +
|
|
971
|
+
' and launch/transition animations will not complete.\n' +
|
|
972
|
+
(candidate
|
|
973
|
+
? ` another open sim looks usable: ${candidate}; target it with --sim.\n`
|
|
974
|
+
: ' run `rnx list` to find the visible sim and target it with --sim.\n') +
|
|
975
|
+
' page-level window.focus() cannot unhide a browser tab.\n\n',
|
|
976
|
+
)
|
|
977
|
+
return { hidden: true, warned: true, simId: health.simId }
|
|
978
|
+
}
|
|
979
|
+
return { hidden: false, warned: false, simId: health.simId }
|
|
980
|
+
} catch {
|
|
981
|
+
return { hidden: false, warned: false }
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Convenience: run arbitrary JavaScript in the rnx page and return the
|
|
987
|
+
* result. Hides the `type: 'evaluate'` message shape. Read-only by default —
|
|
988
|
+
* pass `{ acquireLock: true }` when the eval mutates user-facing state so
|
|
989
|
+
* the server takes/refreshes the cli lease and blocks concurrent agents.
|
|
990
|
+
*/
|
|
991
|
+
export async function evalInBridge<T = unknown>(
|
|
992
|
+
bridge: WsBridge,
|
|
993
|
+
code: string,
|
|
994
|
+
opts: { acquireLock?: boolean } = {},
|
|
995
|
+
): Promise<T> {
|
|
996
|
+
const payload: { type: string; [k: string]: any } = { type: 'evaluate', code }
|
|
997
|
+
if (opts.acquireLock) payload.acquireLock = true
|
|
998
|
+
return bridge.send(payload) as Promise<T>
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Invoke a function on `window.SootSim.bridges.*` over the bridge. Read-only
|
|
1003
|
+
* by default for the same reason as evalInBridge.
|
|
1004
|
+
*/
|
|
1005
|
+
export async function callInBridge<T = unknown>(
|
|
1006
|
+
bridge: WsBridge,
|
|
1007
|
+
path: string,
|
|
1008
|
+
...args: unknown[]
|
|
1009
|
+
): Promise<T> {
|
|
1010
|
+
return bridge.send({ type: 'call', path, args }) as Promise<T>
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Write variant — takes/refreshes the cli lease on the target sim. */
|
|
1014
|
+
export async function callInBridgeWrite<T = unknown>(
|
|
1015
|
+
bridge: WsBridge,
|
|
1016
|
+
path: string,
|
|
1017
|
+
...args: unknown[]
|
|
1018
|
+
): Promise<T> {
|
|
1019
|
+
return bridge.send({ type: 'call', path, args, acquireLock: true }) as Promise<T>
|
|
1020
|
+
}
|