Compare commits

..

No commits in common. "9f54ec9d51b3c5ed224bc4a6d56bd562c88293c2" and "f504584ce4fc64da2683698916953d2a37afce01" have entirely different histories.

5 changed files with 86 additions and 131 deletions

View File

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

View File

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

View File

@ -1,26 +1,85 @@
import { basename } from "path"
import { homedir } from "os"
import { stat } from "fs/promises"
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"
// ── Rendering ────────────────────────────────────────────────────────
export async function action(opts: { json?: boolean }) {
const root = await git.repoRoot()
const st = await state.load(root)
const sessions = Object.values(st.sessions)
const styles: Record<string, { icon: string; color: string }> = {
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 },
// Discover prompts from Claude history for sessions that lack one
const needsPrompt = sessions.filter(s => !s.prompt)
if (needsPrompt.length > 0 && (await vm.status()) === "running") {
try {
const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null")
if (result.exitCode === 0 && result.stdout) {
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) {
const cPath = vm.containerPath(s.worktree)
const match = entries.find((e: any) => e.project === cPath)
if (match?.display) {
s.prompt = match.display
}
}
}
} catch {}
}
function renderSessions(
sessions: state.GlobalSession[],
statusMap: Map<state.GlobalSession, string>,
) {
const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length))
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 prefixWidth = branchWidth + 4
@ -28,117 +87,16 @@ function renderSessions(
for (const s of sessions) {
const prompt = (s.prompt ?? "").split("\n")[0]
const status = statusMap.get(s) ?? "idle"
const status = statuses[s.branch] ?? "idle"
const { icon, color: bc } = styles[status]
const maxPrompt = cols - prefixWidth
const truncated = maxPrompt <= 3 ? "" : prompt.length <= maxPrompt ? prompt : prompt.slice(0, maxPrompt - 3) + "..."
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}`)
}
}
// ── Status resolution ────────────────────────────────────────────────
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⊛ review${reset}`)
async function resolveStatus(
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"
if ((await vm.status()) !== "running") {
console.log(`\n${red}VM is not running.${reset}`)
}
}
/** 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,4 +1,3 @@
import { basename } from "path"
import type { Command } from "commander"
import * as vm from "../vm.ts"
import * as git from "../git.ts"
@ -68,7 +67,7 @@ export function register(program: Command) {
// Determine status for each session in parallel
const statusEntries = await Promise.all(
sessions.map(async (sess): Promise<[string, string]> => {
const key = `${basename(sess.repoRoot)}/${sess.branch}`
const key = `${sess.repo}/${sess.branch}`
try {
if (await vm.isClaudeActive(sess.worktree, sess.branch)) return [key, "active"]
if (await git.isDirty(sess.worktree)) return [key, "dirty"]
@ -82,7 +81,7 @@ export function register(program: Command) {
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 repoWidth = Math.max(4, ...sessions.map(sess => basename(sess.repoRoot).length))
const repoWidth = Math.max(4, ...sessions.map(sess => sess.repo.length))
const branchWidth = Math.max(6, ...sessions.map(sess => sess.branch.length))
const cols = process.stdout.columns || 80
const prefixWidth = repoWidth + branchWidth + 6
@ -91,13 +90,13 @@ export function register(program: Command) {
for (const sess of sessions) {
const prompt = (sess.prompt ?? "").split("\n")[0]
const key = `${basename(sess.repoRoot)}/${sess.branch}`
const key = `${sess.repo}/${sess.branch}`
const status = statuses[key]
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} ${dim}${basename(sess.repoRoot).padEnd(repoWidth)}${reset} ${bc}${sess.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
console.log(`${icon} ${dim}${sess.repo.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}`)

View File

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