rnxsim 0.0.0 → 0.1.313

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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +512 -0
  3. package/detox/colors.ts +54 -0
  4. package/detox/config-loader.ts +135 -0
  5. package/detox/element-types.ts +39 -0
  6. package/detox/expectations.ts +537 -0
  7. package/detox/gestures.ts +480 -0
  8. package/detox/index.ts +2118 -0
  9. package/detox/jest-preset.cjs +51 -0
  10. package/detox/jest-setup-after-env.cjs +18 -0
  11. package/detox/matchers.ts +30 -0
  12. package/detox/motion-change.cjs +109 -0
  13. package/detox/motion-change.d.cts +35 -0
  14. package/detox/navigation.ts +43 -0
  15. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  16. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  17. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  18. package/detox/screenshots/theme-blue-toggle.png +0 -0
  19. package/detox/screenshots/theme-blue.png +0 -0
  20. package/detox/screenshots/theme-red-toggle.png +0 -0
  21. package/detox/screenshots/theme-red.png +0 -0
  22. package/dist-cli/bin.js +15 -0
  23. package/dist-cli/chunks/agent-XZ2KTPCU.js +48 -0
  24. package/dist-cli/chunks/agent-wrapper-JJYYW2WH.js +17 -0
  25. package/dist-cli/chunks/app-fonts-IXRNQG6B.js +4 -0
  26. package/dist-cli/chunks/assert-54T5SK5F.js +49 -0
  27. package/dist-cli/chunks/auth-FI5UDI45.js +12 -0
  28. package/dist-cli/chunks/beta-JV6UKADW.js +4 -0
  29. package/dist-cli/chunks/camera-VL73YIKP.js +22 -0
  30. package/dist-cli/chunks/chunk-2D2UPBBR.js +4 -0
  31. package/dist-cli/chunks/chunk-2YR5BGA5.js +5 -0
  32. package/dist-cli/chunks/chunk-3NV2NCNX.js +4 -0
  33. package/dist-cli/chunks/chunk-46EUUFJ5.js +4 -0
  34. package/dist-cli/chunks/chunk-4NPPOV2N.js +5 -0
  35. package/dist-cli/chunks/chunk-5DHC6KHQ.js +7 -0
  36. package/dist-cli/chunks/chunk-5TEF3ET3.js +74 -0
  37. package/dist-cli/chunks/chunk-5TPRP5QT.js +4 -0
  38. package/dist-cli/chunks/chunk-5YJCOWCH.js +4 -0
  39. package/dist-cli/chunks/chunk-7GN3LVWB.js +4 -0
  40. package/dist-cli/chunks/chunk-BBULZ7CG.js +568 -0
  41. package/dist-cli/chunks/chunk-BTWORNNG.js +71 -0
  42. package/dist-cli/chunks/chunk-D4FFVGI5.js +5 -0
  43. package/dist-cli/chunks/chunk-DCEMHR2Y.js +4 -0
  44. package/dist-cli/chunks/chunk-DZS6WPUI.js +4 -0
  45. package/dist-cli/chunks/chunk-F5ZRSS3C.js +3 -0
  46. package/dist-cli/chunks/chunk-FSUYIVJ6.js +9 -0
  47. package/dist-cli/chunks/chunk-G2WW6L2C.js +23 -0
  48. package/dist-cli/chunks/chunk-GASE6UBA.js +4 -0
  49. package/dist-cli/chunks/chunk-GGRX24GF.js +142 -0
  50. package/dist-cli/chunks/chunk-HI5TFJWN.js +5 -0
  51. package/dist-cli/chunks/chunk-IJ5CAZZC.js +5 -0
  52. package/dist-cli/chunks/chunk-IJO63TDP.js +4 -0
  53. package/dist-cli/chunks/chunk-KTHV3RUS.js +26 -0
  54. package/dist-cli/chunks/chunk-LF2ZVT7O.js +6 -0
  55. package/dist-cli/chunks/chunk-MJRLLB4R.js +38 -0
  56. package/dist-cli/chunks/chunk-NFK7T35W.js +4 -0
  57. package/dist-cli/chunks/chunk-NMF2ZMZQ.js +15 -0
  58. package/dist-cli/chunks/chunk-OHAZNXLK.js +4 -0
  59. package/dist-cli/chunks/chunk-OVFJFXUD.js +101 -0
  60. package/dist-cli/chunks/chunk-OZSSI4WN.js +28 -0
  61. package/dist-cli/chunks/chunk-QKDWYITG.js +25 -0
  62. package/dist-cli/chunks/chunk-RSZWCKNT.js +30 -0
  63. package/dist-cli/chunks/chunk-RTN5C5RL.js +4 -0
  64. package/dist-cli/chunks/chunk-TIVZIMMW.js +4 -0
  65. package/dist-cli/chunks/chunk-TZFFR3SD.js +7 -0
  66. package/dist-cli/chunks/chunk-UC6U3MML.js +5 -0
  67. package/dist-cli/chunks/chunk-VFCMSYZK.js +6 -0
  68. package/dist-cli/chunks/chunk-VNQEB4L7.js +5 -0
  69. package/dist-cli/chunks/chunk-VZXWHRUZ.js +983 -0
  70. package/dist-cli/chunks/chunk-W6K4EFPH.js +107 -0
  71. package/dist-cli/chunks/chunk-WEXDAC74.js +4 -0
  72. package/dist-cli/chunks/chunk-WF3T4SVI.js +14 -0
  73. package/dist-cli/chunks/chunk-WINYQ44O.js +4 -0
  74. package/dist-cli/chunks/chunk-WMIIKMGK.js +10 -0
  75. package/dist-cli/chunks/chunk-WUSWBCWA.js +507 -0
  76. package/dist-cli/chunks/chunk-WWZIXIRD.js +4 -0
  77. package/dist-cli/chunks/chunk-XEVZYVIW.js +766 -0
  78. package/dist-cli/chunks/chunk-YDGQTMQL.js +4 -0
  79. package/dist-cli/chunks/chunk-YIFT42WN.js +4 -0
  80. package/dist-cli/chunks/chunk-ZMJD5GEC.js +125 -0
  81. package/dist-cli/chunks/cleanup-P27PA6JI.js +25 -0
  82. package/dist-cli/chunks/cli-version-WWLPBDQ7.js +4 -0
  83. package/dist-cli/chunks/compat-ZD65FED3.js +20 -0
  84. package/dist-cli/chunks/config-XMJRNM2A.js +13 -0
  85. package/dist-cli/chunks/control-KMIQT3QP.js +4 -0
  86. package/dist-cli/chunks/daemon-G2ME7NLB.js +4 -0
  87. package/dist-cli/chunks/debug-PT4HOP7N.js +200 -0
  88. package/dist-cli/chunks/desktop-S3FG72AK.js +31 -0
  89. package/dist-cli/chunks/detox-B3D4IFCN.js +52 -0
  90. package/dist-cli/chunks/device-XBNDSB2R.js +18 -0
  91. package/dist-cli/chunks/diagnose-HMQXJE5N.js +43 -0
  92. package/dist-cli/chunks/disk-cleanup-BLCZ5BSZ.js +4 -0
  93. package/dist-cli/chunks/drivers-LDECZGP2.js +4 -0
  94. package/dist-cli/chunks/film-BJGTBYZB.js +74 -0
  95. package/dist-cli/chunks/flow-UEQNVTU7.js +4 -0
  96. package/dist-cli/chunks/help-T5FYSVGB.js +4 -0
  97. package/dist-cli/chunks/hidden-runtime-alias-ANOYADHM.js +4 -0
  98. package/dist-cli/chunks/home-paths-GT3LFNOR.js +4 -0
  99. package/dist-cli/chunks/inspect-ZA6XF5LD.js +4 -0
  100. package/dist-cli/chunks/install-desktop-TIMUDHPL.js +4 -0
  101. package/dist-cli/chunks/login-FJ737MWG.js +28 -0
  102. package/dist-cli/chunks/logout-ZCNMMHMY.js +4 -0
  103. package/dist-cli/chunks/maestro-SZTNKLDF.js +104 -0
  104. package/dist-cli/chunks/maestro-generate-DCFAIZ4H.js +36 -0
  105. package/dist-cli/chunks/mode-GRMQCRXR.js +26 -0
  106. package/dist-cli/chunks/optional-demo-registry-W36EWFFB.js +4 -0
  107. package/dist-cli/chunks/perf-QYBAAUZG.js +48 -0
  108. package/dist-cli/chunks/permissions-3QCQ6VF4.js +13 -0
  109. package/dist-cli/chunks/record-QPPC2S4E.js +72 -0
  110. package/dist-cli/chunks/report-issue-7NMFP4HK.js +29 -0
  111. package/dist-cli/chunks/runtime-XOAXMSTU.js +4 -0
  112. package/dist-cli/chunks/screenshot-command-67AECJFB.js +159 -0
  113. package/dist-cli/chunks/screenshot-layers-ASWBYPJL.js +20 -0
  114. package/dist-cli/chunks/screenshots-capture-PXA3HFQK.js +42 -0
  115. package/dist-cli/chunks/serve-BI2NBAXG.js +44 -0
  116. package/dist-cli/chunks/setup-7DWPMRSB.js +32 -0
  117. package/dist-cli/chunks/skills-S3Y22TUA.js +50 -0
  118. package/dist-cli/chunks/storage-XUIMJWAJ.js +37 -0
  119. package/dist-cli/chunks/store-JTHEJLAZ.js +4 -0
  120. package/dist-cli/chunks/telemetry-ZYJGD2DB.js +4 -0
  121. package/dist-cli/chunks/timeline-TMPLQPSP.js +24 -0
  122. package/dist-cli/chunks/upgrade-7HDSIM7K.js +7 -0
  123. package/dist-cli/chunks/upload-GMSZPWM6.js +4 -0
  124. package/dist-cli/chunks/version-HOCHZ37L.js +6 -0
  125. package/dist-cli/chunks/web-DG3WBYD3.js +4 -0
  126. package/dist-cli/chunks/what-happened-XFVUTZR7.js +25 -0
  127. package/dist-lib/agent-daemon-client.cjs +468 -0
  128. package/dist-lib/agent-events.cjs +48 -0
  129. package/dist-lib/agent-identity.cjs +53 -0
  130. package/dist-lib/agent-sessions.cjs +725 -0
  131. package/dist-lib/attached-projects.cjs +526 -0
  132. package/dist-lib/auth/shared-session.cjs +195 -0
  133. package/dist-lib/backend-origin.cjs +70 -0
  134. package/dist-lib/beta.cjs +41 -0
  135. package/dist-lib/beta.mjs +13 -0
  136. package/dist-lib/bridge-constants.cjs +161 -0
  137. package/dist-lib/cli-constants.cjs +32 -0
  138. package/dist-lib/config.cjs +96 -0
  139. package/dist-lib/detox/index.cjs +2863 -0
  140. package/dist-lib/detox/jest-preset.cjs +51 -0
  141. package/dist-lib/dev-bundle-resolution.cjs +223 -0
  142. package/dist-lib/home-paths.cjs +421 -0
  143. package/dist-lib/host/bridge-host.cjs +5600 -0
  144. package/dist-lib/host/fetch-proxy-handler.cjs +373 -0
  145. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  146. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  147. package/dist-lib/host/websocket-proxy.cjs +214 -0
  148. package/dist-lib/index.cjs +513 -0
  149. package/dist-lib/jump-to-source-babel.cjs +93 -0
  150. package/dist-lib/menu.cjs +620 -0
  151. package/dist-lib/menu.mjs +581 -0
  152. package/dist-lib/metro.cjs +297 -0
  153. package/dist-lib/profiles.cjs +246 -0
  154. package/dist-lib/public-brand.cjs +39 -0
  155. package/dist-lib/render-mode.cjs +54 -0
  156. package/dist-lib/scripts/dev-server-scanner.cjs +1034 -0
  157. package/dist-lib/sdk.cjs +2333 -0
  158. package/dist-lib/sdk.mjs +2240 -0
  159. package/dist-lib/skills.cjs +23922 -0
  160. package/dist-lib/vite.cjs +358 -0
  161. package/package.json +189 -18
  162. package/scripts/dev-server-scanner.ts +941 -0
  163. package/scripts/optional-demo-registry.ts +113 -0
  164. package/scripts/postinstall.cjs +3 -0
  165. package/scripts/preuninstall.cjs +118 -0
  166. package/skills/contrast/SKILL.md +171 -0
  167. package/skills/rnx-debug/SKILL.md +425 -0
  168. package/skills/rnx-perf/SKILL.md +295 -0
  169. package/skills/rnx-setup/SKILL.md +160 -0
  170. package/skills/rnx-test/SKILL.md +292 -0
  171. package/skills/rnx-visual/SKILL.md +122 -0
  172. package/src/agent-daemon-client.ts +410 -0
  173. package/src/agent-events.ts +71 -0
  174. package/src/agent-identity.ts +23 -0
  175. package/src/agent-prompt.ts +71 -0
  176. package/src/agent-sessions.ts +573 -0
  177. package/src/attached-projects.ts +539 -0
  178. package/src/auth/shared-session.ts +204 -0
  179. package/src/backend-origin.ts +49 -0
  180. package/src/beta.ts +19 -0
  181. package/src/bridge-constants.ts +171 -0
  182. package/src/browser-cache.ts +16 -0
  183. package/src/cli-constants.ts +1 -0
  184. package/src/cli-version.ts +32 -0
  185. package/src/codex-client.ts +215 -0
  186. package/src/config.ts +134 -0
  187. package/src/dev-bundle-resolution.ts +236 -0
  188. package/src/disk-cleanup.ts +466 -0
  189. package/src/home-paths.ts +552 -0
  190. package/src/host/agent-host.ts +666 -0
  191. package/src/host/bridge-host.ts +2626 -0
  192. package/src/host/fetch-proxy-handler.ts +397 -0
  193. package/src/host/fetch-proxy-overrides.ts +51 -0
  194. package/src/host/open-url.ts +89 -0
  195. package/src/host/websocket-proxy.ts +210 -0
  196. package/src/index.ts +12 -705
  197. package/src/jump-to-source-babel.ts +135 -0
  198. package/src/menu.ts +734 -0
  199. package/src/metro-plugin.ts +139 -0
  200. package/src/native-dev-bundle-url.ts +96 -0
  201. package/src/native-seam-manifest.ts +341 -0
  202. package/src/plist.ts +27 -0
  203. package/src/poll-until.ts +92 -0
  204. package/src/profiles.ts +179 -0
  205. package/src/public-brand.ts +9 -0
  206. package/src/render-mode.ts +27 -0
  207. package/src/runtime-assets.ts +228 -0
  208. package/src/runtime-delivery.ts +54 -0
  209. package/src/screenshots/compose.ts +422 -0
  210. package/src/screenshots/frame-compose.ts +454 -0
  211. package/src/screenshots/orchestrate.ts +256 -0
  212. package/src/screenshots/org-deck.ts +91 -0
  213. package/src/screenshots/registry.ts +58 -0
  214. package/src/screenshots/schema.ts +364 -0
  215. package/src/sdk.ts +7 -0
  216. package/src/settings-bundle.ts +418 -0
  217. package/src/skills/builtin/a11y-review.ts +132 -0
  218. package/src/skills/builtin/compat-check.ts +71 -0
  219. package/src/skills/builtin/maestro-test.ts +121 -0
  220. package/src/skills/builtin/perf-profile.ts +90 -0
  221. package/src/skills/builtin/screenshot-all.ts +52 -0
  222. package/src/skills/builtin/visual-diff.ts +100 -0
  223. package/src/skills/registry.ts +107 -0
  224. package/src/skills/types.ts +41 -0
  225. package/src/team-machine-prompt.ts +73 -0
  226. package/src/vite-plugin-one.ts +190 -0
  227. package/src/vite-plugin.ts +1750 -0
  228. package/src/worklets-babel.ts +134 -0
@@ -0,0 +1,666 @@
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
+ applySessionStatusPatch,
41
+ updateSessionStatuses,
42
+ upsertProject,
43
+ type AgentSession,
44
+ type AttachedProject,
45
+ } from '../attached-projects.ts'
46
+ import {
47
+ sendPromptToTeamMachineSession,
48
+ teamMachineSessionIdFromCliIdentity,
49
+ } from '../team-machine-prompt.ts'
50
+ import type { AgentEvent } from '../agent-events.ts'
51
+ import type { AgentPromptEnvelope } from '../agent-prompt.ts'
52
+ import type { WebSocket } from 'ws'
53
+
54
+ // ws.readyState constant — avoid importing the module just for this enum.
55
+ const WS_OPEN = 1
56
+ const SESSION_STATUS_PERSIST_DELAY_MS = 25
57
+
58
+ interface FanoutSubscription {
59
+ unsubscribe: () => void
60
+ refCount: number
61
+ }
62
+
63
+ interface PendingPromptEcho {
64
+ sentAt: number
65
+ }
66
+
67
+ export interface AgentHostOptions {
68
+ /** callback so hosts with a Contrast-env context (electron, dev middleware)
69
+ * can inject their own exclude list. defaults to the env-derived list
70
+ * the rest of the repo uses, so `sootsim serve` standalone does the
71
+ * right thing. */
72
+ getExcludePorts?: () => number[]
73
+ /** returns the active CLI lease for a sim. used only for prompt routing to
74
+ * an external Team Machine agent that claimed the tab. */
75
+ resolveCliLease?: (simId: string) => AgentCliLeaseSnapshot | null
76
+ }
77
+
78
+ export interface AgentCliLeaseSnapshot {
79
+ kind: 'cli' | 'user-active'
80
+ cliIdentityKey: string
81
+ expiresAt: number
82
+ }
83
+
84
+ /** default excludes keep the dev-server scanner from hitting Contrast's own
85
+ * web / zero / r2 ports. mirrored from
86
+ * sootsim-engine/src/dev-scan-excludes.ts — duplicated rather than
87
+ * imported because sootsim is a dependency of sootsim-engine, not the
88
+ * other way around. */
89
+ function defaultExcludePorts(): number[] {
90
+ return [
91
+ Number(process.env.VITE_PORT_WEB || process.env.PORT || 3000),
92
+ Number(process.env.VITE_PORT_ZERO || 7849),
93
+ Number(process.env.VITE_PORT_R2 || 9500),
94
+ ].filter((p) => Number.isFinite(p) && p > 0)
95
+ }
96
+
97
+ export class AgentHost {
98
+ // live fan-out, keyed by sessionId. refcount tracks how many sockets have
99
+ // subscribed; the FIFO reader stays open as long as refcount > 0.
100
+ private subscriptions = new Map<string, FanoutSubscription>()
101
+ // per-socket subscription set so ws.close can clean up without scanning
102
+ // every session.
103
+ private sessionsBySocket = new Map<WebSocket, Set<string>>()
104
+ // every connected socket (regardless of role) — gets session-status
105
+ // pushes so a CLI `sootsim agent sessions --watch` can react to state
106
+ // changes driven by another client.
107
+ private allSockets = new Set<WebSocket>()
108
+ // agent wrappers also emit prompt-received from the FIFO reader, but the
109
+ // shell/daemon already knows the user-facing display text when send-prompt
110
+ // is accepted. emit the friendly prompt immediately and suppress the raw
111
+ // wrapper echoes that follow a moment later.
112
+ private pendingPromptEchoes = new Map<string, PendingPromptEcho[]>()
113
+ // accepted prompts serialize inside the wrapper, so a second send while the
114
+ // session is already working becomes queued follow-up work. keep a small
115
+ // in-memory count so session status stays `working` until that backlog
116
+ // truly drains.
117
+ private pendingTurns = new Map<string, number>()
118
+ // status fan-out is realtime state. keep the current value in memory and
119
+ // batch its durable store write so an fsync cannot delay the websocket event
120
+ // it describes. close() flushes the final batch synchronously.
121
+ private pendingSessionStates = new Map<string, AgentSession>()
122
+ private pendingSessionPatches = new Map<string, Partial<AgentSession>>()
123
+ private sessionStatusPersistTimer: ReturnType<typeof setTimeout> | null = null
124
+ private opts: AgentHostOptions
125
+
126
+ constructor(opts: AgentHostOptions = {}) {
127
+ this.opts = opts
128
+ }
129
+
130
+ registerSocket(ws: WebSocket): void {
131
+ this.allSockets.add(ws)
132
+ }
133
+
134
+ unregisterSocket(ws: WebSocket): void {
135
+ const sessions = this.sessionsBySocket.get(ws)
136
+ if (sessions) {
137
+ for (const sessionId of sessions) {
138
+ this.decrementSubscription(sessionId)
139
+ }
140
+ this.sessionsBySocket.delete(ws)
141
+ }
142
+ this.allSockets.delete(ws)
143
+ }
144
+
145
+ /** handle an agent:* message. returns true iff the message was recognized
146
+ * as an agent message (so the caller knows to stop dispatching). */
147
+ async handleMessage(ws: WebSocket, msg: any): Promise<boolean> {
148
+ const type = msg?.type
149
+ if (typeof type !== 'string' || !type.startsWith('agent:')) return false
150
+ const id = msg.id
151
+ try {
152
+ const result = await this.dispatch(ws, type, msg)
153
+ this.respond(ws, id, result)
154
+ } catch (err) {
155
+ if (err instanceof AgentSessionError) {
156
+ this.respondError(ws, id, err.message, err.code)
157
+ } else {
158
+ this.respondError(ws, id, err instanceof Error ? err.message : String(err))
159
+ }
160
+ }
161
+ return true
162
+ }
163
+
164
+ /** run once on daemon boot. idempotent — `seedFromDemoAppRegistry` no-ops
165
+ * when the store already has projects. */
166
+ async seedOnBoot(): Promise<void> {
167
+ try {
168
+ await seedFromDemoAppRegistry()
169
+ } catch (err) {
170
+ process.stderr.write(
171
+ `[sootsim-agent] seedFromDemoAppRegistry failed: ${err instanceof Error ? err.message : String(err)}\n`,
172
+ )
173
+ }
174
+ }
175
+
176
+ /** terminate every subscription and drop every socket reference. called
177
+ * by the bridge host during shutdown. */
178
+ close(): void {
179
+ if (this.sessionStatusPersistTimer) {
180
+ clearTimeout(this.sessionStatusPersistTimer)
181
+ this.sessionStatusPersistTimer = null
182
+ }
183
+ this.flushSessionStatuses()
184
+ for (const sub of this.subscriptions.values()) {
185
+ try {
186
+ sub.unsubscribe()
187
+ } catch {}
188
+ }
189
+ this.subscriptions.clear()
190
+ this.sessionsBySocket.clear()
191
+ this.allSockets.clear()
192
+ }
193
+
194
+ // --- dispatch ---
195
+
196
+ private async dispatch(ws: WebSocket, type: string, msg: any): Promise<unknown> {
197
+ switch (type) {
198
+ case 'agent:list-projects':
199
+ return listProjects()
200
+ case 'agent:upsert-project':
201
+ return upsertProject(msg.input ?? {})
202
+ case 'agent:delete-project':
203
+ deleteProject(String(msg.projectId))
204
+ return { ok: true }
205
+ case 'agent:auto-attach-for-url':
206
+ return this.autoAttachForUrl(msg.input ?? {})
207
+ case 'agent:list-sessions':
208
+ return listSessions(msg.projectId ? String(msg.projectId) : undefined)
209
+ case 'agent:start-session':
210
+ return this.doStartSession(msg.input ?? {})
211
+ case 'agent:send-claimed-prompt':
212
+ return this.sendClaimedPrompt(msg)
213
+ case 'agent:send-prompt': {
214
+ const sessionId = String(msg.sessionId)
215
+ const session = findSessionById(sessionId)
216
+ if (!session) {
217
+ throw new AgentSessionError('NO_SESSION', `no session: ${sessionId}`)
218
+ }
219
+ const prompt = this.normalizePromptEnvelope(msg)
220
+ await sendAgentPrompt(sessionId, prompt)
221
+ return this.notePromptAccepted(sessionId, prompt, session.status === 'working')
222
+ }
223
+ case 'agent:end-session':
224
+ this.dropSessionFanout(String(msg.sessionId))
225
+ await endAgentSession(String(msg.sessionId))
226
+ const ended = findSessionById(String(msg.sessionId))
227
+ if (ended) {
228
+ this.broadcastSessionStatus(ended)
229
+ }
230
+ return { ok: true }
231
+ case 'agent:get-transcript':
232
+ return this.getTranscript(String(msg.sessionId))
233
+ case 'agent:get-paths':
234
+ return this.getPaths()
235
+ case 'agent:subscribe-events':
236
+ return this.subscribeSocket(ws, String(msg.sessionId))
237
+ case 'agent:unsubscribe-events':
238
+ return this.unsubscribeSocket(ws, String(msg.sessionId))
239
+ default:
240
+ throw new AgentSessionError('UNKNOWN_AGENT_MSG', `unknown agent message: ${type}`)
241
+ }
242
+ }
243
+
244
+ // --- operation impls ---
245
+
246
+ private async sendClaimedPrompt(msg: any): Promise<{
247
+ ok: true
248
+ routed: 'team-machine'
249
+ sessionId: string
250
+ }> {
251
+ const simId = typeof msg.simId === 'string' ? msg.simId.trim() : ''
252
+ if (!simId) {
253
+ throw new AgentSessionError('NO_SIM', 'agent:send-claimed-prompt requires simId')
254
+ }
255
+ const lease = this.opts.resolveCliLease?.(simId) ?? null
256
+ if (!lease || lease.kind !== 'cli' || lease.expiresAt <= Date.now()) {
257
+ throw new AgentSessionError('NO_CLAIM', `sim ${simId} has no active CLI claim`)
258
+ }
259
+ const sessionId = teamMachineSessionIdFromCliIdentity(lease.cliIdentityKey)
260
+ if (!sessionId) {
261
+ throw new AgentSessionError(
262
+ 'UNSUPPORTED_CLAIM',
263
+ `sim ${simId} is claimed by a CLI identity that is not promptable`,
264
+ )
265
+ }
266
+ await sendPromptToTeamMachineSession({
267
+ sessionId,
268
+ prompt: this.normalizePromptEnvelope(msg),
269
+ })
270
+ return { ok: true, routed: 'team-machine', sessionId }
271
+ }
272
+
273
+ private async doStartSession(input: {
274
+ projectId: string
275
+ provider?: Provider
276
+ codexBin?: string
277
+ claudeBin?: string
278
+ freshThread?: boolean
279
+ }): Promise<{ session: AgentSession; wrapperPid: number }> {
280
+ const project = findProjectById(input.projectId)
281
+ if (!project) {
282
+ throw new AgentSessionError('NO_PROJECT', `no project: ${input.projectId}`)
283
+ }
284
+ const result = await startAgentSession(input)
285
+ this.broadcastSessionStatus(result.session)
286
+ return result
287
+ }
288
+
289
+ private async autoAttachForUrl(input: {
290
+ bundleUrl?: string
291
+ provider?: Provider
292
+ }): Promise<{ project: AttachedProject | null }> {
293
+ const bundleUrl = input.bundleUrl ?? ''
294
+ const targetPort = (() => {
295
+ try {
296
+ return new URL(bundleUrl).port || null
297
+ } catch {
298
+ return null
299
+ }
300
+ })()
301
+ if (!targetPort) return { project: null }
302
+ const excludePorts = this.opts.getExcludePorts?.() ?? defaultExcludePorts()
303
+ const servers = await scanDevServers({ excludePorts })
304
+ const match = servers.find((s) => String(s.port) === targetPort)
305
+ if (!match || !match.cwd) return { project: null }
306
+ const existing = listProjects().find((p) => p.cwd === match.cwd) ?? null
307
+ const knownBundleUrls = Array.from(
308
+ new Set([...(existing?.knownBundleUrls ?? []), match.bundleUrl, bundleUrl]),
309
+ )
310
+ const project = upsertProject({
311
+ cwd: match.cwd,
312
+ name: match.projectName ?? path.basename(match.cwd),
313
+ preferredProvider: input.provider ?? existing?.preferredProvider,
314
+ sourceRoots: existing?.sourceRoots ?? [match.cwd],
315
+ knownBundleUrls,
316
+ framework: existing?.framework ?? mapFrameworkToProjectFramework(match.framework),
317
+ bundleId: match.bundleId ?? existing?.bundleId,
318
+ })
319
+ return { project }
320
+ }
321
+
322
+ private getTranscript(sessionId: string): string | { error: string; code: string } {
323
+ const p = transcriptPath(sessionId)
324
+ if (!fs.existsSync(p)) {
325
+ return { error: 'transcript not found', code: 'NO_TRANSCRIPT' }
326
+ }
327
+ return fs.readFileSync(p, 'utf8')
328
+ }
329
+
330
+ private getPaths() {
331
+ const dir = getUserDataDir()
332
+ return {
333
+ userDataDir: dir,
334
+ storeFile: path.join(dir, 'attached-projects.json'),
335
+ sessionsDir: path.join(dir, 'sessions'),
336
+ transcriptsDir: path.join(dir, 'transcripts'),
337
+ }
338
+ }
339
+
340
+ // --- subscription management ---
341
+
342
+ private subscribeSocket(
343
+ ws: WebSocket,
344
+ sessionId: string,
345
+ ): { ok: true; refCount: number } {
346
+ let sockets = this.sessionsBySocket.get(ws)
347
+ if (!sockets) {
348
+ sockets = new Set()
349
+ this.sessionsBySocket.set(ws, sockets)
350
+ }
351
+ if (sockets.has(sessionId)) {
352
+ return { ok: true, refCount: this.subscriptions.get(sessionId)?.refCount ?? 1 }
353
+ }
354
+ sockets.add(sessionId)
355
+ const existing = this.subscriptions.get(sessionId)
356
+ if (existing) {
357
+ existing.refCount++
358
+ return { ok: true, refCount: existing.refCount }
359
+ }
360
+ const unsubscribe = subscribeAgentEvents(sessionId, (event) => {
361
+ const coalesced = this.coalescePromptEcho(sessionId, event)
362
+ if (coalesced) {
363
+ this.applySessionEvent(sessionId, coalesced)
364
+ this.fanOutEvent(sessionId, coalesced)
365
+ }
366
+ // recordTurnTelemetry mirrors what electron main used to do — keep it
367
+ // here so cost tracking happens regardless of which client subscribed.
368
+ if (event.type === 'turn-completed') {
369
+ const session = findSessionById(sessionId)
370
+ if (session) {
371
+ try {
372
+ recordTurnTelemetry(session.projectId, {
373
+ usd: event.costUsd,
374
+ ts: event.ts,
375
+ })
376
+ } catch (err) {
377
+ process.stderr.write(
378
+ `[sootsim-agent] recordTurnTelemetry failed: ${err instanceof Error ? err.message : String(err)}\n`,
379
+ )
380
+ }
381
+ }
382
+ }
383
+ })
384
+ this.subscriptions.set(sessionId, { unsubscribe, refCount: 1 })
385
+ return { ok: true, refCount: 1 }
386
+ }
387
+
388
+ private unsubscribeSocket(
389
+ ws: WebSocket,
390
+ sessionId: string,
391
+ ): { ok: true; refCount: number } {
392
+ const sockets = this.sessionsBySocket.get(ws)
393
+ if (!sockets || !sockets.has(sessionId)) return { ok: true, refCount: 0 }
394
+ sockets.delete(sessionId)
395
+ return this.decrementSubscription(sessionId)
396
+ }
397
+
398
+ private decrementSubscription(sessionId: string): { ok: true; refCount: number } {
399
+ const existing = this.subscriptions.get(sessionId)
400
+ if (!existing) return { ok: true, refCount: 0 }
401
+ existing.refCount--
402
+ if (existing.refCount <= 0) {
403
+ try {
404
+ existing.unsubscribe()
405
+ } catch {}
406
+ this.subscriptions.delete(sessionId)
407
+ return { ok: true, refCount: 0 }
408
+ }
409
+ return { ok: true, refCount: existing.refCount }
410
+ }
411
+
412
+ /** end-session tears the FIFO down, so drop our reader before it
413
+ * disappears regardless of remaining subscriber refcount. */
414
+ private dropSessionFanout(sessionId: string): void {
415
+ const existing = this.subscriptions.get(sessionId)
416
+ if (existing) {
417
+ try {
418
+ existing.unsubscribe()
419
+ } catch {}
420
+ this.subscriptions.delete(sessionId)
421
+ }
422
+ for (const sockets of this.sessionsBySocket.values()) {
423
+ sockets.delete(sessionId)
424
+ }
425
+ this.clearPromptTracking(sessionId)
426
+ }
427
+
428
+ // --- wire pushes + responses ---
429
+
430
+ private normalizePromptEnvelope(msg: any): AgentPromptEnvelope {
431
+ if (msg?.prompt && typeof msg.prompt === 'object') {
432
+ const prompt = msg.prompt as Record<string, unknown>
433
+ return {
434
+ text: String(prompt.text ?? ''),
435
+ ...(typeof prompt.displayText === 'string'
436
+ ? { displayText: prompt.displayText }
437
+ : {}),
438
+ ...(typeof prompt.inspectSummary === 'string'
439
+ ? { inspectSummary: prompt.inspectSummary }
440
+ : {}),
441
+ ...(typeof prompt.inspectTrace === 'string'
442
+ ? { inspectTrace: prompt.inspectTrace }
443
+ : {}),
444
+ }
445
+ }
446
+ return {
447
+ text: String(msg?.text ?? ''),
448
+ ...(typeof msg?.displayText === 'string' ? { displayText: msg.displayText } : {}),
449
+ ...(typeof msg?.inspectSummary === 'string'
450
+ ? { inspectSummary: msg.inspectSummary }
451
+ : {}),
452
+ ...(typeof msg?.inspectTrace === 'string'
453
+ ? { inspectTrace: msg.inspectTrace }
454
+ : {}),
455
+ }
456
+ }
457
+
458
+ private notePromptAccepted(
459
+ sessionId: string,
460
+ prompt: AgentPromptEnvelope,
461
+ assumeQueued: boolean,
462
+ ): { ok: true; queued: boolean; pendingTurns: number; queueDepth: number } {
463
+ const now = Date.now()
464
+ const echoes = this.pendingPromptEchoes.get(sessionId) ?? []
465
+ echoes.push({ sentAt: now })
466
+ this.pendingPromptEchoes.set(sessionId, echoes)
467
+ const pendingTurns =
468
+ Math.max(this.pendingTurns.get(sessionId) ?? 0, assumeQueued ? 1 : 0) + 1
469
+ this.pendingTurns.set(sessionId, pendingTurns)
470
+ const promptText = prompt.displayText ?? prompt.text
471
+ this.patchSession(sessionId, {
472
+ lastPrompt: promptText,
473
+ status: 'working',
474
+ needsAttention: false,
475
+ })
476
+ this.fanOutEvent(sessionId, {
477
+ type: 'prompt-received',
478
+ text: promptText,
479
+ ...(prompt.inspectSummary ? { inspectSummary: prompt.inspectSummary } : {}),
480
+ ...(prompt.inspectTrace ? { inspectTrace: prompt.inspectTrace } : {}),
481
+ ts: now,
482
+ } as AgentEvent)
483
+ return {
484
+ ok: true,
485
+ queued: pendingTurns > 1,
486
+ pendingTurns,
487
+ queueDepth: Math.max(0, pendingTurns - 1),
488
+ }
489
+ }
490
+
491
+ private applySessionEvent(sessionId: string, event: AgentEvent): void {
492
+ switch (event.type) {
493
+ case 'prompt-received':
494
+ case 'turn-started':
495
+ this.patchSession(sessionId, {
496
+ status: 'working',
497
+ needsAttention: false,
498
+ })
499
+ return
500
+ case 'turn-completed': {
501
+ const pendingTurns = this.consumeSettledTurn(sessionId)
502
+ this.patchSession(sessionId, {
503
+ status: pendingTurns > 0 ? 'working' : 'idle',
504
+ needsAttention: false,
505
+ lastTurnFiles: event.filesTouched,
506
+ currentlyEditing: undefined,
507
+ })
508
+ return
509
+ }
510
+ case 'approval-needed':
511
+ this.patchSession(sessionId, {
512
+ status: 'needs-attention',
513
+ needsAttention: true,
514
+ })
515
+ return
516
+ case 'error': {
517
+ const pendingTurns = this.consumeSettledTurn(sessionId)
518
+ this.patchSession(sessionId, {
519
+ status: pendingTurns > 0 ? 'working' : 'needs-attention',
520
+ needsAttention: pendingTurns <= 0,
521
+ currentlyEditing: undefined,
522
+ })
523
+ return
524
+ }
525
+ case 'exited':
526
+ this.clearPromptTracking(sessionId)
527
+ this.patchSession(sessionId, {
528
+ status: 'ended',
529
+ needsAttention: false,
530
+ wrapperPid: undefined,
531
+ currentlyEditing: undefined,
532
+ })
533
+ return
534
+ case 'ready':
535
+ case 'turn-reasoning':
536
+ case 'turn-message':
537
+ case 'turn-plan':
538
+ case 'tool-call':
539
+ case 'file-edited':
540
+ case 'file-diff-delta':
541
+ return
542
+ }
543
+ }
544
+
545
+ private patchSession(sessionId: string, patch: Partial<AgentSession>): void {
546
+ const existing =
547
+ this.pendingSessionStates.get(sessionId) ?? findSessionById(sessionId)
548
+ if (!existing) return
549
+ const updated = applySessionStatusPatch(existing, patch)
550
+ this.pendingSessionStates.set(sessionId, updated)
551
+ this.pendingSessionPatches.set(sessionId, {
552
+ ...this.pendingSessionPatches.get(sessionId),
553
+ ...patch,
554
+ })
555
+ this.broadcastSessionStatus(updated)
556
+ if (this.sessionStatusPersistTimer) return
557
+ this.sessionStatusPersistTimer = setTimeout(() => {
558
+ this.sessionStatusPersistTimer = null
559
+ this.flushSessionStatuses()
560
+ }, SESSION_STATUS_PERSIST_DELAY_MS)
561
+ }
562
+
563
+ private flushSessionStatuses(): void {
564
+ if (this.pendingSessionPatches.size === 0) return
565
+ const pendingStates = this.pendingSessionStates
566
+ const pendingPatches = this.pendingSessionPatches
567
+ this.pendingSessionStates = new Map()
568
+ this.pendingSessionPatches = new Map()
569
+ try {
570
+ updateSessionStatuses([...pendingPatches].map(([id, patch]) => ({ id, patch })))
571
+ } catch (error) {
572
+ for (const [id, session] of pendingStates) {
573
+ if (!this.pendingSessionStates.has(id)) {
574
+ this.pendingSessionStates.set(id, session)
575
+ }
576
+ }
577
+ for (const [id, patch] of pendingPatches) {
578
+ this.pendingSessionPatches.set(id, {
579
+ ...patch,
580
+ ...this.pendingSessionPatches.get(id),
581
+ })
582
+ }
583
+ process.stderr.write(
584
+ `[sootsim-agent] session status persistence failed: ${error instanceof Error ? error.message : String(error)}\n`,
585
+ )
586
+ }
587
+ }
588
+
589
+ private coalescePromptEcho(sessionId: string, event: AgentEvent): AgentEvent | null {
590
+ if (event.type !== 'prompt-received') return event
591
+ const pending = this.pendingPromptEchoes.get(sessionId)
592
+ if (!pending || pending.length === 0) return event
593
+ while (pending.length > 0 && Date.now() - pending[0]!.sentAt > 15_000) {
594
+ pending.shift()
595
+ }
596
+ if (pending.length === 0) {
597
+ this.pendingPromptEchoes.delete(sessionId)
598
+ return event
599
+ }
600
+ pending.shift()
601
+ if (pending.length === 0) {
602
+ this.pendingPromptEchoes.delete(sessionId)
603
+ } else {
604
+ this.pendingPromptEchoes.set(sessionId, pending)
605
+ }
606
+ return null
607
+ }
608
+
609
+ private consumeSettledTurn(sessionId: string): number {
610
+ const pendingTurns = Math.max(0, (this.pendingTurns.get(sessionId) ?? 1) - 1)
611
+ if (pendingTurns > 0) {
612
+ this.pendingTurns.set(sessionId, pendingTurns)
613
+ } else {
614
+ this.pendingTurns.delete(sessionId)
615
+ }
616
+ return pendingTurns
617
+ }
618
+
619
+ private clearPromptTracking(sessionId: string): void {
620
+ this.pendingPromptEchoes.delete(sessionId)
621
+ this.pendingTurns.delete(sessionId)
622
+ }
623
+
624
+ private fanOutEvent(sessionId: string, event: AgentEvent): void {
625
+ const payload = JSON.stringify({ type: 'agent:event', sessionId, event })
626
+ for (const [ws, sessions] of this.sessionsBySocket) {
627
+ if (!sessions.has(sessionId)) continue
628
+ if (ws.readyState !== WS_OPEN) continue
629
+ try {
630
+ ws.send(payload)
631
+ } catch {}
632
+ }
633
+ }
634
+
635
+ private broadcastSessionStatus(session: AgentSession): void {
636
+ const payload = JSON.stringify({ type: 'agent:session-status', session })
637
+ for (const ws of this.allSockets) {
638
+ if (ws.readyState !== WS_OPEN) continue
639
+ try {
640
+ ws.send(payload)
641
+ } catch {}
642
+ }
643
+ }
644
+
645
+ private respond(ws: WebSocket, id: unknown, result: unknown): void {
646
+ if (ws.readyState !== WS_OPEN) return
647
+ try {
648
+ ws.send(JSON.stringify({ id, result }))
649
+ } catch {}
650
+ }
651
+
652
+ private respondError(ws: WebSocket, id: unknown, error: string, code?: string): void {
653
+ if (ws.readyState !== WS_OPEN) return
654
+ try {
655
+ ws.send(JSON.stringify({ id, error, ...(code ? { code } : {}) }))
656
+ } catch {}
657
+ }
658
+ }
659
+
660
+ function mapFrameworkToProjectFramework(
661
+ fw: DiscoveredServer['framework'],
662
+ ): 'expo' | 'one' | 'rock' | 'unknown' {
663
+ if (fw === 'expo') return 'expo'
664
+ if (fw === 'one' || fw === 'vxrn') return 'one'
665
+ return 'unknown'
666
+ }