// durable data model for attached projects, preview attachments, and agent // sessions. pure main-process state — no UI, no pty, no agent lifecycle. // // persistence: a single JSON file at userData/attached-projects.json holding // all three collections. RMW goes through mutateStore() which re-reads from // disk, applies the mutation, and writes via a tmp file + rename for crash // safety. main.ts is single-threaded so there is no lock — "atomic" means // no await between load and save. import { randomBytes, createHash } from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import { electronUserDataDir } from './home-paths.ts' // plan → attached-projects-and-agents.md §data model export interface AttachedProject { id: string name: string cwd: string repoRoot?: string sourceRoots: string[] framework: 'expo' | 'one' | 'rock' | 'unknown' bundleId?: string knownBundleUrls: string[] preferredProvider: 'codex' | 'claude' preferredTransport: 'tmux' | 'pty' editorOpenCommand?: string moshiWebhookToken?: string pinnedSourceResolutions: Record isolateDiscovery?: boolean git?: { remote?: string; branch?: string } telemetry: { lastOpened: number runsCompleted: number /** rolling log of (timestamp, cost in USD) per completed turn. trimmed to * the last 14 days on write so the file doesn't grow unboundedly. a * 7-day window is the reporting default (see `costThisWeek`). */ costHistory?: Array<{ ts: number; usd: number }> } createdAt: number updatedAt: number } export interface PreviewAttachment { id: string projectId: string | null bundleUrl: string simId: string deviceModel: string status: 'connecting' | 'live' | 'stale' lastSeenAt: number } export interface AgentSession { id: string projectId: string provider: 'codex' | 'claude' transport: 'tmux' | 'pty' cwd: string /** stable uuid passed to `claude --session-id`. persisted so the file at * `~/.claude/projects//.jsonl` is reused across * wrapper restarts, preserving conversational memory. generated once at * session creation for provider=claude; unused for codex. */ claudeSessionUuid?: string tmuxSessionName?: string wrapperPid?: number status: 'idle' | 'working' | 'needs-attention' | 'ended' needsAttention: boolean lastPrompt?: string lastSummary?: string lastTurnFiles?: string[] currentlyEditing?: string lastSeenAt: number createdAt: number } interface StoreShape { version: 1 attachedProjects: AttachedProject[] previewAttachments: PreviewAttachment[] agentSessions: AgentSession[] } const EMPTY_STORE: StoreShape = { version: 1, attachedProjects: [], previewAttachments: [], agentSessions: [], } let overrideDir: string | null = null /** override the user-data directory. set by tests (to a tmp dir) and by the * standalone CLI when it wants to read/write the same state electron uses. * pass null to restore default resolution. */ export function setUserDataDir(dir: string | null): void { overrideDir = dir } /** back-compat alias retained for existing test imports. */ export const __setUserDataDirForTests = setUserDataDir /** resolve the user-data directory where attached-projects.json lives. * precedence: * 1. explicit override from setUserDataDir() (tests, CLI bootstrap) * 2. SOOTSIM_USER_DATA_DIR env var (electron → wrapper handoff) * 3. electronUserDataDir() — the canonical sootsim home, used by both * electron (`app.setPath('userData', …)`) and the standalone CLI. */ function userDataDir(): string { if (overrideDir) return overrideDir const fromEnv = process.env.SOOTSIM_USER_DATA_DIR if (fromEnv) return fromEnv return electronUserDataDir() } /** exposed for CLI status printing so `sootsim agent projects` can show the * user which file it's reading from. */ export function getUserDataDir(): string { return userDataDir() } function storeFile(): string { return path.join(userDataDir(), 'attached-projects.json') } function cloneEmpty(): StoreShape { return { version: 1, attachedProjects: [], previewAttachments: [], agentSessions: [], } } export function loadStore(): StoreShape { const file = storeFile() let raw: string try { raw = fs.readFileSync(file, 'utf8') } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return cloneEmpty() throw err } try { const parsed = JSON.parse(raw) as Partial if (!parsed || typeof parsed !== 'object') throw new Error('not an object') return { version: 1, attachedProjects: Array.isArray(parsed.attachedProjects) ? parsed.attachedProjects : [], previewAttachments: Array.isArray(parsed.previewAttachments) ? parsed.previewAttachments : [], agentSessions: Array.isArray(parsed.agentSessions) ? parsed.agentSessions : [], } } catch (err) { // quarantine the corrupt file instead of silently returning empty — that // path turns a partial-write into permanent data loss. const quarantine = `${file}.corrupt-${Date.now()}` try { fs.renameSync(file, quarantine) console.warn( `[sootsim] attached-projects.json was unparseable; quarantined to ${quarantine}. ` + `original error: ${(err as Error).message}`, ) } catch {} return cloneEmpty() } } function writeStore(store: StoreShape): void { const file = storeFile() fs.mkdirSync(path.dirname(file), { recursive: true }) const tmp = `${file}.tmp-${process.pid}-${Date.now()}` // explicit open + fsync so a crash between write and rename leaves the old // file intact, not a zero-length scrap. without fsync, renameSync's // atomicity only guarantees the directory entry swap — the data could still // be pending in the page cache when the power drops. const fd = fs.openSync(tmp, 'w', 0o600) try { fs.writeFileSync(fd, JSON.stringify(store, null, 2)) fs.fsyncSync(fd) } finally { fs.closeSync(fd) } fs.renameSync(tmp, file) } export function mutateStore(fn: (store: StoreShape) => void): StoreShape { const store = loadStore() fn(store) writeStore(store) return store } // --- ID helpers --- export function projectIdForCwd(cwd: string): string { return createHash('sha256').update(path.resolve(cwd)).digest('hex').slice(0, 16) } function newSessionId(): string { return `s_${randomBytes(10).toString('hex')}` } function newPreviewId(): string { return `pa_${randomBytes(10).toString('hex')}` } // --- project CRUD --- /** upsert by canonicalized cwd — cwd is the unique index. `input.id` is * ignored; the id is always derived from cwd so re-attaches merge cleanly. */ export function upsertProject( input: Partial & { cwd: string }, ): AttachedProject { const cwd = path.resolve(input.cwd) const id = projectIdForCwd(cwd) let result!: AttachedProject mutateStore((store) => { const existing = store.attachedProjects.find((p) => p.id === id) if (existing) { const merged: AttachedProject = { ...existing, ...input, id, cwd, sourceRoots: input.sourceRoots ?? existing.sourceRoots, knownBundleUrls: input.knownBundleUrls ?? existing.knownBundleUrls, pinnedSourceResolutions: input.pinnedSourceResolutions ?? existing.pinnedSourceResolutions, telemetry: input.telemetry ?? existing.telemetry, updatedAt: Date.now(), createdAt: existing.createdAt, } const idx = store.attachedProjects.indexOf(existing) store.attachedProjects[idx] = merged result = merged return } const now = Date.now() const created: AttachedProject = { id, name: input.name ?? path.basename(cwd), cwd, repoRoot: input.repoRoot, sourceRoots: input.sourceRoots ?? [cwd], framework: input.framework ?? 'unknown', bundleId: input.bundleId, knownBundleUrls: input.knownBundleUrls ?? [], preferredProvider: input.preferredProvider ?? 'codex', preferredTransport: input.preferredTransport ?? 'tmux', editorOpenCommand: input.editorOpenCommand, moshiWebhookToken: input.moshiWebhookToken, pinnedSourceResolutions: input.pinnedSourceResolutions ?? {}, isolateDiscovery: input.isolateDiscovery, git: input.git, telemetry: input.telemetry ?? { lastOpened: 0, runsCompleted: 0 }, createdAt: now, updatedAt: now, } store.attachedProjects.push(created) result = created }) return result } export function findProjectById(id: string): AttachedProject | null { return loadStore().attachedProjects.find((p) => p.id === id) ?? null } export function findProjectByCwd(cwd: string): AttachedProject | null { const resolved = path.resolve(cwd) return loadStore().attachedProjects.find((p) => p.cwd === resolved) ?? null } export function findProjectByBundleUrl(bundleUrl: string): AttachedProject | null { // first exact match, then prefix match. the prefix path handles metro query // param drift (?platform=ios&dev=true) so the same base URL binds. const store = loadStore() const exact = store.attachedProjects.find((p) => p.knownBundleUrls.includes(bundleUrl)) if (exact) return exact const base = stripQuery(bundleUrl) return ( store.attachedProjects.find((p) => p.knownBundleUrls.some((u) => stripQuery(u) === base), ) ?? null ) } function stripQuery(url: string): string { const q = url.indexOf('?') return q >= 0 ? url.slice(0, q) : url } export function listProjects(): AttachedProject[] { return loadStore().attachedProjects } const COST_HISTORY_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000 /** record a completed turn's cost into the project's rolling history. trims * entries older than 14 days on write. `usd` is optional — turns without cost * metadata (e.g. claude early result with cost omitted) still bump the * runsCompleted counter but don't add a history entry. */ export function recordTurnTelemetry( projectId: string, input: { usd?: number; ts?: number } = {}, ): void { mutateStore((store) => { const project = store.attachedProjects.find((p) => p.id === projectId) if (!project) return const ts = input.ts ?? Date.now() project.telemetry.runsCompleted = (project.telemetry.runsCompleted ?? 0) + 1 if (typeof input.usd === 'number' && Number.isFinite(input.usd) && input.usd >= 0) { const history = project.telemetry.costHistory ?? [] const cutoff = ts - COST_HISTORY_MAX_AGE_MS const trimmed = history.filter((e) => e.ts >= cutoff) trimmed.push({ ts, usd: input.usd }) project.telemetry.costHistory = trimmed } project.updatedAt = ts }) } /** rolling 7-day cost for a project, or 0 if none recorded. */ export function costThisWeek(project: AttachedProject, now: number = Date.now()): number { const cutoff = now - 7 * 24 * 60 * 60 * 1000 const history = project.telemetry.costHistory ?? [] let total = 0 for (const e of history) { if (e.ts >= cutoff) total += e.usd } return total } export function deleteProject(id: string): void { mutateStore((store) => { store.attachedProjects = store.attachedProjects.filter((p) => p.id !== id) // cascade: drop sessions + preview attachments tied to the project store.agentSessions = store.agentSessions.filter((s) => s.projectId !== id) store.previewAttachments = store.previewAttachments.filter( (pa) => pa.projectId !== id, ) }) } // --- session CRUD --- export function upsertSession( input: Partial & { projectId: string; provider: 'codex' | 'claude' }, ): AgentSession { let result!: AgentSession mutateStore((store) => { if (input.id) { const existing = store.agentSessions.find((s) => s.id === input.id) if (existing) { const merged: AgentSession = { ...existing, ...input, lastSeenAt: Date.now(), } const idx = store.agentSessions.indexOf(existing) store.agentSessions[idx] = merged result = merged return } } const project = store.attachedProjects.find((p) => p.id === input.projectId) if (!project) { throw new Error(`upsertSession: no AttachedProject with id=${input.projectId}`) } const now = Date.now() const created: AgentSession = { id: input.id ?? newSessionId(), projectId: input.projectId, provider: input.provider, transport: input.transport ?? project.preferredTransport, cwd: input.cwd ?? project.cwd, claudeSessionUuid: input.claudeSessionUuid, tmuxSessionName: input.tmuxSessionName, wrapperPid: input.wrapperPid, status: input.status ?? 'idle', needsAttention: input.needsAttention ?? false, lastPrompt: input.lastPrompt, lastSummary: input.lastSummary, lastTurnFiles: input.lastTurnFiles, currentlyEditing: input.currentlyEditing, lastSeenAt: now, createdAt: now, } store.agentSessions.push(created) result = created }) return result } export function findSessionById(id: string): AgentSession | null { return loadStore().agentSessions.find((s) => s.id === id) ?? null } export function listSessions(projectId?: string): AgentSession[] { const all = loadStore().agentSessions return projectId ? all.filter((s) => s.projectId === projectId) : all } export function applySessionStatusPatch( existing: AgentSession, patch: Partial, now = Date.now(), ): AgentSession { return { ...existing, ...patch, id: existing.id, projectId: existing.projectId, createdAt: existing.createdAt, lastSeenAt: now, } } export function updateSessionStatuses( updates: ReadonlyArray<{ id: string; patch: Partial }>, ): void { if (updates.length === 0) return mutateStore((store) => { const now = Date.now() const indexById = new Map( store.agentSessions.map((session, index) => [session.id, index]), ) for (const { id, patch } of updates) { const idx = indexById.get(id) if (idx === undefined) continue store.agentSessions[idx] = applySessionStatusPatch( store.agentSessions[idx]!, patch, now, ) } }) } export function updateSessionStatus(id: string, patch: Partial): void { updateSessionStatuses([{ id, patch }]) } export function deleteSession(id: string): void { mutateStore((store) => { store.agentSessions = store.agentSessions.filter((s) => s.id !== id) }) } // --- preview attachment CRUD --- export function upsertPreviewAttachment( input: Partial & { bundleUrl: string; simId: string }, ): PreviewAttachment { let result!: PreviewAttachment mutateStore((store) => { const existing = store.previewAttachments.find( (pa) => pa.bundleUrl === input.bundleUrl && pa.simId === input.simId, ) if (existing) { const merged: PreviewAttachment = { ...existing, ...input, lastSeenAt: Date.now(), } const idx = store.previewAttachments.indexOf(existing) store.previewAttachments[idx] = merged result = merged return } const created: PreviewAttachment = { id: input.id ?? newPreviewId(), projectId: input.projectId ?? null, bundleUrl: input.bundleUrl, simId: input.simId, deviceModel: input.deviceModel ?? 'unknown', status: input.status ?? 'connecting', lastSeenAt: Date.now(), } store.previewAttachments.push(created) result = created }) return result } export function listPreviewAttachments(projectId?: string): PreviewAttachment[] { const all = loadStore().previewAttachments return projectId ? all.filter((pa) => pa.projectId === projectId) : all } export function deletePreviewAttachment(id: string): void { mutateStore((store) => { store.previewAttachments = store.previewAttachments.filter((pa) => pa.id !== id) }) } // --- seed from demo registry --- /** seed the store from packages/sootsim/scripts/demo-app-registry. only runs * when the store is completely empty (never re-seeds) and only adds apps * whose `dir` exists on disk (concern 6.4 — demo dirs vary per machine). */ export async function seedFromDemoAppRegistry(): Promise { const existing = loadStore().attachedProjects if (existing.length > 0) return // optional internal-only registry — resolves to [] on a published install // (the registry is not shipped), so this seed is a no-op for real users and // never pins their ports to internal demo apps. const { loadOptionalDemoApps } = await import('../scripts/optional-demo-registry.ts') const apps = await loadOptionalDemoApps() if (apps.length === 0) return mutateStore((store) => { for (const app of apps) { if (!fs.existsSync(app.dir)) continue const cwd = path.resolve(app.dir) const id = projectIdForCwd(cwd) if (store.attachedProjects.some((p) => p.id === id)) continue const now = Date.now() store.attachedProjects.push({ id, name: app.label, cwd, sourceRoots: [cwd], framework: app.framework, knownBundleUrls: [`http://localhost:${app.preferredPort}/index.bundle`], preferredProvider: 'codex', preferredTransport: 'tmux', pinnedSourceResolutions: {}, telemetry: { lastOpened: 0, runsCompleted: 0 }, createdAt: now, updatedAt: now, }) } }) }