rnxsim 0.1.313 → 0.1.315

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 (297) hide show
  1. package/LICENSE +11 -10
  2. package/README.md +5 -0
  3. package/cli/app-config.ts +65 -0
  4. package/cli/app-fonts.ts +408 -0
  5. package/cli/app-project.ts +231 -0
  6. package/cli/app-splash.ts +185 -0
  7. package/cli/app-state-reset.ts +24 -0
  8. package/cli/auth.ts +155 -0
  9. package/cli/bin.ts +594 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2830 -0
  12. package/cli/browser-evals.ts +96 -0
  13. package/cli/commands/agent-wrapper.ts +986 -0
  14. package/cli/commands/agent.ts +423 -0
  15. package/cli/commands/app-fonts.ts +98 -0
  16. package/cli/commands/assert.ts +541 -0
  17. package/cli/commands/auth.ts +59 -0
  18. package/cli/commands/camera.ts +266 -0
  19. package/cli/commands/cleanup.ts +169 -0
  20. package/cli/commands/compat.ts +87 -0
  21. package/cli/commands/config.ts +32 -0
  22. package/cli/commands/control.ts +2142 -0
  23. package/cli/commands/cpu-profile.ts +269 -0
  24. package/cli/commands/daemon-mac-app.ts +169 -0
  25. package/cli/commands/daemon.ts +874 -0
  26. package/cli/commands/debug.ts +719 -0
  27. package/cli/commands/desktop.ts +39 -0
  28. package/cli/commands/detect.ts +197 -0
  29. package/cli/commands/detox.ts +385 -0
  30. package/cli/commands/device.ts +133 -0
  31. package/cli/commands/diagnose.ts +589 -0
  32. package/cli/commands/electron.ts +95 -0
  33. package/cli/commands/film.ts +379 -0
  34. package/cli/commands/flow.ts +1124 -0
  35. package/cli/commands/inspect/actions.ts +622 -0
  36. package/cli/commands/inspect/core.ts +2405 -0
  37. package/cli/commands/inspect/count.ts +17 -0
  38. package/cli/commands/inspect/describe.ts +192 -0
  39. package/cli/commands/inspect/env.ts +23 -0
  40. package/cli/commands/inspect/find.ts +171 -0
  41. package/cli/commands/inspect/get-layout.ts +39 -0
  42. package/cli/commands/inspect/keyboard.ts +52 -0
  43. package/cli/commands/inspect/list.ts +58 -0
  44. package/cli/commands/inspect/memory.ts +215 -0
  45. package/cli/commands/inspect/redaction.ts +39 -0
  46. package/cli/commands/inspect/resolve-target.ts +82 -0
  47. package/cli/commands/inspect/screens.ts +78 -0
  48. package/cli/commands/inspect/settle.ts +22 -0
  49. package/cli/commands/inspect/settling.ts +158 -0
  50. package/cli/commands/inspect/shared.ts +353 -0
  51. package/cli/commands/inspect/sleep.ts +14 -0
  52. package/cli/commands/inspect/tree.ts +32 -0
  53. package/cli/commands/inspect/url.ts +17 -0
  54. package/cli/commands/inspect/wait-event.ts +210 -0
  55. package/cli/commands/inspect/wait-idle.ts +24 -0
  56. package/cli/commands/inspect/wait-ready.ts +74 -0
  57. package/cli/commands/inspect/wait-selector.ts +54 -0
  58. package/cli/commands/inspect/wait.ts +31 -0
  59. package/cli/commands/inspect.ts +4519 -0
  60. package/cli/commands/install-desktop.ts +351 -0
  61. package/cli/commands/login.ts +331 -0
  62. package/cli/commands/logout.ts +31 -0
  63. package/cli/commands/maestro-generate.ts +361 -0
  64. package/cli/commands/maestro.ts +453 -0
  65. package/cli/commands/mode.ts +57 -0
  66. package/cli/commands/no-bridge-hint.ts +80 -0
  67. package/cli/commands/perf.ts +66 -0
  68. package/cli/commands/permissions.ts +203 -0
  69. package/cli/commands/profile.ts +108 -0
  70. package/cli/commands/react.ts +353 -0
  71. package/cli/commands/record.ts +1434 -0
  72. package/cli/commands/report-issue.ts +305 -0
  73. package/cli/commands/reset.ts +85 -0
  74. package/cli/commands/runtime.ts +351 -0
  75. package/cli/commands/screenshot-command.ts +106 -0
  76. package/cli/commands/screenshot-layers.ts +143 -0
  77. package/cli/commands/screenshot-mode.ts +37 -0
  78. package/cli/commands/screenshot.ts +488 -0
  79. package/cli/commands/screenshots-capture.ts +607 -0
  80. package/cli/commands/screenshots.ts +127 -0
  81. package/cli/commands/serve.ts +168 -0
  82. package/cli/commands/setup.ts +545 -0
  83. package/cli/commands/shell-boolean-mode.ts +81 -0
  84. package/cli/commands/skills.ts +467 -0
  85. package/cli/commands/slides.ts +361 -0
  86. package/cli/commands/state.ts +87 -0
  87. package/cli/commands/storage.ts +58 -0
  88. package/cli/commands/telemetry.ts +54 -0
  89. package/cli/commands/three-mode.ts +763 -0
  90. package/cli/commands/timeline.ts +122 -0
  91. package/cli/commands/upgrade.ts +208 -0
  92. package/cli/commands/upload.ts +1225 -0
  93. package/cli/commands/version.ts +54 -0
  94. package/cli/commands/what-happened.ts +327 -0
  95. package/cli/current-sim.ts +204 -0
  96. package/cli/desktop-companion.ts +300 -0
  97. package/cli/drivers/electron.ts +70 -0
  98. package/cli/drivers/index.ts +20 -0
  99. package/cli/drivers/playwright-provisioning.ts +180 -0
  100. package/cli/drivers/playwright.ts +698 -0
  101. package/cli/drivers/registry.ts +65 -0
  102. package/cli/drivers/types.ts +102 -0
  103. package/cli/flow-file.ts +142 -0
  104. package/cli/flow-live-status.ts +120 -0
  105. package/cli/flow-session.ts +187 -0
  106. package/cli/help.ts +80 -0
  107. package/cli/hidden-runtime-alias.ts +19 -0
  108. package/cli/hints.ts +216 -0
  109. package/cli/inspect-notice-state.ts +114 -0
  110. package/cli/maestro-js.ts +334 -0
  111. package/cli/open-url.ts +8 -0
  112. package/cli/parent-pid.ts +204 -0
  113. package/cli/parse-args.ts +211 -0
  114. package/cli/prompt.ts +51 -0
  115. package/cli/recording-access.ts +107 -0
  116. package/cli/registry.ts +1 -0
  117. package/cli/resolve-assets.ts +63 -0
  118. package/cli/run-registry.ts +226 -0
  119. package/cli/runtime-notes.ts +66 -0
  120. package/cli/runtime-summary.ts +25 -0
  121. package/cli/setup-repository.ts +187 -0
  122. package/cli/telemetry.ts +187 -0
  123. package/cli/ws-bridge.ts +798 -0
  124. package/dist-cli/bin.js +5 -5
  125. package/dist-cli/chunks/{agent-XZ2KTPCU.js → agent-CBU2WDKV.js} +2 -2
  126. package/dist-cli/chunks/{agent-wrapper-JJYYW2WH.js → agent-wrapper-UNB4R27L.js} +2 -2
  127. package/dist-cli/chunks/{app-fonts-IXRNQG6B.js → app-fonts-YMLRF3JD.js} +2 -2
  128. package/dist-cli/chunks/{assert-54T5SK5F.js → assert-5N6HUCL5.js} +2 -2
  129. package/dist-cli/chunks/{auth-FI5UDI45.js → auth-BHDR3A6G.js} +2 -2
  130. package/dist-cli/chunks/{beta-JV6UKADW.js → beta-LWUVCPYK.js} +2 -2
  131. package/dist-cli/chunks/camera-CNO6B2JZ.js +33 -0
  132. package/dist-cli/chunks/{chunk-ZMJD5GEC.js → chunk-25VCR44B.js} +1 -1
  133. package/dist-cli/chunks/{chunk-WUSWBCWA.js → chunk-26CSIR76.js} +8 -9
  134. package/dist-cli/chunks/{chunk-WINYQ44O.js → chunk-26WMWUH4.js} +1 -1
  135. package/dist-cli/chunks/{chunk-BTWORNNG.js → chunk-2JZ53D4A.js} +1 -1
  136. package/dist-cli/chunks/{chunk-DCEMHR2Y.js → chunk-2POODCTW.js} +2 -2
  137. package/dist-cli/chunks/{chunk-5TEF3ET3.js → chunk-4LTBK3ZN.js} +2 -2
  138. package/dist-cli/chunks/{chunk-NMF2ZMZQ.js → chunk-54MIRTFE.js} +4 -4
  139. package/dist-cli/chunks/{chunk-WEXDAC74.js → chunk-56H6EVO4.js} +2 -2
  140. package/dist-cli/chunks/{chunk-F5ZRSS3C.js → chunk-657SQG4D.js} +1 -1
  141. package/dist-cli/chunks/{chunk-IJ5CAZZC.js → chunk-6ZMRJPKI.js} +1 -1
  142. package/dist-cli/chunks/{chunk-W6K4EFPH.js → chunk-AOGGOC4V.js} +2 -2
  143. package/dist-cli/chunks/chunk-BPCDO7ST.js +15 -0
  144. package/dist-cli/chunks/{chunk-D4FFVGI5.js → chunk-C5O5CTXJ.js} +1 -1
  145. package/dist-cli/chunks/{chunk-RTN5C5RL.js → chunk-CMM64PPR.js} +1 -1
  146. package/dist-cli/chunks/{chunk-BBULZ7CG.js → chunk-D3M36D7O.js} +62 -87
  147. package/dist-cli/chunks/{chunk-5DHC6KHQ.js → chunk-DNCNFZ7V.js} +1 -1
  148. package/dist-cli/chunks/{chunk-YIFT42WN.js → chunk-FNTTIR2P.js} +2 -2
  149. package/dist-cli/chunks/chunk-H362IX2I.js +4 -0
  150. package/dist-cli/chunks/{chunk-IJO63TDP.js → chunk-J6HMTUZH.js} +2 -2
  151. package/dist-cli/chunks/{chunk-RSZWCKNT.js → chunk-JZ6VTZ5B.js} +3 -3
  152. package/dist-cli/chunks/{chunk-MJRLLB4R.js → chunk-KQSJVNFE.js} +4 -4
  153. package/dist-cli/chunks/chunk-L4JZKVCC.js +6 -0
  154. package/dist-cli/chunks/{chunk-VFCMSYZK.js → chunk-LP2BTEJ6.js} +2 -2
  155. package/dist-cli/chunks/{chunk-2YR5BGA5.js → chunk-LT6UC7I3.js} +2 -2
  156. package/dist-cli/chunks/{chunk-OVFJFXUD.js → chunk-MCKQH4AM.js} +2 -2
  157. package/dist-cli/chunks/{chunk-TZFFR3SD.js → chunk-MF7WUHQ3.js} +2 -2
  158. package/dist-cli/chunks/{chunk-46EUUFJ5.js → chunk-MSSY4EEZ.js} +1 -1
  159. package/dist-cli/chunks/{chunk-WMIIKMGK.js → chunk-N5ZCYP5F.js} +2 -2
  160. package/dist-cli/chunks/{chunk-3NV2NCNX.js → chunk-NCKAD3V7.js} +2 -2
  161. package/dist-cli/chunks/{chunk-VNQEB4L7.js → chunk-O2KRHVTU.js} +2 -2
  162. package/dist-cli/chunks/{chunk-OZSSI4WN.js → chunk-OF2HTH25.js} +2 -2
  163. package/dist-cli/chunks/{chunk-GGRX24GF.js → chunk-OXS2IWO2.js} +2 -2
  164. package/dist-cli/chunks/{chunk-WWZIXIRD.js → chunk-PVL7XSPN.js} +1 -1
  165. package/dist-cli/chunks/{chunk-DZS6WPUI.js → chunk-PVPAP3TV.js} +1 -1
  166. package/dist-cli/chunks/{chunk-5YJCOWCH.js → chunk-PXNAZYGV.js} +1 -1
  167. package/dist-cli/chunks/chunk-QR3HTFRB.js +5 -0
  168. package/dist-cli/chunks/{chunk-5TPRP5QT.js → chunk-QR7MWAWE.js} +1 -1
  169. package/dist-cli/chunks/chunk-RJIEZ2NY.js +4 -0
  170. package/dist-cli/chunks/{chunk-UC6U3MML.js → chunk-RVM4EZ4E.js} +2 -2
  171. package/dist-cli/chunks/{chunk-2D2UPBBR.js → chunk-SZKSFMWS.js} +1 -1
  172. package/dist-cli/chunks/{chunk-7GN3LVWB.js → chunk-TEL7G234.js} +2 -2
  173. package/dist-cli/chunks/{chunk-GASE6UBA.js → chunk-U2HNIHBY.js} +1 -1
  174. package/dist-cli/chunks/chunk-UROXJVY3.js +27 -0
  175. package/dist-cli/chunks/{chunk-HI5TFJWN.js → chunk-WM32MGTJ.js} +2 -2
  176. package/dist-cli/chunks/{chunk-VZXWHRUZ.js → chunk-WMZ7NJAO.js} +89 -133
  177. package/dist-cli/chunks/{chunk-XEVZYVIW.js → chunk-WUXCH6WN.js} +10 -9
  178. package/dist-cli/chunks/chunk-XOXGC3EH.js +9 -0
  179. package/dist-cli/chunks/chunk-XSSHENNY.js +4 -0
  180. package/dist-cli/chunks/{chunk-OHAZNXLK.js → chunk-XZ6O67AR.js} +1 -1
  181. package/dist-cli/chunks/{chunk-QKDWYITG.js → chunk-Y2MWQKOS.js} +3 -3
  182. package/dist-cli/chunks/{chunk-YDGQTMQL.js → chunk-YY24QI25.js} +1 -1
  183. package/dist-cli/chunks/{chunk-WF3T4SVI.js → chunk-ZWX6DDIS.js} +2 -2
  184. package/dist-cli/chunks/{cleanup-P27PA6JI.js → cleanup-F3NUJ3SL.js} +2 -2
  185. package/dist-cli/chunks/cli-version-XIW6O3FG.js +4 -0
  186. package/dist-cli/chunks/{compat-ZD65FED3.js → compat-AX2SEPTH.js} +2 -2
  187. package/dist-cli/chunks/{config-XMJRNM2A.js → config-4QQO5JVU.js} +2 -2
  188. package/dist-cli/chunks/{control-KMIQT3QP.js → control-OCKKSOWA.js} +2 -2
  189. package/dist-cli/chunks/daemon-N5VLMYKQ.js +4 -0
  190. package/dist-cli/chunks/{debug-PT4HOP7N.js → debug-T6MWVEVX.js} +5 -5
  191. package/dist-cli/chunks/{desktop-S3FG72AK.js → desktop-NCW7R7CB.js} +3 -3
  192. package/dist-cli/chunks/{detox-B3D4IFCN.js → detox-G6GITO2X.js} +2 -2
  193. package/dist-cli/chunks/{device-XBNDSB2R.js → device-7BTEJCTH.js} +2 -2
  194. package/dist-cli/chunks/{diagnose-HMQXJE5N.js → diagnose-SC5LACE3.js} +2 -2
  195. package/dist-cli/chunks/{disk-cleanup-BLCZ5BSZ.js → disk-cleanup-KK56QQZA.js} +2 -2
  196. package/dist-cli/chunks/drivers-NPGVBWMG.js +4 -0
  197. package/dist-cli/chunks/{film-BJGTBYZB.js → film-WGYEHGIA.js} +3 -3
  198. package/dist-cli/chunks/flow-KQBIBM3N.js +4 -0
  199. package/dist-cli/chunks/help-G7CAKI65.js +4 -0
  200. package/dist-cli/chunks/{hidden-runtime-alias-ANOYADHM.js → hidden-runtime-alias-CWTZWH3L.js} +2 -2
  201. package/dist-cli/chunks/home-paths-EBT4XHAS.js +4 -0
  202. package/dist-cli/chunks/inspect-CCPZXVDU.js +4 -0
  203. package/dist-cli/chunks/install-desktop-NZ3VGTQZ.js +4 -0
  204. package/dist-cli/chunks/{login-FJ737MWG.js → login-GSYOTGVQ.js} +4 -4
  205. package/dist-cli/chunks/{logout-ZCNMMHMY.js → logout-JPRIR7YK.js} +2 -2
  206. package/dist-cli/chunks/{maestro-SZTNKLDF.js → maestro-LXKDNO5M.js} +3 -3
  207. package/dist-cli/chunks/{maestro-generate-DCFAIZ4H.js → maestro-generate-5ZIKOU53.js} +3 -3
  208. package/dist-cli/chunks/{mode-GRMQCRXR.js → mode-N3QHXCES.js} +2 -2
  209. package/dist-cli/chunks/{optional-demo-registry-W36EWFFB.js → optional-demo-registry-XDH6X24N.js} +2 -2
  210. package/dist-cli/chunks/{perf-QYBAAUZG.js → perf-VOPL26FM.js} +2 -2
  211. package/dist-cli/chunks/{permissions-3QCQ6VF4.js → permissions-CZFKM4UD.js} +2 -2
  212. package/dist-cli/chunks/{record-QPPC2S4E.js → record-FODSOGMA.js} +3 -3
  213. package/dist-cli/chunks/{report-issue-7NMFP4HK.js → report-issue-FBPCZKOV.js} +2 -2
  214. package/dist-cli/chunks/reset-3H35GFO3.js +4 -0
  215. package/dist-cli/chunks/runtime-4OVFRXV5.js +4 -0
  216. package/dist-cli/chunks/{screenshot-command-67AECJFB.js → screenshot-command-7S3FYVO7.js} +7 -7
  217. package/dist-cli/chunks/{screenshot-layers-ASWBYPJL.js → screenshot-layers-CDYHPNC3.js} +3 -3
  218. package/dist-cli/chunks/{screenshots-capture-PXA3HFQK.js → screenshots-capture-EBIIY2YU.js} +2 -2
  219. package/dist-cli/chunks/serve-NELNSCZP.js +44 -0
  220. package/dist-cli/chunks/{setup-7DWPMRSB.js → setup-DF5ZDDIV.js} +2 -2
  221. package/dist-cli/chunks/{skills-S3Y22TUA.js → skills-E2QZF6WZ.js} +2 -2
  222. package/dist-cli/chunks/state-MBZDKZ7U.js +14 -0
  223. package/dist-cli/chunks/{storage-XUIMJWAJ.js → storage-J5BMNVXM.js} +6 -6
  224. package/dist-cli/chunks/store-U3DXC7HH.js +4 -0
  225. package/dist-cli/chunks/telemetry-XIPJVT5E.js +4 -0
  226. package/dist-cli/chunks/{timeline-TMPLQPSP.js → timeline-MC6N5BHY.js} +2 -2
  227. package/dist-cli/chunks/{upgrade-7HDSIM7K.js → upgrade-7UTU53DH.js} +2 -2
  228. package/dist-cli/chunks/upload-2M2KOTYI.js +4 -0
  229. package/dist-cli/chunks/version-VNACNYQK.js +6 -0
  230. package/dist-cli/chunks/{web-DG3WBYD3.js → web-MGJ2TUKR.js} +2 -2
  231. package/dist-cli/chunks/{what-happened-XFVUTZR7.js → what-happened-7DLKH3OD.js} +3 -3
  232. package/dist-lib/agent-daemon-client.cjs +1 -1
  233. package/dist-lib/agent-events.cjs +1 -1
  234. package/dist-lib/agent-identity.cjs +1 -1
  235. package/dist-lib/agent-sessions.cjs +1 -1
  236. package/dist-lib/attached-projects.cjs +1 -1
  237. package/dist-lib/auth/shared-session.cjs +1 -1
  238. package/dist-lib/backend-origin.cjs +1 -1
  239. package/dist-lib/beta.cjs +1 -1
  240. package/dist-lib/beta.mjs +1 -1
  241. package/dist-lib/bridge-constants.cjs +1 -1
  242. package/dist-lib/bridge-contract.cjs +20 -0
  243. package/dist-lib/cli-constants.cjs +1 -1
  244. package/dist-lib/config.cjs +1 -1
  245. package/dist-lib/detox/index.cjs +1 -1
  246. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  247. package/dist-lib/home-paths.cjs +67 -28
  248. package/dist-lib/host/bridge-host.cjs +140 -12
  249. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  250. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  251. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  252. package/dist-lib/host/websocket-proxy.cjs +1 -1
  253. package/dist-lib/index.cjs +2815 -40
  254. package/dist-lib/jump-to-source-babel.cjs +1 -1
  255. package/dist-lib/menu.cjs +1 -1
  256. package/dist-lib/menu.mjs +1 -1
  257. package/dist-lib/metro.cjs +1 -1
  258. package/dist-lib/profiles.cjs +1 -1
  259. package/dist-lib/public-brand.cjs +1 -1
  260. package/dist-lib/render-mode.cjs +1 -1
  261. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  262. package/dist-lib/sdk.cjs +2549 -2061
  263. package/dist-lib/sdk.mjs +2543 -2061
  264. package/dist-lib/skills.cjs +480 -280
  265. package/dist-lib/vite.cjs +1 -1
  266. package/package.json +8 -2
  267. package/src/bridge-constants.ts +3 -4
  268. package/src/bridge-contract.ts +251 -0
  269. package/src/connect.ts +83 -0
  270. package/src/disk-cleanup.ts +30 -0
  271. package/src/home-paths.ts +81 -38
  272. package/src/host/bridge-host.ts +134 -6
  273. package/src/index.ts +27 -1
  274. package/src/sdk.ts +8 -0
  275. package/src/sim-client.ts +660 -0
  276. package/dist-cli/chunks/camera-VL73YIKP.js +0 -22
  277. package/dist-cli/chunks/chunk-4NPPOV2N.js +0 -5
  278. package/dist-cli/chunks/chunk-FSUYIVJ6.js +0 -9
  279. package/dist-cli/chunks/chunk-G2WW6L2C.js +0 -23
  280. package/dist-cli/chunks/chunk-KTHV3RUS.js +0 -26
  281. package/dist-cli/chunks/chunk-LF2ZVT7O.js +0 -6
  282. package/dist-cli/chunks/chunk-NFK7T35W.js +0 -4
  283. package/dist-cli/chunks/chunk-TIVZIMMW.js +0 -4
  284. package/dist-cli/chunks/cli-version-WWLPBDQ7.js +0 -4
  285. package/dist-cli/chunks/daemon-G2ME7NLB.js +0 -4
  286. package/dist-cli/chunks/drivers-LDECZGP2.js +0 -4
  287. package/dist-cli/chunks/flow-UEQNVTU7.js +0 -4
  288. package/dist-cli/chunks/help-T5FYSVGB.js +0 -4
  289. package/dist-cli/chunks/home-paths-GT3LFNOR.js +0 -4
  290. package/dist-cli/chunks/inspect-ZA6XF5LD.js +0 -4
  291. package/dist-cli/chunks/install-desktop-TIMUDHPL.js +0 -4
  292. package/dist-cli/chunks/runtime-XOAXMSTU.js +0 -4
  293. package/dist-cli/chunks/serve-BI2NBAXG.js +0 -44
  294. package/dist-cli/chunks/store-JTHEJLAZ.js +0 -4
  295. package/dist-cli/chunks/telemetry-ZYJGD2DB.js +0 -4
  296. package/dist-cli/chunks/upload-GMSZPWM6.js +0 -4
  297. package/dist-cli/chunks/version-HOCHZ37L.js +0 -6
@@ -0,0 +1,986 @@
1
+ // agent-wrapper — long-running bridge process between sootsim desktop and a
2
+ // local coding agent (codex / claude).
3
+ //
4
+ // shipped as an internal sootsim CLI subcommand so it survives `bun build
5
+ // --compile` bundling. callers spawn it as:
6
+ //
7
+ // sootsim agent-wrapper \
8
+ // --session-id <id> --project-id <id> --provider codex \
9
+ // --cwd <path> --prompt-in <fifo> --events-out <fifo> \
10
+ // [--transcript <file>]
11
+ //
12
+ // protocol:
13
+ // prompt.in — newline-delimited prompt text from electron / the CLI.
14
+ // events.out — newline-delimited JSON events (see `sootsim/agent-events`).
15
+ // transcript — append-only raw stdout/stderr of the spawned agent turn.
16
+ //
17
+ // stage 7 scope (codex): replaced the per-turn `codex exec` spawn with a
18
+ // persistent `codex app-server` JSON-RPC child. one thread/start per session
19
+ // (or thread/resume if we find a prior thread for the cwd), one turn/start
20
+ // per prompt. notifications are translated into structured AgentEvents with
21
+ // real filesTouched, file-diff deltas, and plan updates.
22
+ //
23
+ // claude still uses the stage 3 per-turn spawn via `claude -p`; stage 8
24
+ // replaces it with `--output-format stream-json --verbose --resume`.
25
+
26
+ import { execFile, spawn, type ChildProcess } from 'node:child_process'
27
+ import { randomUUID } from 'node:crypto'
28
+ import {
29
+ constants as fsConstants,
30
+ createReadStream,
31
+ createWriteStream,
32
+ existsSync,
33
+ openSync,
34
+ } from 'node:fs'
35
+ import fs from 'node:fs/promises'
36
+ import path from 'node:path'
37
+ import readline from 'node:readline'
38
+ import { promisify } from 'node:util'
39
+ import { decodeAgentPromptEnvelope } from '../../src/agent-prompt'
40
+ import { spawnCodexClient, type CodexClient } from '../../src/codex-client'
41
+ import type { AgentEvent } from '../../src/agent-events'
42
+
43
+ const CODEX_APPROVAL_POLICY = 'never'
44
+ const CODEX_THREAD_SANDBOX = 'danger-full-access'
45
+ const CODEX_TURN_SANDBOX_POLICY = { type: 'dangerFullAccess' } as const
46
+ const CODEX_SERVICE_TIER = 'fast' as const
47
+ const CODEX_REASONING_EFFORT = 'medium' as const
48
+
49
+ const execFileP = promisify(execFile)
50
+
51
+ async function resolveAgentBinary(
52
+ provider: 'codex' | 'claude',
53
+ override: string | undefined,
54
+ ): Promise<{ ok: true; path: string } | { ok: false; message: string }> {
55
+ const bin = override || provider
56
+ // absolute or relative path — check directly
57
+ if (bin.includes('/') || bin.includes('\\')) {
58
+ if (existsSync(bin)) return { ok: true, path: bin }
59
+ return { ok: false, message: `agent binary not found at path: ${bin}` }
60
+ }
61
+ try {
62
+ const { stdout } = await execFileP('which', [bin], { timeout: 1500 })
63
+ const resolved = stdout.trim()
64
+ if (resolved) return { ok: true, path: resolved }
65
+ return { ok: false, message: `${bin} not found on PATH` }
66
+ } catch {
67
+ return {
68
+ ok: false,
69
+ message:
70
+ `${bin} not found on PATH. install the ${provider} CLI and retry, or pass ` +
71
+ `--${provider}-bin /path/to/${provider} to agent start.`,
72
+ }
73
+ }
74
+ }
75
+
76
+ interface WrapperArgs {
77
+ sessionId?: string
78
+ projectId?: string
79
+ provider?: string
80
+ cwd?: string
81
+ promptIn?: string
82
+ eventsOut?: string
83
+ transcript?: string
84
+ codexBin?: string
85
+ claudeBin?: string
86
+ claudeSessionUuid?: string
87
+ freshThread?: boolean
88
+ }
89
+
90
+ function parseWrapperArgs(argv: string[]): WrapperArgs {
91
+ const map: Record<string, keyof WrapperArgs> = {
92
+ '--session-id': 'sessionId',
93
+ '--project-id': 'projectId',
94
+ '--provider': 'provider',
95
+ '--cwd': 'cwd',
96
+ '--prompt-in': 'promptIn',
97
+ '--events-out': 'eventsOut',
98
+ '--transcript': 'transcript',
99
+ '--codex-bin': 'codexBin',
100
+ '--claude-bin': 'claudeBin',
101
+ '--claude-session-uuid': 'claudeSessionUuid',
102
+ }
103
+ const out: WrapperArgs = {}
104
+ for (let i = 0; i < argv.length; i++) {
105
+ const flag = argv[i]
106
+ if (flag === '--fresh-thread') {
107
+ out.freshThread = true
108
+ continue
109
+ }
110
+ const key = map[flag]
111
+ if (!key) continue
112
+ out[key] = argv[i + 1] as never
113
+ i++
114
+ }
115
+ return out
116
+ }
117
+
118
+ /** drives one prompt end-to-end. emits turn-started on entry and exactly one
119
+ * terminal event (turn-completed OR error) before resolving. never throws. */
120
+ type TurnRunner = (text: string) => Promise<void>
121
+
122
+ export async function runAgentWrapper(argv: string[]): Promise<number> {
123
+ const args = parseWrapperArgs(argv)
124
+
125
+ if (
126
+ !args.sessionId ||
127
+ !args.projectId ||
128
+ !args.provider ||
129
+ !args.cwd ||
130
+ !args.promptIn ||
131
+ !args.eventsOut
132
+ ) {
133
+ process.stderr.write(
134
+ 'usage: rnxsim agent-wrapper --session-id <id> --project-id <id>\n' +
135
+ ' --provider codex|claude --cwd <path>\n' +
136
+ ' --prompt-in <fifo> --events-out <fifo>\n' +
137
+ ' [--transcript <file>]\n' +
138
+ ' [--codex-bin <path>] [--claude-bin <path>]\n',
139
+ )
140
+ return 2
141
+ }
142
+ if (args.provider !== 'codex' && args.provider !== 'claude') {
143
+ process.stderr.write(`unknown provider: ${args.provider}\n`)
144
+ return 2
145
+ }
146
+
147
+ const { sessionId, projectId, cwd, promptIn, eventsOut } = args
148
+ const provider = args.provider as 'codex' | 'claude'
149
+
150
+ // keep the events.out write end open for the whole process lifetime. opening
151
+ // and closing per event would SIGPIPE briefly between writers.
152
+ //
153
+ // IMPORTANT: open with O_RDWR (not O_WRONLY/'a') so the wrapper itself
154
+ // holds a reader fd on the FIFO. without this, the bootstrap reader
155
+ // (startSession's waitForFirstEvent) attaches briefly to consume `ready`,
156
+ // then closes — leaving the wrapper's O_WRONLY fd as the only handle on
157
+ // a reader-less FIFO. the next write (prompt-received / turn-started /
158
+ // turn-message …) triggers SIGPIPE / EPIPE and the wrapper dies silently
159
+ // before any client ws subscription lands. O_RDWR makes the FIFO appear
160
+ // to always have a reader, so writes buffer in the kernel until the
161
+ // daemon attaches to drain.
162
+ if (!existsSync(eventsOut)) {
163
+ process.stderr.write(`events-out FIFO missing: ${eventsOut}\n`)
164
+ return 3
165
+ }
166
+ if (!existsSync(promptIn)) {
167
+ process.stderr.write(`prompt-in FIFO missing: ${promptIn}\n`)
168
+ return 3
169
+ }
170
+ const eventsFd = openSync(eventsOut, fsConstants.O_RDWR | fsConstants.O_APPEND)
171
+ const eventsStream = createWriteStream(eventsOut, { fd: eventsFd, flags: 'a' })
172
+
173
+ function emit(event: Partial<AgentEvent> & { type: AgentEvent['type'] }): void {
174
+ const line = JSON.stringify({ ts: Date.now(), ...event }) + '\n'
175
+ eventsStream.write(line)
176
+ }
177
+
178
+ // transcript is optional; append mode so multiple turns accumulate.
179
+ let transcriptStream: ReturnType<typeof createWriteStream> | null = null
180
+ if (args.transcript) {
181
+ try {
182
+ await fs.mkdir(path.dirname(args.transcript), { recursive: true })
183
+ transcriptStream = createWriteStream(args.transcript, { flags: 'a' })
184
+ } catch (err) {
185
+ emit({
186
+ type: 'error',
187
+ message: `transcript open failed: ${(err as Error).message}`,
188
+ } as AgentEvent)
189
+ }
190
+ }
191
+
192
+ // defensive: an EPIPE from a stale reader would otherwise bubble to
193
+ // uncaughtException and tear the wrapper down mid-turn. the O_RDWR open
194
+ // above prevents the common case, but a rogue close on the read side
195
+ // can still briefly surface here. log it and move on — the FIFO
196
+ // self-heals on the next reader attach.
197
+ eventsStream.on('error', (err) => {
198
+ const message = `[events-out] stream error: ${(err as Error).message}\n`
199
+ try {
200
+ if (transcriptStream) transcriptStream.write(message)
201
+ else process.stderr.write(message)
202
+ } catch {}
203
+ })
204
+
205
+ // preflight: resolve the agent binary once up front. emitting ready without
206
+ // this check means the first `agent prompt` silently completes with an empty
207
+ // turn-completed — users think "the agent said nothing" instead of "codex
208
+ // isn't installed."
209
+ const bin = await resolveAgentBinary(
210
+ provider,
211
+ provider === 'codex' ? args.codexBin : args.claudeBin,
212
+ )
213
+ if (!bin.ok) {
214
+ emit({ type: 'error', message: bin.message } as AgentEvent)
215
+ // give the reader a moment to drain before we exit
216
+ await new Promise((r) => setTimeout(r, 50))
217
+ eventsStream.end()
218
+ return 4
219
+ }
220
+ const resolvedAgentBin = bin.path
221
+
222
+ // for codex: boot the persistent app-server now. any startup failure is a
223
+ // clean error event before we advertise ready.
224
+ let codexClient: CodexClient | null = null
225
+ let codexThreadId: string | null = null
226
+ if (provider === 'codex') {
227
+ try {
228
+ codexClient = spawnCodexClient({
229
+ bin: resolvedAgentBin,
230
+ cwd,
231
+ env: {
232
+ SOOTSIM_CLI_IDENTITY: sessionId,
233
+ SOOTSIM_PROJECT_ID: projectId,
234
+ },
235
+ })
236
+ // surface stderr into the transcript without cluttering events.
237
+ codexClient.on('__stderr__', (p) => {
238
+ const text = (p as { text?: string })?.text
239
+ if (text) transcriptStream?.write(`[codex-stderr] ${text}`)
240
+ })
241
+ // capture async errors so they don't kill the wrapper silently.
242
+ void codexClient.exited.then(({ code, signal }) => {
243
+ if (!shuttingDown) {
244
+ emit({
245
+ type: 'error',
246
+ message: `codex app-server exited (code=${code}, signal=${signal ?? ''})`,
247
+ } as AgentEvent)
248
+ shutdown(5)
249
+ }
250
+ })
251
+ // 30s cap on the boot sequence — if codex hangs on init/thread setup,
252
+ // emit an error instead of silently never advertising ready.
253
+ await withTimeout(
254
+ (async () => {
255
+ await codexClient!.request('initialize', {
256
+ clientInfo: { name: 'sootsim', title: null, version: '0.1.0' },
257
+ capabilities: null,
258
+ })
259
+ // prefer an existing non-archived thread for this cwd unless the
260
+ // caller explicitly asked for a fresh one.
261
+ codexThreadId = await resolveCodexThread(codexClient!, cwd, {
262
+ fresh: args.freshThread === true,
263
+ })
264
+ })(),
265
+ 30_000,
266
+ 'codex app-server boot timed out after 30s',
267
+ )
268
+ } catch (err) {
269
+ const message = err instanceof Error ? err.message : String(err)
270
+ emit({
271
+ type: 'error',
272
+ message: `codex app-server init failed: ${message}`,
273
+ } as AgentEvent)
274
+ try {
275
+ codexClient?.kill()
276
+ } catch {}
277
+ eventsStream.end()
278
+ return 4
279
+ }
280
+ }
281
+
282
+ emit({
283
+ type: 'ready',
284
+ sessionId,
285
+ projectId,
286
+ provider,
287
+ cwd,
288
+ } as AgentEvent)
289
+
290
+ // serialize turn execution per-session — queue prompts while one is in
291
+ // flight. both codex and claude serialize per-session internally, but
292
+ // gating here keeps wrapper event ordering honest.
293
+ let turnInFlight = false
294
+ let currentClaudeChild: ChildProcess | null = null
295
+ const pendingPrompts: string[] = []
296
+
297
+ const { promise: exitPromise, resolve: resolveExit } = createDeferred<number>()
298
+
299
+ let shuttingDown = false
300
+ function shutdown(code: number, emitExit = true): void {
301
+ if (shuttingDown) return
302
+ shuttingDown = true
303
+ clearInterval(orphanWatchdog)
304
+ try {
305
+ currentClaudeChild?.kill('SIGTERM')
306
+ } catch {}
307
+ try {
308
+ void codexClient?.shutdown(800)
309
+ } catch {}
310
+ if (emitExit) emit({ type: 'exited', code } as AgentEvent)
311
+ eventsStream.end(() => {
312
+ try {
313
+ transcriptStream?.end()
314
+ } catch {}
315
+ resolveExit(code)
316
+ })
317
+ ;(setTimeout(() => resolveExit(code), 1500) as unknown as NodeJS.Timeout).unref()
318
+ }
319
+
320
+ // orphan watchdog: if our session dir / FIFOs are deleted out from under
321
+ // us (test cleanup did `rm -rf tmpdir` without calling endSession, the
322
+ // daemon crashed, the user force-nuked app-data) we can't be reached by
323
+ // anyone anyway — stop holding fds and spinning. poll every 10s to keep
324
+ // cost negligible; tests override via SOOTSIM_WRAPPER_WATCHDOG_MS.
325
+ const watchdogMs = (() => {
326
+ const raw = Number(process.env.SOOTSIM_WRAPPER_WATCHDOG_MS)
327
+ return Number.isFinite(raw) && raw > 0 ? raw : 10_000
328
+ })()
329
+ const orphanWatchdog = setInterval(() => {
330
+ if (shuttingDown) return
331
+ const eventsOutExists = existsSync(eventsOut)
332
+ const promptInExists = existsSync(promptIn)
333
+ if (!eventsOutExists || !promptInExists) {
334
+ process.stderr.write(
335
+ `[wrapper] session fifos gone (eventsOut=${eventsOutExists} promptIn=${promptInExists}), exiting\n`,
336
+ )
337
+ // once events.out is gone there is no recipient for an exit event. a
338
+ // final write only introduces a broken-pipe race into clean orphan
339
+ // shutdown; normal exits and prompt-only loss still report over the wire.
340
+ shutdown(0, eventsOutExists)
341
+ }
342
+ }, watchdogMs)
343
+ ;(orphanWatchdog as unknown as NodeJS.Timeout).unref()
344
+
345
+ process.on('SIGTERM', () => shutdown(143))
346
+ process.on('SIGINT', () => shutdown(130))
347
+ // detached: true takes us out of the parent's process group, but SIGHUP
348
+ // can still propagate in edge cases (e.g. crashed parent terminal). exit
349
+ // cleanly instead of being killed uncleanly with no `exited` event.
350
+ process.on('SIGHUP', () => shutdown(129))
351
+ process.on('uncaughtException', (err) => {
352
+ emit({
353
+ type: 'error',
354
+ message: `uncaught: ${(err as Error).message}`,
355
+ } as AgentEvent)
356
+ shutdown(1)
357
+ })
358
+
359
+ // build the per-turn runner for whichever provider this is.
360
+ const runTurn: TurnRunner =
361
+ provider === 'codex' && codexClient
362
+ ? buildCodexTurnRunner({
363
+ client: codexClient,
364
+ getThreadId: () => codexThreadId,
365
+ setThreadId: (id) => (codexThreadId = id),
366
+ emit,
367
+ transcript: transcriptStream,
368
+ onTurnSettled: () => {
369
+ turnInFlight = false
370
+ const next = pendingPrompts.shift()
371
+ if (next) void invokeTurn(next)
372
+ },
373
+ })
374
+ : buildClaudeTurnRunner({
375
+ resolvedBin: resolvedAgentBin,
376
+ sessionId,
377
+ projectId,
378
+ // prefer the uuid persisted on the AgentSession (passed in by
379
+ // startSession so `~/.claude/projects/<cwd>/<uuid>.jsonl` is
380
+ // reused across wrapper restarts). fall back to a fresh uuid for
381
+ // standalone invocations (tests, ad-hoc debugging).
382
+ claudeSessionUuid: args.claudeSessionUuid ?? randomUUID(),
383
+ cwd,
384
+ emit,
385
+ transcript: transcriptStream,
386
+ setChild: (c) => (currentClaudeChild = c),
387
+ onTurnSettled: () => {
388
+ turnInFlight = false
389
+ const next = pendingPrompts.shift()
390
+ if (next) void invokeTurn(next)
391
+ },
392
+ })
393
+
394
+ async function invokeTurn(text: string): Promise<void> {
395
+ turnInFlight = true
396
+ try {
397
+ await runTurn(text)
398
+ } catch (err) {
399
+ emit({
400
+ type: 'error',
401
+ message: `turn failed: ${(err as Error).message}`,
402
+ } as AgentEvent)
403
+ turnInFlight = false
404
+ const next = pendingPrompts.shift()
405
+ if (next) void invokeTurn(next)
406
+ }
407
+ }
408
+
409
+ // open prompt.in with O_RDWR so our own write end keeps the FIFO alive
410
+ // across external writer open/close cycles. `sootsim agent prompt` opens,
411
+ // writes one line, closes — without a self-writer the reader would EOF
412
+ // after the first prompt and subsequent prompts would be lost.
413
+ const promptFd = openSync(promptIn, fsConstants.O_RDWR)
414
+ const promptStream = createReadStream('', { fd: promptFd, autoClose: false })
415
+ const rl = readline.createInterface({ input: promptStream, crlfDelay: Infinity })
416
+
417
+ rl.on('line', (rawLine) => {
418
+ const prompt = decodeAgentPromptEnvelope(rawLine)
419
+ if (!prompt) return
420
+ const text = prompt.text
421
+ emit({
422
+ type: 'prompt-received',
423
+ text: prompt.displayText ?? text,
424
+ ...(prompt.inspectSummary ? { inspectSummary: prompt.inspectSummary } : {}),
425
+ ...(prompt.inspectTrace ? { inspectTrace: prompt.inspectTrace } : {}),
426
+ } as AgentEvent)
427
+ if (turnInFlight) {
428
+ pendingPrompts.push(text)
429
+ return
430
+ }
431
+ void invokeTurn(text)
432
+ })
433
+
434
+ promptStream.on('error', (err) => {
435
+ emit({
436
+ type: 'error',
437
+ message: `prompt-in read failed: ${err.message}`,
438
+ } as AgentEvent)
439
+ })
440
+
441
+ return exitPromise
442
+ }
443
+
444
+ // --- codex app-server integration ---
445
+
446
+ export function extractCodexThreadId(
447
+ started: { threadId?: string; thread?: { id?: string } } | undefined,
448
+ ): string | null {
449
+ return started?.threadId ?? started?.thread?.id ?? null
450
+ }
451
+
452
+ async function resolveCodexThread(
453
+ client: CodexClient,
454
+ cwd: string,
455
+ opts: { fresh?: boolean } = {},
456
+ ): Promise<string> {
457
+ // try to reuse the most recent active thread for this cwd. if anything
458
+ // goes wrong, or the caller requested a fresh thread, use thread/start.
459
+ if (!opts.fresh) {
460
+ try {
461
+ const res = (await client.request('thread/list', {
462
+ cwd,
463
+ limit: 1,
464
+ archived: false,
465
+ })) as { threads?: Array<{ id: string }> } | undefined
466
+ const existing = res?.threads?.[0]?.id
467
+ if (existing) {
468
+ try {
469
+ await client.request('thread/resume', {
470
+ threadId: existing,
471
+ // codex 0.121 rejects `persistExtendedHistory: true` with "requires
472
+ // experimentalApi capability". we don't need extended rollouts for
473
+ // v1 — thread history on resume is already sufficient.
474
+ persistExtendedHistory: false,
475
+ approvalPolicy: CODEX_APPROVAL_POLICY,
476
+ sandbox: CODEX_THREAD_SANDBOX,
477
+ serviceTier: CODEX_SERVICE_TIER,
478
+ })
479
+ return existing
480
+ } catch {
481
+ // fall through to start a fresh thread
482
+ }
483
+ }
484
+ } catch {
485
+ // thread/list not available in this codex build; just start a new one.
486
+ }
487
+ }
488
+ const started = (await client.request('thread/start', {
489
+ cwd,
490
+ experimentalRawEvents: false,
491
+ // codex 0.121 rejects `persistExtendedHistory: true` without the
492
+ // `experimentalApi` capability (which we don't opt into). extended
493
+ // rollouts aren't needed for v1 — the default short history is fine.
494
+ persistExtendedHistory: false,
495
+ approvalPolicy: CODEX_APPROVAL_POLICY,
496
+ sandbox: CODEX_THREAD_SANDBOX,
497
+ serviceTier: CODEX_SERVICE_TIER,
498
+ })) as { threadId?: string; thread?: { id?: string } } | undefined
499
+ const threadId = extractCodexThreadId(started)
500
+ if (!threadId) {
501
+ throw new Error('thread/start returned no threadId')
502
+ }
503
+ return threadId
504
+ }
505
+
506
+ interface CodexTurnDeps {
507
+ client: CodexClient
508
+ getThreadId: () => string | null
509
+ setThreadId: (id: string) => void
510
+ emit: (event: Partial<AgentEvent> & { type: AgentEvent['type'] }) => void
511
+ transcript: ReturnType<typeof createWriteStream> | null
512
+ onTurnSettled: () => void
513
+ }
514
+
515
+ function buildCodexTurnRunner(deps: CodexTurnDeps): TurnRunner {
516
+ const { client, emit, transcript } = deps
517
+
518
+ // install notification handlers once; they feed the per-turn state via a
519
+ // module-local map keyed by turnId. a new turn clears its bucket on entry.
520
+ interface TurnState {
521
+ started: number
522
+ resolve: () => void
523
+ filesTouched: Set<string>
524
+ pendingEmit: string
525
+ }
526
+ const turns = new Map<string, TurnState>()
527
+ let latestTurnId: string | null = null
528
+
529
+ client.on('turn/started', (params) => {
530
+ const p = params as { threadId?: string; turn?: { id?: string; startedAt?: number } }
531
+ const turnId = p?.turn?.id
532
+ if (!turnId) return
533
+ latestTurnId = turnId
534
+ const state = turns.get(turnId) ?? {
535
+ started: Date.now(),
536
+ resolve: () => {},
537
+ filesTouched: new Set<string>(),
538
+ pendingEmit: '',
539
+ }
540
+ if (!turns.has(turnId)) turns.set(turnId, state)
541
+ emit({ type: 'turn-started', turnId } as AgentEvent)
542
+ })
543
+
544
+ client.on('item/agentMessage/delta', (params) => {
545
+ const p = params as { delta?: string }
546
+ if (!p?.delta) return
547
+ transcript?.write(p.delta)
548
+ emit({ type: 'turn-message', delta: p.delta } as AgentEvent)
549
+ })
550
+
551
+ client.on('item/reasoning/textDelta', (params) => {
552
+ const p = params as { delta?: string }
553
+ if (!p?.delta) return
554
+ emit({ type: 'turn-reasoning', delta: p.delta } as AgentEvent)
555
+ })
556
+
557
+ client.on('item/reasoning/summaryTextDelta', (params) => {
558
+ const p = params as { delta?: string }
559
+ if (!p?.delta) return
560
+ emit({ type: 'turn-reasoning', delta: p.delta } as AgentEvent)
561
+ })
562
+
563
+ // intentionally NOT subscribing to `turn/diff/updated` — the notification
564
+ // carries an aggregated unified diff for the whole turn with no per-file
565
+ // attribution, which would force consumers (HMR halo) to key on an empty
566
+ // path. `item/completed` with type=fileChange delivers the same information
567
+ // per-file, so we rely on that instead.
568
+
569
+ client.on('turn/plan/updated', (params) => {
570
+ const p = params as {
571
+ plan?: Array<{ step: string; status: string }>
572
+ turnId?: string
573
+ }
574
+ if (!Array.isArray(p?.plan)) return
575
+ emit({
576
+ type: 'turn-plan',
577
+ steps: p.plan.map((s, i) => ({
578
+ id: String(i),
579
+ title: s.step,
580
+ status: s.status,
581
+ })),
582
+ } as AgentEvent)
583
+ })
584
+
585
+ client.on('item/started', (params) => {
586
+ const p = params as {
587
+ item?: { type?: string; command?: string; tool?: string }
588
+ turnId?: string
589
+ }
590
+ const item = p?.item
591
+ if (!item) return
592
+ if (item.type === 'commandExecution' && item.command) {
593
+ emit({
594
+ type: 'tool-call',
595
+ name: 'commandExecution',
596
+ args: { command: item.command },
597
+ } as AgentEvent)
598
+ } else if (item.type === 'mcpToolCall' || item.type === 'dynamicToolCall') {
599
+ emit({
600
+ type: 'tool-call',
601
+ name: item.tool ?? item.type,
602
+ args: {},
603
+ } as AgentEvent)
604
+ }
605
+ })
606
+
607
+ client.on('item/completed', (params) => {
608
+ const p = params as {
609
+ item?: {
610
+ type?: string
611
+ status?: string
612
+ changes?: Array<{
613
+ path: string
614
+ kind: { type?: string; move_path?: string | null }
615
+ diff?: string
616
+ }>
617
+ }
618
+ turnId?: string
619
+ }
620
+ const item = p?.item
621
+ if (!item) return
622
+ if (item.type !== 'fileChange') return
623
+ if (item.status !== 'completed') return
624
+ if (!Array.isArray(item.changes)) return
625
+ const turnId = p?.turnId
626
+ const state = turnId ? turns.get(turnId) : null
627
+ for (const change of item.changes) {
628
+ const kindType = change.kind?.type
629
+ const kind: 'add' | 'modify' | 'delete' =
630
+ kindType === 'add' ? 'add' : kindType === 'delete' ? 'delete' : 'modify'
631
+ emit({
632
+ type: 'file-edited',
633
+ path: change.path,
634
+ kind,
635
+ diff: change.diff,
636
+ } as AgentEvent)
637
+ state?.filesTouched.add(change.path)
638
+ // a rename (`update` with move_path) touches two files — track both so
639
+ // HMR halo can highlight the new path too.
640
+ const movePath = change.kind?.move_path
641
+ if (movePath && movePath !== change.path) {
642
+ state?.filesTouched.add(movePath)
643
+ }
644
+ }
645
+ })
646
+
647
+ client.on('turn/completed', (params) => {
648
+ const p = params as {
649
+ threadId?: string
650
+ turn?: {
651
+ id?: string
652
+ status?: string
653
+ durationMs?: number | null
654
+ startedAt?: number | null
655
+ }
656
+ }
657
+ const turnId = p?.turn?.id
658
+ if (!turnId) return
659
+ const state = turns.get(turnId)
660
+ const filesTouched = state ? Array.from(state.filesTouched) : []
661
+ const durationMs = p.turn?.durationMs ?? (state ? Date.now() - state.started : 0)
662
+ emit({
663
+ type: 'turn-completed',
664
+ turnId,
665
+ filesTouched,
666
+ durationMs,
667
+ } as AgentEvent)
668
+ state?.resolve()
669
+ turns.delete(turnId)
670
+ })
671
+
672
+ client.on('error', (params) => {
673
+ const p = params as {
674
+ error?: { message?: string }
675
+ turnId?: string
676
+ }
677
+ const msg = p?.error?.message ?? 'codex reported an error'
678
+ emit({ type: 'error', message: msg } as AgentEvent)
679
+ if (p.turnId) {
680
+ const state = turns.get(p.turnId)
681
+ state?.resolve()
682
+ turns.delete(p.turnId)
683
+ }
684
+ })
685
+
686
+ return async function codexRunTurn(text: string): Promise<void> {
687
+ const threadId = deps.getThreadId()
688
+ if (!threadId) {
689
+ emit({ type: 'error', message: 'no active codex thread' } as AgentEvent)
690
+ deps.onTurnSettled()
691
+ return
692
+ }
693
+ const { promise, resolve } = createDeferred<void>()
694
+ // we don't know the turnId until turn/started arrives. register a
695
+ // placeholder and patch it up in the notification handler.
696
+ const pendingId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
697
+ turns.set(pendingId, {
698
+ started: Date.now(),
699
+ resolve,
700
+ filesTouched: new Set(),
701
+ pendingEmit: '',
702
+ })
703
+ try {
704
+ const res = (await client.request('turn/start', {
705
+ threadId,
706
+ input: [{ type: 'text', text, text_elements: [] }],
707
+ approvalPolicy: CODEX_APPROVAL_POLICY,
708
+ sandboxPolicy: CODEX_TURN_SANDBOX_POLICY,
709
+ serviceTier: CODEX_SERVICE_TIER,
710
+ effort: CODEX_REASONING_EFFORT,
711
+ })) as { turn?: { id?: string } } | undefined
712
+ const actualTurnId = res?.turn?.id ?? latestTurnId
713
+ if (actualTurnId && actualTurnId !== pendingId) {
714
+ // hoist the placeholder bucket onto the real turnId so future
715
+ // notifications accumulate into it.
716
+ const placeholder = turns.get(pendingId)
717
+ if (placeholder) {
718
+ turns.delete(pendingId)
719
+ const existing = turns.get(actualTurnId)
720
+ if (existing) {
721
+ for (const f of placeholder.filesTouched) existing.filesTouched.add(f)
722
+ existing.resolve = placeholder.resolve
723
+ } else {
724
+ turns.set(actualTurnId, placeholder)
725
+ }
726
+ }
727
+ }
728
+ } catch (err) {
729
+ turns.delete(pendingId)
730
+ const message = err instanceof Error ? err.message : String(err)
731
+ emit({ type: 'error', message: `turn/start failed: ${message}` } as AgentEvent)
732
+ emit({
733
+ type: 'turn-completed',
734
+ filesTouched: [] as string[],
735
+ durationMs: 0,
736
+ })
737
+ deps.onTurnSettled()
738
+ return
739
+ }
740
+ // safety timeout so a lost turn/completed notification doesn't wedge
741
+ // the wrapper forever. 10min is far beyond normal codex turn duration.
742
+ const timeout = (
743
+ setTimeout(() => resolve(), 10 * 60 * 1000) as unknown as NodeJS.Timeout
744
+ ).unref()
745
+ try {
746
+ await promise
747
+ } finally {
748
+ clearTimeout(timeout)
749
+ deps.onTurnSettled()
750
+ }
751
+ }
752
+ }
753
+
754
+ // --- claude per-turn spawn via --output-format stream-json ---
755
+ //
756
+ // each prompt spawns a fresh `claude --print --output-format stream-json
757
+ // --verbose --permission-mode bypassPermissions --session-id <uuid>` process.
758
+ // stdout is a newline-delimited stream of well-typed objects; we translate
759
+ // them into our AgentEvent wire format. the deterministic session uuid means
760
+ // successive spawns all land in the same `~/.claude/projects/<cwd>/<uuid>.jsonl`
761
+ // file, so turn-over-turn state is preserved without a long-lived child.
762
+
763
+ interface ClaudeStreamEvent {
764
+ type?: string
765
+ subtype?: string
766
+ message?: {
767
+ content?: Array<{
768
+ type?: string
769
+ text?: string
770
+ name?: string
771
+ id?: string
772
+ input?: { file_path?: string; path?: string; [k: string]: unknown }
773
+ }>
774
+ }
775
+ duration_ms?: number
776
+ total_cost_usd?: number
777
+ result?: string
778
+ is_error?: boolean
779
+ permission_denials?: Array<{ tool_name?: string; tool_input?: unknown }>
780
+ session_id?: string
781
+ }
782
+
783
+ const CLAUDE_FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
784
+
785
+ interface ClaudeTurnDeps {
786
+ resolvedBin: string
787
+ sessionId: string
788
+ projectId: string
789
+ claudeSessionUuid: string
790
+ cwd: string
791
+ emit: (event: Partial<AgentEvent> & { type: AgentEvent['type'] }) => void
792
+ transcript: ReturnType<typeof createWriteStream> | null
793
+ setChild: (child: ChildProcess | null) => void
794
+ onTurnSettled: () => void
795
+ }
796
+
797
+ function buildClaudeTurnRunner(deps: ClaudeTurnDeps): TurnRunner {
798
+ return async function claudeRunTurn(text: string): Promise<void> {
799
+ const {
800
+ resolvedBin,
801
+ sessionId,
802
+ projectId,
803
+ claudeSessionUuid,
804
+ cwd,
805
+ emit,
806
+ transcript,
807
+ setChild,
808
+ } = deps
809
+ const startedAt = Date.now()
810
+ emit({ type: 'turn-started' } as AgentEvent)
811
+
812
+ const child = spawn(
813
+ resolvedBin,
814
+ [
815
+ '--print',
816
+ '--output-format',
817
+ 'stream-json',
818
+ '--verbose',
819
+ '--permission-mode',
820
+ 'bypassPermissions',
821
+ '--session-id',
822
+ claudeSessionUuid,
823
+ text,
824
+ ],
825
+ {
826
+ cwd,
827
+ stdio: ['ignore', 'pipe', 'pipe'],
828
+ env: {
829
+ ...process.env,
830
+ SOOTSIM_CLI_IDENTITY: sessionId,
831
+ SOOTSIM_PROJECT_ID: projectId,
832
+ },
833
+ },
834
+ )
835
+ setChild(child)
836
+
837
+ const filesTouched = new Set<string>()
838
+ let completedEmitted = false
839
+
840
+ function processEvent(ev: ClaudeStreamEvent): void {
841
+ const type = ev.type
842
+ if (!type) return
843
+ if (type === 'assistant' && Array.isArray(ev.message?.content)) {
844
+ for (const item of ev.message!.content!) {
845
+ if (item.type === 'text' && item.text) {
846
+ transcript?.write(item.text)
847
+ emit({ type: 'turn-message', delta: item.text } as AgentEvent)
848
+ } else if (item.type === 'tool_use' && item.name) {
849
+ emit({
850
+ type: 'tool-call',
851
+ name: item.name,
852
+ args: item.input ?? {},
853
+ } as AgentEvent)
854
+ const filePath = item.input?.file_path ?? item.input?.path
855
+ if (typeof filePath === 'string' && CLAUDE_FILE_TOOLS.has(item.name)) {
856
+ filesTouched.add(filePath)
857
+ emit({
858
+ type: 'file-edited',
859
+ path: filePath,
860
+ kind: 'modify',
861
+ } as AgentEvent)
862
+ }
863
+ }
864
+ }
865
+ } else if (type === 'result') {
866
+ completedEmitted = true
867
+ if (Array.isArray(ev.permission_denials)) {
868
+ for (const denial of ev.permission_denials) {
869
+ emit({
870
+ type: 'approval-needed',
871
+ kind: denial.tool_name ?? 'unknown',
872
+ detail: denial.tool_input ?? null,
873
+ } as AgentEvent)
874
+ }
875
+ }
876
+ if (ev.is_error && typeof ev.result === 'string') {
877
+ emit({ type: 'error', message: ev.result } as AgentEvent)
878
+ }
879
+ emit({
880
+ type: 'turn-completed',
881
+ filesTouched: Array.from(filesTouched),
882
+ durationMs: ev.duration_ms ?? Date.now() - startedAt,
883
+ // suppress cost on failed turns so the 7-day rolling history in
884
+ // `recordTurnTelemetry` reflects work the agent actually did, not
885
+ // rate-limited / user-aborted attempts.
886
+ costUsd: ev.is_error ? undefined : ev.total_cost_usd,
887
+ } as AgentEvent)
888
+ }
889
+ }
890
+
891
+ // parse stdout line-by-line; claude emits one JSON object per line in
892
+ // stream-json mode, but chunks can split mid-line so we buffer.
893
+ let leftover = ''
894
+ child.stdout?.setEncoding('utf8')
895
+ child.stdout?.on('data', (chunk: string) => {
896
+ leftover += chunk
897
+ let idx: number
898
+ while ((idx = leftover.indexOf('\n')) >= 0) {
899
+ const line = leftover.slice(0, idx)
900
+ leftover = leftover.slice(idx + 1)
901
+ const trimmed = line.trim()
902
+ if (!trimmed) continue
903
+ let parsed: ClaudeStreamEvent
904
+ try {
905
+ parsed = JSON.parse(trimmed) as ClaudeStreamEvent
906
+ } catch {
907
+ // non-JSON noise (e.g. pre-init stderr crossover) — skip.
908
+ continue
909
+ }
910
+ try {
911
+ processEvent(parsed)
912
+ } catch (err) {
913
+ emit({
914
+ type: 'error',
915
+ message: `claude event handler failed: ${(err as Error).message}`,
916
+ } as AgentEvent)
917
+ }
918
+ }
919
+ })
920
+
921
+ child.stderr?.setEncoding('utf8')
922
+ child.stderr?.on('data', (chunk: string) => {
923
+ transcript?.write(`[claude-stderr] ${chunk}`)
924
+ })
925
+
926
+ child.on('error', (err) => {
927
+ emit({
928
+ type: 'error',
929
+ message: `spawn failed: ${err.message}`,
930
+ } as AgentEvent)
931
+ })
932
+
933
+ const { promise, resolve } = createDeferred<void>()
934
+ child.on('exit', (code) => {
935
+ if (!completedEmitted) {
936
+ // claude exited without emitting a `result` event — usually a crash,
937
+ // rate-limit, or SIGTERM. emit a terminal event so the queue drains.
938
+ emit({
939
+ type: 'turn-completed',
940
+ filesTouched: Array.from(filesTouched),
941
+ durationMs: Date.now() - startedAt,
942
+ } as AgentEvent)
943
+ if (code !== 0 && code !== null) {
944
+ emit({
945
+ type: 'error',
946
+ message: `claude exited with code ${code}`,
947
+ } as AgentEvent)
948
+ }
949
+ }
950
+ setChild(null)
951
+ resolve()
952
+ })
953
+ try {
954
+ await promise
955
+ } finally {
956
+ deps.onTurnSettled()
957
+ }
958
+ }
959
+ }
960
+
961
+ function createDeferred<T>(): {
962
+ promise: Promise<T>
963
+ resolve: (value: T) => void
964
+ } {
965
+ let resolve!: (value: T) => void
966
+ const promise = new Promise<T>((res) => {
967
+ resolve = res
968
+ })
969
+ return { promise, resolve }
970
+ }
971
+
972
+ function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
973
+ return new Promise<T>((resolve, reject) => {
974
+ const t = setTimeout(() => reject(new Error(message)), ms)
975
+ p.then(
976
+ (v) => {
977
+ clearTimeout(t)
978
+ resolve(v)
979
+ },
980
+ (err: unknown) => {
981
+ clearTimeout(t)
982
+ reject(err instanceof Error ? err : new Error(String(err)))
983
+ },
984
+ )
985
+ })
986
+ }