sootsim 0.1.82 → 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 (209) 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-3C6Z6YXA.js → agent-2CWD6W6P.js} +2 -2
  21. package/dist-cli/chunks/{agent-wrapper-7Z4UFACX.js → agent-wrapper-5W3LOX6S.js} +2 -2
  22. package/dist-cli/chunks/{assert-XYBIZRDK.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-EJGEDUOC.js → chunk-4K7BH2D4.js} +3 -3
  27. package/dist-cli/chunks/{chunk-2EFQQWEC.js → chunk-4OPRODFA.js} +2 -2
  28. package/dist-cli/chunks/{chunk-Z6G5SDG7.js → chunk-4OWVPRZV.js} +2 -2
  29. package/dist-cli/chunks/{chunk-DCFGNIJC.js → chunk-5XCXOLG2.js} +2 -2
  30. package/dist-cli/chunks/chunk-67ZZ2CM5.js +1 -0
  31. package/dist-cli/chunks/{chunk-M3OULYY3.js → chunk-73UZXB4B.js} +2 -2
  32. package/dist-cli/chunks/{chunk-QPDWMYCA.js → chunk-7NWNTUJF.js} +1 -1
  33. package/dist-cli/chunks/chunk-7YHDJLO2.js +119 -0
  34. package/dist-cli/chunks/{chunk-EX6IOT23.js → chunk-AJVTY6KY.js} +2 -2
  35. package/dist-cli/chunks/chunk-AWSQUOAS.js +67 -0
  36. package/dist-cli/chunks/{chunk-JVNGH5S7.js → chunk-BCBNVJVG.js} +1 -1
  37. package/dist-cli/chunks/{chunk-WZLKUS54.js → chunk-BKBL6K2G.js} +1 -1
  38. package/dist-cli/chunks/{chunk-DSYW2NOW.js → chunk-C3DPQZ4J.js} +2 -2
  39. package/dist-cli/chunks/chunk-D3ZSBIIY.js +2 -0
  40. package/dist-cli/chunks/{chunk-PYDAVGCZ.js → chunk-D4HUVLZR.js} +1 -1
  41. package/dist-cli/chunks/{chunk-H6NBDJIO.js → chunk-DUUSJDES.js} +1 -1
  42. package/dist-cli/chunks/{chunk-BVXP2GDN.js → chunk-ELJLF4SG.js} +3 -3
  43. package/dist-cli/chunks/{chunk-TR7NIFSL.js → chunk-EQ7TFQ2F.js} +1 -1
  44. package/dist-cli/chunks/{chunk-UOWBKSSI.js → chunk-EQCKGC4B.js} +1 -1
  45. package/dist-cli/chunks/chunk-FUCGLWNN.js +1 -0
  46. package/dist-cli/chunks/{chunk-BISEHRNE.js → chunk-HYPJW65U.js} +2 -2
  47. package/dist-cli/chunks/chunk-IILJQCZA.js +2 -0
  48. package/dist-cli/chunks/{chunk-2XULSYS6.js → chunk-KU6MSPAH.js} +2 -2
  49. package/dist-cli/chunks/{chunk-QTJJHBCI.js → chunk-OOOR7NT2.js} +1 -1
  50. package/dist-cli/chunks/{chunk-U3XCDQRH.js → chunk-P7WDNKOS.js} +3 -3
  51. package/dist-cli/chunks/{chunk-C7JOLDDQ.js → chunk-PPKKA5VW.js} +2 -2
  52. package/dist-cli/chunks/{chunk-JUCV3VHM.js → chunk-PS2G44GT.js} +2 -2
  53. package/dist-cli/chunks/{chunk-PO64TMRT.js → chunk-QMSJR5R2.js} +2 -2
  54. package/dist-cli/chunks/{chunk-4QUAOBUB.js → chunk-RF4R2U46.js} +2 -2
  55. package/dist-cli/chunks/{chunk-D3SM2JYB.js → chunk-RIXUH3NK.js} +2 -2
  56. package/dist-cli/chunks/{chunk-2JQIKL3B.js → chunk-SFGUPL2X.js} +2 -2
  57. package/dist-cli/chunks/{chunk-GI5MF6LP.js → chunk-SQX5CAYG.js} +1 -1
  58. package/dist-cli/chunks/{chunk-Q4JNA5VO.js → chunk-SQZAC7C4.js} +1 -1
  59. package/dist-cli/chunks/{chunk-M4ERVRM4.js → chunk-SV7FOGJ3.js} +2 -2
  60. package/dist-cli/chunks/{chunk-ZN2C7V5R.js → chunk-TK3OJSEO.js} +2 -2
  61. package/dist-cli/chunks/{chunk-7SCQEPXK.js → chunk-TL7SIZ7S.js} +1 -1
  62. package/dist-cli/chunks/{chunk-IZ2OO47Y.js → chunk-V2GQ4WXJ.js} +2 -2
  63. package/dist-cli/chunks/{chunk-JUDJXJSE.js → chunk-VH7F45CN.js} +1 -1
  64. package/dist-cli/chunks/chunk-WNVNU2OW.js +4 -0
  65. package/dist-cli/chunks/{chunk-O3AOQP3V.js → chunk-XQ2OBHBE.js} +2 -2
  66. package/dist-cli/chunks/{chunk-MQXYJTXM.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-2DVSCCR7.js → compat-FWSEEGEH.js} +3 -3
  70. package/dist-cli/chunks/{config-YDX4Q4XM.js → config-CYI2WAGP.js} +2 -2
  71. package/dist-cli/chunks/control-UXY7YQVX.js +2 -0
  72. package/dist-cli/chunks/{cpu-profile-GU62WVZZ.js → cpu-profile-IKAE3KTY.js} +2 -2
  73. package/dist-cli/chunks/{daemon-V5NLDTSB.js → daemon-ZUMF53YB.js} +2 -2
  74. package/dist-cli/chunks/{debug-HAOCONNB.js → debug-P6KULKKS.js} +3 -3
  75. package/dist-cli/chunks/{detox-YLC4DLXB.js → detox-SPWAZCYG.js} +2 -2
  76. package/dist-cli/chunks/{device-ZQ4DN4H6.js → device-JWEPK6I2.js} +2 -2
  77. package/dist-cli/chunks/{diagnose-HNUO3Z5F.js → diagnose-IZODTXV2.js} +2 -2
  78. package/dist-cli/chunks/drivers-MK6WJKBC.js +2 -0
  79. package/dist-cli/chunks/{electron-ZAASAHSW.js → electron-R5GP6RVB.js} +3 -3
  80. package/dist-cli/chunks/flow-6O4GEOPJ.js +2 -0
  81. package/dist-cli/chunks/{hints-BA3GE5W5.js → hints-DYDNYX7N.js} +2 -2
  82. package/dist-cli/chunks/{home-paths-MQXRHBTW.js → home-paths-GLMX5OKL.js} +2 -2
  83. package/dist-cli/chunks/{inspect-T4RMS5KX.js → inspect-FJOPCTY2.js} +3 -3
  84. package/dist-cli/chunks/install-A3TUGGHN.js +2 -0
  85. package/dist-cli/chunks/{install-desktop-MH26VONS.js → install-desktop-YPJZMZM5.js} +3 -3
  86. package/dist-cli/chunks/{keys-IELIDRGB.js → keys-GSYPHWNY.js} +2 -2
  87. package/dist-cli/chunks/{launch-VMT3OWOB.js → launch-4G2PKW5X.js} +3 -3
  88. package/dist-cli/chunks/{login-VZBANVLU.js → login-KJQGHA64.js} +4 -4
  89. package/dist-cli/chunks/{logout-GWXBTQ4H.js → logout-XM2SYH5C.js} +2 -2
  90. package/dist-cli/chunks/{maestro-JYHR4HFR.js → maestro-EOWGI7DG.js} +2 -2
  91. package/dist-cli/chunks/{preview-RPZ4UQ2B.js → preview-F73TKK37.js} +2 -2
  92. package/dist-cli/chunks/{profile-7FLDF2AP.js → profile-22FDKBUO.js} +2 -2
  93. package/dist-cli/chunks/{react-3RC4CNDZ.js → react-5L6VPFUP.js} +2 -2
  94. package/dist-cli/chunks/record-JZXCQ4IN.js +70 -0
  95. package/dist-cli/chunks/runtime-EEBX7CFV.js +2 -0
  96. package/dist-cli/chunks/{runtime-delivery-Z7I2KIRB.js → runtime-delivery-LXUM3R4A.js} +2 -2
  97. package/dist-cli/chunks/{screenshot-GRCZ6AM4.js → screenshot-HDRRG33Q.js} +2 -2
  98. package/dist-cli/chunks/{screenshot-mode-E4YHXHH5.js → screenshot-mode-WY63LZIX.js} +2 -2
  99. package/dist-cli/chunks/{screenshots-7SXMX2AY.js → screenshots-MPV2ENL5.js} +2 -2
  100. package/dist-cli/chunks/{server-GDJ2TCRV.js → server-5LBMCJ3G.js} +2 -2
  101. package/dist-cli/chunks/setup-repo-SZSYNKNI.js +2 -0
  102. package/dist-cli/chunks/{skills-62E7NDRC.js → skills-BQ73YOBF.js} +2 -2
  103. package/dist-cli/chunks/{start-Y7KR5ZQ3.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-IYMSUPVC.js → test-OVO4CQTG.js} +3 -3
  107. package/dist-cli/chunks/{three-mode-QKKXCCC2.js → three-mode-BKM3KFM7.js} +2 -2
  108. package/dist-cli/chunks/{timeline-PF6NQ7RT.js → timeline-MDXGEDQL.js} +2 -2
  109. package/dist-cli/chunks/{upgrade-CE2Y3TAN.js → upgrade-JGQABWVF.js} +2 -2
  110. package/dist-cli/chunks/upload-UJNUA4ZV.js +2 -0
  111. package/dist-cli/chunks/{web-XEO3ZCPF.js → web-WYFAYQ72.js} +2 -2
  112. package/dist-cli/chunks/{what-happened-372J7YF7.js → what-happened-PZW2KW6A.js} +2 -2
  113. package/dist-cli/chunks/{whoami-B4E7KCT5.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-D2EQVL7R.js +0 -2
  187. package/dist-cli/chunks/beta-VHPXECZY.js +0 -2
  188. package/dist-cli/chunks/chunk-27HBWBE6.js +0 -4
  189. package/dist-cli/chunks/chunk-2W5C5J4O.js +0 -64
  190. package/dist-cli/chunks/chunk-3OH4VCJA.js +0 -1
  191. package/dist-cli/chunks/chunk-45HLFQRI.js +0 -2
  192. package/dist-cli/chunks/chunk-7YLCK5HG.js +0 -5
  193. package/dist-cli/chunks/chunk-BRDUKIZI.js +0 -119
  194. package/dist-cli/chunks/chunk-GADW2Q5S.js +0 -1
  195. package/dist-cli/chunks/chunk-HST43CVE.js +0 -2
  196. package/dist-cli/chunks/chunk-QJBQOGTK.js +0 -73
  197. package/dist-cli/chunks/chunk-V26REV7G.js +0 -1
  198. package/dist-cli/chunks/cli-version-WF7T6IKI.js +0 -2
  199. package/dist-cli/chunks/control-EAK2OPGB.js +0 -2
  200. package/dist-cli/chunks/demo-app-registry-52A2MI72.js +0 -2
  201. package/dist-cli/chunks/drivers-B4QPIZ4B.js +0 -2
  202. package/dist-cli/chunks/flow-PFLHFNVM.js +0 -2
  203. package/dist-cli/chunks/install-ZCPEMK6U.js +0 -2
  204. package/dist-cli/chunks/record-CZ33G5FT.js +0 -70
  205. package/dist-cli/chunks/runtime-AZKHZHJ4.js +0 -2
  206. package/dist-cli/chunks/setup-repo-3Y2QAZRK.js +0 -2
  207. package/dist-cli/chunks/store-TDTFZMGA.js +0 -2
  208. package/dist-cli/chunks/telemetry-G3NIU5NP.js +0 -2
  209. package/dist-cli/chunks/upload-CLWFS7IL.js +0 -2
@@ -0,0 +1,576 @@
1
+ // agent routing extension for SootSimBridgeHost.
2
+ //
3
+ // owns the single FIFO reader per agent session (closes the
4
+ // "single-reader channel" gap that agent-sessions.ts explicitly warns
5
+ // about), and fans events out to every WS subscriber — CLI `sootsim
6
+ // agent watch`, electron main, and browser shells all consume the same
7
+ // stream.
8
+ //
9
+ // message shapes follow the existing ws-bridge convention:
10
+ // client → daemon: { id, type: 'agent:…', …payload }
11
+ // daemon → client: { id, result } | { id, error, code? }
12
+ // plus server-initiated pushes (no id):
13
+ // { type: 'agent:event', sessionId, event }
14
+ // { type: 'agent:session-status', session }
15
+
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import {
19
+ scanDevServers,
20
+ type DiscoveredServer,
21
+ } from '../../scripts/dev-server-scanner.ts'
22
+ import {
23
+ AgentSessionError,
24
+ endSession as endAgentSession,
25
+ sendPrompt as sendAgentPrompt,
26
+ startSession as startAgentSession,
27
+ subscribeEvents as subscribeAgentEvents,
28
+ transcriptPath,
29
+ type Provider,
30
+ } from '../agent-sessions.ts'
31
+ import {
32
+ deleteProject,
33
+ findProjectById,
34
+ findSessionById,
35
+ getUserDataDir,
36
+ listProjects,
37
+ listSessions,
38
+ recordTurnTelemetry,
39
+ seedFromDemoAppRegistry,
40
+ updateSessionStatus,
41
+ upsertProject,
42
+ type AgentSession,
43
+ type AttachedProject,
44
+ } from '../attached-projects.ts'
45
+ import type { AgentEvent } from '../agent-events.ts'
46
+ import type { AgentPromptEnvelope } from '../agent-prompt.ts'
47
+ import type { WebSocket } from 'ws'
48
+
49
+ // ws.readyState constant — avoid importing the module just for this enum.
50
+ const WS_OPEN = 1
51
+
52
+ interface FanoutSubscription {
53
+ unsubscribe: () => void
54
+ refCount: number
55
+ }
56
+
57
+ interface PendingPromptEcho {
58
+ sentAt: number
59
+ }
60
+
61
+ export interface AgentHostOptions {
62
+ /** callback so hosts with a soot-env context (electron, dev middleware)
63
+ * can inject their own exclude list. defaults to the env-derived list
64
+ * the rest of the repo uses, so `sootsim server` standalone does the
65
+ * right thing. */
66
+ getExcludePorts?: () => number[]
67
+ }
68
+
69
+ /** default excludes keep the dev-server scanner from hitting soot's own
70
+ * web / zero / postgres / r2 ports. mirrored from
71
+ * sootsim-engine/src/dev-scan-excludes.ts — duplicated rather than
72
+ * imported because sootsim is a dependency of sootsim-engine, not the
73
+ * other way around. */
74
+ function defaultExcludePorts(): number[] {
75
+ return [
76
+ Number(process.env.VITE_PORT_WEB || process.env.PORT || 3000),
77
+ Number(process.env.VITE_PORT_ZERO || 7849),
78
+ Number(process.env.VITE_PORT_POSTGRES || 7432),
79
+ Number(process.env.VITE_PORT_R2 || 9500),
80
+ ].filter((p) => Number.isFinite(p) && p > 0)
81
+ }
82
+
83
+ export class AgentHost {
84
+ // live fan-out, keyed by sessionId. refcount tracks how many sockets have
85
+ // subscribed; the FIFO reader stays open as long as refcount > 0.
86
+ private subscriptions = new Map<string, FanoutSubscription>()
87
+ // per-socket subscription set so ws.close can clean up without scanning
88
+ // every session.
89
+ private sessionsBySocket = new Map<WebSocket, Set<string>>()
90
+ // every connected socket (regardless of role) — gets session-status
91
+ // pushes so a CLI `sootsim agent sessions --watch` can react to state
92
+ // changes driven by another client.
93
+ private allSockets = new Set<WebSocket>()
94
+ // agent wrappers also emit prompt-received from the FIFO reader, but the
95
+ // shell/daemon already knows the user-facing display text when send-prompt
96
+ // is accepted. emit the friendly prompt immediately and suppress the raw
97
+ // wrapper echoes that follow a moment later.
98
+ private pendingPromptEchoes = new Map<string, PendingPromptEcho[]>()
99
+ // accepted prompts serialize inside the wrapper, so a second send while the
100
+ // session is already working becomes queued follow-up work. keep a small
101
+ // in-memory count so session status stays `working` until that backlog
102
+ // truly drains.
103
+ private pendingTurns = new Map<string, number>()
104
+ private opts: AgentHostOptions
105
+
106
+ constructor(opts: AgentHostOptions = {}) {
107
+ this.opts = opts
108
+ }
109
+
110
+ registerSocket(ws: WebSocket): void {
111
+ this.allSockets.add(ws)
112
+ }
113
+
114
+ unregisterSocket(ws: WebSocket): void {
115
+ const sessions = this.sessionsBySocket.get(ws)
116
+ if (sessions) {
117
+ for (const sessionId of sessions) {
118
+ this.decrementSubscription(sessionId)
119
+ }
120
+ this.sessionsBySocket.delete(ws)
121
+ }
122
+ this.allSockets.delete(ws)
123
+ }
124
+
125
+ /** handle an agent:* message. returns true iff the message was recognized
126
+ * as an agent message (so the caller knows to stop dispatching). */
127
+ async handleMessage(ws: WebSocket, msg: any): Promise<boolean> {
128
+ const type = msg?.type
129
+ if (typeof type !== 'string' || !type.startsWith('agent:')) return false
130
+ const id = msg.id
131
+ try {
132
+ const result = await this.dispatch(ws, type, msg)
133
+ this.respond(ws, id, result)
134
+ } catch (err) {
135
+ if (err instanceof AgentSessionError) {
136
+ this.respondError(ws, id, err.message, err.code)
137
+ } else {
138
+ this.respondError(ws, id, err instanceof Error ? err.message : String(err))
139
+ }
140
+ }
141
+ return true
142
+ }
143
+
144
+ /** run once on daemon boot. idempotent — `seedFromDemoAppRegistry` no-ops
145
+ * when the store already has projects. */
146
+ async seedOnBoot(): Promise<void> {
147
+ try {
148
+ await seedFromDemoAppRegistry()
149
+ } catch (err) {
150
+ process.stderr.write(
151
+ `[sootsim-agent] seedFromDemoAppRegistry failed: ${err instanceof Error ? err.message : String(err)}\n`,
152
+ )
153
+ }
154
+ }
155
+
156
+ /** terminate every subscription and drop every socket reference. called
157
+ * by the bridge host during shutdown. */
158
+ close(): void {
159
+ for (const sub of this.subscriptions.values()) {
160
+ try {
161
+ sub.unsubscribe()
162
+ } catch {}
163
+ }
164
+ this.subscriptions.clear()
165
+ this.sessionsBySocket.clear()
166
+ this.allSockets.clear()
167
+ }
168
+
169
+ // --- dispatch ---
170
+
171
+ private async dispatch(ws: WebSocket, type: string, msg: any): Promise<unknown> {
172
+ switch (type) {
173
+ case 'agent:list-projects':
174
+ return listProjects()
175
+ case 'agent:upsert-project':
176
+ return upsertProject(msg.input ?? {})
177
+ case 'agent:delete-project':
178
+ deleteProject(String(msg.projectId))
179
+ return { ok: true }
180
+ case 'agent:auto-attach-for-url':
181
+ return this.autoAttachForUrl(msg.input ?? {})
182
+ case 'agent:list-sessions':
183
+ return listSessions(msg.projectId ? String(msg.projectId) : undefined)
184
+ case 'agent:start-session':
185
+ return this.doStartSession(msg.input ?? {})
186
+ case 'agent:send-prompt': {
187
+ const sessionId = String(msg.sessionId)
188
+ const session = findSessionById(sessionId)
189
+ if (!session) {
190
+ throw new AgentSessionError('NO_SESSION', `no session: ${sessionId}`)
191
+ }
192
+ const prompt = this.normalizePromptEnvelope(msg)
193
+ await sendAgentPrompt(sessionId, prompt)
194
+ return this.notePromptAccepted(sessionId, prompt, session.status === 'working')
195
+ }
196
+ case 'agent:end-session':
197
+ this.dropSessionFanout(String(msg.sessionId))
198
+ await endAgentSession(String(msg.sessionId))
199
+ const ended = findSessionById(String(msg.sessionId))
200
+ if (ended) {
201
+ this.broadcastSessionStatus(ended)
202
+ }
203
+ return { ok: true }
204
+ case 'agent:get-transcript':
205
+ return this.getTranscript(String(msg.sessionId))
206
+ case 'agent:get-paths':
207
+ return this.getPaths()
208
+ case 'agent:subscribe-events':
209
+ return this.subscribeSocket(ws, String(msg.sessionId))
210
+ case 'agent:unsubscribe-events':
211
+ return this.unsubscribeSocket(ws, String(msg.sessionId))
212
+ default:
213
+ throw new AgentSessionError('UNKNOWN_AGENT_MSG', `unknown agent message: ${type}`)
214
+ }
215
+ }
216
+
217
+ // --- operation impls ---
218
+
219
+ private async doStartSession(input: {
220
+ projectId: string
221
+ provider?: Provider
222
+ codexBin?: string
223
+ claudeBin?: string
224
+ freshThread?: boolean
225
+ }): Promise<{ session: AgentSession; wrapperPid: number }> {
226
+ const project = findProjectById(input.projectId)
227
+ if (!project) {
228
+ throw new AgentSessionError('NO_PROJECT', `no project: ${input.projectId}`)
229
+ }
230
+ const result = await startAgentSession(input)
231
+ this.broadcastSessionStatus(result.session)
232
+ return result
233
+ }
234
+
235
+ private async autoAttachForUrl(input: {
236
+ bundleUrl?: string
237
+ provider?: Provider
238
+ }): Promise<{ project: AttachedProject | null }> {
239
+ const bundleUrl = input.bundleUrl ?? ''
240
+ const targetPort = (() => {
241
+ try {
242
+ return new URL(bundleUrl).port || null
243
+ } catch {
244
+ return null
245
+ }
246
+ })()
247
+ if (!targetPort) return { project: null }
248
+ const excludePorts = this.opts.getExcludePorts?.() ?? defaultExcludePorts()
249
+ const servers = await scanDevServers({ excludePorts })
250
+ const match = servers.find((s) => String(s.port) === targetPort)
251
+ if (!match || !match.cwd) return { project: null }
252
+ const existing = listProjects().find((p) => p.cwd === match.cwd) ?? null
253
+ const knownBundleUrls = Array.from(
254
+ new Set([...(existing?.knownBundleUrls ?? []), match.bundleUrl, bundleUrl]),
255
+ )
256
+ const project = upsertProject({
257
+ cwd: match.cwd,
258
+ name: match.projectName ?? path.basename(match.cwd),
259
+ preferredProvider: input.provider ?? existing?.preferredProvider,
260
+ sourceRoots: existing?.sourceRoots ?? [match.cwd],
261
+ knownBundleUrls,
262
+ framework: existing?.framework ?? mapFrameworkToProjectFramework(match.framework),
263
+ bundleId: match.bundleId ?? existing?.bundleId,
264
+ })
265
+ return { project }
266
+ }
267
+
268
+ private getTranscript(sessionId: string): string | { error: string; code: string } {
269
+ const p = transcriptPath(sessionId)
270
+ if (!fs.existsSync(p)) {
271
+ return { error: 'transcript not found', code: 'NO_TRANSCRIPT' }
272
+ }
273
+ return fs.readFileSync(p, 'utf8')
274
+ }
275
+
276
+ private getPaths() {
277
+ const dir = getUserDataDir()
278
+ return {
279
+ userDataDir: dir,
280
+ storeFile: path.join(dir, 'attached-projects.json'),
281
+ sessionsDir: path.join(dir, 'sessions'),
282
+ transcriptsDir: path.join(dir, 'transcripts'),
283
+ }
284
+ }
285
+
286
+ // --- subscription management ---
287
+
288
+ private subscribeSocket(
289
+ ws: WebSocket,
290
+ sessionId: string,
291
+ ): { ok: true; refCount: number } {
292
+ let sockets = this.sessionsBySocket.get(ws)
293
+ if (!sockets) {
294
+ sockets = new Set()
295
+ this.sessionsBySocket.set(ws, sockets)
296
+ }
297
+ if (sockets.has(sessionId)) {
298
+ return { ok: true, refCount: this.subscriptions.get(sessionId)?.refCount ?? 1 }
299
+ }
300
+ sockets.add(sessionId)
301
+ const existing = this.subscriptions.get(sessionId)
302
+ if (existing) {
303
+ existing.refCount++
304
+ return { ok: true, refCount: existing.refCount }
305
+ }
306
+ const unsubscribe = subscribeAgentEvents(sessionId, (event) => {
307
+ const coalesced = this.coalescePromptEcho(sessionId, event)
308
+ if (coalesced) {
309
+ this.applySessionEvent(sessionId, coalesced)
310
+ this.fanOutEvent(sessionId, coalesced)
311
+ }
312
+ // recordTurnTelemetry mirrors what electron main used to do — keep it
313
+ // here so cost tracking happens regardless of which client subscribed.
314
+ if (event.type === 'turn-completed') {
315
+ const session = findSessionById(sessionId)
316
+ if (session) {
317
+ try {
318
+ recordTurnTelemetry(session.projectId, {
319
+ usd: event.costUsd,
320
+ ts: event.ts,
321
+ })
322
+ } catch (err) {
323
+ process.stderr.write(
324
+ `[sootsim-agent] recordTurnTelemetry failed: ${err instanceof Error ? err.message : String(err)}\n`,
325
+ )
326
+ }
327
+ }
328
+ }
329
+ })
330
+ this.subscriptions.set(sessionId, { unsubscribe, refCount: 1 })
331
+ return { ok: true, refCount: 1 }
332
+ }
333
+
334
+ private unsubscribeSocket(
335
+ ws: WebSocket,
336
+ sessionId: string,
337
+ ): { ok: true; refCount: number } {
338
+ const sockets = this.sessionsBySocket.get(ws)
339
+ if (!sockets || !sockets.has(sessionId)) return { ok: true, refCount: 0 }
340
+ sockets.delete(sessionId)
341
+ return this.decrementSubscription(sessionId)
342
+ }
343
+
344
+ private decrementSubscription(sessionId: string): { ok: true; refCount: number } {
345
+ const existing = this.subscriptions.get(sessionId)
346
+ if (!existing) return { ok: true, refCount: 0 }
347
+ existing.refCount--
348
+ if (existing.refCount <= 0) {
349
+ try {
350
+ existing.unsubscribe()
351
+ } catch {}
352
+ this.subscriptions.delete(sessionId)
353
+ return { ok: true, refCount: 0 }
354
+ }
355
+ return { ok: true, refCount: existing.refCount }
356
+ }
357
+
358
+ /** end-session tears the FIFO down, so drop our reader before it
359
+ * disappears regardless of remaining subscriber refcount. */
360
+ private dropSessionFanout(sessionId: string): void {
361
+ const existing = this.subscriptions.get(sessionId)
362
+ if (existing) {
363
+ try {
364
+ existing.unsubscribe()
365
+ } catch {}
366
+ this.subscriptions.delete(sessionId)
367
+ }
368
+ for (const sockets of this.sessionsBySocket.values()) {
369
+ sockets.delete(sessionId)
370
+ }
371
+ this.clearPromptTracking(sessionId)
372
+ }
373
+
374
+ // --- wire pushes + responses ---
375
+
376
+ private normalizePromptEnvelope(msg: any): AgentPromptEnvelope {
377
+ if (msg?.prompt && typeof msg.prompt === 'object') {
378
+ const prompt = msg.prompt as Record<string, unknown>
379
+ return {
380
+ text: String(prompt.text ?? ''),
381
+ ...(typeof prompt.displayText === 'string'
382
+ ? { displayText: prompt.displayText }
383
+ : {}),
384
+ ...(typeof prompt.inspectSummary === 'string'
385
+ ? { inspectSummary: prompt.inspectSummary }
386
+ : {}),
387
+ ...(typeof prompt.inspectTrace === 'string'
388
+ ? { inspectTrace: prompt.inspectTrace }
389
+ : {}),
390
+ }
391
+ }
392
+ return {
393
+ text: String(msg?.text ?? ''),
394
+ ...(typeof msg?.displayText === 'string' ? { displayText: msg.displayText } : {}),
395
+ ...(typeof msg?.inspectSummary === 'string'
396
+ ? { inspectSummary: msg.inspectSummary }
397
+ : {}),
398
+ ...(typeof msg?.inspectTrace === 'string'
399
+ ? { inspectTrace: msg.inspectTrace }
400
+ : {}),
401
+ }
402
+ }
403
+
404
+ private notePromptAccepted(
405
+ sessionId: string,
406
+ prompt: AgentPromptEnvelope,
407
+ assumeQueued: boolean,
408
+ ): { ok: true; queued: boolean; pendingTurns: number; queueDepth: number } {
409
+ const now = Date.now()
410
+ const echoes = this.pendingPromptEchoes.get(sessionId) ?? []
411
+ echoes.push({ sentAt: now })
412
+ this.pendingPromptEchoes.set(sessionId, echoes)
413
+ const pendingTurns =
414
+ Math.max(this.pendingTurns.get(sessionId) ?? 0, assumeQueued ? 1 : 0) + 1
415
+ this.pendingTurns.set(sessionId, pendingTurns)
416
+ const promptText = prompt.displayText ?? prompt.text
417
+ this.patchSession(sessionId, {
418
+ lastPrompt: promptText,
419
+ status: 'working',
420
+ needsAttention: false,
421
+ })
422
+ this.fanOutEvent(sessionId, {
423
+ type: 'prompt-received',
424
+ text: promptText,
425
+ ...(prompt.inspectSummary ? { inspectSummary: prompt.inspectSummary } : {}),
426
+ ...(prompt.inspectTrace ? { inspectTrace: prompt.inspectTrace } : {}),
427
+ ts: now,
428
+ } as AgentEvent)
429
+ return {
430
+ ok: true,
431
+ queued: pendingTurns > 1,
432
+ pendingTurns,
433
+ queueDepth: Math.max(0, pendingTurns - 1),
434
+ }
435
+ }
436
+
437
+ private applySessionEvent(sessionId: string, event: AgentEvent): void {
438
+ switch (event.type) {
439
+ case 'prompt-received':
440
+ case 'turn-started':
441
+ this.patchSession(sessionId, {
442
+ status: 'working',
443
+ needsAttention: false,
444
+ })
445
+ return
446
+ case 'turn-completed': {
447
+ const pendingTurns = this.consumeSettledTurn(sessionId)
448
+ this.patchSession(sessionId, {
449
+ status: pendingTurns > 0 ? 'working' : 'idle',
450
+ needsAttention: false,
451
+ lastTurnFiles: event.filesTouched,
452
+ currentlyEditing: undefined,
453
+ })
454
+ return
455
+ }
456
+ case 'approval-needed':
457
+ this.patchSession(sessionId, {
458
+ status: 'needs-attention',
459
+ needsAttention: true,
460
+ })
461
+ return
462
+ case 'error': {
463
+ const pendingTurns = this.consumeSettledTurn(sessionId)
464
+ this.patchSession(sessionId, {
465
+ status: pendingTurns > 0 ? 'working' : 'needs-attention',
466
+ needsAttention: pendingTurns <= 0,
467
+ currentlyEditing: undefined,
468
+ })
469
+ return
470
+ }
471
+ case 'exited':
472
+ this.clearPromptTracking(sessionId)
473
+ this.patchSession(sessionId, {
474
+ status: 'ended',
475
+ needsAttention: false,
476
+ wrapperPid: undefined,
477
+ currentlyEditing: undefined,
478
+ })
479
+ return
480
+ case 'ready':
481
+ case 'turn-reasoning':
482
+ case 'turn-message':
483
+ case 'turn-plan':
484
+ case 'tool-call':
485
+ case 'file-edited':
486
+ case 'file-diff-delta':
487
+ return
488
+ }
489
+ }
490
+
491
+ private patchSession(sessionId: string, patch: Partial<AgentSession>): void {
492
+ updateSessionStatus(sessionId, patch)
493
+ const updated = findSessionById(sessionId)
494
+ if (updated) {
495
+ this.broadcastSessionStatus(updated)
496
+ }
497
+ }
498
+
499
+ private coalescePromptEcho(sessionId: string, event: AgentEvent): AgentEvent | null {
500
+ if (event.type !== 'prompt-received') return event
501
+ const pending = this.pendingPromptEchoes.get(sessionId)
502
+ if (!pending || pending.length === 0) return event
503
+ while (pending.length > 0 && Date.now() - pending[0]!.sentAt > 15_000) {
504
+ pending.shift()
505
+ }
506
+ if (pending.length === 0) {
507
+ this.pendingPromptEchoes.delete(sessionId)
508
+ return event
509
+ }
510
+ pending.shift()
511
+ if (pending.length === 0) {
512
+ this.pendingPromptEchoes.delete(sessionId)
513
+ } else {
514
+ this.pendingPromptEchoes.set(sessionId, pending)
515
+ }
516
+ return null
517
+ }
518
+
519
+ private consumeSettledTurn(sessionId: string): number {
520
+ const pendingTurns = Math.max(0, (this.pendingTurns.get(sessionId) ?? 1) - 1)
521
+ if (pendingTurns > 0) {
522
+ this.pendingTurns.set(sessionId, pendingTurns)
523
+ } else {
524
+ this.pendingTurns.delete(sessionId)
525
+ }
526
+ return pendingTurns
527
+ }
528
+
529
+ private clearPromptTracking(sessionId: string): void {
530
+ this.pendingPromptEchoes.delete(sessionId)
531
+ this.pendingTurns.delete(sessionId)
532
+ }
533
+
534
+ private fanOutEvent(sessionId: string, event: AgentEvent): void {
535
+ const payload = JSON.stringify({ type: 'agent:event', sessionId, event })
536
+ for (const [ws, sessions] of this.sessionsBySocket) {
537
+ if (!sessions.has(sessionId)) continue
538
+ if (ws.readyState !== WS_OPEN) continue
539
+ try {
540
+ ws.send(payload)
541
+ } catch {}
542
+ }
543
+ }
544
+
545
+ private broadcastSessionStatus(session: AgentSession): void {
546
+ const payload = JSON.stringify({ type: 'agent:session-status', session })
547
+ for (const ws of this.allSockets) {
548
+ if (ws.readyState !== WS_OPEN) continue
549
+ try {
550
+ ws.send(payload)
551
+ } catch {}
552
+ }
553
+ }
554
+
555
+ private respond(ws: WebSocket, id: unknown, result: unknown): void {
556
+ if (ws.readyState !== WS_OPEN) return
557
+ try {
558
+ ws.send(JSON.stringify({ id, result }))
559
+ } catch {}
560
+ }
561
+
562
+ private respondError(ws: WebSocket, id: unknown, error: string, code?: string): void {
563
+ if (ws.readyState !== WS_OPEN) return
564
+ try {
565
+ ws.send(JSON.stringify({ id, error, ...(code ? { code } : {}) }))
566
+ } catch {}
567
+ }
568
+ }
569
+
570
+ function mapFrameworkToProjectFramework(
571
+ fw: DiscoveredServer['framework'],
572
+ ): 'expo' | 'one' | 'rock' | 'unknown' {
573
+ if (fw === 'expo') return 'expo'
574
+ if (fw === 'one' || fw === 'vxrn') return 'one'
575
+ return 'unknown'
576
+ }