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 { die, reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts" // ── Shared rendering ───────────────────────────────────────────────── const styles: Record = { 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 }, } function renderSessions( sessions: state.GlobalSession[], statusMap: Map, ) { const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length)) const cols = process.stdout.columns || 80 const prefixWidth = branchWidth + 4 console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`) for (const s of sessions) { const prompt = (s.prompt ?? "").split("\n")[0] const status = statusMap.get(s) ?? "idle" const { icon, color: bc } = styles[status] const maxPrompt = cols - prefixWidth const truncated = maxPrompt < 1 ? "" : prompt.length <= maxPrompt ? prompt : maxPrompt > 3 ? prompt.slice(0, maxPrompt - 3) + "..." : prompt.slice(0, maxPrompt) console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) } } function renderLegend() { console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⦿ review${reset}`) } // ── Shared logic ───────────────────────────────────────────────────── async function resolveStatus( s: { branch: string; worktree: string; in_review?: boolean }, vmRunning: boolean, ): Promise { 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[], results: string[], ) { const stale = sessions.filter((s, i) => s.in_review && results[i] !== "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 entries = result.stdout.split("\n").filter(Boolean).map(line => { try { return JSON.parse(line) } catch { return null } }).filter(Boolean) const byProject = new Map() for (const e of entries) { if (e.project && e.display) byProject.set(e.project, e.display) } for (const s of needsPrompt) { const display = byProject.get(vm.containerPath(s.worktree)) if (display) s.prompt = display } } // ── Commands ───────────────────────────────────────────────────────── export async function action(opts: { json?: boolean; all?: boolean; add?: string; remove?: string }) { if (opts.add) return actionAdd(opts.add) if (opts.remove) return actionRemove(opts.remove) // Load sessions with repoRoot attached 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, repo: basename(root), 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]])) if (vmRunning) await clearStaleReviews(sessions, results) 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) for (const [repoRoot, repoSessions] of byRepo) { console.log(`\n${dim}── ${reset}${basename(repoRoot)}${dim} ──${reset}`) renderSessions(repoSessions, statusMap) } } else { renderSessions(sessions, statusMap) } renderLegend() if (!vmRunning) console.log(`\n${red}VM is not running.${reset}`) } async function actionAdd(dir: string) { let registered: string[] try { registered = await state.scanAndRegister(dir) } catch (e) { return die(e instanceof Error ? e.message : `Failed to scan ${dir}`) } if (registered.length === 0) { console.log(`No sandlot projects found under ${dir}`) } else { for (const p of registered) { console.log(`${green}+${reset} ${p}`) } console.log(`\nRegistered ${registered.length} project${registered.length === 1 ? "" : "s"}.`) } } async function actionRemove(dir: string) { const resolved = state.normalizePath(dir) let removed = false try { removed = await state.unregisterProject(dir) } catch { return die("Could not acquire registry lock") } if (removed) { console.log(`${red}-${reset} ${resolved}`) } else { die(`Project not found in registry: ${resolved}`) } }