Compare commits
No commits in common. "9f54ec9d51b3c5ed224bc4a6d56bd562c88293c2" and "f504584ce4fc64da2683698916953d2a37afce01" have entirely different histories.
9f54ec9d51
...
f504584ce4
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@because/sandlot",
|
"name": "@because/sandlot",
|
||||||
"version": "0.0.46",
|
"version": "0.0.45",
|
||||||
"description": "Sandboxed, branch-based development with Claude",
|
"description": "Sandboxed, branch-based development with Claude",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { Command, Option } from "commander"
|
||||||
import { yellow, reset } from "./fmt.ts"
|
import { yellow, reset } from "./fmt.ts"
|
||||||
import * as git from "./git.ts"
|
import * as git from "./git.ts"
|
||||||
import * as state from "./state.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 newAction } from "./commands/new.ts"
|
||||||
import { action as listAction } from "./commands/list.ts"
|
import { action as listAction } from "./commands/list.ts"
|
||||||
import { action as openAction } from "./commands/open.ts"
|
import { action as openAction } from "./commands/open.ts"
|
||||||
|
|
@ -53,7 +52,6 @@ program
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("Show all active sessions")
|
.description("Show all active sessions")
|
||||||
.option("--json", "Output as JSON")
|
.option("--json", "Output as JSON")
|
||||||
.option("-a, --all", "Show sessions across all projects")
|
|
||||||
.action(listAction)
|
.action(listAction)
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,85 @@
|
||||||
import { basename } from "path"
|
|
||||||
import { homedir } from "os"
|
import { homedir } from "os"
|
||||||
import { stat } from "fs/promises"
|
|
||||||
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, green, yellow, cyan, magenta, red } from "../fmt.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 }> = {
|
// Discover prompts from Claude history for sessions that lack one
|
||||||
idle: { icon: `${dim}◯${reset}`, color: dim },
|
const needsPrompt = sessions.filter(s => !s.prompt)
|
||||||
active: { icon: `${cyan}◎${reset}`, color: cyan },
|
if (needsPrompt.length > 0 && (await vm.status()) === "running") {
|
||||||
dirty: { icon: `${yellow}◐${reset}`, color: yellow },
|
try {
|
||||||
saved: { icon: `${green}●${reset}`, color: green },
|
const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null")
|
||||||
review: { icon: `${magenta}⦿${reset}`, color: magenta },
|
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)
|
||||||
|
|
||||||
function renderSessions(
|
for (const s of needsPrompt) {
|
||||||
sessions: state.GlobalSession[],
|
const cPath = vm.containerPath(s.worktree)
|
||||||
statusMap: Map<state.GlobalSession, string>,
|
const match = entries.find((e: any) => e.project === cPath)
|
||||||
) {
|
if (match?.display) {
|
||||||
const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length))
|
s.prompt = match.display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cols = process.stdout.columns || 80
|
||||||
const prefixWidth = branchWidth + 4
|
const prefixWidth = branchWidth + 4
|
||||||
|
|
||||||
|
|
@ -28,117 +87,16 @@ function renderSessions(
|
||||||
|
|
||||||
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 = statusMap.get(s) ?? "idle"
|
const status = statuses[s.branch] ?? "idle"
|
||||||
const { icon, color: bc } = styles[status]
|
const { icon, color: bc } = styles[status]
|
||||||
const maxPrompt = cols - prefixWidth
|
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}`)
|
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(
|
if ((await vm.status()) !== "running") {
|
||||||
s: { branch: string; worktree: string; in_review?: boolean },
|
console.log(`\n${red}VM is not running.${reset}`)
|
||||||
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[],
|
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { basename } from "path"
|
|
||||||
import type { Command } from "commander"
|
import type { Command } from "commander"
|
||||||
import * as vm from "../vm.ts"
|
import * as vm from "../vm.ts"
|
||||||
import * as git from "../git.ts"
|
import * as git from "../git.ts"
|
||||||
|
|
@ -68,7 +67,7 @@ export function register(program: Command) {
|
||||||
// Determine status for each session in parallel
|
// Determine status for each session in parallel
|
||||||
const statusEntries = await Promise.all(
|
const statusEntries = await Promise.all(
|
||||||
sessions.map(async (sess): Promise<[string, string]> => {
|
sessions.map(async (sess): Promise<[string, string]> => {
|
||||||
const key = `${basename(sess.repoRoot)}/${sess.branch}`
|
const key = `${sess.repo}/${sess.branch}`
|
||||||
try {
|
try {
|
||||||
if (await vm.isClaudeActive(sess.worktree, sess.branch)) return [key, "active"]
|
if (await vm.isClaudeActive(sess.worktree, sess.branch)) return [key, "active"]
|
||||||
if (await git.isDirty(sess.worktree)) return [key, "dirty"]
|
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 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 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 branchWidth = Math.max(6, ...sessions.map(sess => sess.branch.length))
|
||||||
const cols = process.stdout.columns || 80
|
const cols = process.stdout.columns || 80
|
||||||
const prefixWidth = repoWidth + branchWidth + 6
|
const prefixWidth = repoWidth + branchWidth + 6
|
||||||
|
|
@ -91,13 +90,13 @@ export function register(program: Command) {
|
||||||
|
|
||||||
for (const sess of sessions) {
|
for (const sess of sessions) {
|
||||||
const prompt = (sess.prompt ?? "").split("\n")[0]
|
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 status = statuses[key]
|
||||||
const icon = icons[status]
|
const icon = icons[status]
|
||||||
const bc = branchColors[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} ${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}`)
|
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset}`)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export async function removeSession(repoRoot: string, branch: string): Promise<v
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GlobalSession extends Session {
|
export interface GlobalSession extends Session {
|
||||||
repoRoot: string
|
repo: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Discover all sessions across all repos by scanning ~/.sandlot/ */
|
/** Discover all sessions across all repos by scanning ~/.sandlot/ */
|
||||||
|
|
@ -93,7 +93,7 @@ export async function loadAll(): Promise<GlobalSession[]> {
|
||||||
try {
|
try {
|
||||||
const st = await load(repoRoot)
|
const st = await load(repoRoot)
|
||||||
for (const session of Object.values(st.sessions)) {
|
for (const session of Object.values(st.sessions)) {
|
||||||
all.push({ ...session, repoRoot })
|
all.push({ ...session, repo: entry.name })
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user