Add in_review status to sessions and self-heal stale review flags

Review status now requires Claude to be active, preventing stale flags
from showing after a crash. Consolidates icon/color maps into a single
styles record and defers setting in_review until the container is up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Wanstrath 2026-03-18 23:16:06 -07:00
parent 92dbad3cad
commit bc102e416c
3 changed files with 22 additions and 15 deletions

View File

@ -120,7 +120,8 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t
"branch": "branch-name", "branch": "branch-name",
"worktree": "/Users/you/.sandlot/repo/branch-name", "worktree": "/Users/you/.sandlot/repo/branch-name",
"created_at": "2026-02-16T10:30:00Z", "created_at": "2026-02-16T10:30:00Z",
"prompt": "optional initial prompt text" "prompt": "optional initial prompt text",
"in_review": false
} }
} }
} }
@ -149,7 +150,8 @@ 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 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 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` discovers missing session prompts by parsing Claude's `history.jsonl` from inside the container
- `sandlot list` shows four status icons: idle (dim `◌`), active (cyan `◯`), dirty/unsaved (yellow `◎`), saved (green `●`) - `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` only shows review status if Claude is also active (self-heals stale flags)
- `sandlot new` and `sandlot open` auto-save changes when Claude exits (disable with `--no-save`) - `sandlot new` and `sandlot open` auto-save changes when Claude exits (disable with `--no-save`)
- `sandlot close` has a hidden `rm` alias - `sandlot close` has a hidden `rm` alias
- Default behavior (no subcommand): always runs `list` (which prints "No active sessions." if empty) - Default behavior (no subcommand): always runs `list` (which prints "No active sessions." if empty)

View File

@ -46,8 +46,9 @@ export async function action(opts: { json?: boolean }) {
// 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 (s): Promise<[string, string]> => { sessions.map(async (s): Promise<[string, string]> => {
if (s.in_review) return [s.branch, "review"] const active = await vm.isClaudeActive(s.worktree, s.branch)
if (await vm.isClaudeActive(s.worktree, s.branch)) return [s.branch, "active"] if (active && s.in_review) return [s.branch, "review"]
if (active) return [s.branch, "active"]
const dirty = await git.isDirty(s.worktree) const dirty = await git.isDirty(s.worktree)
if (dirty) return [s.branch, "dirty"] if (dirty) return [s.branch, "dirty"]
const commits = await git.hasNewCommits(s.worktree) const commits = await git.hasNewCommits(s.worktree)
@ -62,8 +63,13 @@ export async function action(opts: { json?: boolean }) {
return return
} }
const icons: Record<string, string> = { idle: `${dim}${reset}`, active: `${cyan}${reset}`, dirty: `${yellow}${reset}`, saved: `${green}${reset}`, review: `${magenta}⦿${reset}` } const styles: Record<string, { icon: string; color: string }> = {
const branchColors: Record<string, string> = { idle: dim, active: cyan, dirty: yellow, saved: green, review: magenta } idle: { icon: `${dim}${reset}`, color: dim },
active: { icon: `${cyan}${reset}`, color: cyan },
dirty: { icon: `${yellow}${reset}`, color: yellow },
saved: { icon: `${green}${reset}`, color: green },
review: { icon: `${magenta}⦿${reset}`, color: magenta },
}
const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length)) 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
@ -73,8 +79,7 @@ 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] const status = statuses[s.branch]
const icon = icons[status] const { icon, color: bc } = styles[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} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)

View File

@ -1,4 +1,3 @@
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 { spinner } from "../spinner.ts" import { spinner } from "../spinner.ts"
@ -7,13 +6,13 @@ import { requireSession, saveChanges } from "./helpers.ts"
export async function action(branch: string, extra: string | undefined, opts: { print?: boolean }) { export async function action(branch: string, extra: string | undefined, opts: { print?: boolean }) {
const { root, session } = await requireSession(branch) const { root, session } = await requireSession(branch)
// Mark session as in review
session.in_review = true
await state.setSession(root, session)
const spin = spinner("Starting container", branch) const spin = spinner("Starting container", branch)
await vm.ensure((msg) => { spin.text = msg }) await vm.ensure((msg) => { spin.text = msg })
// Mark session as in review only after container is confirmed running
session.in_review = true
await state.setSession(root, session)
let prompt = ` let prompt = `
You're a grumpy old senior software engineer. You need to review some code my co-worker wrote. You're a grumpy old senior software engineer. You need to review some code my co-worker wrote.
@ -86,8 +85,9 @@ Your thoughts, in brief.
await saveChanges(session.worktree, session.branch) await saveChanges(session.worktree, session.branch)
} finally { } finally {
// Clear review state // Clear review state — use .catch() per error handling conventions
// so a disk error here doesn't mask the original exception
session.in_review = false session.in_review = false
await state.setSession(root, session) await state.setSession(root, session).catch(() => {})
} }
} }