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.
- package/README.md +0 -1
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/expectations.ts +477 -0
- package/detox/gestures.ts +442 -0
- package/detox/index.ts +1436 -0
- package/detox/jest-environment.ts +86 -0
- package/detox/jest-preset.cjs +50 -0
- package/detox/matchers.ts +29 -0
- package/detox/navigation.ts +43 -0
- package/detox/run-test.ts +113 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-cli/bin.js +3 -3
- package/dist-cli/chunks/{agent-MQ7GLVIB.js → agent-2CWD6W6P.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-7KAFDQCN.js → agent-wrapper-5W3LOX6S.js} +2 -2
- package/dist-cli/chunks/{assert-TV46GUNU.js → assert-ZOMAMKRT.js} +2 -2
- package/dist-cli/chunks/auto-bootstrap-NYYSMTIM.js +2 -0
- package/dist-cli/chunks/beta-4K2SQACK.js +2 -0
- package/dist-cli/chunks/chunk-3HXQ7MJK.js +79 -0
- package/dist-cli/chunks/{chunk-4LS5MZAI.js → chunk-4K7BH2D4.js} +3 -3
- package/dist-cli/chunks/{chunk-FJYT7XL2.js → chunk-4OPRODFA.js} +2 -2
- package/dist-cli/chunks/{chunk-DP7O5MHK.js → chunk-4OWVPRZV.js} +2 -2
- package/dist-cli/chunks/{chunk-PM5NVKLP.js → chunk-5XCXOLG2.js} +2 -2
- package/dist-cli/chunks/chunk-67ZZ2CM5.js +1 -0
- package/dist-cli/chunks/{chunk-WN7M3QON.js → chunk-73UZXB4B.js} +2 -2
- package/dist-cli/chunks/{chunk-5DJXZIFZ.js → chunk-7NWNTUJF.js} +1 -1
- package/dist-cli/chunks/{chunk-Y2VJBRSP.js → chunk-7YHDJLO2.js} +1 -1
- package/dist-cli/chunks/{chunk-6NN2D4EJ.js → chunk-AJVTY6KY.js} +1 -1
- package/dist-cli/chunks/chunk-AWSQUOAS.js +67 -0
- package/dist-cli/chunks/{chunk-CJY3AVI7.js → chunk-BCBNVJVG.js} +1 -1
- package/dist-cli/chunks/{chunk-OYMFNU3M.js → chunk-BKBL6K2G.js} +1 -1
- package/dist-cli/chunks/{chunk-IBNRRAES.js → chunk-C3DPQZ4J.js} +2 -2
- package/dist-cli/chunks/chunk-D3ZSBIIY.js +2 -0
- package/dist-cli/chunks/{chunk-2AWQ7OB2.js → chunk-D4HUVLZR.js} +1 -1
- package/dist-cli/chunks/{chunk-F3HP444U.js → chunk-DUUSJDES.js} +1 -1
- package/dist-cli/chunks/{chunk-277XAALA.js → chunk-ELJLF4SG.js} +3 -3
- package/dist-cli/chunks/{chunk-RH4F2TF7.js → chunk-EQ7TFQ2F.js} +1 -1
- package/dist-cli/chunks/{chunk-HNWEELAE.js → chunk-EQCKGC4B.js} +1 -1
- package/dist-cli/chunks/chunk-FUCGLWNN.js +1 -0
- package/dist-cli/chunks/{chunk-FRM355UL.js → chunk-HYPJW65U.js} +2 -2
- package/dist-cli/chunks/chunk-IILJQCZA.js +2 -0
- package/dist-cli/chunks/{chunk-Y4BUVURT.js → chunk-KU6MSPAH.js} +2 -2
- package/dist-cli/chunks/{chunk-DM6WT7QM.js → chunk-OOOR7NT2.js} +1 -1
- package/dist-cli/chunks/{chunk-HAWOAQAG.js → chunk-P7WDNKOS.js} +3 -3
- package/dist-cli/chunks/{chunk-6TNANCQC.js → chunk-PPKKA5VW.js} +2 -2
- package/dist-cli/chunks/{chunk-JQ7ZXOXJ.js → chunk-PS2G44GT.js} +2 -2
- package/dist-cli/chunks/{chunk-ECJBV65H.js → chunk-QMSJR5R2.js} +2 -2
- package/dist-cli/chunks/{chunk-J2GYISVJ.js → chunk-RF4R2U46.js} +2 -2
- package/dist-cli/chunks/{chunk-VMXWC2JO.js → chunk-RIXUH3NK.js} +2 -2
- package/dist-cli/chunks/{chunk-2PY3UZVO.js → chunk-SFGUPL2X.js} +2 -2
- package/dist-cli/chunks/{chunk-572VSFNP.js → chunk-SQX5CAYG.js} +1 -1
- package/dist-cli/chunks/{chunk-NXATOWWF.js → chunk-SQZAC7C4.js} +1 -1
- package/dist-cli/chunks/{chunk-WTKTOL3C.js → chunk-SV7FOGJ3.js} +2 -2
- package/dist-cli/chunks/{chunk-JHJNODXN.js → chunk-TK3OJSEO.js} +2 -2
- package/dist-cli/chunks/{chunk-KASUZ5XV.js → chunk-TL7SIZ7S.js} +1 -1
- package/dist-cli/chunks/{chunk-6XZOEBTZ.js → chunk-V2GQ4WXJ.js} +2 -2
- package/dist-cli/chunks/{chunk-IP3QJLRH.js → chunk-VH7F45CN.js} +1 -1
- package/dist-cli/chunks/chunk-WNVNU2OW.js +4 -0
- package/dist-cli/chunks/{chunk-YUELRHGB.js → chunk-XQ2OBHBE.js} +2 -2
- package/dist-cli/chunks/{chunk-CYV6Y6YV.js → chunk-YCIA4BHJ.js} +2 -2
- package/dist-cli/chunks/chunk-ZSMMJMPA.js +1 -0
- package/dist-cli/chunks/cli-version-QB4VH24H.js +2 -0
- package/dist-cli/chunks/{compat-QLLWBTS3.js → compat-FWSEEGEH.js} +3 -3
- package/dist-cli/chunks/{config-2DSLDCXV.js → config-CYI2WAGP.js} +2 -2
- package/dist-cli/chunks/control-UXY7YQVX.js +2 -0
- package/dist-cli/chunks/{cpu-profile-GEIKHCPC.js → cpu-profile-IKAE3KTY.js} +2 -2
- package/dist-cli/chunks/{daemon-4EBUFN4D.js → daemon-ZUMF53YB.js} +2 -2
- package/dist-cli/chunks/{debug-WGD6XWOF.js → debug-P6KULKKS.js} +3 -3
- package/dist-cli/chunks/{detox-LNKGRZU6.js → detox-SPWAZCYG.js} +2 -2
- package/dist-cli/chunks/{device-AYKXKVIQ.js → device-JWEPK6I2.js} +2 -2
- package/dist-cli/chunks/{diagnose-TMXSDOOC.js → diagnose-IZODTXV2.js} +2 -2
- package/dist-cli/chunks/drivers-MK6WJKBC.js +2 -0
- package/dist-cli/chunks/{electron-QFPF7TBY.js → electron-R5GP6RVB.js} +3 -3
- package/dist-cli/chunks/flow-6O4GEOPJ.js +2 -0
- package/dist-cli/chunks/{hints-MXKRR4TG.js → hints-DYDNYX7N.js} +2 -2
- package/dist-cli/chunks/{home-paths-REMWQDAO.js → home-paths-GLMX5OKL.js} +2 -2
- package/dist-cli/chunks/{inspect-XGSQNFV7.js → inspect-FJOPCTY2.js} +3 -3
- package/dist-cli/chunks/install-A3TUGGHN.js +2 -0
- package/dist-cli/chunks/{install-desktop-NQG3RZSA.js → install-desktop-YPJZMZM5.js} +3 -3
- package/dist-cli/chunks/{keys-5QZWXL3F.js → keys-GSYPHWNY.js} +2 -2
- package/dist-cli/chunks/{launch-SBXOZWKO.js → launch-4G2PKW5X.js} +3 -3
- package/dist-cli/chunks/{login-EACQXE24.js → login-KJQGHA64.js} +4 -4
- package/dist-cli/chunks/{logout-IBQLMUML.js → logout-XM2SYH5C.js} +2 -2
- package/dist-cli/chunks/{maestro-LFYXUX7O.js → maestro-EOWGI7DG.js} +2 -2
- package/dist-cli/chunks/{preview-U4SBOEGQ.js → preview-F73TKK37.js} +2 -2
- package/dist-cli/chunks/{profile-GWS5ECMY.js → profile-22FDKBUO.js} +2 -2
- package/dist-cli/chunks/{react-QDHLMVYL.js → react-5L6VPFUP.js} +2 -2
- package/dist-cli/chunks/{record-BUEUWPDI.js → record-JZXCQ4IN.js} +2 -2
- package/dist-cli/chunks/runtime-EEBX7CFV.js +2 -0
- package/dist-cli/chunks/{runtime-delivery-G7L6RVZ7.js → runtime-delivery-LXUM3R4A.js} +2 -2
- package/dist-cli/chunks/{screenshot-T2HBA3VI.js → screenshot-HDRRG33Q.js} +2 -2
- package/dist-cli/chunks/{screenshot-mode-EG5HMIH3.js → screenshot-mode-WY63LZIX.js} +2 -2
- package/dist-cli/chunks/{screenshots-S52AFHTV.js → screenshots-MPV2ENL5.js} +2 -2
- package/dist-cli/chunks/{server-MFFVYUGG.js → server-5LBMCJ3G.js} +2 -2
- package/dist-cli/chunks/setup-repo-SZSYNKNI.js +2 -0
- package/dist-cli/chunks/{skills-HQGWBS2O.js → skills-BQ73YOBF.js} +2 -2
- package/dist-cli/chunks/{start-E3DRYY7W.js → start-2WU4W6ZU.js} +4 -4
- package/dist-cli/chunks/store-RE45SUBF.js +2 -0
- package/dist-cli/chunks/telemetry-DG6GJLCP.js +2 -0
- package/dist-cli/chunks/{test-ZY3EF62K.js → test-OVO4CQTG.js} +3 -3
- package/dist-cli/chunks/{three-mode-WSPKQCJ5.js → three-mode-BKM3KFM7.js} +2 -2
- package/dist-cli/chunks/{timeline-3XAB5EWZ.js → timeline-MDXGEDQL.js} +2 -2
- package/dist-cli/chunks/{upgrade-WNENPFM5.js → upgrade-JGQABWVF.js} +2 -2
- package/dist-cli/chunks/upload-UJNUA4ZV.js +2 -0
- package/dist-cli/chunks/{web-D2AOZY44.js → web-WYFAYQ72.js} +2 -2
- package/dist-cli/chunks/{what-happened-F43KNSG6.js → what-happened-PZW2KW6A.js} +2 -2
- package/dist-cli/chunks/{whoami-T22VBR7C.js → whoami-7ATWJQS6.js} +2 -2
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +44 -0
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1770 -0
- package/dist-lib/detox/jest-preset.cjs +50 -0
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +1 -1
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/index.cjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/demo-app-registry.cjs +809 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1269 -0
- package/dist-lib/skills.cjs +8322 -0
- package/dist-lib/vite-base.cjs +3 -3
- package/dist-lib/vite.cjs +1 -1
- package/package.json +39 -10
- package/scripts/demo-app-registry.ts +989 -0
- package/scripts/dev-server-scanner.ts +674 -0
- package/src/agent-daemon-client.ts +390 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +572 -0
- package/src/attached-projects.ts +536 -0
- package/src/auth/shared-session.ts +199 -0
- package/src/backend-origin.ts +49 -0
- package/src/beta.ts +21 -0
- package/src/bridge-constants.ts +10 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +30 -0
- package/src/codex-client.ts +215 -0
- package/src/config.ts +110 -0
- package/src/dev-bundle-resolution.ts +180 -0
- package/src/home-paths.ts +382 -0
- package/src/host/agent-host.ts +576 -0
- package/src/host/bridge-host.ts +2293 -0
- package/src/host/fetch-proxy-handler.ts +288 -0
- package/src/host/fetch-proxy-overrides.ts +39 -0
- package/src/host/open-url.ts +234 -0
- package/src/index.ts +9 -0
- package/src/metro-plugin.ts +207 -0
- package/src/native-dev-bundle-url.ts +62 -0
- package/src/native-seam-manifest.ts +313 -0
- package/src/profiles.ts +179 -0
- package/src/render-mode.ts +27 -0
- package/src/runtime-delivery.ts +334 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +438 -0
- package/src/screenshots/orchestrate.ts +244 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/skills/builtin/a11y-review.ts +126 -0
- package/src/skills/builtin/compat-check.ts +104 -0
- package/src/skills/builtin/perf-profile.ts +84 -0
- package/src/skills/builtin/screenshot-all.ts +46 -0
- package/src/skills/builtin/test-flow.ts +118 -0
- package/src/skills/builtin/visual-diff.ts +94 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/vite-plugin-one.ts +187 -0
- package/src/vite-plugin.ts +1381 -0
- package/src/worklets-babel.ts +132 -0
- package/dist-cli/chunks/auto-bootstrap-FQS4ZD2K.js +0 -2
- package/dist-cli/chunks/beta-VG7CDY2U.js +0 -2
- package/dist-cli/chunks/chunk-2OIBDYHW.js +0 -1
- package/dist-cli/chunks/chunk-6BNLVMXA.js +0 -1
- package/dist-cli/chunks/chunk-6XD6CBJM.js +0 -2
- package/dist-cli/chunks/chunk-CHQTO426.js +0 -1
- package/dist-cli/chunks/chunk-FAPYGVIU.js +0 -4
- package/dist-cli/chunks/chunk-PEHFE3LG.js +0 -64
- package/dist-cli/chunks/chunk-RXH2SLKF.js +0 -2
- package/dist-cli/chunks/chunk-UXQWC5ZR.js +0 -79
- package/dist-cli/chunks/chunk-XFQL74PF.js +0 -5
- package/dist-cli/chunks/cli-version-PWF6I6LY.js +0 -2
- package/dist-cli/chunks/control-UIOXGYXU.js +0 -2
- package/dist-cli/chunks/demo-app-registry-G3BDOFWC.js +0 -2
- package/dist-cli/chunks/drivers-IDQF34HP.js +0 -2
- package/dist-cli/chunks/flow-3JN3Y7RF.js +0 -2
- package/dist-cli/chunks/install-2N3YOOSN.js +0 -2
- package/dist-cli/chunks/runtime-PVB4VGUH.js +0 -2
- package/dist-cli/chunks/setup-repo-YOF7NV5D.js +0 -2
- package/dist-cli/chunks/store-MAI6D3UO.js +0 -2
- package/dist-cli/chunks/telemetry-RCQKCJTH.js +0 -2
- 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
|
+
}
|