Compare commits

..

No commits in common. "2da22b940abd45738c2f5c9ce3c9bb467db33652" and "da4b3a047e76fa85d59a38c1ce4ec02c6198ec0f" have entirely different histories.

6 changed files with 27 additions and 58 deletions

View File

@ -120,8 +120,7 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t
"branch": "branch-name",
"worktree": "/Users/you/.sandlot/repo/branch-name",
"created_at": "2026-02-16T10:30:00Z",
"prompt": "optional initial prompt text",
"in_review": false
"prompt": "optional initial prompt text"
}
}
}
@ -150,8 +149,7 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t
- `sandlot squash` generates an AI commit message for the squash commit via `claudePipe()`; falls back to `"squash <branch>"`
- `sandlot merge` and `sandlot squash` both delegate to `mergeAndClose()` in `helpers.ts`, which merges, resolves conflicts, commits, and then calls `closeAction()` to clean up
- `sandlot list` discovers missing session prompts by parsing Claude's `history.jsonl` from inside the container
- `sandlot list` shows five status icons: idle (dim `◯`), active (cyan `◎`), dirty/unsaved (yellow `◐`), saved (green `●`), review (magenta `⦿`)
- `sandlot review` sets `in_review` on the session during the review and clears it in a `finally` block on exit; `list` detects stale `in_review` flags (Claude not active) and clears them from state
- `sandlot list` shows four status icons: idle (dim `◌`), active (cyan `◯`), dirty/unsaved (yellow `◎`), saved (green `●`)
- `sandlot new` and `sandlot open` auto-save changes when Claude exits (disable with `--no-save`)
- `sandlot close` has a hidden `rm` alias
- Default behavior (no subcommand): always runs `list` (which prints "No active sessions." if empty)

View File

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

View File

@ -2,7 +2,7 @@ import { homedir } from "os"
import * as git from "../git.ts"
import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts"
import { reset, dim, bold, green, yellow, cyan, white, red } from "../fmt.ts"
export async function action(opts: { json?: boolean }) {
const root = await git.repoRoot()
@ -30,7 +30,12 @@ export async function action(opts: { json?: boolean }) {
} catch {}
}
if (sessions.length === 0 && !opts.json) {
if (opts.json) {
console.log(JSON.stringify(sessions, null, 2))
return
}
if (sessions.length === 0) {
console.log("◆ No active sessions.")
if ((await vm.status()) !== "running") {
console.log(`\n${red}VM is not running.${reset}`)
@ -39,16 +44,9 @@ export async function action(opts: { json?: boolean }) {
}
// 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"]
if (await vm.isClaudeActive(s.worktree, s.branch)) return [s.branch, "active"]
const dirty = await git.isDirty(s.worktree)
if (dirty) return [s.branch, "dirty"]
const commits = await git.hasNewCommits(s.worktree)
@ -57,28 +55,14 @@ export async function action(opts: { json?: boolean }) {
)
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 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 branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length))
const cols = process.stdout.columns || 80
const prefixWidth = branchWidth + 4
@ -87,14 +71,15 @@ export async function action(opts: { json?: boolean }) {
for (const s of sessions) {
const prompt = (s.prompt ?? "").split("\n")[0]
const status = statuses[s.branch] ?? "idle"
const { icon, color: bc } = styles[status]
const status = statuses[s.branch]
const icon = icons[status]
const bc = branchColors[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}`)
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset}`)
if ((await vm.status()) !== "running") {
console.log(`\n${red}VM is not running.${reset}`)

View File

@ -1,10 +1,9 @@
import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { spinner } from "../spinner.ts"
import { requireSession, saveChanges } from "./helpers.ts"
export async function action(branch: string, extra: string | undefined, opts: { print?: boolean }) {
const { root, session } = await requireSession(branch)
const { session } = await requireSession(branch)
const spin = spinner("Starting container", branch)
await vm.ensure((msg) => { spin.text = msg })
@ -68,26 +67,15 @@ Your thoughts, in brief.
`
if (extra) prompt += "\n\n" + extra
session.in_review = true
await state.setSession(root, session)
try {
if (opts.print) {
spin.text = "Running review…"
const result = await vm.claude(session.worktree, { print: prompt })
spin.stop()
if (result.output) process.stdout.write(result.output + "\n")
} else {
spin.succeed("Session ready")
await vm.claude(session.worktree, { prompt })
}
} finally {
spin.stop()
// Load fresh session to avoid clobbering changes made during the review
const fresh = await state.getSession(root, session.branch)
if (fresh) {
fresh.in_review = false
await state.setSession(root, fresh).catch(() => {})
}
if (!opts.print) await saveChanges(session.worktree, session.branch).catch(() => {})
}
await saveChanges(session.worktree, session.branch)
}

View File

@ -5,7 +5,6 @@ export const dim = "\x1b[2m"
export const green = "\x1b[32m"
export const yellow = "\x1b[33m"
export const red = "\x1b[31m"
export const magenta = "\x1b[35m"
export const cyan = "\x1b[36m"
// ── Formatted output ────────────────────────────────────────────────

View File

@ -7,7 +7,6 @@ export interface Session {
worktree: string
created_at: string
prompt?: string
in_review?: boolean
}
export interface State {