diff --git a/src/cli.ts b/src/cli.ts
index 39dcc36..c5f081a 100755
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -52,6 +52,9 @@ program
.command("list")
.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 b21e208..30ee151 100644
--- a/src/commands/list.ts
+++ b/src/commands/list.ts
@@ -4,7 +4,11 @@ import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts"
-export async function action(opts: { json?: boolean }) {
+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)
+ if (opts.all) return actionAll(opts)
+
const root = await git.repoRoot()
const st = await state.load(root)
const sessions = Object.values(st.sessions)
@@ -100,3 +104,94 @@ export async function action(opts: { json?: boolean }) {
console.log(`\n${red}VM is not running.${reset}`)
}
}
+
+async function actionAdd(dir: string) {
+ const registered = await state.scanAndRegister(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 removed = await state.unregisterProject(dir)
+ if (removed) {
+ console.log(`${red}-${reset} ${dir}`)
+ } else {
+ console.log(`Project not found in registry: ${dir}`)
+ }
+}
+
+async function actionAll(opts: { json?: boolean }) {
+ const sessions = await state.loadAll()
+
+ if (opts.json) {
+ console.log(JSON.stringify(sessions, null, 2))
+ return
+ }
+
+ if (sessions.length === 0) {
+ console.log("◆ No active sessions across any project.")
+ return
+ }
+
+ // Determine status for each session in parallel
+ const vmRunning = (await vm.status()) === "running"
+ const statusEntries = await Promise.all(
+ sessions.map(async (s): Promise<[string, string]> => {
+ if (!vmRunning) return [s.branch, "idle"]
+ const active = await vm.isClaudeActive(s.worktree, s.branch)
+ if (active && s.in_review) return [s.branch, "review"]
+ if (active) return [s.branch, "active"]
+ const dirty = await git.isDirty(s.worktree)
+ if (dirty) return [s.branch, "dirty"]
+ const commits = await git.hasNewCommits(s.worktree)
+ return [s.branch, commits ? "saved" : "idle"]
+ })
+ )
+ const statuses = Object.fromEntries(statusEntries)
+
+ const styleDefs: [string, string, string][] = [
+ ["idle", dim, "◯"], ["active", cyan, "◎"], ["dirty", yellow, "◐"],
+ ["saved", green, "●"], ["review", magenta, "⊛"],
+ ]
+ const styles = Object.fromEntries(
+ styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }])
+ )
+
+ // Group by repo
+ const byRepo = new Map()
+ for (const s of sessions) {
+ const list = byRepo.get(s.repo) ?? []
+ list.push(s)
+ byRepo.set(s.repo, list)
+ }
+
+ const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length))
+ const cols = process.stdout.columns || 80
+ const prefixWidth = branchWidth + 4
+
+ for (const [repo, repoSessions] of byRepo) {
+ console.log(`\n${dim}── ${reset}${repo}${dim} ──${reset}`)
+ console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`)
+
+ for (const s of repoSessions) {
+ const prompt = (s.prompt ?? "").split("\n")[0]
+ const status = statuses[s.branch] ?? "idle"
+ const { icon, color: bc } = styles[status]
+ const maxPrompt = cols - prefixWidth
+ const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt
+ console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
+ }
+ }
+
+ 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}`)
+ }
+}
diff --git a/src/state.ts b/src/state.ts
index f212ec5..6cc0b33 100644
--- a/src/state.ts
+++ b/src/state.ts
@@ -1,4 +1,4 @@
-import { join, dirname } from "path"
+import { join, basename, dirname, resolve } from "path"
import { readdir, rename } from "fs/promises"
import { homedir } from "os"
@@ -34,6 +34,7 @@ export async function save(repoRoot: string, state: State): Promise {
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 {
@@ -53,50 +54,101 @@ export async function removeSession(repoRoot: string, branch: string): 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
}
-/** Discover all sessions across all repos by scanning ~/.sandlot/ */
+/** Load all sessions across all registered projects. */
export async function loadAll(): Promise {
- const sandlotDir = join(homedir(), ".sandlot")
+ const gs = await loadGlobal()
const all: GlobalSession[] = []
- let repoDirs
- try {
- repoDirs = await readdir(sandlotDir, { withFileTypes: true })
- } catch {
- return []
- }
-
- 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
+ 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 })
}
- }
-
- if (repoRoot) {
- try {
- const st = await load(repoRoot)
- for (const session of Object.values(st.sessions)) {
- all.push({ ...session, repo: entry.name })
- }
- } catch {}
- }
+ } catch {}
}
return all