Deduplicate status resolution, stale-review cleanup, and prompt backfill between single-repo and all-repo list paths. Protect the global registry file with a mkdir-based lock to prevent concurrent read-modify-write races, and add a max-depth guard to scanAndRegister. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
209 lines
7.2 KiB
TypeScript
209 lines
7.2 KiB
TypeScript
import { homedir } from "os"
|
|
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 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 }])
|
|
)
|
|
|
|
function renderSessions(
|
|
sessions: { branch: string; prompt?: string }[],
|
|
statuses: Record<string, string>,
|
|
keyFn: (s: { branch: string }) => string = s => s.branch,
|
|
) {
|
|
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 = statuses[keyFn(s)] ?? "idle"
|
|
const { icon, color: bc } = styles[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}`)
|
|
}
|
|
}
|
|
|
|
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 },
|
|
key: string,
|
|
vmRunning: boolean,
|
|
): Promise<{ key: string; status: string; staleReview: boolean }> {
|
|
let staleReview = false
|
|
if (vmRunning) {
|
|
const active = await vm.isClaudeActive(s.worktree, s.branch)
|
|
if (active && s.in_review) return { key, status: "review", staleReview: false }
|
|
staleReview = !active && !!s.in_review
|
|
if (active) return { key, status: "active", staleReview: false }
|
|
}
|
|
const dirty = await git.isDirty(s.worktree)
|
|
if (dirty) return { key, status: "dirty", staleReview }
|
|
const commits = await git.hasNewCommits(s.worktree)
|
|
return { key, status: commits ? "saved" : "idle", staleReview }
|
|
}
|
|
|
|
async function clearStaleReviews(staleReviews: { repoRoot: string; branch: string }[]) {
|
|
if (staleReviews.length === 0) return
|
|
const byRepo = Map.groupBy(staleReviews, r => r.repoRoot)
|
|
for (const [repoRoot, reviews] of byRepo) {
|
|
const fresh = await state.load(repoRoot)
|
|
for (const { branch } of reviews) {
|
|
if (fresh.sessions[branch]) fresh.sessions[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
|
|
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 {}
|
|
}
|
|
|
|
// ── 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)
|
|
if (opts.all) return actionAll(opts)
|
|
|
|
const root = await git.repoRoot()
|
|
const st = await state.load(root)
|
|
const sessions = Object.values(st.sessions)
|
|
const vmRunning = (await vm.status()) === "running"
|
|
|
|
await backfillPrompts(sessions, vmRunning)
|
|
|
|
if (sessions.length === 0 && !opts.json) {
|
|
console.log("◆ No active sessions.")
|
|
if (!vmRunning) {
|
|
console.log(`\n${red}VM is not running.${reset}`)
|
|
}
|
|
return
|
|
}
|
|
|
|
const results = await Promise.all(
|
|
sessions.map(s => resolveStatus(s, s.branch, vmRunning))
|
|
)
|
|
const statuses = Object.fromEntries(results.map(r => [r.key, r.status]))
|
|
|
|
const staleReviews: { repoRoot: string; branch: string }[] = []
|
|
for (let i = 0; i < results.length; i++) {
|
|
if (results[i].staleReview) {
|
|
sessions[i].in_review = false
|
|
staleReviews.push({ repoRoot: root, branch: sessions[i].branch })
|
|
}
|
|
}
|
|
await clearStaleReviews(staleReviews)
|
|
|
|
if (opts.json) {
|
|
const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] }))
|
|
console.log(JSON.stringify(withStatus, null, 2))
|
|
return
|
|
}
|
|
|
|
renderSessions(sessions, statuses)
|
|
renderLegend()
|
|
|
|
if (!vmRunning) {
|
|
console.log(`\n${red}VM is not running.${reset}`)
|
|
}
|
|
}
|
|
|
|
async function actionAdd(dir: string) {
|
|
const registered = await state.scanAndRegister(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 removed = await state.unregisterProject(dir)
|
|
if (removed) {
|
|
console.log(`${red}-${reset} ${dir}`)
|
|
} else {
|
|
die(`Project not found in registry: ${dir}`)
|
|
}
|
|
}
|
|
|
|
async function actionAll(opts: { json?: boolean }) {
|
|
const sessions = await state.loadAll()
|
|
|
|
if (opts.json) {
|
|
console.log(JSON.stringify(sessions, null, 2))
|
|
return
|
|
}
|
|
|
|
if (sessions.length === 0) {
|
|
console.log("◆ No active sessions across any project.")
|
|
return
|
|
}
|
|
|
|
const vmRunning = (await vm.status()) === "running"
|
|
|
|
await backfillPrompts(sessions, vmRunning)
|
|
|
|
const results = await Promise.all(
|
|
sessions.map(s => resolveStatus(s, `${s.repo}/${s.branch}`, vmRunning))
|
|
)
|
|
const statuses = Object.fromEntries(results.map(r => [r.key, r.status]))
|
|
|
|
const staleReviews: { repoRoot: string; branch: string }[] = []
|
|
for (let i = 0; i < results.length; i++) {
|
|
if (results[i].staleReview) {
|
|
sessions[i].in_review = false
|
|
staleReviews.push({ repoRoot: sessions[i].repoRoot, branch: sessions[i].branch })
|
|
}
|
|
}
|
|
await clearStaleReviews(staleReviews)
|
|
|
|
const byRepo = Map.groupBy(sessions, s => s.repo)
|
|
|
|
for (const [repo, repoSessions] of byRepo) {
|
|
console.log(`\n${dim}── ${reset}${repo}${dim} ──${reset}`)
|
|
renderSessions(repoSessions, statuses, s => `${repo}/${s.branch}`)
|
|
}
|
|
|
|
renderLegend()
|
|
|
|
if (!vmRunning) {
|
|
console.log(`\n${red}VM is not running.${reset}`)
|
|
}
|
|
}
|