import { join, basename, dirname, resolve } from "path" import { readdir, rename } 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) await registerProject(repoRoot).catch(() => {}) } 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) } 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/state.json ────────────────────────────── interface GlobalState { projects: string[] // repo root paths } function globalStatePath(): string { return join(homedir(), ".sandlot", "state.json") } async function loadGlobal(): Promise { const file = Bun.file(globalStatePath()) if (await file.exists()) { return await file.json() } return { projects: [] } } async function saveGlobal(gs: GlobalState): Promise { const path = globalStatePath() const tmpPath = path + ".tmp" await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n") await rename(tmpPath, path) } /** Register a project directory in the global state. */ export async function registerProject(repoRoot: string): Promise { const gs = await loadGlobal() if (!gs.projects.includes(repoRoot)) { gs.projects.push(repoRoot) await saveGlobal(gs) } } /** Remove a project directory from the global state. */ export async function unregisterProject(dir: string): Promise { const target = resolve(dir.replace(/^~/, homedir())) 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): Promise { const root = resolve(dir.replace(/^~/, homedir())) const registered: string[] = [] async function walk(d: string) { let entries try { entries = await readdir(d, { withFileTypes: true }) } catch { return } const hasSandlot = entries.some(e => e.isDirectory() && e.name === ".sandlot") if (hasSandlot) { // Verify it has a state.json const stateFile = Bun.file(join(d, ".sandlot", "state.json")) if (await stateFile.exists()) { await registerProject(d) registered.push(d) } } for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue await walk(join(d, entry.name)) } } await walk(root) return registered } export interface GlobalSession extends Session { repo: string } /** Load all sessions across all registered projects. */ export async function loadAll(): Promise { const gs = await loadGlobal() const all: GlobalSession[] = [] for (const project of gs.projects) { try { const st = await load(project) const repo = basename(project) for (const session of Object.values(st.sessions)) { all.push({ ...session, repo }) } } catch {} } return all }