Compare commits

...

19 Commits

Author SHA1 Message Date
9f54ec9d51 0.0.46 2026-03-23 21:35:28 -07:00
e71ba84cc0 Sort repositories alphabetically by name in list --all output 2026-03-23 21:35:20 -07:00
d402a3f980 Derive repo name from repoRoot instead of storing it separately
Remove the redundant `repo` field from GlobalSession and compute it
via basename(repoRoot) at render time. Also fix prompt truncation
when terminal is extremely narrow, and simplify backfillPrompts to
avoid an intermediate array allocation.
2026-03-23 21:20:44 -07:00
99b0fc0f12 Add config command and replace project registry with filesystem discovery
The global registry (registry.json, locking, scan-and-register) was
unnecessary complexity — we can discover repos by scanning ~/.sandlot/
worktree .git pointers. This also moves the --add/--remove flags off
`list` into a proper `config` subcommand for managing settings like
VM memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00
491c6d7eab Skip clearing stale reviews when VM is not running
Avoids unnecessary cleanup when there is no VM to reconcile against
2026-03-23 18:27:09 -07:00
4574749cde Fix prompt truncation edge case and harden global state handling
Correct list truncation logic to avoid eliding prompts that already fit,
and gracefully handle narrow terminals. Sanitize project entries on load,
deduplicate registration through a shared registerPaths helper, and
simplify scanAndRegister by reusing it.
2026-03-23 18:27:09 -07:00
da7adc674d Fix lock race condition, scan depth off-by-one, and minor cleanups
Close TOCTOU window in withGlobalLock by retrying mkdir immediately
after removing a stale lock. Fix off-by-one in scanAndRegister where
maxDepth was exceeded by one level. Export normalizePath to eliminate
duplicate logic in list.ts, use a Set for faster dedup in scan, and
simplify the styles map to a plain object literal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00
965f233245 Simplify session status tracking with identity-keyed Map
Replace the repoRoot/branch composite-key workaround with a Map keyed
directly by session objects. Also tighten the global lock to properly
throw on acquisition failure and fix prompt truncation at narrow widths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00
3ba27c80b4 Simplify list command and inline resolveAllStatuses
Remove generic helper that obscured a three-line call site, flatten
try/catch nesting in backfillPrompts, and push results directly in
loadAll instead of collecting intermediate objects. Also export
normalizePath for use in actionRemove and drop redundant `acquired`
flag from the lock loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00
e5f1de4717 Remove ListSession type and harden lock acquisition
Consolidate on state.GlobalSession to eliminate redundant interface,
extract clearStaleReviews for clarity, and fix withGlobalLock to
explicitly throw when the lock cannot be acquired instead of
silently proceeding.
2026-03-23 18:27:09 -07:00
3d07643547 Move ListSession interface to module scope and harden error paths
Simplify stale-lock recovery to just retry the loop instead of
nesting a second mkdir, and surface lock failures in actionRemove
rather than letting them propagate as unhandled exceptions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00
ac168d3019 Harden and parallelize session listing
Replace deprecated fs.exists with stat, wrap git calls in try/catch
to gracefully degrade to "idle" for inaccessible worktrees, and load
all projects concurrently in loadAll. Also fix stale-lock retry in
withGlobalLock to re-attempt mkdir immediately after cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00
c46ad53fa3 Inline stale-review cleanup and fix state-loading bugs
Re-load state before clearing stale reviews to narrow the race window
with concurrent writers. Also fix missing await on withGlobalLock,
remove redundant mkdir and normalizePath calls, and reuse the existing
load() helper instead of duplicating file-reading logic in loadAll().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00
7bea6f376b Harden state management against stale and missing entries
The registry lock could spin silently forever on contention, worktrees
could vanish between runs, and loadAll swallowed errors from projects
whose state files were removed. Also skip symlinks during scan to avoid
cycles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00
3f8a3839f1 Unify list and list --all into a single code path
The separate actionAll function duplicated most of the listing logic.
Merging it simplifies status resolution by removing the key/repoRoot
indirection, fixes stale-review detection inline, and batches
scanAndRegister writes into a single lock acquisition. Also bumps the
stale lock timeout to 5 minutes and fixes normalizePath matching bare ~.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00
e2a76a6ad9 Extract resolveAllStatuses helper and fix stale review detection
Move stale review flag outside the vm-running block so sessions
marked in_review are caught even when the VM is stopped. Extract
shared status-resolution logic into resolveAllStatuses to deduplicate
the list and list-all commands. Add stale lock detection to prevent
deadlocks from crashed processes, and include status in JSON output
for list-all.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00
f0b2a55c9c Extract shared helpers in list command and add file lock for global registry
Deduplicate status resolution, stale-review cleanup, and prompt
backfill between single-repo and all-repo list paths. Protect
the global registry file with a mkdir-based lock to prevent
concurrent read-modify-write races, and add a max-depth guard
to scanAndRegister.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00
751b4773c9 Fix status collisions in multi-repo list and clear stale review flags
Keying statuses by branch alone caused collisions when multiple repos
shared branch names. Key by repo/branch instead and auto-clear
in_review when Claude is no longer active. Also extract shared
rendering helpers, batch scanAndRegister writes, normalize paths
in the global registry, and move registerProject to setSession.
2026-03-23 18:27:09 -07:00
5893e07530 Replace filesystem-scanning session discovery with an explicit project registry
The old loadAll() walked ~/.sandlot/ and reverse-engineered repo roots
from .git worktree pointers, which was fragile and slow. A simple
registry (~/.sandlot/state.json) tracks known projects explicitly,
with commands to add, remove, and list across all of them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00
5 changed files with 131 additions and 86 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@because/sandlot", "name": "@because/sandlot",
"version": "0.0.45", "version": "0.0.46",
"description": "Sandboxed, branch-based development with Claude", "description": "Sandboxed, branch-based development with Claude",
"type": "module", "type": "module",
"bin": { "bin": {

View File

@ -4,6 +4,7 @@ import { Command, Option } from "commander"
import { yellow, reset } from "./fmt.ts" import { yellow, reset } from "./fmt.ts"
import * as git from "./git.ts" import * as git from "./git.ts"
import * as state from "./state.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 newAction } from "./commands/new.ts"
import { action as listAction } from "./commands/list.ts" import { action as listAction } from "./commands/list.ts"
import { action as openAction } from "./commands/open.ts" import { action as openAction } from "./commands/open.ts"
@ -52,6 +53,7 @@ program
.command("list") .command("list")
.description("Show all active sessions") .description("Show all active sessions")
.option("--json", "Output as JSON") .option("--json", "Output as JSON")
.option("-a, --all", "Show sessions across all projects")
.action(listAction) .action(listAction)
program program

View File

@ -1,85 +1,26 @@
import { basename } from "path"
import { homedir } from "os" import { homedir } from "os"
import { stat } from "fs/promises"
import * as git from "../git.ts" import * as git from "../git.ts"
import * as vm from "../vm.ts" import * as vm from "../vm.ts"
import * as state from "../state.ts" import * as state from "../state.ts"
import { reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts" import { reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts"
export async function action(opts: { json?: boolean }) { // ── Rendering ────────────────────────────────────────────────────────
const root = await git.repoRoot()
const st = await state.load(root)
const sessions = Object.values(st.sessions)
// Discover prompts from Claude history for sessions that lack one const styles: Record<string, { icon: string; color: string }> = {
const needsPrompt = sessions.filter(s => !s.prompt) idle: { icon: `${dim}${reset}`, color: dim },
if (needsPrompt.length > 0 && (await vm.status()) === "running") { active: { icon: `${cyan}${reset}`, color: cyan },
try { dirty: { icon: `${yellow}${reset}`, color: yellow },
const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null") saved: { icon: `${green}${reset}`, color: green },
if (result.exitCode === 0 && result.stdout) { review: { icon: `${magenta}⦿${reset}`, color: magenta },
const entries = result.stdout.split("\n").filter(Boolean).map(line => { }
try { return JSON.parse(line) } catch { return null }
}).filter(Boolean)
for (const s of needsPrompt) { function renderSessions(
const cPath = vm.containerPath(s.worktree) sessions: state.GlobalSession[],
const match = entries.find((e: any) => e.project === cPath) statusMap: Map<state.GlobalSession, string>,
if (match?.display) { ) {
s.prompt = match.display const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length))
}
}
}
} catch {}
}
if (sessions.length === 0 && !opts.json) {
console.log("◆ No active sessions.")
if ((await vm.status()) !== "running") {
console.log(`\n${red}VM is not running.${reset}`)
}
return
}
// Determine status for each session in parallel
const staleReviewBranches: string[] = []
const statusEntries = await Promise.all(
sessions.map(async (s): Promise<[string, string]> => {
const active = await vm.isClaudeActive(s.worktree, s.branch)
if (active && s.in_review) return [s.branch, "review"]
if (!active && s.in_review) {
staleReviewBranches.push(s.branch)
s.in_review = false
}
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)
// Clear stale in_review flags in a single load/save cycle
if (staleReviewBranches.length > 0) {
const fresh = await state.load(root)
for (const branch of staleReviewBranches) {
if (fresh.sessions[branch]) fresh.sessions[branch].in_review = false
}
await state.save(root, fresh).catch(() => {})
}
if (opts.json) {
const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] }))
console.log(JSON.stringify(withStatus, null, 2))
return
}
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 }])
)
const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length))
const cols = process.stdout.columns || 80 const cols = process.stdout.columns || 80
const prefixWidth = branchWidth + 4 const prefixWidth = branchWidth + 4
@ -87,16 +28,117 @@ export async function action(opts: { json?: boolean }) {
for (const s of sessions) { for (const s of sessions) {
const prompt = (s.prompt ?? "").split("\n")[0] const prompt = (s.prompt ?? "").split("\n")[0]
const status = statuses[s.branch] ?? "idle" const status = statusMap.get(s) ?? "idle"
const { icon, color: bc } = styles[status] const { icon, color: bc } = styles[status]
const maxPrompt = cols - prefixWidth const maxPrompt = cols - prefixWidth
const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt const truncated = maxPrompt <= 3 ? "" : prompt.length <= maxPrompt ? prompt : prompt.slice(0, maxPrompt - 3) + "..."
console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) 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}`) // ── Status resolution ────────────────────────────────────────────────
if ((await vm.status()) !== "running") { async function resolveStatus(
console.log(`\n${red}VM is not running.${reset}`) s: { branch: string; worktree: string; in_review?: boolean },
vmRunning: boolean,
): Promise<string> {
try { await stat(s.worktree) } catch { return "idle" }
if (vmRunning) {
const active = await vm.isClaudeActive(s.worktree, s.branch).catch(() => false)
if (active && s.in_review) return "review"
if (active) return "active"
}
try {
const dirty = await git.isDirty(s.worktree)
if (dirty) return "dirty"
const commits = await git.hasNewCommits(s.worktree)
return commits ? "saved" : "idle"
} catch {
return "idle"
} }
} }
/** Clear in_review flags for sessions where Claude is no longer active. */
async function clearStaleReviews(
sessions: state.GlobalSession[],
statusMap: Map<state.GlobalSession, string>,
) {
const stale = sessions.filter(s => s.in_review && statusMap.get(s) !== "review")
if (stale.length === 0) return
const byRepo = Map.groupBy(stale, s => s.repoRoot)
for (const [repoRoot, staleSessions] of byRepo) {
const fresh = await state.load(repoRoot)
for (const s of staleSessions) {
if (fresh.sessions[s.branch]) fresh.sessions[s.branch].in_review = false
}
await state.save(repoRoot, fresh).catch(() => {})
}
}
async function backfillPrompts(sessions: { worktree: string; prompt?: string }[], vmRunning: boolean) {
if (!vmRunning) return
const needsPrompt = sessions.filter(s => !s.prompt)
if (needsPrompt.length === 0) return
const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null").catch(() => null)
if (!result || result.exitCode !== 0 || !result.stdout) return
const byProject = new Map<string, string>()
for (const line of result.stdout.split("\n")) {
if (!line) continue
try {
const e = JSON.parse(line)
if (e.project && e.display) byProject.set(e.project, e.display)
} catch {}
}
for (const s of needsPrompt) {
const display = byProject.get(vm.containerPath(s.worktree))
if (display) s.prompt = display
}
}
// ── Command ──────────────────────────────────────────────────────────
export async function action(opts: { json?: boolean; all?: boolean }) {
let sessions: state.GlobalSession[]
if (opts.all) {
sessions = await state.loadAll()
} else {
const root = await git.repoRoot()
const st = await state.load(root)
sessions = Object.values(st.sessions).map(s => ({ ...s, repoRoot: root }))
}
const vmRunning = (await vm.status()) === "running"
if (sessions.length === 0 && !opts.json) {
console.log(opts.all ? "◆ No active sessions across any project." : "◆ No active sessions.")
if (!opts.all && !vmRunning) console.log(`\n${red}VM is not running.${reset}`)
return
}
await backfillPrompts(sessions, vmRunning)
const results = await Promise.all(sessions.map(s => resolveStatus(s, vmRunning)))
const statusMap = new Map(sessions.map((s, i) => [s, results[i]]))
await clearStaleReviews(sessions, statusMap)
if (opts.json) {
const withStatus = sessions.map(s => ({ ...s, status: statusMap.get(s) ?? "idle" }))
console.log(JSON.stringify(withStatus, null, 2))
return
}
if (opts.all) {
const byRepo = Map.groupBy(sessions, s => s.repoRoot)
const sorted = [...byRepo.entries()].sort((a, b) => basename(a[0]).localeCompare(basename(b[0])))
for (const [repoRoot, repoSessions] of sorted) {
console.log(`\n${dim}── ${reset}${basename(repoRoot)}${dim} ──${reset}`)
renderSessions(repoSessions, statusMap)
}
} else {
renderSessions(sessions, statusMap)
}
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}`)
}

View File

@ -1,3 +1,4 @@
import { basename } from "path"
import type { Command } from "commander" import type { Command } from "commander"
import * as vm from "../vm.ts" import * as vm from "../vm.ts"
import * as git from "../git.ts" import * as git from "../git.ts"
@ -67,7 +68,7 @@ export function register(program: Command) {
// Determine status for each session in parallel // Determine status for each session in parallel
const statusEntries = await Promise.all( const statusEntries = await Promise.all(
sessions.map(async (sess): Promise<[string, string]> => { sessions.map(async (sess): Promise<[string, string]> => {
const key = `${sess.repo}/${sess.branch}` const key = `${basename(sess.repoRoot)}/${sess.branch}`
try { try {
if (await vm.isClaudeActive(sess.worktree, sess.branch)) return [key, "active"] if (await vm.isClaudeActive(sess.worktree, sess.branch)) return [key, "active"]
if (await git.isDirty(sess.worktree)) return [key, "dirty"] if (await git.isDirty(sess.worktree)) return [key, "dirty"]
@ -81,7 +82,7 @@ export function register(program: Command) {
const icons: Record<string, string> = { idle: `${dim}${reset}`, active: `${cyan}${reset}`, dirty: `${yellow}${reset}`, saved: `${green}${reset}` } const icons: Record<string, string> = { idle: `${dim}${reset}`, active: `${cyan}${reset}`, dirty: `${yellow}${reset}`, saved: `${green}${reset}` }
const branchColors: Record<string, string> = { idle: dim, active: cyan, dirty: yellow, saved: green } const branchColors: Record<string, string> = { idle: dim, active: cyan, dirty: yellow, saved: green }
const repoWidth = Math.max(4, ...sessions.map(sess => sess.repo.length)) const repoWidth = Math.max(4, ...sessions.map(sess => basename(sess.repoRoot).length))
const branchWidth = Math.max(6, ...sessions.map(sess => sess.branch.length)) const branchWidth = Math.max(6, ...sessions.map(sess => sess.branch.length))
const cols = process.stdout.columns || 80 const cols = process.stdout.columns || 80
const prefixWidth = repoWidth + branchWidth + 6 const prefixWidth = repoWidth + branchWidth + 6
@ -90,13 +91,13 @@ export function register(program: Command) {
for (const sess of sessions) { for (const sess of sessions) {
const prompt = (sess.prompt ?? "").split("\n")[0] const prompt = (sess.prompt ?? "").split("\n")[0]
const key = `${sess.repo}/${sess.branch}` const key = `${basename(sess.repoRoot)}/${sess.branch}`
const status = statuses[key] const status = statuses[key]
const icon = icons[status] const icon = icons[status]
const bc = branchColors[status] const bc = branchColors[status]
const maxPrompt = cols - prefixWidth const maxPrompt = cols - prefixWidth
const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt
console.log(`${icon} ${dim}${sess.repo.padEnd(repoWidth)}${reset} ${bc}${sess.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) console.log(`${icon} ${dim}${basename(sess.repoRoot).padEnd(repoWidth)}${reset} ${bc}${sess.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
} }
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset}`) console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset}`)

View File

@ -54,7 +54,7 @@ export async function removeSession(repoRoot: string, branch: string): Promise<v
} }
export interface GlobalSession extends Session { export interface GlobalSession extends Session {
repo: string repoRoot: string
} }
/** Discover all sessions across all repos by scanning ~/.sandlot/ */ /** Discover all sessions across all repos by scanning ~/.sandlot/ */
@ -93,7 +93,7 @@ export async function loadAll(): Promise<GlobalSession[]> {
try { try {
const st = await load(repoRoot) const st = await load(repoRoot)
for (const session of Object.values(st.sessions)) { for (const session of Object.values(st.sessions)) {
all.push({ ...session, repo: entry.name }) all.push({ ...session, repoRoot })
} }
} catch {} } catch {}
} }