sootsim 0.1.83 → 0.1.84

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 (207) hide show
  1. package/README.md +0 -1
  2. package/detox/colors.ts +54 -0
  3. package/detox/config-loader.ts +135 -0
  4. package/detox/expectations.ts +477 -0
  5. package/detox/gestures.ts +442 -0
  6. package/detox/index.ts +1436 -0
  7. package/detox/jest-environment.ts +86 -0
  8. package/detox/jest-preset.cjs +50 -0
  9. package/detox/matchers.ts +29 -0
  10. package/detox/navigation.ts +43 -0
  11. package/detox/run-test.ts +113 -0
  12. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  13. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  14. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  15. package/detox/screenshots/theme-blue-toggle.png +0 -0
  16. package/detox/screenshots/theme-blue.png +0 -0
  17. package/detox/screenshots/theme-red-toggle.png +0 -0
  18. package/detox/screenshots/theme-red.png +0 -0
  19. package/dist-cli/bin.js +3 -3
  20. package/dist-cli/chunks/{agent-MQ7GLVIB.js → agent-2CWD6W6P.js} +2 -2
  21. package/dist-cli/chunks/{agent-wrapper-7KAFDQCN.js → agent-wrapper-5W3LOX6S.js} +2 -2
  22. package/dist-cli/chunks/{assert-TV46GUNU.js → assert-ZOMAMKRT.js} +2 -2
  23. package/dist-cli/chunks/auto-bootstrap-NYYSMTIM.js +2 -0
  24. package/dist-cli/chunks/beta-4K2SQACK.js +2 -0
  25. package/dist-cli/chunks/chunk-3HXQ7MJK.js +79 -0
  26. package/dist-cli/chunks/{chunk-4LS5MZAI.js → chunk-4K7BH2D4.js} +3 -3
  27. package/dist-cli/chunks/{chunk-FJYT7XL2.js → chunk-4OPRODFA.js} +2 -2
  28. package/dist-cli/chunks/{chunk-DP7O5MHK.js → chunk-4OWVPRZV.js} +2 -2
  29. package/dist-cli/chunks/{chunk-PM5NVKLP.js → chunk-5XCXOLG2.js} +2 -2
  30. package/dist-cli/chunks/chunk-67ZZ2CM5.js +1 -0
  31. package/dist-cli/chunks/{chunk-WN7M3QON.js → chunk-73UZXB4B.js} +2 -2
  32. package/dist-cli/chunks/{chunk-5DJXZIFZ.js → chunk-7NWNTUJF.js} +1 -1
  33. package/dist-cli/chunks/{chunk-Y2VJBRSP.js → chunk-7YHDJLO2.js} +1 -1
  34. package/dist-cli/chunks/{chunk-6NN2D4EJ.js → chunk-AJVTY6KY.js} +1 -1
  35. package/dist-cli/chunks/chunk-AWSQUOAS.js +67 -0
  36. package/dist-cli/chunks/{chunk-CJY3AVI7.js → chunk-BCBNVJVG.js} +1 -1
  37. package/dist-cli/chunks/{chunk-OYMFNU3M.js → chunk-BKBL6K2G.js} +1 -1
  38. package/dist-cli/chunks/{chunk-IBNRRAES.js → chunk-C3DPQZ4J.js} +2 -2
  39. package/dist-cli/chunks/chunk-D3ZSBIIY.js +2 -0
  40. package/dist-cli/chunks/{chunk-2AWQ7OB2.js → chunk-D4HUVLZR.js} +1 -1
  41. package/dist-cli/chunks/{chunk-F3HP444U.js → chunk-DUUSJDES.js} +1 -1
  42. package/dist-cli/chunks/{chunk-277XAALA.js → chunk-ELJLF4SG.js} +3 -3
  43. package/dist-cli/chunks/{chunk-RH4F2TF7.js → chunk-EQ7TFQ2F.js} +1 -1
  44. package/dist-cli/chunks/{chunk-HNWEELAE.js → chunk-EQCKGC4B.js} +1 -1
  45. package/dist-cli/chunks/chunk-FUCGLWNN.js +1 -0
  46. package/dist-cli/chunks/{chunk-FRM355UL.js → chunk-HYPJW65U.js} +2 -2
  47. package/dist-cli/chunks/chunk-IILJQCZA.js +2 -0
  48. package/dist-cli/chunks/{chunk-Y4BUVURT.js → chunk-KU6MSPAH.js} +2 -2
  49. package/dist-cli/chunks/{chunk-DM6WT7QM.js → chunk-OOOR7NT2.js} +1 -1
  50. package/dist-cli/chunks/{chunk-HAWOAQAG.js → chunk-P7WDNKOS.js} +3 -3
  51. package/dist-cli/chunks/{chunk-6TNANCQC.js → chunk-PPKKA5VW.js} +2 -2
  52. package/dist-cli/chunks/{chunk-JQ7ZXOXJ.js → chunk-PS2G44GT.js} +2 -2
  53. package/dist-cli/chunks/{chunk-ECJBV65H.js → chunk-QMSJR5R2.js} +2 -2
  54. package/dist-cli/chunks/{chunk-J2GYISVJ.js → chunk-RF4R2U46.js} +2 -2
  55. package/dist-cli/chunks/{chunk-VMXWC2JO.js → chunk-RIXUH3NK.js} +2 -2
  56. package/dist-cli/chunks/{chunk-2PY3UZVO.js → chunk-SFGUPL2X.js} +2 -2
  57. package/dist-cli/chunks/{chunk-572VSFNP.js → chunk-SQX5CAYG.js} +1 -1
  58. package/dist-cli/chunks/{chunk-NXATOWWF.js → chunk-SQZAC7C4.js} +1 -1
  59. package/dist-cli/chunks/{chunk-WTKTOL3C.js → chunk-SV7FOGJ3.js} +2 -2
  60. package/dist-cli/chunks/{chunk-JHJNODXN.js → chunk-TK3OJSEO.js} +2 -2
  61. package/dist-cli/chunks/{chunk-KASUZ5XV.js → chunk-TL7SIZ7S.js} +1 -1
  62. package/dist-cli/chunks/{chunk-6XZOEBTZ.js → chunk-V2GQ4WXJ.js} +2 -2
  63. package/dist-cli/chunks/{chunk-IP3QJLRH.js → chunk-VH7F45CN.js} +1 -1
  64. package/dist-cli/chunks/chunk-WNVNU2OW.js +4 -0
  65. package/dist-cli/chunks/{chunk-YUELRHGB.js → chunk-XQ2OBHBE.js} +2 -2
  66. package/dist-cli/chunks/{chunk-CYV6Y6YV.js → chunk-YCIA4BHJ.js} +2 -2
  67. package/dist-cli/chunks/chunk-ZSMMJMPA.js +1 -0
  68. package/dist-cli/chunks/cli-version-QB4VH24H.js +2 -0
  69. package/dist-cli/chunks/{compat-QLLWBTS3.js → compat-FWSEEGEH.js} +3 -3
  70. package/dist-cli/chunks/{config-2DSLDCXV.js → config-CYI2WAGP.js} +2 -2
  71. package/dist-cli/chunks/control-UXY7YQVX.js +2 -0
  72. package/dist-cli/chunks/{cpu-profile-GEIKHCPC.js → cpu-profile-IKAE3KTY.js} +2 -2
  73. package/dist-cli/chunks/{daemon-4EBUFN4D.js → daemon-ZUMF53YB.js} +2 -2
  74. package/dist-cli/chunks/{debug-WGD6XWOF.js → debug-P6KULKKS.js} +3 -3
  75. package/dist-cli/chunks/{detox-LNKGRZU6.js → detox-SPWAZCYG.js} +2 -2
  76. package/dist-cli/chunks/{device-AYKXKVIQ.js → device-JWEPK6I2.js} +2 -2
  77. package/dist-cli/chunks/{diagnose-TMXSDOOC.js → diagnose-IZODTXV2.js} +2 -2
  78. package/dist-cli/chunks/drivers-MK6WJKBC.js +2 -0
  79. package/dist-cli/chunks/{electron-QFPF7TBY.js → electron-R5GP6RVB.js} +3 -3
  80. package/dist-cli/chunks/flow-6O4GEOPJ.js +2 -0
  81. package/dist-cli/chunks/{hints-MXKRR4TG.js → hints-DYDNYX7N.js} +2 -2
  82. package/dist-cli/chunks/{home-paths-REMWQDAO.js → home-paths-GLMX5OKL.js} +2 -2
  83. package/dist-cli/chunks/{inspect-XGSQNFV7.js → inspect-FJOPCTY2.js} +3 -3
  84. package/dist-cli/chunks/install-A3TUGGHN.js +2 -0
  85. package/dist-cli/chunks/{install-desktop-NQG3RZSA.js → install-desktop-YPJZMZM5.js} +3 -3
  86. package/dist-cli/chunks/{keys-5QZWXL3F.js → keys-GSYPHWNY.js} +2 -2
  87. package/dist-cli/chunks/{launch-SBXOZWKO.js → launch-4G2PKW5X.js} +3 -3
  88. package/dist-cli/chunks/{login-EACQXE24.js → login-KJQGHA64.js} +4 -4
  89. package/dist-cli/chunks/{logout-IBQLMUML.js → logout-XM2SYH5C.js} +2 -2
  90. package/dist-cli/chunks/{maestro-LFYXUX7O.js → maestro-EOWGI7DG.js} +2 -2
  91. package/dist-cli/chunks/{preview-U4SBOEGQ.js → preview-F73TKK37.js} +2 -2
  92. package/dist-cli/chunks/{profile-GWS5ECMY.js → profile-22FDKBUO.js} +2 -2
  93. package/dist-cli/chunks/{react-QDHLMVYL.js → react-5L6VPFUP.js} +2 -2
  94. package/dist-cli/chunks/{record-BUEUWPDI.js → record-JZXCQ4IN.js} +2 -2
  95. package/dist-cli/chunks/runtime-EEBX7CFV.js +2 -0
  96. package/dist-cli/chunks/{runtime-delivery-G7L6RVZ7.js → runtime-delivery-LXUM3R4A.js} +2 -2
  97. package/dist-cli/chunks/{screenshot-T2HBA3VI.js → screenshot-HDRRG33Q.js} +2 -2
  98. package/dist-cli/chunks/{screenshot-mode-EG5HMIH3.js → screenshot-mode-WY63LZIX.js} +2 -2
  99. package/dist-cli/chunks/{screenshots-S52AFHTV.js → screenshots-MPV2ENL5.js} +2 -2
  100. package/dist-cli/chunks/{server-MFFVYUGG.js → server-5LBMCJ3G.js} +2 -2
  101. package/dist-cli/chunks/setup-repo-SZSYNKNI.js +2 -0
  102. package/dist-cli/chunks/{skills-HQGWBS2O.js → skills-BQ73YOBF.js} +2 -2
  103. package/dist-cli/chunks/{start-E3DRYY7W.js → start-2WU4W6ZU.js} +4 -4
  104. package/dist-cli/chunks/store-RE45SUBF.js +2 -0
  105. package/dist-cli/chunks/telemetry-DG6GJLCP.js +2 -0
  106. package/dist-cli/chunks/{test-ZY3EF62K.js → test-OVO4CQTG.js} +3 -3
  107. package/dist-cli/chunks/{three-mode-WSPKQCJ5.js → three-mode-BKM3KFM7.js} +2 -2
  108. package/dist-cli/chunks/{timeline-3XAB5EWZ.js → timeline-MDXGEDQL.js} +2 -2
  109. package/dist-cli/chunks/{upgrade-WNENPFM5.js → upgrade-JGQABWVF.js} +2 -2
  110. package/dist-cli/chunks/upload-UJNUA4ZV.js +2 -0
  111. package/dist-cli/chunks/{web-D2AOZY44.js → web-WYFAYQ72.js} +2 -2
  112. package/dist-cli/chunks/{what-happened-F43KNSG6.js → what-happened-PZW2KW6A.js} +2 -2
  113. package/dist-cli/chunks/{whoami-T22VBR7C.js → whoami-7ATWJQS6.js} +2 -2
  114. package/dist-lib/agent-daemon-client.cjs +1 -1
  115. package/dist-lib/agent-events.cjs +1 -1
  116. package/dist-lib/agent-sessions.cjs +1 -1
  117. package/dist-lib/attached-projects.cjs +1 -1
  118. package/dist-lib/auth/shared-session.cjs +1 -1
  119. package/dist-lib/backend-origin.cjs +1 -1
  120. package/dist-lib/beta.cjs +44 -0
  121. package/dist-lib/bridge-constants.cjs +1 -1
  122. package/dist-lib/cli-constants.cjs +1 -1
  123. package/dist-lib/config.cjs +1 -1
  124. package/dist-lib/detox/index.cjs +1770 -0
  125. package/dist-lib/detox/jest-preset.cjs +50 -0
  126. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  127. package/dist-lib/home-paths.cjs +1 -1
  128. package/dist-lib/host/bridge-host.cjs +1 -1
  129. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  130. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  131. package/dist-lib/index.cjs +1 -1
  132. package/dist-lib/metro.cjs +1 -1
  133. package/dist-lib/profiles.cjs +1 -1
  134. package/dist-lib/render-mode.cjs +1 -1
  135. package/dist-lib/scripts/demo-app-registry.cjs +809 -0
  136. package/dist-lib/scripts/dev-server-scanner.cjs +1269 -0
  137. package/dist-lib/skills.cjs +8322 -0
  138. package/dist-lib/vite-base.cjs +3 -3
  139. package/dist-lib/vite.cjs +1 -1
  140. package/package.json +39 -10
  141. package/scripts/demo-app-registry.ts +989 -0
  142. package/scripts/dev-server-scanner.ts +674 -0
  143. package/src/agent-daemon-client.ts +390 -0
  144. package/src/agent-events.ts +71 -0
  145. package/src/agent-prompt.ts +71 -0
  146. package/src/agent-sessions.ts +572 -0
  147. package/src/attached-projects.ts +536 -0
  148. package/src/auth/shared-session.ts +199 -0
  149. package/src/backend-origin.ts +49 -0
  150. package/src/beta.ts +21 -0
  151. package/src/bridge-constants.ts +10 -0
  152. package/src/cli-constants.ts +1 -0
  153. package/src/cli-version.ts +30 -0
  154. package/src/codex-client.ts +215 -0
  155. package/src/config.ts +110 -0
  156. package/src/dev-bundle-resolution.ts +180 -0
  157. package/src/home-paths.ts +382 -0
  158. package/src/host/agent-host.ts +576 -0
  159. package/src/host/bridge-host.ts +2293 -0
  160. package/src/host/fetch-proxy-handler.ts +288 -0
  161. package/src/host/fetch-proxy-overrides.ts +39 -0
  162. package/src/host/open-url.ts +234 -0
  163. package/src/index.ts +9 -0
  164. package/src/metro-plugin.ts +207 -0
  165. package/src/native-dev-bundle-url.ts +62 -0
  166. package/src/native-seam-manifest.ts +313 -0
  167. package/src/profiles.ts +179 -0
  168. package/src/render-mode.ts +27 -0
  169. package/src/runtime-delivery.ts +334 -0
  170. package/src/screenshots/compose.ts +422 -0
  171. package/src/screenshots/frame-compose.ts +438 -0
  172. package/src/screenshots/orchestrate.ts +244 -0
  173. package/src/screenshots/registry.ts +58 -0
  174. package/src/screenshots/schema.ts +364 -0
  175. package/src/skills/builtin/a11y-review.ts +126 -0
  176. package/src/skills/builtin/compat-check.ts +104 -0
  177. package/src/skills/builtin/perf-profile.ts +84 -0
  178. package/src/skills/builtin/screenshot-all.ts +46 -0
  179. package/src/skills/builtin/test-flow.ts +118 -0
  180. package/src/skills/builtin/visual-diff.ts +94 -0
  181. package/src/skills/registry.ts +107 -0
  182. package/src/skills/types.ts +41 -0
  183. package/src/vite-plugin-one.ts +187 -0
  184. package/src/vite-plugin.ts +1381 -0
  185. package/src/worklets-babel.ts +132 -0
  186. package/dist-cli/chunks/auto-bootstrap-FQS4ZD2K.js +0 -2
  187. package/dist-cli/chunks/beta-VG7CDY2U.js +0 -2
  188. package/dist-cli/chunks/chunk-2OIBDYHW.js +0 -1
  189. package/dist-cli/chunks/chunk-6BNLVMXA.js +0 -1
  190. package/dist-cli/chunks/chunk-6XD6CBJM.js +0 -2
  191. package/dist-cli/chunks/chunk-CHQTO426.js +0 -1
  192. package/dist-cli/chunks/chunk-FAPYGVIU.js +0 -4
  193. package/dist-cli/chunks/chunk-PEHFE3LG.js +0 -64
  194. package/dist-cli/chunks/chunk-RXH2SLKF.js +0 -2
  195. package/dist-cli/chunks/chunk-UXQWC5ZR.js +0 -79
  196. package/dist-cli/chunks/chunk-XFQL74PF.js +0 -5
  197. package/dist-cli/chunks/cli-version-PWF6I6LY.js +0 -2
  198. package/dist-cli/chunks/control-UIOXGYXU.js +0 -2
  199. package/dist-cli/chunks/demo-app-registry-G3BDOFWC.js +0 -2
  200. package/dist-cli/chunks/drivers-IDQF34HP.js +0 -2
  201. package/dist-cli/chunks/flow-3JN3Y7RF.js +0 -2
  202. package/dist-cli/chunks/install-2N3YOOSN.js +0 -2
  203. package/dist-cli/chunks/runtime-PVB4VGUH.js +0 -2
  204. package/dist-cli/chunks/setup-repo-YOF7NV5D.js +0 -2
  205. package/dist-cli/chunks/store-MAI6D3UO.js +0 -2
  206. package/dist-cli/chunks/telemetry-RCQKCJTH.js +0 -2
  207. package/dist-cli/chunks/upload-YLJ4RA73.js +0 -2
@@ -0,0 +1,572 @@
1
+ // session lifecycle shared between the CLI (`sootsim agent ...`) and electron
2
+ // main (IPC handlers). both contexts spawn the same `sootsim agent-wrapper`
3
+ // subcommand, mutate the same AttachedProjects store, and subscribe to the
4
+ // same events.out FIFO.
5
+ //
6
+ // this file is the canonical implementation; thin wrappers in the CLI and
7
+ // electron layers call these functions for their UX needs.
8
+
9
+ import { spawn, spawnSync } from 'node:child_process'
10
+ import { randomUUID } from 'node:crypto'
11
+ import fs, { constants as fsConstants } from 'node:fs'
12
+ import path from 'node:path'
13
+ import readline from 'node:readline'
14
+ import { parseAgentEventLine, type AgentEvent } from './agent-events.ts'
15
+ import { encodeAgentPromptEnvelope, type AgentPromptEnvelope } from './agent-prompt.ts'
16
+ import {
17
+ findProjectById,
18
+ findSessionById,
19
+ getUserDataDir,
20
+ listSessions,
21
+ updateSessionStatus,
22
+ upsertSession,
23
+ type AgentSession,
24
+ } from './attached-projects.ts'
25
+
26
+ export type Provider = 'codex' | 'claude'
27
+
28
+ // --- fs layout ---
29
+
30
+ export function sessionDir(sessionId: string): string {
31
+ return path.join(getUserDataDir(), 'sessions', sessionId)
32
+ }
33
+ export function promptFifoPath(sessionId: string): string {
34
+ return path.join(sessionDir(sessionId), 'prompt.in')
35
+ }
36
+ export function eventsFifoPath(sessionId: string): string {
37
+ return path.join(sessionDir(sessionId), 'events.out')
38
+ }
39
+ export function transcriptPath(sessionId: string): string {
40
+ return path.join(getUserDataDir(), 'transcripts', `${sessionId}.log`)
41
+ }
42
+
43
+ // --- pid helpers ---
44
+
45
+ /** returns true iff a process with this pid is alive AND owned by us. pid
46
+ * recycling means kill(0) can return true for an unrelated process that
47
+ * happened to take the same number — we also require the sentinel file
48
+ * written by the wrapper at startup to still exist. */
49
+ export function pidIsAlive(pid: number | undefined, sessionId?: string): boolean {
50
+ if (!pid) return false
51
+ try {
52
+ process.kill(pid, 0)
53
+ } catch {
54
+ return false
55
+ }
56
+ if (sessionId) {
57
+ // sentinel: the events.out fifo's session dir. if the session was ended
58
+ // via cmdEnd, the dir was removed; kill(0) returning true on that pid
59
+ // would be a recycled pid from an unrelated process.
60
+ if (!fs.existsSync(sessionDir(sessionId))) return false
61
+ }
62
+ return true
63
+ }
64
+
65
+ // --- invocation resolution ---
66
+
67
+ /** figure out how to re-invoke the sootsim CLI to run `agent-wrapper`.
68
+ * priority:
69
+ * 1. SOOTSIM_BIN env var (explicit override)
70
+ * 2. electron prod: bundled binary under process.resourcesPath/bin/
71
+ * 3. workspace build artifacts (dist-bin/<binary> or dist-cli/bin.js)
72
+ * — shared between electron dev, the vite-plugin-hosted daemon, and
73
+ * any other node process that isn't launched with the CLI entry
74
+ * script directly (e.g. argv[1] = .../node_modules/.bin/vite).
75
+ * 4. CLI dev (argv[1] points at the entry script): re-use argv[0] + argv[1]
76
+ * 5. CLI standalone: argv[0] is the compiled binary, no prefix needed
77
+ */
78
+ export function resolveSootsimInvocation(): { cmd: string; prefixArgs: string[] } {
79
+ if (process.env.SOOTSIM_BIN) {
80
+ return { cmd: process.env.SOOTSIM_BIN, prefixArgs: [] }
81
+ }
82
+ // electron prod: packaged binary lives under Resources/bin/.
83
+ if (process.versions.electron) {
84
+ const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string })
85
+ .resourcesPath
86
+ if (resourcesPath) {
87
+ const candidates = [
88
+ path.join(resourcesPath, 'bin', 'sootsim'),
89
+ path.join(resourcesPath, 'bin', `sootsim-${process.platform}-${process.arch}`),
90
+ ]
91
+ for (const c of candidates) {
92
+ if (fs.existsSync(c)) return { cmd: c, prefixArgs: [] }
93
+ }
94
+ }
95
+ }
96
+
97
+ // workspace build artifacts — tried for every non-CLI-entry node context
98
+ // (electron dev, vite-plugin-hosted daemon, random script) before falling
99
+ // back to argv. prefers the compiled bun binary because `bun dev` runs
100
+ // `watch:cli:binary` and that stays fresh; dist-cli/bin.js is the node
101
+ // fallback but only `watch:cli` (not in the default dev graph) refreshes
102
+ // it, so we warn when it's older than agent-wrapper.ts.
103
+ const workspace = tryWorkspaceSootsim()
104
+ if (workspace) return workspace
105
+
106
+ // argv fallback. only trust it when argv[1] looks like a real entry
107
+ // script — `.js`/`.ts`/`.mjs` etc. launching vite gives us argv[1] =
108
+ // `…/node_modules/.bin/vite` (no extension), which would make us try
109
+ // to run agent-wrapper under the vite shim and silently fail.
110
+ const argv0 = process.argv[0]
111
+ const argv1 = process.argv[1]
112
+ if (argv1 && /\.(ts|tsx|mjs|cjs|js)$/.test(argv1)) {
113
+ return { cmd: argv0, prefixArgs: [argv1] }
114
+ }
115
+ if (!argv1 || argv1.includes('/.bin/')) {
116
+ // bin-shim case falls through to the error below instead of silently
117
+ // running `node` without args.
118
+ throw new Error(
119
+ 'sootsim CLI not found. set SOOTSIM_BIN to the path of the sootsim binary, ' +
120
+ 'or build the workspace CLI via `bun run --cwd packages/sootsim build:cli`.',
121
+ )
122
+ }
123
+ return { cmd: argv0, prefixArgs: [] }
124
+ }
125
+
126
+ function tryWorkspaceSootsim(): { cmd: string; prefixArgs: string[] } | null {
127
+ try {
128
+ const sootsimDir = resolveSootsimPackageDir()
129
+ if (!sootsimDir) return null
130
+ const binaryName = `sootsim-${process.platform}-${process.arch}`
131
+ const distBinary = path.join(sootsimDir, 'dist-bin', binaryName)
132
+ if (fs.existsSync(distBinary)) return { cmd: distBinary, prefixArgs: [] }
133
+ const distBin = path.join(sootsimDir, 'dist-cli', 'bin.js')
134
+ if (fs.existsSync(distBin)) {
135
+ try {
136
+ const src = path.join(sootsimDir, 'cli', 'commands', 'agent-wrapper.ts')
137
+ if (fs.existsSync(src)) {
138
+ const srcMtime = fs.statSync(src).mtimeMs
139
+ const buildMtime = fs.statSync(distBin).mtimeMs
140
+ if (buildMtime < srcMtime) {
141
+ console.warn(
142
+ `[sootsim] dist-cli/bin.js is older than agent-wrapper.ts — ` +
143
+ `rebuild with \`bun run --cwd packages/sootsim build:cli\` ` +
144
+ `(watch:cli:binary builds dist-bin/ instead).`,
145
+ )
146
+ }
147
+ }
148
+ } catch {}
149
+ return { cmd: process.execPath, prefixArgs: [distBin] }
150
+ }
151
+ return null
152
+ } catch {
153
+ return null
154
+ }
155
+ }
156
+
157
+ /** locate the workspace sootsim package directory. tries `require.resolve`
158
+ * first (works in most contexts), then walks up from this module file
159
+ * looking for `packages/sootsim/package.json` (works when sootsim is
160
+ * loaded via vite's native TS resolution, where require.resolve doesn't
161
+ * know about the workspace). */
162
+ function resolveSootsimPackageDir(): string | null {
163
+ try {
164
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
165
+ const resolved = require.resolve('sootsim/package.json')
166
+ return path.dirname(resolved)
167
+ } catch {}
168
+ // walk up from this file: .../packages/sootsim/src/agent-sessions.ts
169
+ // → .../packages/sootsim/. the module path is whatever the runtime
170
+ // gave us; when bundled by esbuild this will be the bundle path, not
171
+ // the source path, which is why we try require.resolve first.
172
+ const here = fileFromImportMeta()
173
+ if (!here) return null
174
+ let cur = path.dirname(here)
175
+ for (let i = 0; i < 8; i++) {
176
+ const pkg = path.join(cur, 'package.json')
177
+ try {
178
+ if (fs.existsSync(pkg)) {
179
+ const parsed = JSON.parse(fs.readFileSync(pkg, 'utf8')) as { name?: string }
180
+ if (parsed.name === 'sootsim') return cur
181
+ }
182
+ } catch {}
183
+ const parent = path.dirname(cur)
184
+ if (parent === cur) break
185
+ cur = parent
186
+ }
187
+ return null
188
+ }
189
+
190
+ function fileFromImportMeta(): string | null {
191
+ try {
192
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
193
+ const url = (import.meta as unknown as { url?: string }).url
194
+ if (!url || !url.startsWith('file://')) return null
195
+ return decodeURIComponent(url.slice('file://'.length))
196
+ } catch {
197
+ return null
198
+ }
199
+ }
200
+
201
+ // --- lockfile for start races ---
202
+
203
+ async function withStartLock<T>(
204
+ projectId: string,
205
+ provider: Provider,
206
+ fn: () => Promise<T>,
207
+ ): Promise<T> {
208
+ const lockDir = path.join(getUserDataDir(), 'locks')
209
+ fs.mkdirSync(lockDir, { recursive: true })
210
+ try {
211
+ fs.chmodSync(lockDir, 0o700)
212
+ } catch {}
213
+ const lockPath = path.join(lockDir, `start-${projectId}-${provider}.lock`)
214
+ const deadline = Date.now() + 4000
215
+ let fd: number | null = null
216
+ while (fd === null) {
217
+ try {
218
+ fd = fs.openSync(
219
+ lockPath,
220
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL,
221
+ 0o600,
222
+ )
223
+ } catch (err) {
224
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
225
+ // staleness check: if the pid in the lockfile is dead, steal it
226
+ try {
227
+ const stale = Number(fs.readFileSync(lockPath, 'utf8').trim())
228
+ if (stale && !isProcessAlive(stale)) {
229
+ fs.unlinkSync(lockPath)
230
+ continue
231
+ }
232
+ } catch {}
233
+ if (Date.now() > deadline) {
234
+ throw new Error(
235
+ `another start is in progress for project=${projectId} provider=${provider} ` +
236
+ `(lock: ${lockPath})`,
237
+ )
238
+ }
239
+ await new Promise((r) => setTimeout(r, 50))
240
+ }
241
+ }
242
+ try {
243
+ fs.writeFileSync(fd, String(process.pid))
244
+ return await fn()
245
+ } finally {
246
+ try {
247
+ fs.closeSync(fd)
248
+ } catch {}
249
+ try {
250
+ fs.unlinkSync(lockPath)
251
+ } catch {}
252
+ }
253
+ }
254
+
255
+ function isProcessAlive(pid: number): boolean {
256
+ try {
257
+ process.kill(pid, 0)
258
+ return true
259
+ } catch {
260
+ return false
261
+ }
262
+ }
263
+
264
+ // --- FIFO helpers ---
265
+
266
+ export function mkfifoSync(p: string): void {
267
+ const parent = path.dirname(p)
268
+ fs.mkdirSync(parent, { recursive: true })
269
+ try {
270
+ fs.chmodSync(parent, 0o700)
271
+ } catch {}
272
+ if (fs.existsSync(p)) {
273
+ try {
274
+ const stat = fs.statSync(p)
275
+ if (stat.isFIFO()) {
276
+ try {
277
+ fs.chmodSync(p, 0o600)
278
+ } catch {}
279
+ return
280
+ }
281
+ fs.unlinkSync(p)
282
+ } catch {
283
+ fs.unlinkSync(p)
284
+ }
285
+ }
286
+ const result = spawnSync('mkfifo', ['-m', '600', p])
287
+ if (result.status !== 0) {
288
+ throw new Error(
289
+ `mkfifo(${p}) failed: ${result.stderr?.toString().trim() || 'unknown error'}`,
290
+ )
291
+ }
292
+ }
293
+
294
+ // --- session lifecycle ---
295
+
296
+ export interface StartSessionOpts {
297
+ projectId: string
298
+ provider?: Provider
299
+ codexBin?: string
300
+ claudeBin?: string
301
+ freshThread?: boolean
302
+ readyTimeoutMs?: number
303
+ }
304
+
305
+ export interface StartSessionResult {
306
+ session: AgentSession
307
+ /** child pid — the same value stored on session.wrapperPid */
308
+ wrapperPid: number
309
+ }
310
+
311
+ export class AgentSessionError extends Error {
312
+ code: string
313
+ constructor(code: string, message: string) {
314
+ super(message)
315
+ this.code = code
316
+ }
317
+ }
318
+
319
+ export async function startSession(opts: StartSessionOpts): Promise<StartSessionResult> {
320
+ const project = findProjectById(opts.projectId)
321
+ if (!project) {
322
+ throw new AgentSessionError('NO_PROJECT', `no project with id=${opts.projectId}`)
323
+ }
324
+ const provider: Provider = opts.provider || project.preferredProvider || 'codex'
325
+
326
+ return withStartLock(project.id, provider, async () => {
327
+ // re-check inside the lock. a concurrent start that slipped through the
328
+ // pre-check can't slip through both the check AND the lock.
329
+ const existingLive = listSessions(project.id).find(
330
+ (s) =>
331
+ s.provider === provider && s.status !== 'ended' && pidIsAlive(s.wrapperPid, s.id),
332
+ )
333
+ if (existingLive) {
334
+ throw new AgentSessionError(
335
+ 'ALREADY_RUNNING',
336
+ `session already running for project=${project.id} provider=${provider} ` +
337
+ `(session ${existingLive.id}, pid ${existingLive.wrapperPid}). ` +
338
+ 'end it first with `sootsim agent end <sessionId>`.',
339
+ )
340
+ }
341
+
342
+ // generate the claude session uuid once and persist it so successive
343
+ // wrapper restarts resume the same `~/.claude/projects/<cwd>/<uuid>.jsonl`
344
+ // file. codex has its own thread-id persistence via `thread/list`, so we
345
+ // only need this for claude.
346
+ const claudeSessionUuid = provider === 'claude' ? randomUUID() : undefined
347
+
348
+ const session = upsertSession({
349
+ projectId: project.id,
350
+ provider,
351
+ transport: 'pty',
352
+ cwd: project.cwd,
353
+ status: 'idle',
354
+ claudeSessionUuid,
355
+ })
356
+
357
+ const promptIn = promptFifoPath(session.id)
358
+ const eventsOut = eventsFifoPath(session.id)
359
+ const transcript = transcriptPath(session.id)
360
+ mkfifoSync(promptIn)
361
+ mkfifoSync(eventsOut)
362
+ // transcripts dir locked too
363
+ const transcriptDir = path.dirname(transcript)
364
+ fs.mkdirSync(transcriptDir, { recursive: true })
365
+ try {
366
+ fs.chmodSync(transcriptDir, 0o700)
367
+ } catch {}
368
+
369
+ const { cmd, prefixArgs } = resolveSootsimInvocation()
370
+ const wrapperArgs = [
371
+ ...prefixArgs,
372
+ 'agent-wrapper',
373
+ '--session-id',
374
+ session.id,
375
+ '--project-id',
376
+ project.id,
377
+ '--provider',
378
+ provider,
379
+ '--cwd',
380
+ project.cwd,
381
+ '--prompt-in',
382
+ promptIn,
383
+ '--events-out',
384
+ eventsOut,
385
+ '--transcript',
386
+ transcript,
387
+ ]
388
+ if (opts.codexBin) wrapperArgs.push('--codex-bin', opts.codexBin)
389
+ if (opts.claudeBin) wrapperArgs.push('--claude-bin', opts.claudeBin)
390
+ if (opts.freshThread) wrapperArgs.push('--fresh-thread')
391
+ if (claudeSessionUuid) {
392
+ wrapperArgs.push('--claude-session-uuid', claudeSessionUuid)
393
+ }
394
+
395
+ const child = spawn(cmd, wrapperArgs, {
396
+ detached: true,
397
+ stdio: 'ignore',
398
+ env: {
399
+ ...process.env,
400
+ SOOTSIM_USER_DATA_DIR: getUserDataDir(),
401
+ },
402
+ })
403
+ child.unref()
404
+
405
+ const readyTimeout = opts.readyTimeoutMs ?? 6000
406
+ const boot = await waitForFirstEvent(
407
+ eventsOut,
408
+ (e) => e.type === 'ready' || e.type === 'error',
409
+ readyTimeout,
410
+ )
411
+ if (!boot || boot.type === 'error') {
412
+ if (child.pid) {
413
+ try {
414
+ process.kill(child.pid, 'SIGTERM')
415
+ } catch {}
416
+ }
417
+ try {
418
+ fs.rmSync(sessionDir(session.id), { recursive: true, force: true })
419
+ } catch {}
420
+ updateSessionStatus(session.id, { status: 'ended' })
421
+ const reason =
422
+ boot && boot.type === 'error'
423
+ ? boot.message
424
+ : `no ready event within ${readyTimeout}ms`
425
+ throw new AgentSessionError('WRAPPER_FAILED', reason)
426
+ }
427
+
428
+ updateSessionStatus(session.id, {
429
+ wrapperPid: child.pid,
430
+ status: 'idle',
431
+ })
432
+ const updated = findSessionById(session.id)!
433
+ return { session: updated, wrapperPid: child.pid! }
434
+ })
435
+ }
436
+
437
+ export async function sendPrompt(
438
+ sessionId: string,
439
+ prompt: AgentPromptEnvelope,
440
+ ): Promise<void> {
441
+ const session = findSessionById(sessionId)
442
+ if (!session) {
443
+ throw new AgentSessionError('NO_SESSION', `no session with id=${sessionId}`)
444
+ }
445
+ if (!pidIsAlive(session.wrapperPid, sessionId)) {
446
+ updateSessionStatus(sessionId, { status: 'ended' })
447
+ throw new AgentSessionError(
448
+ 'NOT_ALIVE',
449
+ `session wrapper is not alive (pid=${session.wrapperPid}). start a new session.`,
450
+ )
451
+ }
452
+ const fifo = promptFifoPath(sessionId)
453
+ if (!fs.existsSync(fifo)) {
454
+ throw new AgentSessionError('NO_FIFO', `prompt FIFO missing: ${fifo}`)
455
+ }
456
+ const fd = fs.openSync(fifo, fsConstants.O_WRONLY)
457
+ try {
458
+ const wireText = encodeAgentPromptEnvelope(prompt)
459
+ if (!wireText) {
460
+ throw new AgentSessionError('EMPTY_PROMPT', 'prompt text is empty')
461
+ }
462
+ fs.writeSync(fd, wireText + '\n')
463
+ } finally {
464
+ fs.closeSync(fd)
465
+ }
466
+ updateSessionStatus(sessionId, {
467
+ lastPrompt: prompt.displayText ?? prompt.text,
468
+ status: 'working',
469
+ })
470
+ }
471
+
472
+ export async function endSession(sessionId: string): Promise<void> {
473
+ const session = findSessionById(sessionId)
474
+ if (!session) {
475
+ throw new AgentSessionError('NO_SESSION', `no session with id=${sessionId}`)
476
+ }
477
+ if (pidIsAlive(session.wrapperPid, sessionId)) {
478
+ try {
479
+ process.kill(session.wrapperPid!, 'SIGTERM')
480
+ } catch {}
481
+ }
482
+ // guard rm: only delete paths we fully own, derived from getUserDataDir()
483
+ const dir = sessionDir(sessionId)
484
+ const base = getUserDataDir()
485
+ if (dir.startsWith(base)) {
486
+ try {
487
+ fs.rmSync(dir, { recursive: true, force: true })
488
+ } catch {}
489
+ }
490
+ updateSessionStatus(sessionId, { status: 'ended', wrapperPid: undefined })
491
+ }
492
+
493
+ // --- event subscription ---
494
+
495
+ /** open events.out and stream events to onEvent. returns an unsubscribe fn.
496
+ * this is a single-reader channel — only ONE caller (the bridge daemon's
497
+ * AgentHost) should hold a live subscription at a time. every other
498
+ * consumer (electron, cli watch, browser shell) routes through the daemon
499
+ * via the agent:* ws protocol, where the daemon fans events out to N
500
+ * subscribers without re-reading the FIFO. */
501
+ export function subscribeEvents(
502
+ sessionId: string,
503
+ onEvent: (event: AgentEvent) => void,
504
+ ): () => void {
505
+ const fifo = eventsFifoPath(sessionId)
506
+ if (!fs.existsSync(fifo)) {
507
+ throw new AgentSessionError('NO_FIFO', `events FIFO missing: ${fifo}`)
508
+ }
509
+ // O_RDWR keeps the FIFO open even if every writer closes — without it,
510
+ // the wrapper's first flush-then-close between turns would EOF the
511
+ // read stream. autoClose: true lets stream.destroy() close the fd
512
+ // synchronously, so unsubscribe doesn't have to race a separate
513
+ // fs.closeSync against an in-flight fs.read syscall (which wedges on
514
+ // bun under O_RDWR FIFOs).
515
+ const fd = fs.openSync(fifo, fsConstants.O_RDWR)
516
+ const stream = fs.createReadStream('', { fd, autoClose: true })
517
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
518
+ rl.on('line', (line) => {
519
+ const event = parseAgentEventLine(line)
520
+ if (event) onEvent(event)
521
+ })
522
+ let closed = false
523
+ return () => {
524
+ if (closed) return
525
+ closed = true
526
+ try {
527
+ rl.close()
528
+ } catch {}
529
+ try {
530
+ stream.destroy()
531
+ } catch {}
532
+ }
533
+ }
534
+
535
+ // --- wait for a specific event (used by startSession + watch tools) ---
536
+
537
+ async function waitForFirstEvent(
538
+ fifo: string,
539
+ predicate: (e: AgentEvent) => boolean,
540
+ timeoutMs: number,
541
+ ): Promise<AgentEvent | null> {
542
+ const fd = fs.openSync(fifo, fsConstants.O_RDWR | fsConstants.O_NONBLOCK)
543
+ const buf = Buffer.alloc(8192)
544
+ let leftover = ''
545
+ const deadline = Date.now() + timeoutMs
546
+ try {
547
+ while (Date.now() < deadline) {
548
+ let n = 0
549
+ try {
550
+ n = fs.readSync(fd, buf, 0, buf.length, null)
551
+ } catch (err) {
552
+ if ((err as NodeJS.ErrnoException).code !== 'EAGAIN') throw err
553
+ n = 0
554
+ }
555
+ if (n > 0) {
556
+ leftover += buf.subarray(0, n).toString('utf8')
557
+ let idx: number
558
+ while ((idx = leftover.indexOf('\n')) >= 0) {
559
+ const line = leftover.slice(0, idx)
560
+ leftover = leftover.slice(idx + 1)
561
+ const event = parseAgentEventLine(line)
562
+ if (event && predicate(event)) return event
563
+ }
564
+ } else {
565
+ await new Promise((r) => setTimeout(r, 30))
566
+ }
567
+ }
568
+ return null
569
+ } finally {
570
+ fs.closeSync(fd)
571
+ }
572
+ }