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.
Files changed (324) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +640 -0
  3. package/cli/app-config.ts +82 -0
  4. package/cli/app-fonts.ts +388 -0
  5. package/cli/app-project.ts +59 -0
  6. package/cli/app-splash.ts +171 -0
  7. package/cli/app-state-reset.ts +26 -0
  8. package/cli/auth.ts +184 -0
  9. package/cli/bin.ts +59 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2806 -0
  12. package/cli/browser-backend.ts +44 -0
  13. package/cli/browser-evals.ts +96 -0
  14. package/cli/cli-update.ts +261 -0
  15. package/cli/cloud-client.ts +1530 -0
  16. package/cli/cloud-dispatch.ts +206 -0
  17. package/cli/cloud-session.ts +227 -0
  18. package/cli/command-contract.ts +221 -0
  19. package/cli/command-registry.ts +304 -0
  20. package/cli/commands/agent-wrapper.ts +1048 -0
  21. package/cli/commands/agent.ts +439 -0
  22. package/cli/commands/app-fonts.ts +99 -0
  23. package/cli/commands/assert.ts +542 -0
  24. package/cli/commands/auth.ts +71 -0
  25. package/cli/commands/box/bundle-plane.ts +71 -0
  26. package/cli/commands/box/checkout-plane.ts +256 -0
  27. package/cli/commands/box/plane-share.ts +208 -0
  28. package/cli/commands/box/rnx-command.ts +60 -0
  29. package/cli/commands/box/search-command.ts +157 -0
  30. package/cli/commands/box.ts +925 -0
  31. package/cli/commands/camera.ts +266 -0
  32. package/cli/commands/cleanup.ts +169 -0
  33. package/cli/commands/compat.ts +176 -0
  34. package/cli/commands/config.ts +34 -0
  35. package/cli/commands/control.ts +2154 -0
  36. package/cli/commands/cpu-profile.ts +941 -0
  37. package/cli/commands/daemon-mac-app.ts +165 -0
  38. package/cli/commands/daemon.ts +800 -0
  39. package/cli/commands/debug.ts +751 -0
  40. package/cli/commands/desktop.ts +39 -0
  41. package/cli/commands/detect.ts +201 -0
  42. package/cli/commands/detox.ts +481 -0
  43. package/cli/commands/device.ts +134 -0
  44. package/cli/commands/diagnose.ts +589 -0
  45. package/cli/commands/do-chain.ts +408 -0
  46. package/cli/commands/electron.ts +94 -0
  47. package/cli/commands/film.ts +379 -0
  48. package/cli/commands/flow.ts +1240 -0
  49. package/cli/commands/inspect/actions.ts +516 -0
  50. package/cli/commands/inspect/core.ts +2086 -0
  51. package/cli/commands/inspect/count.ts +17 -0
  52. package/cli/commands/inspect/describe.ts +119 -0
  53. package/cli/commands/inspect/env.ts +25 -0
  54. package/cli/commands/inspect/find.ts +169 -0
  55. package/cli/commands/inspect/get-layout.ts +53 -0
  56. package/cli/commands/inspect/keyboard.ts +54 -0
  57. package/cli/commands/inspect/list.ts +175 -0
  58. package/cli/commands/inspect/memory.ts +215 -0
  59. package/cli/commands/inspect/redaction.ts +39 -0
  60. package/cli/commands/inspect/resolve-target.ts +77 -0
  61. package/cli/commands/inspect/screens.ts +78 -0
  62. package/cli/commands/inspect/settle.ts +32 -0
  63. package/cli/commands/inspect/settling.ts +197 -0
  64. package/cli/commands/inspect/shared.ts +259 -0
  65. package/cli/commands/inspect/sleep.ts +15 -0
  66. package/cli/commands/inspect/tree.ts +23 -0
  67. package/cli/commands/inspect/url.ts +17 -0
  68. package/cli/commands/inspect/wait-event.ts +211 -0
  69. package/cli/commands/inspect/wait-idle.ts +25 -0
  70. package/cli/commands/inspect/wait-ready.ts +87 -0
  71. package/cli/commands/inspect/wait-selector.ts +59 -0
  72. package/cli/commands/inspect/wait.ts +32 -0
  73. package/cli/commands/inspect.ts +4738 -0
  74. package/cli/commands/install-cli.ts +275 -0
  75. package/cli/commands/install-desktop.ts +385 -0
  76. package/cli/commands/login.ts +357 -0
  77. package/cli/commands/logout.ts +24 -0
  78. package/cli/commands/maestro-generate.ts +375 -0
  79. package/cli/commands/maestro.ts +536 -0
  80. package/cli/commands/mode.ts +58 -0
  81. package/cli/commands/no-bridge-hint.ts +80 -0
  82. package/cli/commands/perf.ts +67 -0
  83. package/cli/commands/permissions.ts +203 -0
  84. package/cli/commands/platform.ts +410 -0
  85. package/cli/commands/preview.ts +103 -0
  86. package/cli/commands/profile.ts +106 -0
  87. package/cli/commands/react.ts +353 -0
  88. package/cli/commands/record.ts +1435 -0
  89. package/cli/commands/report-issue.ts +305 -0
  90. package/cli/commands/reset.ts +89 -0
  91. package/cli/commands/runtime.ts +348 -0
  92. package/cli/commands/screenshot-command.ts +106 -0
  93. package/cli/commands/screenshot-layers.ts +144 -0
  94. package/cli/commands/screenshot-mode.ts +38 -0
  95. package/cli/commands/screenshot.ts +492 -0
  96. package/cli/commands/screenshots-capture.ts +608 -0
  97. package/cli/commands/screenshots.ts +127 -0
  98. package/cli/commands/serve.ts +139 -0
  99. package/cli/commands/setup.ts +416 -0
  100. package/cli/commands/shell-boolean-mode.ts +82 -0
  101. package/cli/commands/skills.ts +468 -0
  102. package/cli/commands/slides.ts +357 -0
  103. package/cli/commands/state.ts +94 -0
  104. package/cli/commands/storage.ts +58 -0
  105. package/cli/commands/telemetry.ts +55 -0
  106. package/cli/commands/test.ts +535 -0
  107. package/cli/commands/three-mode.ts +771 -0
  108. package/cli/commands/timeline.ts +121 -0
  109. package/cli/commands/upgrade.ts +167 -0
  110. package/cli/commands/upload.ts +1203 -0
  111. package/cli/commands/version.ts +63 -0
  112. package/cli/commands/welcome.ts +17 -0
  113. package/cli/commands/what-happened.ts +328 -0
  114. package/cli/current-sim.ts +217 -0
  115. package/cli/desktop-companion.ts +315 -0
  116. package/cli/drivers/electron.ts +70 -0
  117. package/cli/drivers/index.ts +20 -0
  118. package/cli/drivers/playwright-provisioning.ts +184 -0
  119. package/cli/drivers/playwright-sim-host.ts +537 -0
  120. package/cli/drivers/playwright.ts +209 -0
  121. package/cli/drivers/registry.ts +65 -0
  122. package/cli/drivers/types.ts +102 -0
  123. package/cli/flow-export.ts +55 -0
  124. package/cli/flow-file.ts +142 -0
  125. package/cli/flow-live-status.ts +126 -0
  126. package/cli/flow-session.ts +140 -0
  127. package/cli/help-core.ts +143 -0
  128. package/cli/help.ts +44 -0
  129. package/cli/hidden-runtime-alias.ts +19 -0
  130. package/cli/hints.ts +214 -0
  131. package/cli/inspect-notice-state.ts +114 -0
  132. package/cli/internal-child.ts +176 -0
  133. package/cli/maestro-js.ts +323 -0
  134. package/cli/main.ts +704 -0
  135. package/cli/open-url.ts +8 -0
  136. package/cli/outbound-endpoints.ts +830 -0
  137. package/cli/parent-pid.ts +204 -0
  138. package/cli/parse-args.ts +189 -0
  139. package/cli/privacy.ts +53 -0
  140. package/cli/prompt.ts +56 -0
  141. package/cli/recording-access.ts +108 -0
  142. package/cli/registry.ts +2 -0
  143. package/cli/resolve-assets.ts +63 -0
  144. package/cli/rnx-in-process.ts +98 -0
  145. package/cli/run-registry.ts +226 -0
  146. package/cli/run-rnx-core.ts +148 -0
  147. package/cli/run-rnx.ts +137 -0
  148. package/cli/runtime-notes.ts +66 -0
  149. package/cli/runtime-summary.ts +25 -0
  150. package/cli/secure-file.ts +31 -0
  151. package/cli/self-invocation.ts +58 -0
  152. package/cli/send-to-box.ts +109 -0
  153. package/cli/setup-repository.ts +191 -0
  154. package/cli/shell-init.ts +98 -0
  155. package/cli/standalone.ts +4 -0
  156. package/cli/telemetry.ts +84 -0
  157. package/cli/ui.ts +68 -0
  158. package/cli/ws-bridge.ts +1020 -0
  159. package/detox/attempt-events.cjs +23 -0
  160. package/detox/colors.ts +54 -0
  161. package/detox/config-loader.ts +135 -0
  162. package/detox/element-types.ts +48 -0
  163. package/detox/expectations.ts +534 -0
  164. package/detox/gestures.ts +501 -0
  165. package/detox/index.ts +2694 -0
  166. package/detox/jest-preset.cjs +29 -0
  167. package/detox/jest-setup-after-env.cjs +22 -0
  168. package/detox/matchers.ts +30 -0
  169. package/detox/motion-change.cjs +109 -0
  170. package/detox/motion-change.d.cts +35 -0
  171. package/detox/navigation-lifecycle.ts +290 -0
  172. package/detox/navigation.ts +43 -0
  173. package/detox/proof-frame.cjs +114 -0
  174. package/detox/record-block-execution.cjs +78 -0
  175. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  176. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  177. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  178. package/detox/screenshots/theme-blue-toggle.png +0 -0
  179. package/detox/screenshots/theme-blue.png +0 -0
  180. package/detox/screenshots/theme-red-toggle.png +0 -0
  181. package/detox/screenshots/theme-red.png +0 -0
  182. package/dist-lib/agent-daemon-client.cjs +502 -0
  183. package/dist-lib/agent-events.cjs +48 -0
  184. package/dist-lib/agent-identity.cjs +53 -0
  185. package/dist-lib/agent-sessions.cjs +759 -0
  186. package/dist-lib/attached-projects.cjs +526 -0
  187. package/dist-lib/auth/shared-session.cjs +208 -0
  188. package/dist-lib/backend-origin.cjs +88 -0
  189. package/dist-lib/beta.cjs +41 -0
  190. package/dist-lib/beta.mjs +13 -0
  191. package/dist-lib/bridge-constants.cjs +161 -0
  192. package/dist-lib/bridge-contract-input.cjs +451 -0
  193. package/dist-lib/bridge-contract-input.mjs +414 -0
  194. package/dist-lib/bridge-contract.cjs +130 -0
  195. package/dist-lib/bridge-contract.mjs +94 -0
  196. package/dist-lib/capture-contract.cjs +220 -0
  197. package/dist-lib/capture-contract.mjs +190 -0
  198. package/dist-lib/cli-constants.cjs +32 -0
  199. package/dist-lib/cloud-contract.cjs +194 -0
  200. package/dist-lib/cloud-contract.mjs +145 -0
  201. package/dist-lib/cloud.cjs +670 -0
  202. package/dist-lib/cloud.mjs +639 -0
  203. package/dist-lib/config.cjs +96 -0
  204. package/dist-lib/detox/index.cjs +3789 -0
  205. package/dist-lib/detox/jest-preset.cjs +29 -0
  206. package/dist-lib/dev-bundle-resolution.cjs +290 -0
  207. package/dist-lib/home-paths.cjs +638 -0
  208. package/dist-lib/host/bridge-host.cjs +10903 -0
  209. package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
  210. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  211. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  212. package/dist-lib/host/replacement-module-handler.cjs +469 -0
  213. package/dist-lib/host/websocket-proxy.cjs +298 -0
  214. package/dist-lib/index.cjs +4268 -0
  215. package/dist-lib/jump-to-source-babel.cjs +93 -0
  216. package/dist-lib/jump-to-source-native.cjs +112 -0
  217. package/dist-lib/menu.cjs +646 -0
  218. package/dist-lib/menu.mjs +604 -0
  219. package/dist-lib/metro-fingerprint-registry.cjs +186 -0
  220. package/dist-lib/metro-fingerprint-registry.mjs +156 -0
  221. package/dist-lib/metro-production-bundle.cjs +329 -0
  222. package/dist-lib/metro-production-bundle.mjs +295 -0
  223. package/dist-lib/metro.cjs +904 -0
  224. package/dist-lib/profiles.cjs +246 -0
  225. package/dist-lib/public-brand.cjs +39 -0
  226. package/dist-lib/react-native-host-modules.cjs +51 -0
  227. package/dist-lib/react-native-host-modules.mjs +25 -0
  228. package/dist-lib/render-mode.cjs +54 -0
  229. package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
  230. package/dist-lib/sdk.cjs +2433 -0
  231. package/dist-lib/sdk.mjs +2336 -0
  232. package/dist-lib/skills.cjs +39132 -0
  233. package/dist-lib/swift-submit.cjs +92 -0
  234. package/dist-lib/swift-submit.mjs +65 -0
  235. package/dist-lib/swift.cjs +1646 -0
  236. package/dist-lib/vite.cjs +1591 -0
  237. package/package.json +290 -0
  238. package/scripts/dev-server-scanner.ts +1291 -0
  239. package/scripts/optional-demo-registry.ts +113 -0
  240. package/skills/contrast/SKILL.md +165 -0
  241. package/skills/rnx-debug/SKILL.md +433 -0
  242. package/skills/rnx-perf/SKILL.md +399 -0
  243. package/skills/rnx-setup/SKILL.md +172 -0
  244. package/skills/rnx-test/SKILL.md +321 -0
  245. package/skills/rnx-visual/SKILL.md +126 -0
  246. package/src/agent-daemon-client.ts +410 -0
  247. package/src/agent-events.ts +71 -0
  248. package/src/agent-identity.ts +23 -0
  249. package/src/agent-prompt.ts +71 -0
  250. package/src/agent-sessions.ts +580 -0
  251. package/src/app-url.ts +39 -0
  252. package/src/attached-projects.ts +539 -0
  253. package/src/auth/shared-session.ts +235 -0
  254. package/src/backend-origin.ts +70 -0
  255. package/src/beta.ts +19 -0
  256. package/src/bridge-constants.ts +175 -0
  257. package/src/bridge-contract-input.ts +502 -0
  258. package/src/bridge-contract.ts +558 -0
  259. package/src/browser-cache.ts +16 -0
  260. package/src/capture-contract.ts +587 -0
  261. package/src/cli-constants.ts +1 -0
  262. package/src/cli-version.ts +40 -0
  263. package/src/cloud-contract.ts +274 -0
  264. package/src/cloud.ts +762 -0
  265. package/src/codex-client.ts +234 -0
  266. package/src/config.ts +132 -0
  267. package/src/connect.ts +101 -0
  268. package/src/dev-bundle-resolution.ts +346 -0
  269. package/src/dev-server-open.ts +198 -0
  270. package/src/disk-cleanup.ts +540 -0
  271. package/src/flow-timeout-scale.ts +15 -0
  272. package/src/home-paths.ts +806 -0
  273. package/src/host/agent-host.ts +666 -0
  274. package/src/host/bridge-host.ts +2788 -0
  275. package/src/host/fetch-proxy-handler.ts +449 -0
  276. package/src/host/fetch-proxy-overrides.ts +51 -0
  277. package/src/host/open-url.ts +89 -0
  278. package/src/host/plane-host.ts +203 -0
  279. package/src/host/replacement-module-handler.ts +158 -0
  280. package/src/host/websocket-proxy.ts +315 -0
  281. package/src/index.ts +71 -0
  282. package/src/jump-to-source-babel.ts +135 -0
  283. package/src/jump-to-source-native.ts +102 -0
  284. package/src/menu.ts +777 -0
  285. package/src/metro-fingerprint-registry.ts +302 -0
  286. package/src/metro-plugin.ts +341 -0
  287. package/src/metro-production-bundle.ts +471 -0
  288. package/src/native-dev-bundle-url.ts +118 -0
  289. package/src/native-seam-manifest.ts +341 -0
  290. package/src/playwright-host-log.ts +16 -0
  291. package/src/plist.ts +27 -0
  292. package/src/poll-until.ts +123 -0
  293. package/src/profiles.ts +179 -0
  294. package/src/public-brand.ts +9 -0
  295. package/src/react-native-host-modules.ts +26 -0
  296. package/src/render-mode.ts +27 -0
  297. package/src/rolldown-plugin-swift.ts +240 -0
  298. package/src/runtime-assets.ts +233 -0
  299. package/src/runtime-delivery.ts +54 -0
  300. package/src/screenshots/compose.ts +422 -0
  301. package/src/screenshots/frame-compose.ts +514 -0
  302. package/src/screenshots/orchestrate.ts +256 -0
  303. package/src/screenshots/org-deck.ts +91 -0
  304. package/src/screenshots/registry.ts +58 -0
  305. package/src/screenshots/schema.ts +364 -0
  306. package/src/sdk.ts +15 -0
  307. package/src/settings-bundle.ts +418 -0
  308. package/src/shell-bridge-identity.ts +15 -0
  309. package/src/sim-client.ts +697 -0
  310. package/src/skills/builtin/a11y-review.ts +132 -0
  311. package/src/skills/builtin/compat-check.ts +71 -0
  312. package/src/skills/builtin/maestro-test.ts +121 -0
  313. package/src/skills/builtin/perf-profile.ts +90 -0
  314. package/src/skills/builtin/screenshot-all.ts +52 -0
  315. package/src/skills/builtin/visual-diff.ts +100 -0
  316. package/src/skills/registry.ts +107 -0
  317. package/src/skills/types.ts +41 -0
  318. package/src/swift-submit.ts +185 -0
  319. package/src/swift.ts +7 -0
  320. package/src/team-machine-prompt.ts +73 -0
  321. package/src/vite-plugin-one.ts +264 -0
  322. package/src/vite-plugin-swift.ts +620 -0
  323. package/src/vite-plugin.ts +1946 -0
  324. package/src/worklets-babel.ts +250 -0
@@ -0,0 +1,1048 @@
1
+ // agent-wrapper — long-running bridge process between rnx desktop and a
2
+ // local coding agent (codex / claude).
3
+ //
4
+ // shipped as an internal rnx CLI subcommand so it survives `bun build
5
+ // --compile` bundling. callers spawn it as:
6
+ //
7
+ // rnx 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 } 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 os from 'node:os'
37
+ import path from 'node:path'
38
+ import readline from 'node:readline'
39
+ import { promisify } from 'node:util'
40
+ import { decodeAgentPromptEnvelope } from '../../src/agent-prompt'
41
+ import { spawnCodexClient, type CodexClient } from '../../src/codex-client'
42
+ import type { AgentEvent } from '../../src/agent-events'
43
+
44
+ const CODEX_APPROVAL_POLICY = 'never'
45
+ const CODEX_THREAD_SANDBOX = 'danger-full-access'
46
+ const CODEX_TURN_SANDBOX_POLICY = { type: 'dangerFullAccess' } as const
47
+ const CODEX_MODEL = 'gpt-5.6-luna'
48
+ const CODEX_SERVICE_TIER = 'fast' as const
49
+ // prompt-bar turns are small targeted edits, so both providers think briefly.
50
+ // long-horizon callers (the one-take film) raise it through the env; the
51
+ // take's stack launch sets SOOTSIM_AGENT_EFFORT, never the repo.
52
+ const AGENT_REASONING_EFFORT =
53
+ process.env.SOOTSIM_AGENT_EFFORT?.trim() || ('low' as const)
54
+ const CODEX_READY_TIMEOUT_MS = 5_000
55
+ const CODEX_THREAD_SETUP_TIMEOUT_MS = 10_000
56
+
57
+ const execFileP = promisify(execFile)
58
+
59
+ async function resolveAgentBinary(
60
+ provider: 'codex' | 'claude',
61
+ override: string | undefined,
62
+ ): Promise<{ ok: true; path: string } | { ok: false; message: string }> {
63
+ const bin = override || provider
64
+ // absolute or relative path — check directly
65
+ if (bin.includes('/') || bin.includes('\\')) {
66
+ if (existsSync(bin)) return { ok: true, path: bin }
67
+ return { ok: false, message: `agent binary not found at path: ${bin}` }
68
+ }
69
+ try {
70
+ const { stdout } = await execFileP('which', [bin], { timeout: 1500 })
71
+ const resolved = stdout.trim()
72
+ if (resolved) return { ok: true, path: resolved }
73
+ return { ok: false, message: `${bin} not found on PATH` }
74
+ } catch {
75
+ return {
76
+ ok: false,
77
+ message:
78
+ `${bin} not found on PATH. install the ${provider} CLI and retry, or pass ` +
79
+ `--${provider}-bin /path/to/${provider} to agent start.`,
80
+ }
81
+ }
82
+ }
83
+
84
+ interface WrapperArgs {
85
+ sessionId?: string
86
+ projectId?: string
87
+ provider?: string
88
+ cwd?: string
89
+ promptIn?: string
90
+ eventsOut?: string
91
+ transcript?: string
92
+ codexBin?: string
93
+ claudeBin?: string
94
+ claudeSessionUuid?: string
95
+ freshThread?: boolean
96
+ }
97
+
98
+ interface KillableChildProcess {
99
+ kill(signal?: 'SIGTERM'): boolean
100
+ }
101
+
102
+ interface AgentTranscriptStream {
103
+ write(chunk: string): unknown
104
+ end(): unknown
105
+ }
106
+
107
+ function parseWrapperArgs(argv: string[]): WrapperArgs {
108
+ const map: Record<string, keyof WrapperArgs> = {
109
+ '--session-id': 'sessionId',
110
+ '--project-id': 'projectId',
111
+ '--provider': 'provider',
112
+ '--cwd': 'cwd',
113
+ '--prompt-in': 'promptIn',
114
+ '--events-out': 'eventsOut',
115
+ '--transcript': 'transcript',
116
+ '--codex-bin': 'codexBin',
117
+ '--claude-bin': 'claudeBin',
118
+ '--claude-session-uuid': 'claudeSessionUuid',
119
+ }
120
+ const out: WrapperArgs = {}
121
+ for (let i = 0; i < argv.length; i++) {
122
+ const flag = argv[i]
123
+ if (flag === '--fresh-thread') {
124
+ out.freshThread = true
125
+ continue
126
+ }
127
+ const key = map[flag]
128
+ if (!key) continue
129
+ out[key] = argv[i + 1] as never
130
+ i++
131
+ }
132
+ return out
133
+ }
134
+
135
+ /** drives one prompt end-to-end. emits turn-started on entry and exactly one
136
+ * terminal event (turn-completed OR error) before resolving. never throws. */
137
+ type TurnRunner = (text: string) => Promise<void>
138
+
139
+ export async function runAgentWrapper(argv: string[]): Promise<number> {
140
+ const args = parseWrapperArgs(argv)
141
+
142
+ if (
143
+ !args.sessionId ||
144
+ !args.projectId ||
145
+ !args.provider ||
146
+ !args.cwd ||
147
+ !args.promptIn ||
148
+ !args.eventsOut
149
+ ) {
150
+ process.stderr.write(
151
+ 'usage: rnx agent-wrapper --session-id <id> --project-id <id>\n' +
152
+ ' --provider codex|claude --cwd <path>\n' +
153
+ ' --prompt-in <fifo> --events-out <fifo>\n' +
154
+ ' [--transcript <file>]\n' +
155
+ ' [--codex-bin <path>] [--claude-bin <path>]\n',
156
+ )
157
+ return 2
158
+ }
159
+ if (args.provider !== 'codex' && args.provider !== 'claude') {
160
+ process.stderr.write(`unknown provider: ${args.provider}\n`)
161
+ return 2
162
+ }
163
+
164
+ const { sessionId, projectId, cwd, promptIn, eventsOut } = args
165
+ const provider = args.provider as 'codex' | 'claude'
166
+
167
+ // keep the events.out write end open for the whole process lifetime. opening
168
+ // and closing per event would SIGPIPE briefly between writers.
169
+ //
170
+ // IMPORTANT: open with O_RDWR (not O_WRONLY/'a') so the wrapper itself
171
+ // holds a reader fd on the FIFO. without this, the bootstrap reader
172
+ // (startSession's waitForFirstEvent) attaches briefly to consume `ready`,
173
+ // then closes — leaving the wrapper's O_WRONLY fd as the only handle on
174
+ // a reader-less FIFO. the next write (prompt-received / turn-started /
175
+ // turn-message …) triggers SIGPIPE / EPIPE and the wrapper dies silently
176
+ // before any client ws subscription lands. O_RDWR makes the FIFO appear
177
+ // to always have a reader, so writes buffer in the kernel until the
178
+ // daemon attaches to drain.
179
+ if (!existsSync(eventsOut)) {
180
+ process.stderr.write(`events-out FIFO missing: ${eventsOut}\n`)
181
+ return 3
182
+ }
183
+ if (!existsSync(promptIn)) {
184
+ process.stderr.write(`prompt-in FIFO missing: ${promptIn}\n`)
185
+ return 3
186
+ }
187
+ const eventsFd = openSync(eventsOut, fsConstants.O_RDWR | fsConstants.O_APPEND)
188
+ const eventsStream = createWriteStream(eventsOut, { fd: eventsFd, flags: 'a' })
189
+
190
+ function emit(event: Partial<AgentEvent> & { type: AgentEvent['type'] }): void {
191
+ const line = JSON.stringify({ ts: Date.now(), ...event }) + '\n'
192
+ eventsStream.write(line)
193
+ }
194
+
195
+ // transcript is optional; append mode so multiple turns accumulate.
196
+ let transcriptStream: AgentTranscriptStream | null = null
197
+ if (args.transcript) {
198
+ try {
199
+ await fs.mkdir(path.dirname(args.transcript), { recursive: true })
200
+ transcriptStream = createWriteStream(args.transcript, { flags: 'a' })
201
+ } catch (err) {
202
+ emit({
203
+ type: 'error',
204
+ message: `transcript open failed: ${(err as Error).message}`,
205
+ } as AgentEvent)
206
+ }
207
+ }
208
+
209
+ // defensive: an EPIPE from a stale reader would otherwise bubble to
210
+ // uncaughtException and tear the wrapper down mid-turn. the O_RDWR open
211
+ // above prevents the common case, but a rogue close on the read side
212
+ // can still briefly surface here. log it and move on — the FIFO
213
+ // self-heals on the next reader attach.
214
+ eventsStream.on('error', (err) => {
215
+ const message = `[events-out] stream error: ${(err as Error).message}\n`
216
+ try {
217
+ if (transcriptStream) transcriptStream.write(message)
218
+ else process.stderr.write(message)
219
+ } catch {}
220
+ })
221
+
222
+ // preflight: resolve the agent binary once up front. emitting ready without
223
+ // this check means the first `agent prompt` silently completes with an empty
224
+ // turn-completed — users think "the agent said nothing" instead of "codex
225
+ // isn't installed."
226
+ const bin = await resolveAgentBinary(
227
+ provider,
228
+ provider === 'codex' ? args.codexBin : args.claudeBin,
229
+ )
230
+ if (!bin.ok) {
231
+ emit({ type: 'error', message: bin.message } as AgentEvent)
232
+ // give the reader a moment to drain before we exit
233
+ await new Promise((r) => setTimeout(r, 50))
234
+ eventsStream.end()
235
+ return 4
236
+ }
237
+ const resolvedAgentBin = bin.path
238
+
239
+ // for codex: boot the persistent app-server now. any startup failure is a
240
+ // clean error event before we advertise ready.
241
+ let codexClient: CodexClient | null = null
242
+ let codexThreadId: string | null = null
243
+ if (provider === 'codex') {
244
+ try {
245
+ codexClient = spawnCodexClient({
246
+ bin: resolvedAgentBin,
247
+ cwd,
248
+ env: {
249
+ RNX_CLI_IDENTITY: sessionId,
250
+ SOOTSIM_PROJECT_ID: projectId,
251
+ },
252
+ })
253
+ // surface stderr into the transcript without cluttering events.
254
+ codexClient.on('__stderr__', (p) => {
255
+ const text = (p as { text?: string })?.text
256
+ if (text) transcriptStream?.write(`[codex-stderr] ${text}`)
257
+ })
258
+ // capture async errors so they don't kill the wrapper silently.
259
+ void codexClient.exited.then(({ code, signal }) => {
260
+ if (!shuttingDown) {
261
+ emit({
262
+ type: 'error',
263
+ message: `codex app-server exited (code=${code}, signal=${signal ?? ''})`,
264
+ } as AgentEvent)
265
+ shutdown(5)
266
+ }
267
+ })
268
+ // only app-server initialization gates ready. thread lookup can take
269
+ // several seconds on a healthy codex install, so the first prompt owns
270
+ // that work below instead of making session startup wait for it.
271
+ await withTimeout(
272
+ codexClient.request('initialize', {
273
+ clientInfo: { name: 'sootsim', title: null, version: '0.1.0' },
274
+ capabilities: null,
275
+ }),
276
+ CODEX_READY_TIMEOUT_MS,
277
+ `codex app-server initialize timed out after ${CODEX_READY_TIMEOUT_MS}ms`,
278
+ )
279
+ } catch (err) {
280
+ const message = err instanceof Error ? err.message : String(err)
281
+ emit({
282
+ type: 'error',
283
+ message: `codex app-server init failed: ${message}`,
284
+ } as AgentEvent)
285
+ try {
286
+ codexClient?.kill()
287
+ } catch {}
288
+ eventsStream.end()
289
+ return 4
290
+ }
291
+ }
292
+
293
+ // serialize turn execution per-session — queue prompts while one is in
294
+ // flight. both codex and claude serialize per-session internally, but
295
+ // gating here keeps wrapper event ordering honest.
296
+ let turnInFlight = false
297
+ let currentClaudeChild: KillableChildProcess | null = null
298
+ const pendingPrompts: string[] = []
299
+
300
+ const { promise: exitPromise, resolve: resolveExit } = createDeferred<number>()
301
+
302
+ let shuttingDown = false
303
+ function shutdown(code: number, emitExit = true): void {
304
+ if (shuttingDown) return
305
+ shuttingDown = true
306
+ clearInterval(orphanWatchdog)
307
+ try {
308
+ currentClaudeChild?.kill('SIGTERM')
309
+ } catch {}
310
+ try {
311
+ void codexClient?.shutdown(800)
312
+ } catch {}
313
+ if (emitExit) emit({ type: 'exited', code } as AgentEvent)
314
+ eventsStream.end(() => {
315
+ try {
316
+ transcriptStream?.end()
317
+ } catch {}
318
+ resolveExit(code)
319
+ })
320
+ ;(setTimeout(() => resolveExit(code), 1500) as unknown as NodeJS.Timeout).unref()
321
+ }
322
+
323
+ // orphan watchdog: if our session dir / FIFOs are deleted out from under
324
+ // us (test cleanup did `rm -rf tmpdir` without calling endSession, the
325
+ // daemon crashed, the user force-nuked app-data) we can't be reached by
326
+ // anyone anyway — stop holding fds and spinning. poll every 10s to keep
327
+ // cost negligible; tests override via SOOTSIM_WRAPPER_WATCHDOG_MS.
328
+ const watchdogMs = (() => {
329
+ const raw = Number(process.env.SOOTSIM_WRAPPER_WATCHDOG_MS)
330
+ return Number.isFinite(raw) && raw > 0 ? raw : 10_000
331
+ })()
332
+ const orphanWatchdog = setInterval(() => {
333
+ if (shuttingDown) return
334
+ const eventsOutExists = existsSync(eventsOut)
335
+ const promptInExists = existsSync(promptIn)
336
+ if (!eventsOutExists || !promptInExists) {
337
+ process.stderr.write(
338
+ `[wrapper] session fifos gone (eventsOut=${eventsOutExists} promptIn=${promptInExists}), exiting\n`,
339
+ )
340
+ // once events.out is gone there is no recipient for an exit event. a
341
+ // final write only introduces a broken-pipe race into clean orphan
342
+ // shutdown; normal exits and prompt-only loss still report over the wire.
343
+ shutdown(0, eventsOutExists)
344
+ }
345
+ }, watchdogMs)
346
+ ;(orphanWatchdog as unknown as NodeJS.Timeout).unref()
347
+
348
+ process.on('SIGTERM', () => shutdown(143))
349
+ process.on('SIGINT', () => shutdown(130))
350
+ // detached: true takes us out of the parent's process group, but SIGHUP
351
+ // can still propagate in edge cases (e.g. crashed parent terminal). exit
352
+ // cleanly instead of being killed uncleanly with no `exited` event.
353
+ process.on('SIGHUP', () => shutdown(129))
354
+ process.on('uncaughtException', (err) => {
355
+ emit({
356
+ type: 'error',
357
+ message: `uncaught: ${(err as Error).message}`,
358
+ } as AgentEvent)
359
+ shutdown(1)
360
+ })
361
+
362
+ // build the per-turn runner for whichever provider this is.
363
+ const runTurn: TurnRunner =
364
+ provider === 'codex' && codexClient
365
+ ? buildCodexTurnRunner({
366
+ client: codexClient,
367
+ resolveThreadId: async () => {
368
+ if (codexThreadId) return codexThreadId
369
+ codexThreadId = await withTimeout(
370
+ resolveCodexThread(codexClient, cwd, {
371
+ fresh: args.freshThread === true,
372
+ }),
373
+ CODEX_THREAD_SETUP_TIMEOUT_MS,
374
+ `codex thread setup timed out after ${CODEX_THREAD_SETUP_TIMEOUT_MS}ms`,
375
+ )
376
+ return codexThreadId
377
+ },
378
+ emit,
379
+ transcript: transcriptStream,
380
+ onTurnSettled: () => {
381
+ turnInFlight = false
382
+ const next = pendingPrompts.shift()
383
+ if (next) void invokeTurn(next)
384
+ },
385
+ })
386
+ : buildClaudeTurnRunner({
387
+ resolvedBin: resolvedAgentBin,
388
+ sessionId,
389
+ projectId,
390
+ // prefer the uuid persisted on the AgentSession (passed in by
391
+ // startSession so `~/.claude/projects/<cwd>/<uuid>.jsonl` is
392
+ // reused across wrapper restarts). fall back to a fresh uuid for
393
+ // standalone invocations (tests, ad-hoc debugging).
394
+ claudeSessionUuid: args.claudeSessionUuid ?? randomUUID(),
395
+ cwd,
396
+ emit,
397
+ transcript: transcriptStream,
398
+ setChild: (c) => (currentClaudeChild = c),
399
+ onTurnSettled: () => {
400
+ turnInFlight = false
401
+ const next = pendingPrompts.shift()
402
+ if (next) void invokeTurn(next)
403
+ },
404
+ })
405
+
406
+ async function invokeTurn(text: string): Promise<void> {
407
+ turnInFlight = true
408
+ try {
409
+ await runTurn(text)
410
+ } catch (err) {
411
+ emit({
412
+ type: 'error',
413
+ message: `turn failed: ${(err as Error).message}`,
414
+ } as AgentEvent)
415
+ turnInFlight = false
416
+ const next = pendingPrompts.shift()
417
+ if (next) void invokeTurn(next)
418
+ }
419
+ }
420
+
421
+ // open prompt.in with O_RDWR so our own write end keeps the FIFO alive
422
+ // across external writer open/close cycles. `rnx agent prompt` opens,
423
+ // writes one line, closes — without a self-writer the reader would EOF
424
+ // after the first prompt and subsequent prompts would be lost.
425
+ const promptFd = openSync(promptIn, fsConstants.O_RDWR)
426
+ const promptStream = createReadStream('', { fd: promptFd, autoClose: false })
427
+ const rl = readline.createInterface({ input: promptStream, crlfDelay: Infinity })
428
+
429
+ rl.on('line', (rawLine) => {
430
+ const prompt = decodeAgentPromptEnvelope(rawLine)
431
+ if (!prompt) return
432
+ const text = prompt.text
433
+ emit({
434
+ type: 'prompt-received',
435
+ text: prompt.displayText ?? text,
436
+ ...(prompt.inspectSummary ? { inspectSummary: prompt.inspectSummary } : {}),
437
+ ...(prompt.inspectTrace ? { inspectTrace: prompt.inspectTrace } : {}),
438
+ } as AgentEvent)
439
+ if (turnInFlight) {
440
+ pendingPrompts.push(text)
441
+ return
442
+ }
443
+ void invokeTurn(text)
444
+ })
445
+
446
+ promptStream.on('error', (err) => {
447
+ emit({
448
+ type: 'error',
449
+ message: `prompt-in read failed: ${err.message}`,
450
+ } as AgentEvent)
451
+ })
452
+
453
+ // readiness means the prompt channel is open and orphan handling is armed.
454
+ // callers may act on this event immediately, including deleting the session.
455
+ emit({
456
+ type: 'ready',
457
+ sessionId,
458
+ projectId,
459
+ provider,
460
+ cwd,
461
+ } as AgentEvent)
462
+
463
+ return exitPromise
464
+ }
465
+
466
+ // --- codex app-server integration ---
467
+
468
+ export function extractCodexThreadId(
469
+ started: { threadId?: string; thread?: { id?: string } } | undefined,
470
+ ): string | null {
471
+ return started?.threadId ?? started?.thread?.id ?? null
472
+ }
473
+
474
+ export function extractCodexThreadListId(response: unknown): string | null {
475
+ if (typeof response !== 'object' || response === null) return null
476
+ const data = Reflect.get(response, 'data')
477
+ if (!Array.isArray(data)) return null
478
+ const first = data[0]
479
+ if (typeof first !== 'object' || first === null) return null
480
+ const id = Reflect.get(first, 'id')
481
+ return typeof id === 'string' && id.length > 0 ? id : null
482
+ }
483
+
484
+ async function resolveCodexThread(
485
+ client: CodexClient,
486
+ cwd: string,
487
+ opts: { fresh?: boolean } = {},
488
+ ): Promise<string> {
489
+ // try to reuse the most recent active thread for this cwd. if anything
490
+ // goes wrong, or the caller requested a fresh thread, use thread/start.
491
+ if (!opts.fresh) {
492
+ try {
493
+ const res = await client.request('thread/list', {
494
+ cwd,
495
+ limit: 1,
496
+ archived: false,
497
+ })
498
+ const existing = extractCodexThreadListId(res)
499
+ if (existing) {
500
+ try {
501
+ await client.request('thread/resume', {
502
+ threadId: existing,
503
+ // codex 0.121 rejects `persistExtendedHistory: true` with "requires
504
+ // experimentalApi capability". we don't need extended rollouts for
505
+ // v1 — thread history on resume is already sufficient.
506
+ persistExtendedHistory: false,
507
+ approvalPolicy: CODEX_APPROVAL_POLICY,
508
+ sandbox: CODEX_THREAD_SANDBOX,
509
+ model: CODEX_MODEL,
510
+ serviceTier: CODEX_SERVICE_TIER,
511
+ })
512
+ return existing
513
+ } catch {
514
+ // fall through to start a fresh thread
515
+ }
516
+ }
517
+ } catch {
518
+ // thread/list not available in this codex build; just start a new one.
519
+ }
520
+ }
521
+ const started = (await client.request('thread/start', {
522
+ cwd,
523
+ experimentalRawEvents: false,
524
+ // codex 0.121 rejects `persistExtendedHistory: true` without the
525
+ // `experimentalApi` capability (which we don't opt into). extended
526
+ // rollouts aren't needed for v1 — the default short history is fine.
527
+ persistExtendedHistory: false,
528
+ approvalPolicy: CODEX_APPROVAL_POLICY,
529
+ sandbox: CODEX_THREAD_SANDBOX,
530
+ model: CODEX_MODEL,
531
+ serviceTier: CODEX_SERVICE_TIER,
532
+ })) as { threadId?: string; thread?: { id?: string } } | undefined
533
+ const threadId = extractCodexThreadId(started)
534
+ if (!threadId) {
535
+ throw new Error('thread/start returned no threadId')
536
+ }
537
+ return threadId
538
+ }
539
+
540
+ interface CodexTurnDeps {
541
+ client: CodexClient
542
+ resolveThreadId: () => Promise<string>
543
+ emit: (event: Partial<AgentEvent> & { type: AgentEvent['type'] }) => void
544
+ transcript: AgentTranscriptStream | null
545
+ onTurnSettled: () => void
546
+ }
547
+
548
+ function buildCodexTurnRunner(deps: CodexTurnDeps): TurnRunner {
549
+ const { client, emit, transcript } = deps
550
+
551
+ // install notification handlers once; they feed the per-turn state via a
552
+ // module-local map keyed by turnId. a new turn clears its bucket on entry.
553
+ interface TurnState {
554
+ started: number
555
+ resolve: () => void
556
+ filesTouched: Set<string>
557
+ pendingEmit: string
558
+ }
559
+ const turns = new Map<string, TurnState>()
560
+ let latestTurnId: string | null = null
561
+
562
+ client.on('turn/started', (params) => {
563
+ const p = params as { threadId?: string; turn?: { id?: string; startedAt?: number } }
564
+ const turnId = p?.turn?.id
565
+ if (!turnId) return
566
+ latestTurnId = turnId
567
+ const state = turns.get(turnId) ?? {
568
+ started: Date.now(),
569
+ resolve: () => {},
570
+ filesTouched: new Set<string>(),
571
+ pendingEmit: '',
572
+ }
573
+ if (!turns.has(turnId)) turns.set(turnId, state)
574
+ emit({ type: 'turn-started', turnId } as AgentEvent)
575
+ })
576
+
577
+ client.on('item/agentMessage/delta', (params) => {
578
+ const p = params as { delta?: string }
579
+ if (!p?.delta) return
580
+ transcript?.write(p.delta)
581
+ emit({ type: 'turn-message', delta: p.delta } as AgentEvent)
582
+ })
583
+
584
+ client.on('item/reasoning/textDelta', (params) => {
585
+ const p = params as { delta?: string }
586
+ if (!p?.delta) return
587
+ emit({ type: 'turn-reasoning', delta: p.delta } as AgentEvent)
588
+ })
589
+
590
+ client.on('item/reasoning/summaryTextDelta', (params) => {
591
+ const p = params as { delta?: string }
592
+ if (!p?.delta) return
593
+ emit({ type: 'turn-reasoning', delta: p.delta } as AgentEvent)
594
+ })
595
+
596
+ // intentionally NOT subscribing to `turn/diff/updated` — the notification
597
+ // carries an aggregated unified diff for the whole turn with no per-file
598
+ // attribution, which would force consumers (HMR halo) to key on an empty
599
+ // path. `item/completed` with type=fileChange delivers the same information
600
+ // per-file, so we rely on that instead.
601
+
602
+ client.on('turn/plan/updated', (params) => {
603
+ const p = params as {
604
+ plan?: Array<{ step: string; status: string }>
605
+ turnId?: string
606
+ }
607
+ if (!Array.isArray(p?.plan)) return
608
+ emit({
609
+ type: 'turn-plan',
610
+ steps: p.plan.map((s, i) => ({
611
+ id: String(i),
612
+ title: s.step,
613
+ status: s.status,
614
+ })),
615
+ } as AgentEvent)
616
+ })
617
+
618
+ client.on('item/started', (params) => {
619
+ const p = params as {
620
+ item?: { type?: string; command?: string; tool?: string }
621
+ turnId?: string
622
+ }
623
+ const item = p?.item
624
+ if (!item) return
625
+ if (item.type === 'commandExecution' && item.command) {
626
+ emit({
627
+ type: 'tool-call',
628
+ name: 'commandExecution',
629
+ args: { command: item.command },
630
+ } as AgentEvent)
631
+ } else if (item.type === 'mcpToolCall' || item.type === 'dynamicToolCall') {
632
+ emit({
633
+ type: 'tool-call',
634
+ name: item.tool ?? item.type,
635
+ args: {},
636
+ } as AgentEvent)
637
+ }
638
+ })
639
+
640
+ client.on('item/completed', (params) => {
641
+ const p = params as {
642
+ item?: {
643
+ type?: string
644
+ status?: string
645
+ changes?: Array<{
646
+ path: string
647
+ kind: { type?: string; move_path?: string | null }
648
+ diff?: string
649
+ }>
650
+ }
651
+ turnId?: string
652
+ }
653
+ const item = p?.item
654
+ if (!item) return
655
+ if (item.type !== 'fileChange') return
656
+ if (item.status !== 'completed') return
657
+ if (!Array.isArray(item.changes)) return
658
+ const turnId = p?.turnId
659
+ const state = turnId ? turns.get(turnId) : null
660
+ for (const change of item.changes) {
661
+ const kindType = change.kind?.type
662
+ const kind: 'add' | 'modify' | 'delete' =
663
+ kindType === 'add' ? 'add' : kindType === 'delete' ? 'delete' : 'modify'
664
+ emit({
665
+ type: 'file-edited',
666
+ path: change.path,
667
+ kind,
668
+ diff: change.diff,
669
+ } as AgentEvent)
670
+ state?.filesTouched.add(change.path)
671
+ // a rename (`update` with move_path) touches two files — track both so
672
+ // HMR halo can highlight the new path too.
673
+ const movePath = change.kind?.move_path
674
+ if (movePath && movePath !== change.path) {
675
+ state?.filesTouched.add(movePath)
676
+ }
677
+ }
678
+ })
679
+
680
+ client.on('turn/completed', (params) => {
681
+ const p = params as {
682
+ threadId?: string
683
+ turn?: {
684
+ id?: string
685
+ status?: string
686
+ durationMs?: number | null
687
+ startedAt?: number | null
688
+ }
689
+ }
690
+ const turnId = p?.turn?.id
691
+ if (!turnId) return
692
+ const state = turns.get(turnId)
693
+ const filesTouched = state ? Array.from(state.filesTouched) : []
694
+ const durationMs = p.turn?.durationMs ?? (state ? Date.now() - state.started : 0)
695
+ emit({
696
+ type: 'turn-completed',
697
+ turnId,
698
+ filesTouched,
699
+ durationMs,
700
+ } as AgentEvent)
701
+ state?.resolve()
702
+ turns.delete(turnId)
703
+ })
704
+
705
+ client.on('error', (params) => {
706
+ const p = params as {
707
+ error?: { message?: string }
708
+ turnId?: string
709
+ }
710
+ const msg = p?.error?.message ?? 'codex reported an error'
711
+ emit({ type: 'error', message: msg } as AgentEvent)
712
+ if (p.turnId) {
713
+ const state = turns.get(p.turnId)
714
+ state?.resolve()
715
+ turns.delete(p.turnId)
716
+ }
717
+ })
718
+
719
+ return async function codexRunTurn(text: string): Promise<void> {
720
+ let threadId: string
721
+ try {
722
+ threadId = await deps.resolveThreadId()
723
+ } catch (err) {
724
+ const message = err instanceof Error ? err.message : String(err)
725
+ emit({
726
+ type: 'error',
727
+ message: `codex thread setup failed: ${message}`,
728
+ } as AgentEvent)
729
+ emit({
730
+ type: 'turn-completed',
731
+ filesTouched: [] as string[],
732
+ durationMs: 0,
733
+ })
734
+ deps.onTurnSettled()
735
+ return
736
+ }
737
+ const { promise, resolve } = createDeferred<void>()
738
+ // we don't know the turnId until turn/started arrives. register a
739
+ // placeholder and patch it up in the notification handler.
740
+ const pendingId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
741
+ turns.set(pendingId, {
742
+ started: Date.now(),
743
+ resolve,
744
+ filesTouched: new Set(),
745
+ pendingEmit: '',
746
+ })
747
+ try {
748
+ const res = (await client.request('turn/start', {
749
+ threadId,
750
+ input: [{ type: 'text', text, text_elements: [] }],
751
+ approvalPolicy: CODEX_APPROVAL_POLICY,
752
+ sandboxPolicy: CODEX_TURN_SANDBOX_POLICY,
753
+ model: CODEX_MODEL,
754
+ serviceTier: CODEX_SERVICE_TIER,
755
+ effort: AGENT_REASONING_EFFORT,
756
+ })) as { turn?: { id?: string } } | undefined
757
+ const actualTurnId = res?.turn?.id ?? latestTurnId
758
+ if (actualTurnId && actualTurnId !== pendingId) {
759
+ // hoist the placeholder bucket onto the real turnId so future
760
+ // notifications accumulate into it.
761
+ const placeholder = turns.get(pendingId)
762
+ if (placeholder) {
763
+ turns.delete(pendingId)
764
+ const existing = turns.get(actualTurnId)
765
+ if (existing) {
766
+ for (const f of placeholder.filesTouched) existing.filesTouched.add(f)
767
+ existing.resolve = placeholder.resolve
768
+ } else {
769
+ turns.set(actualTurnId, placeholder)
770
+ }
771
+ }
772
+ }
773
+ } catch (err) {
774
+ turns.delete(pendingId)
775
+ const message = err instanceof Error ? err.message : String(err)
776
+ emit({ type: 'error', message: `turn/start failed: ${message}` } as AgentEvent)
777
+ emit({
778
+ type: 'turn-completed',
779
+ filesTouched: [] as string[],
780
+ durationMs: 0,
781
+ })
782
+ deps.onTurnSettled()
783
+ return
784
+ }
785
+ // safety timeout so a lost turn/completed notification doesn't wedge
786
+ // the wrapper forever. 10min is far beyond normal codex turn duration.
787
+ const timeout = (
788
+ setTimeout(() => resolve(), 10 * 60 * 1000) as unknown as NodeJS.Timeout
789
+ ).unref()
790
+ try {
791
+ await promise
792
+ } finally {
793
+ clearTimeout(timeout)
794
+ deps.onTurnSettled()
795
+ }
796
+ }
797
+ }
798
+
799
+ // --- claude per-turn spawn via --output-format stream-json ---
800
+ //
801
+ // each prompt spawns a fresh `claude --print --output-format stream-json
802
+ // --verbose --permission-mode bypassPermissions --effort low --session-id <uuid>`
803
+ // process.
804
+ // stdout is a newline-delimited stream of well-typed objects; we translate
805
+ // them into our AgentEvent wire format. the deterministic session uuid means
806
+ // successive spawns all land in the same `~/.claude/projects/<cwd>/<uuid>.jsonl`
807
+ // file, so turn-over-turn state is preserved without a long-lived child.
808
+
809
+ interface ClaudeStreamEvent {
810
+ type?: string
811
+ subtype?: string
812
+ message?: {
813
+ content?: Array<{
814
+ type?: string
815
+ text?: string
816
+ name?: string
817
+ id?: string
818
+ input?: { file_path?: string; path?: string; [k: string]: unknown }
819
+ }>
820
+ }
821
+ duration_ms?: number
822
+ total_cost_usd?: number
823
+ result?: string
824
+ is_error?: boolean
825
+ permission_denials?: Array<{ tool_name?: string; tool_input?: unknown }>
826
+ session_id?: string
827
+ }
828
+
829
+ const CLAUDE_FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
830
+
831
+ interface ClaudeTurnDeps {
832
+ resolvedBin: string
833
+ sessionId: string
834
+ projectId: string
835
+ claudeSessionUuid: string
836
+ cwd: string
837
+ emit: (event: Partial<AgentEvent> & { type: AgentEvent['type'] }) => void
838
+ transcript: AgentTranscriptStream | null
839
+ setChild: (child: KillableChildProcess | null) => void
840
+ onTurnSettled: () => void
841
+ }
842
+
843
+ function buildClaudeTurnRunner(deps: ClaudeTurnDeps): TurnRunner {
844
+ return async function claudeRunTurn(text: string): Promise<void> {
845
+ const {
846
+ resolvedBin,
847
+ sessionId,
848
+ projectId,
849
+ claudeSessionUuid,
850
+ cwd,
851
+ emit,
852
+ transcript,
853
+ setChild,
854
+ } = deps
855
+ const startedAt = Date.now()
856
+ emit({ type: 'turn-started' } as AgentEvent)
857
+ // the agent runs as the user, on the user's claude login. the host app's
858
+ // own api key must not become the agent's credential.
859
+ const { ANTHROPIC_API_KEY: _hostApiKey, ...claudeEnv } = process.env
860
+ // `--session-id` creates the transcript and refuses a uuid that already
861
+ // has one, so every turn after the first (including the first turn of a
862
+ // restarted wrapper) resumes it instead.
863
+ const transcriptPath = path.join(
864
+ os.homedir(),
865
+ '.claude',
866
+ 'projects',
867
+ cwd.replace(/[/.]/g, '-'),
868
+ `${claudeSessionUuid}.jsonl`,
869
+ )
870
+ const sessionFlag = existsSync(transcriptPath) ? '--resume' : '--session-id'
871
+
872
+ const child = spawn(
873
+ resolvedBin,
874
+ [
875
+ '--print',
876
+ '--output-format',
877
+ 'stream-json',
878
+ '--verbose',
879
+ '--permission-mode',
880
+ 'bypassPermissions',
881
+ '--effort',
882
+ AGENT_REASONING_EFFORT,
883
+ sessionFlag,
884
+ claudeSessionUuid,
885
+ text,
886
+ ],
887
+ {
888
+ cwd,
889
+ stdio: ['ignore', 'pipe', 'pipe'],
890
+ env: {
891
+ ...claudeEnv,
892
+ RNX_CLI_IDENTITY: sessionId,
893
+ SOOTSIM_PROJECT_ID: projectId,
894
+ },
895
+ },
896
+ )
897
+ setChild(child)
898
+
899
+ const filesTouched = new Set<string>()
900
+ let completedEmitted = false
901
+
902
+ function processEvent(ev: ClaudeStreamEvent): void {
903
+ const type = ev.type
904
+ if (!type) return
905
+ if (type === 'assistant' && Array.isArray(ev.message?.content)) {
906
+ for (const item of ev.message!.content!) {
907
+ if (item.type === 'text' && item.text) {
908
+ transcript?.write(item.text)
909
+ emit({ type: 'turn-message', delta: item.text } as AgentEvent)
910
+ } else if (item.type === 'tool_use' && item.name) {
911
+ emit({
912
+ type: 'tool-call',
913
+ name: item.name,
914
+ args: item.input ?? {},
915
+ } as AgentEvent)
916
+ const filePath = item.input?.file_path ?? item.input?.path
917
+ if (typeof filePath === 'string' && CLAUDE_FILE_TOOLS.has(item.name)) {
918
+ filesTouched.add(filePath)
919
+ emit({
920
+ type: 'file-edited',
921
+ path: filePath,
922
+ kind: 'modify',
923
+ } as AgentEvent)
924
+ }
925
+ }
926
+ }
927
+ } else if (type === 'result') {
928
+ completedEmitted = true
929
+ if (Array.isArray(ev.permission_denials)) {
930
+ for (const denial of ev.permission_denials) {
931
+ emit({
932
+ type: 'approval-needed',
933
+ kind: denial.tool_name ?? 'unknown',
934
+ detail: denial.tool_input ?? null,
935
+ } as AgentEvent)
936
+ }
937
+ }
938
+ if (ev.is_error && typeof ev.result === 'string') {
939
+ emit({ type: 'error', message: ev.result } as AgentEvent)
940
+ }
941
+ emit({
942
+ type: 'turn-completed',
943
+ filesTouched: Array.from(filesTouched),
944
+ durationMs: ev.duration_ms ?? Date.now() - startedAt,
945
+ // suppress cost on failed turns so the 7-day rolling history in
946
+ // `recordTurnTelemetry` reflects work the agent actually did, not
947
+ // rate-limited / user-aborted attempts.
948
+ costUsd: ev.is_error ? undefined : ev.total_cost_usd,
949
+ } as AgentEvent)
950
+ }
951
+ }
952
+
953
+ // parse stdout line-by-line; claude emits one JSON object per line in
954
+ // stream-json mode, but chunks can split mid-line so we buffer.
955
+ let leftover = ''
956
+ child.stdout?.setEncoding('utf8')
957
+ child.stdout?.on('data', (chunk: string) => {
958
+ leftover += chunk
959
+ let idx: number
960
+ while ((idx = leftover.indexOf('\n')) >= 0) {
961
+ const line = leftover.slice(0, idx)
962
+ leftover = leftover.slice(idx + 1)
963
+ const trimmed = line.trim()
964
+ if (!trimmed) continue
965
+ let parsed: ClaudeStreamEvent
966
+ try {
967
+ parsed = JSON.parse(trimmed) as ClaudeStreamEvent
968
+ } catch {
969
+ // non-JSON noise (e.g. pre-init stderr crossover) — skip.
970
+ continue
971
+ }
972
+ try {
973
+ processEvent(parsed)
974
+ } catch (err) {
975
+ emit({
976
+ type: 'error',
977
+ message: `claude event handler failed: ${(err as Error).message}`,
978
+ } as AgentEvent)
979
+ }
980
+ }
981
+ })
982
+
983
+ child.stderr?.setEncoding('utf8')
984
+ child.stderr?.on('data', (chunk: string) => {
985
+ transcript?.write(`[claude-stderr] ${chunk}`)
986
+ })
987
+
988
+ child.on('error', (err) => {
989
+ emit({
990
+ type: 'error',
991
+ message: `spawn failed: ${err.message}`,
992
+ } as AgentEvent)
993
+ })
994
+
995
+ const { promise, resolve } = createDeferred<void>()
996
+ child.on('close', (code) => {
997
+ if (!completedEmitted) {
998
+ // claude exited without emitting a `result` event — usually a crash,
999
+ // rate-limit, or SIGTERM. emit a terminal event so the queue drains.
1000
+ emit({
1001
+ type: 'turn-completed',
1002
+ filesTouched: Array.from(filesTouched),
1003
+ durationMs: Date.now() - startedAt,
1004
+ } as AgentEvent)
1005
+ if (code !== 0 && code !== null) {
1006
+ emit({
1007
+ type: 'error',
1008
+ message: `claude exited with code ${code}`,
1009
+ } as AgentEvent)
1010
+ }
1011
+ }
1012
+ setChild(null)
1013
+ resolve()
1014
+ })
1015
+ try {
1016
+ await promise
1017
+ } finally {
1018
+ deps.onTurnSettled()
1019
+ }
1020
+ }
1021
+ }
1022
+
1023
+ function createDeferred<T>(): {
1024
+ promise: Promise<T>
1025
+ resolve: (value: T) => void
1026
+ } {
1027
+ let resolve!: (value: T) => void
1028
+ const promise = new Promise<T>((res) => {
1029
+ resolve = res
1030
+ })
1031
+ return { promise, resolve }
1032
+ }
1033
+
1034
+ function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
1035
+ return new Promise<T>((resolve, reject) => {
1036
+ const t = setTimeout(() => reject(new Error(message)), ms)
1037
+ p.then(
1038
+ (v) => {
1039
+ clearTimeout(t)
1040
+ resolve(v)
1041
+ },
1042
+ (err: unknown) => {
1043
+ clearTimeout(t)
1044
+ reject(err instanceof Error ? err : new Error(String(err)))
1045
+ },
1046
+ )
1047
+ })
1048
+ }