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,536 @@
1
+ // durable data model for attached projects, preview attachments, and agent
2
+ // sessions. pure main-process state — no UI, no pty, no agent lifecycle.
3
+ //
4
+ // persistence: a single JSON file at userData/attached-projects.json holding
5
+ // all three collections. RMW goes through mutateStore() which re-reads from
6
+ // disk, applies the mutation, and writes via a tmp file + rename for crash
7
+ // safety. main.ts is single-threaded so there is no lock — "atomic" means
8
+ // no await between load and save.
9
+
10
+ import { randomBytes, createHash } from 'node:crypto'
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import { electronUserDataDir } from './home-paths'
14
+
15
+ // plan → attached-projects-and-agents.md §data model
16
+ export interface AttachedProject {
17
+ id: string
18
+ name: string
19
+ cwd: string
20
+ repoRoot?: string
21
+ sourceRoots: string[]
22
+ framework: 'expo' | 'one' | 'rock' | 'unknown'
23
+ bundleId?: string
24
+ knownBundleUrls: string[]
25
+ preferredProvider: 'codex' | 'claude'
26
+ preferredTransport: 'tmux' | 'pty'
27
+ editorOpenCommand?: string
28
+ moshiWebhookToken?: string
29
+ pinnedSourceResolutions: Record<string, string>
30
+ isolateDiscovery?: boolean
31
+ git?: { remote?: string; branch?: string }
32
+ telemetry: {
33
+ lastOpened: number
34
+ runsCompleted: number
35
+ /** rolling log of (timestamp, cost in USD) per completed turn. trimmed to
36
+ * the last 14 days on write so the file doesn't grow unboundedly. a
37
+ * 7-day window is the reporting default (see `costThisWeek`). */
38
+ costHistory?: Array<{ ts: number; usd: number }>
39
+ }
40
+ createdAt: number
41
+ updatedAt: number
42
+ }
43
+
44
+ export interface PreviewAttachment {
45
+ id: string
46
+ projectId: string | null
47
+ bundleUrl: string
48
+ simId: string
49
+ deviceModel: string
50
+ status: 'connecting' | 'live' | 'stale'
51
+ lastSeenAt: number
52
+ }
53
+
54
+ export interface AgentSession {
55
+ id: string
56
+ projectId: string
57
+ provider: 'codex' | 'claude'
58
+ transport: 'tmux' | 'pty'
59
+ cwd: string
60
+ /** stable uuid passed to `claude --session-id`. persisted so the file at
61
+ * `~/.claude/projects/<encoded-cwd>/<uuid>.jsonl` is reused across
62
+ * wrapper restarts, preserving conversational memory. generated once at
63
+ * session creation for provider=claude; unused for codex. */
64
+ claudeSessionUuid?: string
65
+ tmuxSessionName?: string
66
+ wrapperPid?: number
67
+ status: 'idle' | 'working' | 'needs-attention' | 'ended'
68
+ needsAttention: boolean
69
+ lastPrompt?: string
70
+ lastSummary?: string
71
+ lastTurnFiles?: string[]
72
+ currentlyEditing?: string
73
+ lastSeenAt: number
74
+ createdAt: number
75
+ }
76
+
77
+ interface StoreShape {
78
+ version: 1
79
+ attachedProjects: AttachedProject[]
80
+ previewAttachments: PreviewAttachment[]
81
+ agentSessions: AgentSession[]
82
+ }
83
+
84
+ const EMPTY_STORE: StoreShape = {
85
+ version: 1,
86
+ attachedProjects: [],
87
+ previewAttachments: [],
88
+ agentSessions: [],
89
+ }
90
+
91
+ let overrideDir: string | null = null
92
+
93
+ /** override the user-data directory. set by tests (to a tmp dir) and by the
94
+ * standalone CLI when it wants to read/write the same state electron uses.
95
+ * pass null to restore default resolution. */
96
+ export function setUserDataDir(dir: string | null): void {
97
+ overrideDir = dir
98
+ }
99
+
100
+ /** back-compat alias retained for existing test imports. */
101
+ export const __setUserDataDirForTests = setUserDataDir
102
+
103
+ /** resolve the user-data directory where attached-projects.json lives.
104
+ * precedence:
105
+ * 1. explicit override from setUserDataDir() (tests, CLI bootstrap)
106
+ * 2. SOOTSIM_USER_DATA_DIR env var (electron → wrapper handoff)
107
+ * 3. electronUserDataDir() — the canonical sootsim home, used by both
108
+ * electron (`app.setPath('userData', …)`) and the standalone CLI.
109
+ */
110
+ function userDataDir(): string {
111
+ if (overrideDir) return overrideDir
112
+ const fromEnv = process.env.SOOTSIM_USER_DATA_DIR
113
+ if (fromEnv) return fromEnv
114
+ return electronUserDataDir()
115
+ }
116
+
117
+ /** exposed for CLI status printing so `sootsim agent projects` can show the
118
+ * user which file it's reading from. */
119
+ export function getUserDataDir(): string {
120
+ return userDataDir()
121
+ }
122
+
123
+ function storeFile(): string {
124
+ return path.join(userDataDir(), 'attached-projects.json')
125
+ }
126
+
127
+ function cloneEmpty(): StoreShape {
128
+ return {
129
+ version: 1,
130
+ attachedProjects: [],
131
+ previewAttachments: [],
132
+ agentSessions: [],
133
+ }
134
+ }
135
+
136
+ export function loadStore(): StoreShape {
137
+ const file = storeFile()
138
+ let raw: string
139
+ try {
140
+ raw = fs.readFileSync(file, 'utf8')
141
+ } catch (err) {
142
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return cloneEmpty()
143
+ throw err
144
+ }
145
+ try {
146
+ const parsed = JSON.parse(raw) as Partial<StoreShape>
147
+ if (!parsed || typeof parsed !== 'object') throw new Error('not an object')
148
+ return {
149
+ version: 1,
150
+ attachedProjects: Array.isArray(parsed.attachedProjects)
151
+ ? parsed.attachedProjects
152
+ : [],
153
+ previewAttachments: Array.isArray(parsed.previewAttachments)
154
+ ? parsed.previewAttachments
155
+ : [],
156
+ agentSessions: Array.isArray(parsed.agentSessions) ? parsed.agentSessions : [],
157
+ }
158
+ } catch (err) {
159
+ // quarantine the corrupt file instead of silently returning empty — that
160
+ // path turns a partial-write into permanent data loss.
161
+ const quarantine = `${file}.corrupt-${Date.now()}`
162
+ try {
163
+ fs.renameSync(file, quarantine)
164
+ console.warn(
165
+ `[sootsim] attached-projects.json was unparseable; quarantined to ${quarantine}. ` +
166
+ `original error: ${(err as Error).message}`,
167
+ )
168
+ } catch {}
169
+ return cloneEmpty()
170
+ }
171
+ }
172
+
173
+ function writeStore(store: StoreShape): void {
174
+ const file = storeFile()
175
+ fs.mkdirSync(path.dirname(file), { recursive: true })
176
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
177
+ // explicit open + fsync so a crash between write and rename leaves the old
178
+ // file intact, not a zero-length scrap. without fsync, renameSync's
179
+ // atomicity only guarantees the directory entry swap — the data could still
180
+ // be pending in the page cache when the power drops.
181
+ const fd = fs.openSync(tmp, 'w', 0o600)
182
+ try {
183
+ fs.writeFileSync(fd, JSON.stringify(store, null, 2))
184
+ fs.fsyncSync(fd)
185
+ } finally {
186
+ fs.closeSync(fd)
187
+ }
188
+ fs.renameSync(tmp, file)
189
+ }
190
+
191
+ export function mutateStore(fn: (store: StoreShape) => void): StoreShape {
192
+ const store = loadStore()
193
+ fn(store)
194
+ writeStore(store)
195
+ return store
196
+ }
197
+
198
+ // --- ID helpers ---
199
+
200
+ export function projectIdForCwd(cwd: string): string {
201
+ return createHash('sha256').update(path.resolve(cwd)).digest('hex').slice(0, 16)
202
+ }
203
+
204
+ function newSessionId(): string {
205
+ return `s_${randomBytes(10).toString('hex')}`
206
+ }
207
+
208
+ function newPreviewId(): string {
209
+ return `pa_${randomBytes(10).toString('hex')}`
210
+ }
211
+
212
+ // --- project CRUD ---
213
+
214
+ /** upsert by canonicalized cwd — cwd is the unique index. `input.id` is
215
+ * ignored; the id is always derived from cwd so re-attaches merge cleanly. */
216
+ export function upsertProject(
217
+ input: Partial<AttachedProject> & { cwd: string },
218
+ ): AttachedProject {
219
+ const cwd = path.resolve(input.cwd)
220
+ const id = projectIdForCwd(cwd)
221
+ let result!: AttachedProject
222
+ mutateStore((store) => {
223
+ const existing = store.attachedProjects.find((p) => p.id === id)
224
+ if (existing) {
225
+ const merged: AttachedProject = {
226
+ ...existing,
227
+ ...input,
228
+ id,
229
+ cwd,
230
+ sourceRoots: input.sourceRoots ?? existing.sourceRoots,
231
+ knownBundleUrls: input.knownBundleUrls ?? existing.knownBundleUrls,
232
+ pinnedSourceResolutions:
233
+ input.pinnedSourceResolutions ?? existing.pinnedSourceResolutions,
234
+ telemetry: input.telemetry ?? existing.telemetry,
235
+ updatedAt: Date.now(),
236
+ createdAt: existing.createdAt,
237
+ }
238
+ const idx = store.attachedProjects.indexOf(existing)
239
+ store.attachedProjects[idx] = merged
240
+ result = merged
241
+ return
242
+ }
243
+ const now = Date.now()
244
+ const created: AttachedProject = {
245
+ id,
246
+ name: input.name ?? path.basename(cwd),
247
+ cwd,
248
+ repoRoot: input.repoRoot,
249
+ sourceRoots: input.sourceRoots ?? [cwd],
250
+ framework: input.framework ?? 'unknown',
251
+ bundleId: input.bundleId,
252
+ knownBundleUrls: input.knownBundleUrls ?? [],
253
+ preferredProvider: input.preferredProvider ?? 'codex',
254
+ preferredTransport: input.preferredTransport ?? 'tmux',
255
+ editorOpenCommand: input.editorOpenCommand,
256
+ moshiWebhookToken: input.moshiWebhookToken,
257
+ pinnedSourceResolutions: input.pinnedSourceResolutions ?? {},
258
+ isolateDiscovery: input.isolateDiscovery,
259
+ git: input.git,
260
+ telemetry: input.telemetry ?? { lastOpened: 0, runsCompleted: 0 },
261
+ createdAt: now,
262
+ updatedAt: now,
263
+ }
264
+ store.attachedProjects.push(created)
265
+ result = created
266
+ })
267
+ return result
268
+ }
269
+
270
+ export function findProjectById(id: string): AttachedProject | null {
271
+ return loadStore().attachedProjects.find((p) => p.id === id) ?? null
272
+ }
273
+
274
+ export function findProjectByCwd(cwd: string): AttachedProject | null {
275
+ const resolved = path.resolve(cwd)
276
+ return loadStore().attachedProjects.find((p) => p.cwd === resolved) ?? null
277
+ }
278
+
279
+ export function findProjectByBundleUrl(bundleUrl: string): AttachedProject | null {
280
+ // first exact match, then prefix match. the prefix path handles metro query
281
+ // param drift (?platform=ios&dev=true) so the same base URL binds.
282
+ const store = loadStore()
283
+ const exact = store.attachedProjects.find((p) => p.knownBundleUrls.includes(bundleUrl))
284
+ if (exact) return exact
285
+ const base = stripQuery(bundleUrl)
286
+ return (
287
+ store.attachedProjects.find((p) =>
288
+ p.knownBundleUrls.some((u) => stripQuery(u) === base),
289
+ ) ?? null
290
+ )
291
+ }
292
+
293
+ function stripQuery(url: string): string {
294
+ const q = url.indexOf('?')
295
+ return q >= 0 ? url.slice(0, q) : url
296
+ }
297
+
298
+ export function listProjects(): AttachedProject[] {
299
+ return loadStore().attachedProjects
300
+ }
301
+
302
+ const COST_HISTORY_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000
303
+
304
+ /** record a completed turn's cost into the project's rolling history. trims
305
+ * entries older than 14 days on write. `usd` is optional — turns without cost
306
+ * metadata (e.g. claude early result with cost omitted) still bump the
307
+ * runsCompleted counter but don't add a history entry. */
308
+ export function recordTurnTelemetry(
309
+ projectId: string,
310
+ input: { usd?: number; ts?: number } = {},
311
+ ): void {
312
+ mutateStore((store) => {
313
+ const project = store.attachedProjects.find((p) => p.id === projectId)
314
+ if (!project) return
315
+ const ts = input.ts ?? Date.now()
316
+ project.telemetry.runsCompleted = (project.telemetry.runsCompleted ?? 0) + 1
317
+ if (typeof input.usd === 'number' && Number.isFinite(input.usd) && input.usd >= 0) {
318
+ const history = project.telemetry.costHistory ?? []
319
+ const cutoff = ts - COST_HISTORY_MAX_AGE_MS
320
+ const trimmed = history.filter((e) => e.ts >= cutoff)
321
+ trimmed.push({ ts, usd: input.usd })
322
+ project.telemetry.costHistory = trimmed
323
+ }
324
+ project.updatedAt = ts
325
+ })
326
+ }
327
+
328
+ /** rolling 7-day cost for a project, or 0 if none recorded. */
329
+ export function costThisWeek(project: AttachedProject, now: number = Date.now()): number {
330
+ const cutoff = now - 7 * 24 * 60 * 60 * 1000
331
+ const history = project.telemetry.costHistory ?? []
332
+ let total = 0
333
+ for (const e of history) {
334
+ if (e.ts >= cutoff) total += e.usd
335
+ }
336
+ return total
337
+ }
338
+
339
+ export function deleteProject(id: string): void {
340
+ mutateStore((store) => {
341
+ store.attachedProjects = store.attachedProjects.filter((p) => p.id !== id)
342
+ // cascade: drop sessions + preview attachments tied to the project
343
+ store.agentSessions = store.agentSessions.filter((s) => s.projectId !== id)
344
+ store.previewAttachments = store.previewAttachments.filter(
345
+ (pa) => pa.projectId !== id,
346
+ )
347
+ })
348
+ }
349
+
350
+ // --- session CRUD ---
351
+
352
+ export function upsertSession(
353
+ input: Partial<AgentSession> & { projectId: string; provider: 'codex' | 'claude' },
354
+ ): AgentSession {
355
+ let result!: AgentSession
356
+ mutateStore((store) => {
357
+ if (input.id) {
358
+ const existing = store.agentSessions.find((s) => s.id === input.id)
359
+ if (existing) {
360
+ const merged: AgentSession = {
361
+ ...existing,
362
+ ...input,
363
+ lastSeenAt: Date.now(),
364
+ }
365
+ const idx = store.agentSessions.indexOf(existing)
366
+ store.agentSessions[idx] = merged
367
+ result = merged
368
+ return
369
+ }
370
+ }
371
+ const project = store.attachedProjects.find((p) => p.id === input.projectId)
372
+ if (!project) {
373
+ throw new Error(`upsertSession: no AttachedProject with id=${input.projectId}`)
374
+ }
375
+ const now = Date.now()
376
+ const created: AgentSession = {
377
+ id: input.id ?? newSessionId(),
378
+ projectId: input.projectId,
379
+ provider: input.provider,
380
+ transport: input.transport ?? project.preferredTransport,
381
+ cwd: input.cwd ?? project.cwd,
382
+ claudeSessionUuid: input.claudeSessionUuid,
383
+ tmuxSessionName: input.tmuxSessionName,
384
+ wrapperPid: input.wrapperPid,
385
+ status: input.status ?? 'idle',
386
+ needsAttention: input.needsAttention ?? false,
387
+ lastPrompt: input.lastPrompt,
388
+ lastSummary: input.lastSummary,
389
+ lastTurnFiles: input.lastTurnFiles,
390
+ currentlyEditing: input.currentlyEditing,
391
+ lastSeenAt: now,
392
+ createdAt: now,
393
+ }
394
+ store.agentSessions.push(created)
395
+ result = created
396
+ })
397
+ return result
398
+ }
399
+
400
+ export function findSessionById(id: string): AgentSession | null {
401
+ return loadStore().agentSessions.find((s) => s.id === id) ?? null
402
+ }
403
+
404
+ export function listSessions(projectId?: string): AgentSession[] {
405
+ const all = loadStore().agentSessions
406
+ return projectId ? all.filter((s) => s.projectId === projectId) : all
407
+ }
408
+
409
+ export function updateSessionStatus(id: string, patch: Partial<AgentSession>): void {
410
+ mutateStore((store) => {
411
+ const existing = store.agentSessions.find((s) => s.id === id)
412
+ if (!existing) return
413
+ const idx = store.agentSessions.indexOf(existing)
414
+ store.agentSessions[idx] = {
415
+ ...existing,
416
+ ...patch,
417
+ id: existing.id,
418
+ projectId: existing.projectId,
419
+ createdAt: existing.createdAt,
420
+ lastSeenAt: Date.now(),
421
+ }
422
+ })
423
+ }
424
+
425
+ export function deleteSession(id: string): void {
426
+ mutateStore((store) => {
427
+ store.agentSessions = store.agentSessions.filter((s) => s.id !== id)
428
+ })
429
+ }
430
+
431
+ // --- preview attachment CRUD ---
432
+
433
+ export function upsertPreviewAttachment(
434
+ input: Partial<PreviewAttachment> & { bundleUrl: string; simId: string },
435
+ ): PreviewAttachment {
436
+ let result!: PreviewAttachment
437
+ mutateStore((store) => {
438
+ const existing = store.previewAttachments.find(
439
+ (pa) => pa.bundleUrl === input.bundleUrl && pa.simId === input.simId,
440
+ )
441
+ if (existing) {
442
+ const merged: PreviewAttachment = {
443
+ ...existing,
444
+ ...input,
445
+ lastSeenAt: Date.now(),
446
+ }
447
+ const idx = store.previewAttachments.indexOf(existing)
448
+ store.previewAttachments[idx] = merged
449
+ result = merged
450
+ return
451
+ }
452
+ const created: PreviewAttachment = {
453
+ id: input.id ?? newPreviewId(),
454
+ projectId: input.projectId ?? null,
455
+ bundleUrl: input.bundleUrl,
456
+ simId: input.simId,
457
+ deviceModel: input.deviceModel ?? 'unknown',
458
+ status: input.status ?? 'connecting',
459
+ lastSeenAt: Date.now(),
460
+ }
461
+ store.previewAttachments.push(created)
462
+ result = created
463
+ })
464
+ return result
465
+ }
466
+
467
+ export function listPreviewAttachments(projectId?: string): PreviewAttachment[] {
468
+ const all = loadStore().previewAttachments
469
+ return projectId ? all.filter((pa) => pa.projectId === projectId) : all
470
+ }
471
+
472
+ export function deletePreviewAttachment(id: string): void {
473
+ mutateStore((store) => {
474
+ store.previewAttachments = store.previewAttachments.filter((pa) => pa.id !== id)
475
+ })
476
+ }
477
+
478
+ // --- seed from demo registry ---
479
+
480
+ /** seed the store from packages/sootsim/scripts/demo-app-registry. only runs
481
+ * when the store is completely empty (never re-seeds) and only adds apps
482
+ * whose `dir` exists on disk (concern 6.4 — demo dirs vary per machine). */
483
+ export async function seedFromDemoAppRegistry(): Promise<void> {
484
+ const existing = loadStore().attachedProjects
485
+ if (existing.length > 0) return
486
+ let APPS: unknown
487
+ try {
488
+ const mod = (await import('sootsim/scripts/demo-app-registry')) as {
489
+ APPS: Array<{
490
+ name: string
491
+ label: string
492
+ dir: string
493
+ preferredPort: number
494
+ framework: 'expo' | 'one' | 'rock'
495
+ }>
496
+ }
497
+ APPS = mod.APPS
498
+ } catch (err) {
499
+ console.warn(
500
+ '[sootsim] seedFromDemoAppRegistry: could not load demo registry:',
501
+ (err as Error).message,
502
+ )
503
+ return
504
+ }
505
+ if (!Array.isArray(APPS)) return
506
+ const apps = APPS as Array<{
507
+ name: string
508
+ label: string
509
+ dir: string
510
+ preferredPort: number
511
+ framework: 'expo' | 'one' | 'rock'
512
+ }>
513
+ mutateStore((store) => {
514
+ for (const app of apps) {
515
+ if (!fs.existsSync(app.dir)) continue
516
+ const cwd = path.resolve(app.dir)
517
+ const id = projectIdForCwd(cwd)
518
+ if (store.attachedProjects.some((p) => p.id === id)) continue
519
+ const now = Date.now()
520
+ store.attachedProjects.push({
521
+ id,
522
+ name: app.label,
523
+ cwd,
524
+ sourceRoots: [cwd],
525
+ framework: app.framework,
526
+ knownBundleUrls: [`http://localhost:${app.preferredPort}/index.bundle`],
527
+ preferredProvider: 'codex',
528
+ preferredTransport: 'tmux',
529
+ pinnedSourceResolutions: {},
530
+ telemetry: { lastOpened: 0, runsCompleted: 0 },
531
+ createdAt: now,
532
+ updatedAt: now,
533
+ })
534
+ }
535
+ })
536
+ }