diff --git a/src/cli.ts b/src/cli.ts
index c5f081a..723323e 100755
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -4,6 +4,7 @@ import { Command, Option } from "commander"
import { yellow, reset } from "./fmt.ts"
import * as git from "./git.ts"
import * as state from "./state.ts"
+import { action as configAction } from "./commands/config.ts"
import { action as newAction } from "./commands/new.ts"
import { action as listAction } from "./commands/list.ts"
import { action as openAction } from "./commands/open.ts"
@@ -53,8 +54,6 @@ program
.description("Show all active sessions")
.option("--json", "Output as JSON")
.option("-a, --all", "Show sessions across all projects")
- .option("--add
", "Scan a directory for sandlot projects and register them")
- .option("-r, --remove ", "Remove a project from the registry")
.action(listAction)
program
diff --git a/src/commands/list.ts b/src/commands/list.ts
index be08e25..708ab97 100644
--- a/src/commands/list.ts
+++ b/src/commands/list.ts
@@ -4,9 +4,9 @@ import { stat } from "fs/promises"
import * as git from "../git.ts"
import * as vm from "../vm.ts"
import * as state from "../state.ts"
-import { die, reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts"
+import { reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts"
-// ── Shared rendering ─────────────────────────────────────────────────
+// ── Rendering ────────────────────────────────────────────────────────
const styles: Record = {
idle: { icon: `${dim}◯${reset}`, color: dim },
@@ -31,16 +31,12 @@ function renderSessions(
const status = statusMap.get(s) ?? "idle"
const { icon, color: bc } = styles[status]
const maxPrompt = cols - prefixWidth
- const truncated = maxPrompt < 1 ? "" : prompt.length <= maxPrompt ? prompt : maxPrompt > 3 ? prompt.slice(0, maxPrompt - 3) + "..." : prompt.slice(0, maxPrompt)
+ const truncated = prompt.length <= maxPrompt ? prompt : prompt.slice(0, maxPrompt - 3) + "..."
console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
}
}
-function renderLegend() {
- console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⦿ review${reset}`)
-}
-
-// ── Shared logic ─────────────────────────────────────────────────────
+// ── Status resolution ────────────────────────────────────────────────
async function resolveStatus(
s: { branch: string; worktree: string; in_review?: boolean },
@@ -101,13 +97,9 @@ async function backfillPrompts(sessions: { worktree: string; prompt?: string }[]
}
}
-// ── Commands ─────────────────────────────────────────────────────────
+// ── Command ──────────────────────────────────────────────────────────
-export async function action(opts: { json?: boolean; all?: boolean; add?: string; remove?: string }) {
- if (opts.add) return actionAdd(opts.add)
- if (opts.remove) return actionRemove(opts.remove)
-
- // Load sessions with repoRoot attached
+export async function action(opts: { json?: boolean; all?: boolean }) {
let sessions: state.GlobalSession[]
if (opts.all) {
sessions = await state.loadAll()
@@ -128,7 +120,7 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
const results = await Promise.all(sessions.map(s => resolveStatus(s, vmRunning)))
const statusMap = new Map(sessions.map((s, i) => [s, results[i]]))
- if (vmRunning) await clearStaleReviews(sessions, results)
+ await clearStaleReviews(sessions, results)
if (opts.json) {
const withStatus = sessions.map(s => ({ ...s, status: statusMap.get(s) ?? "idle" }))
@@ -146,38 +138,6 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
renderSessions(sessions, statusMap)
}
- renderLegend()
+ console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⦿ review${reset}`)
if (!vmRunning) console.log(`\n${red}VM is not running.${reset}`)
}
-
-async function actionAdd(dir: string) {
- let registered: string[]
- try {
- registered = await state.scanAndRegister(dir)
- } catch (e) {
- return die(e instanceof Error ? e.message : `Failed to scan ${dir}`)
- }
- if (registered.length === 0) {
- console.log(`No sandlot projects found under ${dir}`)
- } else {
- for (const p of registered) {
- console.log(`${green}+${reset} ${p}`)
- }
- console.log(`\nRegistered ${registered.length} project${registered.length === 1 ? "" : "s"}.`)
- }
-}
-
-async function actionRemove(dir: string) {
- const resolved = state.normalizePath(dir)
- let removed = false
- try {
- removed = await state.unregisterProject(dir)
- } catch {
- return die("Could not acquire registry lock")
- }
- if (removed) {
- console.log(`${red}-${reset} ${resolved}`)
- } else {
- die(`Project not found in registry: ${resolved}`)
- }
-}
diff --git a/src/state.ts b/src/state.ts
index 0e3944e..452cd92 100644
--- a/src/state.ts
+++ b/src/state.ts
@@ -1,5 +1,5 @@
-import { join, basename, resolve } from "path"
-import { readdir, rename, mkdir, rmdir, stat } from "fs/promises"
+import { join, dirname } from "path"
+import { readdir, rename } from "fs/promises"
import { homedir } from "os"
export interface Session {
@@ -45,7 +45,6 @@ export async function setSession(repoRoot: string, session: Session): Promise {})
}
export async function removeSession(repoRoot: string, branch: string): Promise {
@@ -54,171 +53,51 @@ export async function removeSession(repoRoot: string, branch: string): Promise(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 { projects: data.projects.filter((p: unknown) => typeof p === "string") }
- } 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()))
-}
-
-async function registerPaths(paths: string[]): Promise {
- if (paths.length === 0) return
- await withGlobalLock(async () => {
- const gs = await loadGlobal()
- const existing = new Set(gs.projects)
- let changed = false
- for (const p of paths) {
- if (!existing.has(p)) {
- gs.projects.push(p)
- changed = true
- }
- }
- if (changed) await saveGlobal(gs)
- })
-}
-
-/** Register a project directory in the global state. */
-export async function registerProject(repoRoot: string): Promise {
- await registerPaths([normalizePath(repoRoot)])
-}
-
-/** 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")
- for (const entry of children) await walk(join(d, entry.name), depth + 1)
- }
-
- await walk(root, 0)
- await registerPaths(found)
- return found
-}
-
export interface GlobalSession extends Session {
repo: string
repoRoot: string
}
-/** Load all sessions across all registered projects. Prunes stale entries. */
+/** Discover all sessions across all repos by scanning ~/.sandlot/ */
export async function loadAll(): Promise {
- const gs = await loadGlobal()
+ const sandlotDir = join(homedir(), ".sandlot")
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 })
- }
- }))
+ let repoDirs
+ try {
+ repoDirs = await readdir(sandlotDir, { withFileTypes: true })
+ } catch {
+ return []
+ }
- // 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(() => {})
+ for (const entry of repoDirs) {
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue
+ const repoDir = join(sandlotDir, entry.name)
+
+ // Find the main repo root from a worktree's .git pointer
+ let repoRoot: string | null = null
+ const branchEntries = await readdir(repoDir, { withFileTypes: true }).catch(() => [])
+
+ for (const be of branchEntries) {
+ if (!be.isDirectory() || be.name.startsWith(".")) continue
+ const dotGit = await Bun.file(join(repoDir, be.name, ".git")).text().catch(() => "")
+ const m = dotGit.match(/^gitdir:\s*(.+)/m)
+ if (m) {
+ // gitdir: /path/to/repo/.git/worktrees/
+ const mainGit = m[1].trim().replace(/\/worktrees\/[^/]+$/, "")
+ repoRoot = dirname(mainGit)
+ break
+ }
+ }
+
+ if (repoRoot) {
+ try {
+ const st = await load(repoRoot)
+ for (const session of Object.values(st.sessions)) {
+ all.push({ ...session, repo: entry.name, repoRoot })
+ }
+ } catch {}
+ }
}
return all