rnxsim 0.1.312 → 0.1.314
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +11 -10
- package/README.md +5 -0
- package/cli/app-config.ts +65 -0
- package/cli/app-fonts.ts +408 -0
- package/cli/app-project.ts +231 -0
- package/cli/app-splash.ts +185 -0
- package/cli/app-state-reset.ts +24 -0
- package/cli/auth.ts +155 -0
- package/cli/bin.ts +594 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2830 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/commands/agent-wrapper.ts +986 -0
- package/cli/commands/agent.ts +423 -0
- package/cli/commands/app-fonts.ts +98 -0
- package/cli/commands/assert.ts +541 -0
- package/cli/commands/auth.ts +59 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +87 -0
- package/cli/commands/config.ts +32 -0
- package/cli/commands/control.ts +2142 -0
- package/cli/commands/cpu-profile.ts +269 -0
- package/cli/commands/daemon-mac-app.ts +169 -0
- package/cli/commands/daemon.ts +874 -0
- package/cli/commands/debug.ts +719 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +197 -0
- package/cli/commands/detox.ts +385 -0
- package/cli/commands/device.ts +133 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/electron.ts +95 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1124 -0
- package/cli/commands/inspect/actions.ts +622 -0
- package/cli/commands/inspect/core.ts +2405 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +192 -0
- package/cli/commands/inspect/env.ts +23 -0
- package/cli/commands/inspect/find.ts +171 -0
- package/cli/commands/inspect/get-layout.ts +39 -0
- package/cli/commands/inspect/keyboard.ts +52 -0
- package/cli/commands/inspect/list.ts +58 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +82 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +22 -0
- package/cli/commands/inspect/settling.ts +158 -0
- package/cli/commands/inspect/shared.ts +353 -0
- package/cli/commands/inspect/sleep.ts +14 -0
- package/cli/commands/inspect/tree.ts +32 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +210 -0
- package/cli/commands/inspect/wait-idle.ts +24 -0
- package/cli/commands/inspect/wait-ready.ts +74 -0
- package/cli/commands/inspect/wait-selector.ts +54 -0
- package/cli/commands/inspect/wait.ts +31 -0
- package/cli/commands/inspect.ts +4519 -0
- package/cli/commands/install-desktop.ts +351 -0
- package/cli/commands/login.ts +331 -0
- package/cli/commands/logout.ts +31 -0
- package/cli/commands/maestro-generate.ts +361 -0
- package/cli/commands/maestro.ts +453 -0
- package/cli/commands/mode.ts +57 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +66 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/profile.ts +108 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1434 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +85 -0
- package/cli/commands/runtime.ts +351 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +143 -0
- package/cli/commands/screenshot-mode.ts +37 -0
- package/cli/commands/screenshot.ts +488 -0
- package/cli/commands/screenshots-capture.ts +607 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +168 -0
- package/cli/commands/setup.ts +545 -0
- package/cli/commands/shell-boolean-mode.ts +81 -0
- package/cli/commands/skills.ts +467 -0
- package/cli/commands/slides.ts +361 -0
- package/cli/commands/state.ts +87 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +54 -0
- package/cli/commands/three-mode.ts +763 -0
- package/cli/commands/timeline.ts +122 -0
- package/cli/commands/upgrade.ts +208 -0
- package/cli/commands/upload.ts +1225 -0
- package/cli/commands/version.ts +54 -0
- package/cli/commands/what-happened.ts +327 -0
- package/cli/current-sim.ts +204 -0
- package/cli/desktop-companion.ts +300 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +180 -0
- package/cli/drivers/playwright.ts +698 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +120 -0
- package/cli/flow-session.ts +187 -0
- package/cli/help.ts +80 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +216 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/maestro-js.ts +334 -0
- package/cli/open-url.ts +8 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +211 -0
- package/cli/prompt.ts +51 -0
- package/cli/recording-access.ts +107 -0
- package/cli/registry.ts +1 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/run-registry.ts +226 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/setup-repository.ts +187 -0
- package/cli/telemetry.ts +187 -0
- package/cli/ws-bridge.ts +798 -0
- package/dist-cli/bin.js +5 -5
- package/dist-cli/chunks/{agent-NXVDDCFG.js → agent-7YBDCYMA.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-DXJCFKL3.js → agent-wrapper-2GHFBHCR.js} +2 -2
- package/dist-cli/chunks/{app-fonts-W5AJZQJF.js → app-fonts-RSNVPQSU.js} +2 -2
- package/dist-cli/chunks/{assert-3LOJEUDT.js → assert-XCMX3XJX.js} +2 -2
- package/dist-cli/chunks/{auth-MFTYYPNX.js → auth-B442HRAX.js} +2 -2
- package/dist-cli/chunks/{beta-GT3NN67B.js → beta-XJ55JK3M.js} +2 -2
- package/dist-cli/chunks/camera-UGYSSLIK.js +33 -0
- package/dist-cli/chunks/{chunk-EUAVBTKU.js → chunk-277AEQZX.js} +2 -2
- package/dist-cli/chunks/{chunk-5OX7ANRA.js → chunk-2JNSK774.js} +2 -2
- package/dist-cli/chunks/{chunk-L24Z3CRS.js → chunk-32WOTSTR.js} +3 -3
- package/dist-cli/chunks/{chunk-BKNOLP3J.js → chunk-3S753SNQ.js} +2 -2
- package/dist-cli/chunks/{chunk-N6A3SXMR.js → chunk-46ZOLOYA.js} +2 -2
- package/dist-cli/chunks/{chunk-GZDHEONW.js → chunk-5L7ELDQL.js} +8 -9
- package/dist-cli/chunks/{chunk-YZRFF4YA.js → chunk-7OOPFSQS.js} +2 -2
- package/dist-cli/chunks/{chunk-TBMVBI26.js → chunk-7SV3RPRW.js} +2 -2
- package/dist-cli/chunks/{chunk-JG6S4RSH.js → chunk-7ZC35MOU.js} +1 -1
- package/dist-cli/chunks/chunk-AMG5E6CC.js +9 -0
- package/dist-cli/chunks/{chunk-MENV7VLD.js → chunk-APWNH3A4.js} +1 -1
- package/dist-cli/chunks/chunk-B57XUKY3.js +4 -0
- package/dist-cli/chunks/{chunk-3D57IBPC.js → chunk-C2NL26TD.js} +1 -1
- package/dist-cli/chunks/chunk-D2FNUWAB.js +15 -0
- package/dist-cli/chunks/{chunk-4BXCZHAU.js → chunk-E5T4XSJ3.js} +2 -2
- package/dist-cli/chunks/{chunk-FTC4M26Q.js → chunk-EAC34EQS.js} +1 -1
- package/dist-cli/chunks/{chunk-NPVGMH2N.js → chunk-EG32ML36.js} +2 -2
- package/dist-cli/chunks/{chunk-DM6KUAOZ.js → chunk-FXUAC6D5.js} +1 -1
- package/dist-cli/chunks/chunk-GQSL4USA.js +6 -0
- package/dist-cli/chunks/{chunk-U4PN6JOT.js → chunk-HAXW27SS.js} +2 -2
- package/dist-cli/chunks/{chunk-ZMZTHU7V.js → chunk-IZAHPAN6.js} +1 -1
- package/dist-cli/chunks/{chunk-SI3BUTQR.js → chunk-J62KM5TB.js} +2 -2
- package/dist-cli/chunks/{chunk-Y2FSZSO7.js → chunk-JEMCD5E4.js} +1 -1
- package/dist-cli/chunks/{chunk-T4FT6CM7.js → chunk-JZS3Q37N.js} +2 -2
- package/dist-cli/chunks/{chunk-2SA2VTGP.js → chunk-KR4JON7D.js} +1 -1
- package/dist-cli/chunks/chunk-KWCYKQIQ.js +4 -0
- package/dist-cli/chunks/{chunk-HG7ORJMT.js → chunk-MCWPL644.js} +2 -2
- package/dist-cli/chunks/chunk-MJYK3N2I.js +4 -0
- package/dist-cli/chunks/{chunk-YRV7HR3P.js → chunk-O6TRIZNS.js} +2 -2
- package/dist-cli/chunks/{chunk-WBT32R6N.js → chunk-P7XL2E73.js} +3 -3
- package/dist-cli/chunks/{chunk-MNGQ42QE.js → chunk-PFQTUKQ4.js} +62 -87
- package/dist-cli/chunks/{chunk-Q3RGLERJ.js → chunk-PG5RZCTN.js} +2 -2
- package/dist-cli/chunks/{chunk-OR3XTXEB.js → chunk-QGRI2Z4M.js} +2 -2
- package/dist-cli/chunks/chunk-QLXXE7GE.js +125 -0
- package/dist-cli/chunks/{chunk-KZW5WBGU.js → chunk-QOJJJWJE.js} +89 -133
- package/dist-cli/chunks/{chunk-BRIZOR4K.js → chunk-RWZY5427.js} +2 -2
- package/dist-cli/chunks/{chunk-TYE3WBSR.js → chunk-RZKU2K3J.js} +2 -2
- package/dist-cli/chunks/{chunk-7VWOSTD5.js → chunk-SBV4IK4H.js} +1 -1
- package/dist-cli/chunks/{chunk-FWYYCUAZ.js → chunk-SGMVFFMK.js} +1 -1
- package/dist-cli/chunks/{chunk-LDQ7HVCW.js → chunk-TAX4UT2N.js} +1 -1
- package/dist-cli/chunks/{chunk-G74YIEVS.js → chunk-TUOFAWXT.js} +1 -1
- package/dist-cli/chunks/{chunk-274UD5FD.js → chunk-UHZLOHGP.js} +1 -1
- package/dist-cli/chunks/{chunk-37I5AIS7.js → chunk-VC7V76U3.js} +1 -1
- package/dist-cli/chunks/{chunk-B7KMNHX7.js → chunk-VQVMLW4U.js} +1 -1
- package/dist-cli/chunks/{chunk-VZBLFMYC.js → chunk-VUKKYPZN.js} +2 -2
- package/dist-cli/chunks/{chunk-VUFRB347.js → chunk-WGGRJDRE.js} +1 -1
- package/dist-cli/chunks/chunk-X6H76EKP.js +15 -0
- package/dist-cli/chunks/chunk-XLZ5FNRT.js +27 -0
- package/dist-cli/chunks/{chunk-TCWQJILS.js → chunk-XULEACM4.js} +2 -2
- package/dist-cli/chunks/{chunk-XRGEEPJ5.js → chunk-YFSDM7AX.js} +4 -4
- package/dist-cli/chunks/chunk-YWI3UEVX.js +5 -0
- package/dist-cli/chunks/{chunk-TGQNQ6XT.js → chunk-ZBSJO4NB.js} +10 -9
- package/dist-cli/chunks/{cleanup-H3GWQB63.js → cleanup-EYLGCA6Z.js} +2 -2
- package/dist-cli/chunks/cli-version-LL2UGIHE.js +4 -0
- package/dist-cli/chunks/{compat-5WG2XJV7.js → compat-TLJYHB4E.js} +2 -2
- package/dist-cli/chunks/{config-ANMBVVLL.js → config-4JWUOEXK.js} +2 -2
- package/dist-cli/chunks/{control-NSGNQFJG.js → control-HPAOYF4N.js} +2 -2
- package/dist-cli/chunks/daemon-OYLASXLE.js +4 -0
- package/dist-cli/chunks/{debug-CHV2O2KF.js → debug-4BKXF6KI.js} +5 -5
- package/dist-cli/chunks/{desktop-FIPIK4CH.js → desktop-ZQ6ZD2S6.js} +3 -3
- package/dist-cli/chunks/{detox-CVUJP7K6.js → detox-WNPASSS3.js} +2 -2
- package/dist-cli/chunks/{device-6WCUM4W6.js → device-BXLXVG3V.js} +2 -2
- package/dist-cli/chunks/{diagnose-I6EMWEII.js → diagnose-3QX7W5RW.js} +2 -2
- package/dist-cli/chunks/{disk-cleanup-P27CYFTN.js → disk-cleanup-KSWKI7WB.js} +2 -2
- package/dist-cli/chunks/drivers-EPIEFF7P.js +4 -0
- package/dist-cli/chunks/{film-UOQFFYNF.js → film-GDMR33VO.js} +3 -3
- package/dist-cli/chunks/flow-NKMPBYCJ.js +4 -0
- package/dist-cli/chunks/help-OGCBHOKA.js +4 -0
- package/dist-cli/chunks/{hidden-runtime-alias-NJYFSJKZ.js → hidden-runtime-alias-S2GTDX3T.js} +2 -2
- package/dist-cli/chunks/home-paths-QRCDLTTV.js +4 -0
- package/dist-cli/chunks/inspect-FUYMOZPY.js +4 -0
- package/dist-cli/chunks/install-desktop-FR6YKW7Y.js +4 -0
- package/dist-cli/chunks/{login-BPFQENCX.js → login-IJAPUZHI.js} +4 -4
- package/dist-cli/chunks/{logout-5A45ZGY7.js → logout-QMXDFU2X.js} +2 -2
- package/dist-cli/chunks/{maestro-E4CSDMLM.js → maestro-ZXU3YCVX.js} +3 -3
- package/dist-cli/chunks/{maestro-generate-CKMOJQX2.js → maestro-generate-PYB5QY7K.js} +3 -3
- package/dist-cli/chunks/{mode-SZYNMIQF.js → mode-WGUCL5FZ.js} +2 -2
- package/dist-cli/chunks/{optional-demo-registry-FRH4SFVG.js → optional-demo-registry-WH2O6H36.js} +2 -2
- package/dist-cli/chunks/{perf-VXU2XJAX.js → perf-TOD3UFAH.js} +2 -2
- package/dist-cli/chunks/{permissions-IRIAOVND.js → permissions-I5BRJGTB.js} +2 -2
- package/dist-cli/chunks/{record-RY7FPLLH.js → record-ZYL2FYSK.js} +3 -3
- package/dist-cli/chunks/{report-issue-KNWXOE53.js → report-issue-TAI6DYZO.js} +2 -2
- package/dist-cli/chunks/reset-7YGYKQPQ.js +4 -0
- package/dist-cli/chunks/runtime-B4JO6QRI.js +4 -0
- package/dist-cli/chunks/{screenshot-command-QLALR6GY.js → screenshot-command-7ANLODZY.js} +7 -7
- package/dist-cli/chunks/{screenshot-layers-OQFMFVRL.js → screenshot-layers-A7FYXSVU.js} +3 -3
- package/dist-cli/chunks/{screenshots-capture-6TSRJFGB.js → screenshots-capture-WT2ZY6CB.js} +2 -2
- package/dist-cli/chunks/serve-TG5WKA4V.js +44 -0
- package/dist-cli/chunks/{setup-7V6UKX4U.js → setup-PCWLD22V.js} +2 -2
- package/dist-cli/chunks/{skills-QQBOSETH.js → skills-27ZHWCQS.js} +2 -2
- package/dist-cli/chunks/state-ZQVY46ZO.js +14 -0
- package/dist-cli/chunks/{storage-TAPMKK5O.js → storage-OYC57C4X.js} +6 -6
- package/dist-cli/chunks/store-32HSPZHI.js +4 -0
- package/dist-cli/chunks/telemetry-PBJR7XHR.js +4 -0
- package/dist-cli/chunks/{timeline-IAM564NA.js → timeline-RAJJFQT6.js} +2 -2
- package/dist-cli/chunks/{upgrade-CD55QXIX.js → upgrade-OEFNZRIP.js} +2 -2
- package/dist-cli/chunks/upload-LQJITLKK.js +4 -0
- package/dist-cli/chunks/version-NIXTY6PL.js +6 -0
- package/dist-cli/chunks/{web-6NW6YLPI.js → web-XBUTBVGR.js} +2 -2
- package/dist-cli/chunks/{what-happened-CZZDOOVG.js → what-happened-YNHRFUDX.js} +3 -3
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract.cjs +20 -0
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +67 -28
- package/dist-lib/host/bridge-host.cjs +140 -12
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +2815 -40
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +2549 -2061
- package/dist-lib/sdk.mjs +2543 -2061
- package/dist-lib/skills.cjs +493 -282
- package/dist-lib/vite.cjs +1 -1
- package/package.json +8 -2
- package/src/bridge-constants.ts +3 -4
- package/src/bridge-contract.ts +251 -0
- package/src/connect.ts +83 -0
- package/src/disk-cleanup.ts +30 -0
- package/src/home-paths.ts +81 -38
- package/src/host/bridge-host.ts +134 -6
- package/src/index.ts +27 -1
- package/src/sdk.ts +8 -0
- package/src/sim-client.ts +660 -0
- package/dist-cli/chunks/camera-6EAK7CUL.js +0 -22
- package/dist-cli/chunks/chunk-33FKQH54.js +0 -5
- package/dist-cli/chunks/chunk-66NF2KNG.js +0 -9
- package/dist-cli/chunks/chunk-6UKJB2VC.js +0 -125
- package/dist-cli/chunks/chunk-7INBNA3U.js +0 -26
- package/dist-cli/chunks/chunk-AY47WKS5.js +0 -23
- package/dist-cli/chunks/chunk-HMDOLCKG.js +0 -15
- package/dist-cli/chunks/chunk-KR5BGMBZ.js +0 -4
- package/dist-cli/chunks/chunk-NLEMQ4PY.js +0 -6
- package/dist-cli/chunks/chunk-PEPUGJVI.js +0 -4
- package/dist-cli/chunks/cli-version-3QPAJ3LC.js +0 -4
- package/dist-cli/chunks/daemon-JHF4BJ3U.js +0 -4
- package/dist-cli/chunks/drivers-HQ6AKZT6.js +0 -4
- package/dist-cli/chunks/flow-N27MRRNL.js +0 -4
- package/dist-cli/chunks/help-SF6FEGEE.js +0 -4
- package/dist-cli/chunks/home-paths-XOV44JEF.js +0 -4
- package/dist-cli/chunks/inspect-CWFRFQTY.js +0 -4
- package/dist-cli/chunks/install-desktop-KLQDNXZQ.js +0 -4
- package/dist-cli/chunks/runtime-AR3GJTGK.js +0 -4
- package/dist-cli/chunks/serve-F54J2NQO.js +0 -44
- package/dist-cli/chunks/store-7NX3S43H.js +0 -4
- package/dist-cli/chunks/telemetry-E7BUK6RA.js +0 -4
- package/dist-cli/chunks/upload-GZ6LIMYM.js +0 -4
- package/dist-cli/chunks/version-3DYJ5AS3.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
|
+
}
|