Compare commits
14 Commits
da4b3a047e
...
2da22b940a
| Author | SHA1 | Date | |
|---|---|---|---|
| 2da22b940a | |||
| e8e5b850a0 | |||
| d10adac712 | |||
| 9336deed9c | |||
| bd9d481e81 | |||
| 69ba73b3c3 | |||
| ad90c9dcc1 | |||
| 2f524da292 | |||
| 99f5a378f1 | |||
| a682539db3 | |||
| 3ba550d80a | |||
| bc102e416c | |||
| 92dbad3cad | |||
| 8172065928 |
|
|
@ -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` detects stale `in_review` flags (Claude not active) and clears them from state
|
||||||
- `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)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@because/sandlot",
|
"name": "@because/sandlot",
|
||||||
"version": "0.0.37",
|
"version": "0.0.38",
|
||||||
"description": "Sandboxed, branch-based development with Claude",
|
"description": "Sandboxed, branch-based development with Claude",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { homedir } from "os"
|
||||||
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, bold, green, yellow, cyan, white, red } from "../fmt.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 }) {
|
||||||
const root = await git.repoRoot()
|
const root = await git.repoRoot()
|
||||||
|
|
@ -30,12 +30,7 @@ export async function action(opts: { json?: boolean }) {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.json) {
|
if (sessions.length === 0 && !opts.json) {
|
||||||
console.log(JSON.stringify(sessions, null, 2))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
|
||||||
console.log("◆ No active sessions.")
|
console.log("◆ No active sessions.")
|
||||||
if ((await vm.status()) !== "running") {
|
if ((await vm.status()) !== "running") {
|
||||||
console.log(`\n${red}VM is not running.${reset}`)
|
console.log(`\n${red}VM is not running.${reset}`)
|
||||||
|
|
@ -44,9 +39,16 @@ export async function action(opts: { json?: boolean }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine status for each session in parallel
|
// Determine status for each session in parallel
|
||||||
|
const staleReviewBranches: string[] = []
|
||||||
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 (await vm.isClaudeActive(s.worktree, s.branch)) return [s.branch, "active"]
|
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)
|
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)
|
||||||
|
|
@ -55,14 +57,28 @@ export async function action(opts: { json?: boolean }) {
|
||||||
)
|
)
|
||||||
const statuses = Object.fromEntries(statusEntries)
|
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) {
|
if (opts.json) {
|
||||||
const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] }))
|
const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] }))
|
||||||
console.log(JSON.stringify(withStatus, null, 2))
|
console.log(JSON.stringify(withStatus, null, 2))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const icons: Record<string, string> = { idle: `${dim}◯${reset}`, active: `${cyan}◎${reset}`, dirty: `${yellow}◐${reset}`, saved: `${green}●${reset}` }
|
const styleDefs: [string, string, string][] = [
|
||||||
const branchColors: Record<string, string> = { idle: dim, active: cyan, dirty: yellow, saved: green }
|
["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 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
|
||||||
|
|
@ -71,15 +87,14 @@ 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] ?? "idle"
|
||||||
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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
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} · ${magenta}⦿ review${reset}`)
|
||||||
|
|
||||||
if ((await vm.status()) !== "running") {
|
if ((await vm.status()) !== "running") {
|
||||||
console.log(`\n${red}VM is not running.${reset}`)
|
console.log(`\n${red}VM is not running.${reset}`)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import * as vm from "../vm.ts"
|
import * as vm from "../vm.ts"
|
||||||
|
import * as state from "../state.ts"
|
||||||
import { spinner } from "../spinner.ts"
|
import { spinner } from "../spinner.ts"
|
||||||
import { requireSession, saveChanges } from "./helpers.ts"
|
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 { session } = await requireSession(branch)
|
const { root, session } = await requireSession(branch)
|
||||||
|
|
||||||
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 })
|
||||||
|
|
@ -67,15 +68,26 @@ Your thoughts, in brief.
|
||||||
`
|
`
|
||||||
if (extra) prompt += "\n\n" + extra
|
if (extra) prompt += "\n\n" + extra
|
||||||
|
|
||||||
if (opts.print) {
|
session.in_review = true
|
||||||
spin.text = "Running review…"
|
await state.setSession(root, session)
|
||||||
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 })
|
|
||||||
}
|
|
||||||
|
|
||||||
await saveChanges(session.worktree, session.branch)
|
try {
|
||||||
|
if (opts.print) {
|
||||||
|
spin.text = "Running review…"
|
||||||
|
const result = await vm.claude(session.worktree, { print: prompt })
|
||||||
|
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(() => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export const dim = "\x1b[2m"
|
||||||
export const green = "\x1b[32m"
|
export const green = "\x1b[32m"
|
||||||
export const yellow = "\x1b[33m"
|
export const yellow = "\x1b[33m"
|
||||||
export const red = "\x1b[31m"
|
export const red = "\x1b[31m"
|
||||||
|
export const magenta = "\x1b[35m"
|
||||||
export const cyan = "\x1b[36m"
|
export const cyan = "\x1b[36m"
|
||||||
|
|
||||||
// ── Formatted output ────────────────────────────────────────────────
|
// ── Formatted output ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export interface Session {
|
||||||
worktree: string
|
worktree: string
|
||||||
created_at: string
|
created_at: string
|
||||||
prompt?: string
|
prompt?: string
|
||||||
|
in_review?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user