184 lines
6.7 KiB
TypeScript
184 lines
6.7 KiB
TypeScript
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<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 },
|
|
}
|
|
|
|
function renderSessions(
|
|
sessions: state.GlobalSession[],
|
|
statusMap: Map<state.GlobalSession, string>,
|
|
) {
|
|
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<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"
|
|
}
|
|
}
|
|
|
|
/** 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<string, string>()
|
|
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}`)
|
|
}
|
|
}
|