rnxsim 0.0.0 → 0.1.312

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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +512 -0
  3. package/detox/colors.ts +54 -0
  4. package/detox/config-loader.ts +135 -0
  5. package/detox/element-types.ts +39 -0
  6. package/detox/expectations.ts +537 -0
  7. package/detox/gestures.ts +480 -0
  8. package/detox/index.ts +2118 -0
  9. package/detox/jest-preset.cjs +51 -0
  10. package/detox/jest-setup-after-env.cjs +18 -0
  11. package/detox/matchers.ts +30 -0
  12. package/detox/motion-change.cjs +109 -0
  13. package/detox/motion-change.d.cts +35 -0
  14. package/detox/navigation.ts +43 -0
  15. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  16. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  17. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  18. package/detox/screenshots/theme-blue-toggle.png +0 -0
  19. package/detox/screenshots/theme-blue.png +0 -0
  20. package/detox/screenshots/theme-red-toggle.png +0 -0
  21. package/detox/screenshots/theme-red.png +0 -0
  22. package/dist-cli/bin.js +15 -0
  23. package/dist-cli/chunks/agent-NXVDDCFG.js +48 -0
  24. package/dist-cli/chunks/agent-wrapper-DXJCFKL3.js +17 -0
  25. package/dist-cli/chunks/app-fonts-W5AJZQJF.js +4 -0
  26. package/dist-cli/chunks/assert-3LOJEUDT.js +49 -0
  27. package/dist-cli/chunks/auth-MFTYYPNX.js +12 -0
  28. package/dist-cli/chunks/beta-GT3NN67B.js +4 -0
  29. package/dist-cli/chunks/camera-6EAK7CUL.js +22 -0
  30. package/dist-cli/chunks/chunk-274UD5FD.js +4 -0
  31. package/dist-cli/chunks/chunk-2SA2VTGP.js +5 -0
  32. package/dist-cli/chunks/chunk-33FKQH54.js +5 -0
  33. package/dist-cli/chunks/chunk-37I5AIS7.js +3 -0
  34. package/dist-cli/chunks/chunk-3D57IBPC.js +4 -0
  35. package/dist-cli/chunks/chunk-4BXCZHAU.js +4 -0
  36. package/dist-cli/chunks/chunk-5OX7ANRA.js +5 -0
  37. package/dist-cli/chunks/chunk-66NF2KNG.js +9 -0
  38. package/dist-cli/chunks/chunk-6UKJB2VC.js +125 -0
  39. package/dist-cli/chunks/chunk-7INBNA3U.js +26 -0
  40. package/dist-cli/chunks/chunk-7VWOSTD5.js +5 -0
  41. package/dist-cli/chunks/chunk-AY47WKS5.js +23 -0
  42. package/dist-cli/chunks/chunk-B7KMNHX7.js +4 -0
  43. package/dist-cli/chunks/chunk-BKNOLP3J.js +4 -0
  44. package/dist-cli/chunks/chunk-BRIZOR4K.js +28 -0
  45. package/dist-cli/chunks/chunk-DM6KUAOZ.js +4 -0
  46. package/dist-cli/chunks/chunk-EUAVBTKU.js +4 -0
  47. package/dist-cli/chunks/chunk-FTC4M26Q.js +4 -0
  48. package/dist-cli/chunks/chunk-FWYYCUAZ.js +4 -0
  49. package/dist-cli/chunks/chunk-G74YIEVS.js +7 -0
  50. package/dist-cli/chunks/chunk-GZDHEONW.js +507 -0
  51. package/dist-cli/chunks/chunk-HG7ORJMT.js +5 -0
  52. package/dist-cli/chunks/chunk-HMDOLCKG.js +15 -0
  53. package/dist-cli/chunks/chunk-JG6S4RSH.js +4 -0
  54. package/dist-cli/chunks/chunk-KR5BGMBZ.js +4 -0
  55. package/dist-cli/chunks/chunk-KZW5WBGU.js +983 -0
  56. package/dist-cli/chunks/chunk-L24Z3CRS.js +30 -0
  57. package/dist-cli/chunks/chunk-LDQ7HVCW.js +4 -0
  58. package/dist-cli/chunks/chunk-MENV7VLD.js +4 -0
  59. package/dist-cli/chunks/chunk-MNGQ42QE.js +568 -0
  60. package/dist-cli/chunks/chunk-N6A3SXMR.js +4 -0
  61. package/dist-cli/chunks/chunk-NLEMQ4PY.js +6 -0
  62. package/dist-cli/chunks/chunk-NPVGMH2N.js +101 -0
  63. package/dist-cli/chunks/chunk-OR3XTXEB.js +107 -0
  64. package/dist-cli/chunks/chunk-PEPUGJVI.js +4 -0
  65. package/dist-cli/chunks/chunk-Q3RGLERJ.js +142 -0
  66. package/dist-cli/chunks/chunk-SI3BUTQR.js +4 -0
  67. package/dist-cli/chunks/chunk-T4FT6CM7.js +4 -0
  68. package/dist-cli/chunks/chunk-TBMVBI26.js +7 -0
  69. package/dist-cli/chunks/chunk-TCWQJILS.js +5 -0
  70. package/dist-cli/chunks/chunk-TGQNQ6XT.js +766 -0
  71. package/dist-cli/chunks/chunk-TYE3WBSR.js +5 -0
  72. package/dist-cli/chunks/chunk-U4PN6JOT.js +6 -0
  73. package/dist-cli/chunks/chunk-VUFRB347.js +4 -0
  74. package/dist-cli/chunks/chunk-VZBLFMYC.js +10 -0
  75. package/dist-cli/chunks/chunk-WBT32R6N.js +25 -0
  76. package/dist-cli/chunks/chunk-XRGEEPJ5.js +38 -0
  77. package/dist-cli/chunks/chunk-Y2FSZSO7.js +71 -0
  78. package/dist-cli/chunks/chunk-YRV7HR3P.js +74 -0
  79. package/dist-cli/chunks/chunk-YZRFF4YA.js +14 -0
  80. package/dist-cli/chunks/chunk-ZMZTHU7V.js +4 -0
  81. package/dist-cli/chunks/cleanup-H3GWQB63.js +25 -0
  82. package/dist-cli/chunks/cli-version-3QPAJ3LC.js +4 -0
  83. package/dist-cli/chunks/compat-5WG2XJV7.js +20 -0
  84. package/dist-cli/chunks/config-ANMBVVLL.js +13 -0
  85. package/dist-cli/chunks/control-NSGNQFJG.js +4 -0
  86. package/dist-cli/chunks/daemon-JHF4BJ3U.js +4 -0
  87. package/dist-cli/chunks/debug-CHV2O2KF.js +200 -0
  88. package/dist-cli/chunks/desktop-FIPIK4CH.js +31 -0
  89. package/dist-cli/chunks/detox-CVUJP7K6.js +52 -0
  90. package/dist-cli/chunks/device-6WCUM4W6.js +18 -0
  91. package/dist-cli/chunks/diagnose-I6EMWEII.js +43 -0
  92. package/dist-cli/chunks/disk-cleanup-P27CYFTN.js +4 -0
  93. package/dist-cli/chunks/drivers-HQ6AKZT6.js +4 -0
  94. package/dist-cli/chunks/film-UOQFFYNF.js +74 -0
  95. package/dist-cli/chunks/flow-N27MRRNL.js +4 -0
  96. package/dist-cli/chunks/help-SF6FEGEE.js +4 -0
  97. package/dist-cli/chunks/hidden-runtime-alias-NJYFSJKZ.js +4 -0
  98. package/dist-cli/chunks/home-paths-XOV44JEF.js +4 -0
  99. package/dist-cli/chunks/inspect-CWFRFQTY.js +4 -0
  100. package/dist-cli/chunks/install-desktop-KLQDNXZQ.js +4 -0
  101. package/dist-cli/chunks/login-BPFQENCX.js +28 -0
  102. package/dist-cli/chunks/logout-5A45ZGY7.js +4 -0
  103. package/dist-cli/chunks/maestro-E4CSDMLM.js +104 -0
  104. package/dist-cli/chunks/maestro-generate-CKMOJQX2.js +36 -0
  105. package/dist-cli/chunks/mode-SZYNMIQF.js +26 -0
  106. package/dist-cli/chunks/optional-demo-registry-FRH4SFVG.js +4 -0
  107. package/dist-cli/chunks/perf-VXU2XJAX.js +48 -0
  108. package/dist-cli/chunks/permissions-IRIAOVND.js +13 -0
  109. package/dist-cli/chunks/record-RY7FPLLH.js +72 -0
  110. package/dist-cli/chunks/report-issue-KNWXOE53.js +29 -0
  111. package/dist-cli/chunks/runtime-AR3GJTGK.js +4 -0
  112. package/dist-cli/chunks/screenshot-command-QLALR6GY.js +159 -0
  113. package/dist-cli/chunks/screenshot-layers-OQFMFVRL.js +20 -0
  114. package/dist-cli/chunks/screenshots-capture-6TSRJFGB.js +42 -0
  115. package/dist-cli/chunks/serve-F54J2NQO.js +44 -0
  116. package/dist-cli/chunks/setup-7V6UKX4U.js +32 -0
  117. package/dist-cli/chunks/skills-QQBOSETH.js +50 -0
  118. package/dist-cli/chunks/storage-TAPMKK5O.js +37 -0
  119. package/dist-cli/chunks/store-7NX3S43H.js +4 -0
  120. package/dist-cli/chunks/telemetry-E7BUK6RA.js +4 -0
  121. package/dist-cli/chunks/timeline-IAM564NA.js +24 -0
  122. package/dist-cli/chunks/upgrade-CD55QXIX.js +7 -0
  123. package/dist-cli/chunks/upload-GZ6LIMYM.js +4 -0
  124. package/dist-cli/chunks/version-3DYJ5AS3.js +6 -0
  125. package/dist-cli/chunks/web-6NW6YLPI.js +4 -0
  126. package/dist-cli/chunks/what-happened-CZZDOOVG.js +25 -0
  127. package/dist-lib/agent-daemon-client.cjs +468 -0
  128. package/dist-lib/agent-events.cjs +48 -0
  129. package/dist-lib/agent-identity.cjs +53 -0
  130. package/dist-lib/agent-sessions.cjs +725 -0
  131. package/dist-lib/attached-projects.cjs +526 -0
  132. package/dist-lib/auth/shared-session.cjs +195 -0
  133. package/dist-lib/backend-origin.cjs +70 -0
  134. package/dist-lib/beta.cjs +41 -0
  135. package/dist-lib/beta.mjs +13 -0
  136. package/dist-lib/bridge-constants.cjs +161 -0
  137. package/dist-lib/cli-constants.cjs +32 -0
  138. package/dist-lib/config.cjs +96 -0
  139. package/dist-lib/detox/index.cjs +2863 -0
  140. package/dist-lib/detox/jest-preset.cjs +51 -0
  141. package/dist-lib/dev-bundle-resolution.cjs +223 -0
  142. package/dist-lib/home-paths.cjs +421 -0
  143. package/dist-lib/host/bridge-host.cjs +5600 -0
  144. package/dist-lib/host/fetch-proxy-handler.cjs +373 -0
  145. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  146. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  147. package/dist-lib/host/websocket-proxy.cjs +214 -0
  148. package/dist-lib/index.cjs +513 -0
  149. package/dist-lib/jump-to-source-babel.cjs +93 -0
  150. package/dist-lib/menu.cjs +620 -0
  151. package/dist-lib/menu.mjs +581 -0
  152. package/dist-lib/metro.cjs +297 -0
  153. package/dist-lib/profiles.cjs +246 -0
  154. package/dist-lib/public-brand.cjs +39 -0
  155. package/dist-lib/render-mode.cjs +54 -0
  156. package/dist-lib/scripts/dev-server-scanner.cjs +1034 -0
  157. package/dist-lib/sdk.cjs +2333 -0
  158. package/dist-lib/sdk.mjs +2240 -0
  159. package/dist-lib/skills.cjs +23911 -0
  160. package/dist-lib/vite.cjs +358 -0
  161. package/package.json +189 -18
  162. package/scripts/dev-server-scanner.ts +941 -0
  163. package/scripts/optional-demo-registry.ts +113 -0
  164. package/scripts/postinstall.cjs +3 -0
  165. package/scripts/preuninstall.cjs +118 -0
  166. package/skills/contrast/SKILL.md +171 -0
  167. package/skills/rnx-debug/SKILL.md +425 -0
  168. package/skills/rnx-perf/SKILL.md +295 -0
  169. package/skills/rnx-setup/SKILL.md +160 -0
  170. package/skills/rnx-test/SKILL.md +292 -0
  171. package/skills/rnx-visual/SKILL.md +122 -0
  172. package/src/agent-daemon-client.ts +410 -0
  173. package/src/agent-events.ts +71 -0
  174. package/src/agent-identity.ts +23 -0
  175. package/src/agent-prompt.ts +71 -0
  176. package/src/agent-sessions.ts +573 -0
  177. package/src/attached-projects.ts +539 -0
  178. package/src/auth/shared-session.ts +204 -0
  179. package/src/backend-origin.ts +49 -0
  180. package/src/beta.ts +19 -0
  181. package/src/bridge-constants.ts +171 -0
  182. package/src/browser-cache.ts +16 -0
  183. package/src/cli-constants.ts +1 -0
  184. package/src/cli-version.ts +32 -0
  185. package/src/codex-client.ts +215 -0
  186. package/src/config.ts +134 -0
  187. package/src/dev-bundle-resolution.ts +236 -0
  188. package/src/disk-cleanup.ts +466 -0
  189. package/src/home-paths.ts +552 -0
  190. package/src/host/agent-host.ts +666 -0
  191. package/src/host/bridge-host.ts +2626 -0
  192. package/src/host/fetch-proxy-handler.ts +397 -0
  193. package/src/host/fetch-proxy-overrides.ts +51 -0
  194. package/src/host/open-url.ts +89 -0
  195. package/src/host/websocket-proxy.ts +210 -0
  196. package/src/index.ts +12 -705
  197. package/src/jump-to-source-babel.ts +135 -0
  198. package/src/menu.ts +734 -0
  199. package/src/metro-plugin.ts +139 -0
  200. package/src/native-dev-bundle-url.ts +96 -0
  201. package/src/native-seam-manifest.ts +341 -0
  202. package/src/plist.ts +27 -0
  203. package/src/poll-until.ts +92 -0
  204. package/src/profiles.ts +179 -0
  205. package/src/public-brand.ts +9 -0
  206. package/src/render-mode.ts +27 -0
  207. package/src/runtime-assets.ts +228 -0
  208. package/src/runtime-delivery.ts +54 -0
  209. package/src/screenshots/compose.ts +422 -0
  210. package/src/screenshots/frame-compose.ts +454 -0
  211. package/src/screenshots/orchestrate.ts +256 -0
  212. package/src/screenshots/org-deck.ts +91 -0
  213. package/src/screenshots/registry.ts +58 -0
  214. package/src/screenshots/schema.ts +364 -0
  215. package/src/sdk.ts +7 -0
  216. package/src/settings-bundle.ts +418 -0
  217. package/src/skills/builtin/a11y-review.ts +132 -0
  218. package/src/skills/builtin/compat-check.ts +71 -0
  219. package/src/skills/builtin/maestro-test.ts +121 -0
  220. package/src/skills/builtin/perf-profile.ts +90 -0
  221. package/src/skills/builtin/screenshot-all.ts +52 -0
  222. package/src/skills/builtin/visual-diff.ts +100 -0
  223. package/src/skills/registry.ts +107 -0
  224. package/src/skills/types.ts +41 -0
  225. package/src/team-machine-prompt.ts +73 -0
  226. package/src/vite-plugin-one.ts +190 -0
  227. package/src/vite-plugin.ts +1750 -0
  228. package/src/worklets-babel.ts +134 -0
@@ -0,0 +1,573 @@
1
+ // session lifecycle shared between the CLI (`sootsim agent ...`) and electron
2
+ // main (IPC handlers). both contexts spawn the same `sootsim agent-wrapper`
3
+ // subcommand, mutate the same AttachedProjects store, and subscribe to the
4
+ // same events.out FIFO.
5
+ //
6
+ // this file is the canonical implementation; thin wrappers in the CLI and
7
+ // electron layers call these functions for their UX needs.
8
+
9
+ import { spawn, spawnSync } from 'node:child_process'
10
+ import { randomUUID } from 'node:crypto'
11
+ import fs, { constants as fsConstants } from 'node:fs'
12
+ import path from 'node:path'
13
+ import readline from 'node:readline'
14
+ import { parseAgentEventLine, type AgentEvent } from './agent-events.ts'
15
+ import { encodeAgentPromptEnvelope, type AgentPromptEnvelope } from './agent-prompt.ts'
16
+ import {
17
+ findProjectById,
18
+ findSessionById,
19
+ getUserDataDir,
20
+ listSessions,
21
+ updateSessionStatus,
22
+ upsertSession,
23
+ type AgentSession,
24
+ } from './attached-projects.ts'
25
+ import { rnxPublicBrand } from './public-brand.ts'
26
+
27
+ export type Provider = 'codex' | 'claude'
28
+
29
+ // --- fs layout ---
30
+
31
+ export function sessionDir(sessionId: string): string {
32
+ return path.join(getUserDataDir(), 'sessions', sessionId)
33
+ }
34
+ export function promptFifoPath(sessionId: string): string {
35
+ return path.join(sessionDir(sessionId), 'prompt.in')
36
+ }
37
+ export function eventsFifoPath(sessionId: string): string {
38
+ return path.join(sessionDir(sessionId), 'events.out')
39
+ }
40
+ export function transcriptPath(sessionId: string): string {
41
+ return path.join(getUserDataDir(), 'transcripts', `${sessionId}.log`)
42
+ }
43
+
44
+ // --- pid helpers ---
45
+
46
+ /** returns true iff a process with this pid is alive AND owned by us. pid
47
+ * recycling means kill(0) can return true for an unrelated process that
48
+ * happened to take the same number — we also require the sentinel file
49
+ * written by the wrapper at startup to still exist. */
50
+ export function pidIsAlive(pid: number | undefined, sessionId?: string): boolean {
51
+ if (!pid) return false
52
+ try {
53
+ process.kill(pid, 0)
54
+ } catch {
55
+ return false
56
+ }
57
+ if (sessionId) {
58
+ // sentinel: the events.out fifo's session dir. if the session was ended
59
+ // via cmdEnd, the dir was removed; kill(0) returning true on that pid
60
+ // would be a recycled pid from an unrelated process.
61
+ if (!fs.existsSync(sessionDir(sessionId))) return false
62
+ }
63
+ return true
64
+ }
65
+
66
+ // --- invocation resolution ---
67
+
68
+ /** figure out how to re-invoke the sootsim CLI to run `agent-wrapper`.
69
+ * priority:
70
+ * 1. SOOTSIM_BIN env var (explicit override)
71
+ * 2. electron prod: bundled binary under process.resourcesPath/bin/
72
+ * 3. workspace build artifacts (dist-bin/<binary> or dist-cli/bin.js)
73
+ * — shared between electron dev, the vite-plugin-hosted daemon, and
74
+ * any other node process that isn't launched with the CLI entry
75
+ * script directly (e.g. argv[1] = .../node_modules/.bin/vite).
76
+ * 4. CLI dev (argv[1] points at the entry script): re-use argv[0] + argv[1]
77
+ * 5. CLI standalone: argv[0] is the compiled binary, no prefix needed
78
+ */
79
+ export function resolveSootsimInvocation(): { cmd: string; prefixArgs: string[] } {
80
+ if (process.env.SOOTSIM_BIN) {
81
+ return { cmd: process.env.SOOTSIM_BIN, prefixArgs: [] }
82
+ }
83
+ // electron prod: packaged binary lives under Resources/bin/.
84
+ if (process.versions.electron) {
85
+ const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string })
86
+ .resourcesPath
87
+ if (resourcesPath) {
88
+ const candidates = [
89
+ path.join(resourcesPath, 'bin', 'sootsim'),
90
+ path.join(resourcesPath, 'bin', `sootsim-${process.platform}-${process.arch}`),
91
+ ]
92
+ for (const c of candidates) {
93
+ if (fs.existsSync(c)) return { cmd: c, prefixArgs: [] }
94
+ }
95
+ }
96
+ }
97
+
98
+ // workspace build artifacts — tried for every non-CLI-entry node context
99
+ // (electron dev, vite-plugin-hosted daemon, random script) before falling
100
+ // back to argv. prefers the compiled bun binary because `bun dev` runs
101
+ // `watch:cli:binary`; the same dev graph runs `watch:cli` to keep the node
102
+ // fallback and browser-facing dist-lib exports fresh. still warn when a
103
+ // caller starts outside that graph with stale artifacts.
104
+ const workspace = tryWorkspaceSootsim()
105
+ if (workspace) return workspace
106
+
107
+ // argv fallback. only trust it when argv[1] looks like a real entry
108
+ // script — `.js`/`.ts`/`.mjs` etc. launching vite gives us argv[1] =
109
+ // `…/node_modules/.bin/vite` (no extension), which would make us try
110
+ // to run agent-wrapper under the vite shim and silently fail.
111
+ const argv0 = process.argv[0]
112
+ const argv1 = process.argv[1]
113
+ if (argv1 && /\.(ts|tsx|mjs|cjs|js)$/.test(argv1)) {
114
+ return { cmd: argv0, prefixArgs: [argv1] }
115
+ }
116
+ if (!argv1 || argv1.includes('/.bin/')) {
117
+ // bin-shim case falls through to the error below instead of silently
118
+ // running `node` without args.
119
+ throw new Error(
120
+ 'rnxsim CLI not found. set SOOTSIM_BIN to the path of the rnxsim binary, ' +
121
+ 'or build the workspace CLI via `bun run --cwd packages/sootsim build:cli`.',
122
+ )
123
+ }
124
+ return { cmd: argv0, prefixArgs: [] }
125
+ }
126
+
127
+ function tryWorkspaceSootsim(): { cmd: string; prefixArgs: string[] } | null {
128
+ try {
129
+ const sootsimDir = resolveSootsimPackageDir()
130
+ if (!sootsimDir) return null
131
+ const binaryName = `sootsim-${process.platform}-${process.arch}`
132
+ const distBinary = path.join(sootsimDir, 'dist-bin', binaryName)
133
+ if (fs.existsSync(distBinary)) return { cmd: distBinary, prefixArgs: [] }
134
+ const distBin = path.join(sootsimDir, 'dist-cli', 'bin.js')
135
+ if (fs.existsSync(distBin)) {
136
+ try {
137
+ const src = path.join(sootsimDir, 'cli', 'commands', 'agent-wrapper.ts')
138
+ if (fs.existsSync(src)) {
139
+ const srcMtime = fs.statSync(src).mtimeMs
140
+ const buildMtime = fs.statSync(distBin).mtimeMs
141
+ if (buildMtime < srcMtime) {
142
+ console.warn(
143
+ `[rnx] dist-cli/bin.js is older than agent-wrapper.ts — ` +
144
+ `rebuild with \`bun run --cwd packages/sootsim build:cli\`.`,
145
+ )
146
+ }
147
+ }
148
+ } catch {}
149
+ return { cmd: process.execPath, prefixArgs: [distBin] }
150
+ }
151
+ return null
152
+ } catch {
153
+ return null
154
+ }
155
+ }
156
+
157
+ /** locate the workspace sootsim package directory. tries `require.resolve`
158
+ * first (works in most contexts), then walks up from this module file
159
+ * looking for `packages/sootsim/package.json` (works when sootsim is
160
+ * loaded via vite's native TS resolution, where require.resolve doesn't
161
+ * know about the workspace). */
162
+ function resolveSootsimPackageDir(): string | null {
163
+ try {
164
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
165
+ const resolved = require.resolve(`${rnxPublicBrand.packageName}/package.json`)
166
+ return path.dirname(resolved)
167
+ } catch {}
168
+ // walk up from this file: .../packages/sootsim/src/agent-sessions.ts
169
+ // → .../packages/sootsim/. the module path is whatever the runtime
170
+ // gave us; when bundled by esbuild this will be the bundle path, not
171
+ // the source path, which is why we try require.resolve first.
172
+ const here = fileFromImportMeta()
173
+ if (!here) return null
174
+ let cur = path.dirname(here)
175
+ for (let i = 0; i < 8; i++) {
176
+ const pkg = path.join(cur, 'package.json')
177
+ try {
178
+ if (fs.existsSync(pkg)) {
179
+ const parsed = JSON.parse(fs.readFileSync(pkg, 'utf8')) as { name?: string }
180
+ if (parsed.name === rnxPublicBrand.packageName) return cur
181
+ }
182
+ } catch {}
183
+ const parent = path.dirname(cur)
184
+ if (parent === cur) break
185
+ cur = parent
186
+ }
187
+ return null
188
+ }
189
+
190
+ function fileFromImportMeta(): string | null {
191
+ try {
192
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
193
+ const url = (import.meta as unknown as { url?: string }).url
194
+ if (!url || !url.startsWith('file://')) return null
195
+ return decodeURIComponent(url.slice('file://'.length))
196
+ } catch {
197
+ return null
198
+ }
199
+ }
200
+
201
+ // --- lockfile for start races ---
202
+
203
+ async function withStartLock<T>(
204
+ projectId: string,
205
+ provider: Provider,
206
+ fn: () => Promise<T>,
207
+ ): Promise<T> {
208
+ const lockDir = path.join(getUserDataDir(), 'locks')
209
+ fs.mkdirSync(lockDir, { recursive: true })
210
+ try {
211
+ fs.chmodSync(lockDir, 0o700)
212
+ } catch {}
213
+ const lockPath = path.join(lockDir, `start-${projectId}-${provider}.lock`)
214
+ const deadline = Date.now() + 4000
215
+ let fd: number | null = null
216
+ while (fd === null) {
217
+ try {
218
+ fd = fs.openSync(
219
+ lockPath,
220
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL,
221
+ 0o600,
222
+ )
223
+ } catch (err) {
224
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
225
+ // staleness check: if the pid in the lockfile is dead, steal it
226
+ try {
227
+ const stale = Number(fs.readFileSync(lockPath, 'utf8').trim())
228
+ if (stale && !isProcessAlive(stale)) {
229
+ fs.unlinkSync(lockPath)
230
+ continue
231
+ }
232
+ } catch {}
233
+ if (Date.now() > deadline) {
234
+ throw new Error(
235
+ `another start is in progress for project=${projectId} provider=${provider} ` +
236
+ `(lock: ${lockPath})`,
237
+ )
238
+ }
239
+ await new Promise((r) => setTimeout(r, 50))
240
+ }
241
+ }
242
+ try {
243
+ fs.writeFileSync(fd, String(process.pid))
244
+ return await fn()
245
+ } finally {
246
+ try {
247
+ fs.closeSync(fd)
248
+ } catch {}
249
+ try {
250
+ fs.unlinkSync(lockPath)
251
+ } catch {}
252
+ }
253
+ }
254
+
255
+ function isProcessAlive(pid: number): boolean {
256
+ try {
257
+ process.kill(pid, 0)
258
+ return true
259
+ } catch {
260
+ return false
261
+ }
262
+ }
263
+
264
+ // --- FIFO helpers ---
265
+
266
+ export function mkfifoSync(p: string): void {
267
+ const parent = path.dirname(p)
268
+ fs.mkdirSync(parent, { recursive: true })
269
+ try {
270
+ fs.chmodSync(parent, 0o700)
271
+ } catch {}
272
+ if (fs.existsSync(p)) {
273
+ try {
274
+ const stat = fs.statSync(p)
275
+ if (stat.isFIFO()) {
276
+ try {
277
+ fs.chmodSync(p, 0o600)
278
+ } catch {}
279
+ return
280
+ }
281
+ fs.unlinkSync(p)
282
+ } catch {
283
+ fs.unlinkSync(p)
284
+ }
285
+ }
286
+ const result = spawnSync('mkfifo', ['-m', '600', p])
287
+ if (result.status !== 0) {
288
+ throw new Error(
289
+ `mkfifo(${p}) failed: ${result.stderr?.toString().trim() || 'unknown error'}`,
290
+ )
291
+ }
292
+ }
293
+
294
+ // --- session lifecycle ---
295
+
296
+ export interface StartSessionOpts {
297
+ projectId: string
298
+ provider?: Provider
299
+ codexBin?: string
300
+ claudeBin?: string
301
+ freshThread?: boolean
302
+ readyTimeoutMs?: number
303
+ }
304
+
305
+ export interface StartSessionResult {
306
+ session: AgentSession
307
+ /** child pid — the same value stored on session.wrapperPid */
308
+ wrapperPid: number
309
+ }
310
+
311
+ export class AgentSessionError extends Error {
312
+ code: string
313
+ constructor(code: string, message: string) {
314
+ super(message)
315
+ this.code = code
316
+ }
317
+ }
318
+
319
+ export async function startSession(opts: StartSessionOpts): Promise<StartSessionResult> {
320
+ const project = findProjectById(opts.projectId)
321
+ if (!project) {
322
+ throw new AgentSessionError('NO_PROJECT', `no project with id=${opts.projectId}`)
323
+ }
324
+ const provider: Provider = opts.provider || project.preferredProvider || 'codex'
325
+
326
+ return withStartLock(project.id, provider, async () => {
327
+ // re-check inside the lock. a concurrent start that slipped through the
328
+ // pre-check can't slip through both the check AND the lock.
329
+ const existingLive = listSessions(project.id).find(
330
+ (s) =>
331
+ s.provider === provider && s.status !== 'ended' && pidIsAlive(s.wrapperPid, s.id),
332
+ )
333
+ if (existingLive) {
334
+ throw new AgentSessionError(
335
+ 'ALREADY_RUNNING',
336
+ `session already running for project=${project.id} provider=${provider} ` +
337
+ `(session ${existingLive.id}, pid ${existingLive.wrapperPid}). ` +
338
+ 'end it first with `rnxsim agent end <sessionId>`.',
339
+ )
340
+ }
341
+
342
+ // generate the claude session uuid once and persist it so successive
343
+ // wrapper restarts resume the same `~/.claude/projects/<cwd>/<uuid>.jsonl`
344
+ // file. codex has its own thread-id persistence via `thread/list`, so we
345
+ // only need this for claude.
346
+ const claudeSessionUuid = provider === 'claude' ? randomUUID() : undefined
347
+
348
+ const session = upsertSession({
349
+ projectId: project.id,
350
+ provider,
351
+ transport: 'pty',
352
+ cwd: project.cwd,
353
+ status: 'idle',
354
+ claudeSessionUuid,
355
+ })
356
+
357
+ const promptIn = promptFifoPath(session.id)
358
+ const eventsOut = eventsFifoPath(session.id)
359
+ const transcript = transcriptPath(session.id)
360
+ mkfifoSync(promptIn)
361
+ mkfifoSync(eventsOut)
362
+ // transcripts dir locked too
363
+ const transcriptDir = path.dirname(transcript)
364
+ fs.mkdirSync(transcriptDir, { recursive: true })
365
+ try {
366
+ fs.chmodSync(transcriptDir, 0o700)
367
+ } catch {}
368
+
369
+ const { cmd, prefixArgs } = resolveSootsimInvocation()
370
+ const wrapperArgs = [
371
+ ...prefixArgs,
372
+ 'agent-wrapper',
373
+ '--session-id',
374
+ session.id,
375
+ '--project-id',
376
+ project.id,
377
+ '--provider',
378
+ provider,
379
+ '--cwd',
380
+ project.cwd,
381
+ '--prompt-in',
382
+ promptIn,
383
+ '--events-out',
384
+ eventsOut,
385
+ '--transcript',
386
+ transcript,
387
+ ]
388
+ if (opts.codexBin) wrapperArgs.push('--codex-bin', opts.codexBin)
389
+ if (opts.claudeBin) wrapperArgs.push('--claude-bin', opts.claudeBin)
390
+ if (opts.freshThread) wrapperArgs.push('--fresh-thread')
391
+ if (claudeSessionUuid) {
392
+ wrapperArgs.push('--claude-session-uuid', claudeSessionUuid)
393
+ }
394
+
395
+ const child = spawn(cmd, wrapperArgs, {
396
+ detached: true,
397
+ stdio: 'ignore',
398
+ env: {
399
+ ...process.env,
400
+ SOOTSIM_USER_DATA_DIR: getUserDataDir(),
401
+ },
402
+ })
403
+ child.unref()
404
+
405
+ const readyTimeout = opts.readyTimeoutMs ?? 6000
406
+ const boot = await waitForFirstEvent(
407
+ eventsOut,
408
+ (e) => e.type === 'ready' || e.type === 'error',
409
+ readyTimeout,
410
+ )
411
+ if (!boot || boot.type === 'error') {
412
+ if (child.pid) {
413
+ try {
414
+ process.kill(child.pid, 'SIGTERM')
415
+ } catch {}
416
+ }
417
+ try {
418
+ fs.rmSync(sessionDir(session.id), { recursive: true, force: true })
419
+ } catch {}
420
+ updateSessionStatus(session.id, { status: 'ended' })
421
+ const reason =
422
+ boot && boot.type === 'error'
423
+ ? boot.message
424
+ : `no ready event within ${readyTimeout}ms`
425
+ throw new AgentSessionError('WRAPPER_FAILED', reason)
426
+ }
427
+
428
+ updateSessionStatus(session.id, {
429
+ wrapperPid: child.pid,
430
+ status: 'idle',
431
+ })
432
+ const updated = findSessionById(session.id)!
433
+ return { session: updated, wrapperPid: child.pid! }
434
+ })
435
+ }
436
+
437
+ export async function sendPrompt(
438
+ sessionId: string,
439
+ prompt: AgentPromptEnvelope,
440
+ ): Promise<void> {
441
+ const session = findSessionById(sessionId)
442
+ if (!session) {
443
+ throw new AgentSessionError('NO_SESSION', `no session with id=${sessionId}`)
444
+ }
445
+ if (!pidIsAlive(session.wrapperPid, sessionId)) {
446
+ updateSessionStatus(sessionId, { status: 'ended' })
447
+ throw new AgentSessionError(
448
+ 'NOT_ALIVE',
449
+ `session wrapper is not alive (pid=${session.wrapperPid}). start a new session.`,
450
+ )
451
+ }
452
+ const fifo = promptFifoPath(sessionId)
453
+ if (!fs.existsSync(fifo)) {
454
+ throw new AgentSessionError('NO_FIFO', `prompt FIFO missing: ${fifo}`)
455
+ }
456
+ const fd = fs.openSync(fifo, fsConstants.O_WRONLY)
457
+ try {
458
+ const wireText = encodeAgentPromptEnvelope(prompt)
459
+ if (!wireText) {
460
+ throw new AgentSessionError('EMPTY_PROMPT', 'prompt text is empty')
461
+ }
462
+ fs.writeSync(fd, wireText + '\n')
463
+ } finally {
464
+ fs.closeSync(fd)
465
+ }
466
+ updateSessionStatus(sessionId, {
467
+ lastPrompt: prompt.displayText ?? prompt.text,
468
+ status: 'working',
469
+ })
470
+ }
471
+
472
+ export async function endSession(sessionId: string): Promise<void> {
473
+ const session = findSessionById(sessionId)
474
+ if (!session) {
475
+ throw new AgentSessionError('NO_SESSION', `no session with id=${sessionId}`)
476
+ }
477
+ if (pidIsAlive(session.wrapperPid, sessionId)) {
478
+ try {
479
+ process.kill(session.wrapperPid!, 'SIGTERM')
480
+ } catch {}
481
+ }
482
+ // guard rm: only delete paths we fully own, derived from getUserDataDir()
483
+ const dir = sessionDir(sessionId)
484
+ const base = getUserDataDir()
485
+ if (dir.startsWith(base)) {
486
+ try {
487
+ fs.rmSync(dir, { recursive: true, force: true })
488
+ } catch {}
489
+ }
490
+ updateSessionStatus(sessionId, { status: 'ended', wrapperPid: undefined })
491
+ }
492
+
493
+ // --- event subscription ---
494
+
495
+ /** open events.out and stream events to onEvent. returns an unsubscribe fn.
496
+ * this is a single-reader channel — only ONE caller (the bridge daemon's
497
+ * AgentHost) should hold a live subscription at a time. every other
498
+ * consumer (electron, cli watch, browser shell) routes through the daemon
499
+ * via the agent:* ws protocol, where the daemon fans events out to N
500
+ * subscribers without re-reading the FIFO. */
501
+ export function subscribeEvents(
502
+ sessionId: string,
503
+ onEvent: (event: AgentEvent) => void,
504
+ ): () => void {
505
+ const fifo = eventsFifoPath(sessionId)
506
+ if (!fs.existsSync(fifo)) {
507
+ throw new AgentSessionError('NO_FIFO', `events FIFO missing: ${fifo}`)
508
+ }
509
+ // O_RDWR keeps the FIFO open even if every writer closes. the empty line in
510
+ // unsubscribe wakes any blocking read before destroy closes the fd; without
511
+ // that wake, repeated daemon teardown leaves reads occupying libuv workers
512
+ // and later sessions stop receiving events.
513
+ const fd = fs.openSync(fifo, fsConstants.O_RDWR)
514
+ const stream = fs.createReadStream('', { fd, autoClose: true })
515
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
516
+ rl.on('line', (line) => {
517
+ const event = parseAgentEventLine(line)
518
+ if (event) onEvent(event)
519
+ })
520
+ let closed = false
521
+ return () => {
522
+ if (closed) return
523
+ closed = true
524
+ try {
525
+ fs.writeSync(fd, '\n')
526
+ } catch {}
527
+ try {
528
+ rl.close()
529
+ } catch {}
530
+ try {
531
+ stream.destroy()
532
+ } catch {}
533
+ }
534
+ }
535
+
536
+ // --- wait for a specific event (used by startSession + watch tools) ---
537
+
538
+ async function waitForFirstEvent(
539
+ fifo: string,
540
+ predicate: (e: AgentEvent) => boolean,
541
+ timeoutMs: number,
542
+ ): Promise<AgentEvent | null> {
543
+ const fd = fs.openSync(fifo, fsConstants.O_RDWR | fsConstants.O_NONBLOCK)
544
+ const buf = Buffer.alloc(8192)
545
+ let leftover = ''
546
+ const deadline = Date.now() + timeoutMs
547
+ try {
548
+ while (Date.now() < deadline) {
549
+ let n = 0
550
+ try {
551
+ n = fs.readSync(fd, buf, 0, buf.length, null)
552
+ } catch (err) {
553
+ if ((err as NodeJS.ErrnoException).code !== 'EAGAIN') throw err
554
+ n = 0
555
+ }
556
+ if (n > 0) {
557
+ leftover += buf.subarray(0, n).toString('utf8')
558
+ let idx: number
559
+ while ((idx = leftover.indexOf('\n')) >= 0) {
560
+ const line = leftover.slice(0, idx)
561
+ leftover = leftover.slice(idx + 1)
562
+ const event = parseAgentEventLine(line)
563
+ if (event && predicate(event)) return event
564
+ }
565
+ } else {
566
+ await new Promise((r) => setTimeout(r, 30))
567
+ }
568
+ }
569
+ return null
570
+ } finally {
571
+ fs.closeSync(fd)
572
+ }
573
+ }