import { join, basename, resolve } from "path" import { readdir, rename, mkdir, rmdir, stat } from "fs/promises" import { homedir } from "os" export interface Session { branch: string worktree: string created_at: string prompt?: string in_review?: boolean } export interface State { sessions: Record } function statePath(repoRoot: string): string { return join(repoRoot, ".sandlot", "state.json") } export async function load(repoRoot: string): Promise { const path = statePath(repoRoot) const file = Bun.file(path) if (await file.exists()) { return await file.json() } return { sessions: {} } } export async function save(repoRoot: string, state: State): Promise { const path = statePath(repoRoot) const tmpPath = path + ".tmp" const dir = join(repoRoot, ".sandlot") await Bun.write(join(dir, ".gitkeep"), "") // ensure dir exists await Bun.write(tmpPath, JSON.stringify(state, null, 2) + "\n") await rename(tmpPath, path) } export async function getSession(repoRoot: string, branch: string): Promise { const state = await load(repoRoot) return state.sessions[branch] } export async function setSession(repoRoot: string, session: Session): Promise { const state = await load(repoRoot) state.sessions[session.branch] = session await save(repoRoot, state) await registerProject(repoRoot).catch(() => {}) } export async function removeSession(repoRoot: string, branch: string): Promise { const state = await load(repoRoot) delete state.sessions[branch] await save(repoRoot, state) } // ── Global state: ~/.sandlot/registry.json ───────────────────────── interface GlobalState { projects: string[] // repo root paths } const GLOBAL_DIR = join(homedir(), ".sandlot") const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json") async function withGlobalLock(fn: () => Promise): Promise { const lockPath = join(GLOBAL_DIR, "registry.lock") await mkdir(GLOBAL_DIR, { recursive: true }) for (let i = 0; i < 20; i++) { try { await mkdir(lockPath) } catch { // If the lock is older than 5 minutes, assume it's stale (crashed process) try { const info = await stat(lockPath) if (Date.now() - info.mtimeMs > 300_000) { await rmdir(lockPath).catch(() => {}) // Retry mkdir immediately to close the TOCTOU window try { await mkdir(lockPath) } catch { continue } } else { if (i === 19) throw new Error("Could not acquire registry lock") await Bun.sleep(50) continue } } catch { // Lock dir vanished between our mkdir and stat — retry immediately continue } } try { return await fn() } finally { await rmdir(lockPath).catch(() => {}) } } throw new Error("Could not acquire registry lock") } async function loadGlobal(): Promise { const file = Bun.file(GLOBAL_STATE_PATH) if (await file.exists()) { try { const data = await file.json() if (data && Array.isArray(data.projects)) return data } catch {} } return { projects: [] } } async function saveGlobal(gs: GlobalState): Promise { const tmpPath = GLOBAL_STATE_PATH + ".tmp" await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n") await rename(tmpPath, GLOBAL_STATE_PATH) } export function normalizePath(dir: string): string { return resolve(dir.replace(/^~(?=\/|$)/, homedir())) } /** Register a project directory in the global state. */ export async function registerProject(repoRoot: string): Promise { const normalized = normalizePath(repoRoot) await withGlobalLock(async () => { const gs = await loadGlobal() if (!gs.projects.includes(normalized)) { gs.projects.push(normalized) await saveGlobal(gs) } }) } /** Remove a project directory from the global state. */ export async function unregisterProject(dir: string): Promise { const target = normalizePath(dir) return withGlobalLock(async () => { const gs = await loadGlobal() const idx = gs.projects.indexOf(target) if (idx === -1) return false gs.projects.splice(idx, 1) await saveGlobal(gs) return true }) } /** Recursively scan a directory for .sandlot dirs and register their parent projects. */ export async function scanAndRegister(dir: string, maxDepth = 5): Promise { const root = normalizePath(dir) const found: string[] = [] async function walk(d: string, depth: number) { if (depth >= maxDepth) return let entries try { entries = await readdir(d, { withFileTypes: true }) } catch { return } const hasSandlot = entries.some(e => e.isDirectory() && e.name === ".sandlot") if (hasSandlot) { const stateFile = Bun.file(join(d, ".sandlot", "state.json")) if (await stateFile.exists()) { found.push(d) } } const children = entries.filter(e => e.isDirectory() && !e.isSymbolicLink() && !e.name.startsWith(".") && e.name !== "node_modules") await Promise.all(children.map(entry => walk(join(d, entry.name), depth + 1))) } await walk(root, 0) if (found.length > 0) { await withGlobalLock(async () => { const gs = await loadGlobal() const existing = new Set(gs.projects) let changed = false for (const p of found) { if (!existing.has(p)) { gs.projects.push(p) changed = true } } if (changed) await saveGlobal(gs) }) } return found } export interface GlobalSession extends Session { repo: string repoRoot: string } /** Load all sessions across all registered projects. Prunes stale entries. */ export async function loadAll(): Promise { const gs = await loadGlobal() const all: GlobalSession[] = [] const stale: string[] = [] await Promise.all(gs.projects.map(async (project) => { let st: State try { st = await load(project) } catch { // Corrupt state.json — skip but don't prune (self-heals on next session activity) return } if (Object.keys(st.sessions).length === 0) { try { await stat(join(project, ".sandlot")) } catch { stale.push(project) } return } const repo = basename(project) for (const s of Object.values(st.sessions)) { all.push({ ...s, repo, repoRoot: project }) } })) // Prune projects whose state files no longer exist if (stale.length > 0) { const staleSet = new Set(stale) await withGlobalLock(async () => { const fresh = await loadGlobal() fresh.projects = fresh.projects.filter(p => !staleSet.has(p)) await saveGlobal(fresh) }).catch(() => {}) } return all }