Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 306 additions & 1 deletion packages/opencode/src/cli/cmd/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { Locale } from "../../util/locale"
import { Flag } from "../../flag/flag"
import { EOL } from "os"
import path from "path"
import { Storage } from "../../storage/storage"
import { Global } from "../../global"
import fs from "fs/promises"

function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
Expand Down Expand Up @@ -38,7 +41,8 @@ function pagerCmd(): string[] {
export const SessionCommand = cmd({
command: "session",
describe: "manage sessions",
builder: (yargs: Argv) => yargs.command(SessionListCommand).demandCommand(),
builder: (yargs: Argv) =>
yargs.command(SessionListCommand).command(SessionPruneCommand).demandCommand(),
async handler() {},
})

Expand Down Expand Up @@ -133,3 +137,304 @@ function formatSessionJSON(sessions: Session.Info[]): string {
}))
return JSON.stringify(jsonData, null, 2)
}

function parseDuration(duration: string): number {
const match = duration.match(/^(\d+)([dhwm])$/)
if (!match) throw new Error(`Invalid duration format: ${duration}. Use format like 1d, 7d, 2w, 1m`)

const value = parseInt(match[1], 10)
const unit = match[2]

const ms = {
d: 24 * 60 * 60 * 1000,
h: 60 * 60 * 1000,
w: 7 * 24 * 60 * 60 * 1000,
m: 30 * 24 * 60 * 60 * 1000,
}[unit]!

return value * ms
}

function formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + " B"
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + " MB"
return (bytes / 1024 / 1024 / 1024).toFixed(1) + " GB"
}

async function getDirectorySize(dir: string): Promise<number> {
try {
let size = 0
for await (const file of new Bun.Glob("**/*").scan({ cwd: dir, absolute: true, onlyFiles: true })) {
try {
const stat = await fs.stat(file)
size += stat.size
} catch {}
}
return size
} catch {
return 0
}
}

export const SessionPruneCommand = cmd({
command: "prune <duration>",
describe: "delete sessions older than the specified duration",
builder: (yargs: Argv) => {
return yargs
.positional("duration", {
describe: "duration (e.g., 1d, 7d, 2w, 1m for days/hours/weeks/months)",
type: "string",
demandOption: true,
})
.option("dry-run", {
describe: "show what would be deleted without deleting",
type: "boolean",
default: false,
})
.option("force", {
alias: "f",
describe: "skip confirmation prompt",
type: "boolean",
default: false,
})
.option("all", {
alias: "a",
describe: "prune sessions from all projects (not just current)",
type: "boolean",
default: false,
})
.option("logs", {
describe: "also prune old log files",
type: "boolean",
default: false,
})
.option("snapshots", {
describe: "also prune orphaned snapshots",
type: "boolean",
default: false,
})
},
handler: async (args) => {
const duration = parseDuration(args.duration as string)
const cutoff = Date.now() - duration
const dryRun = args.dryRun as boolean
const force = args.force as boolean
const pruneAll = args.all as boolean
const pruneLogs = args.logs as boolean
const pruneSnapshots = args.snapshots as boolean

await bootstrap(process.cwd(), async () => {
const sessionsToDelete: Array<{ session: Session.Info; project: string }> = []
let totalSize = 0

// Get all projects
const storageDir = path.join(Global.Path.data, "storage")
const sessionDir = path.join(storageDir, "session")

let projects: string[] = []
try {
projects = await fs.readdir(sessionDir)
} catch {
console.log("No sessions found")
return
}

// Collect sessions to delete
for (const projectID of projects) {
const projectSessionDir = path.join(sessionDir, projectID)
try {
const files = await fs.readdir(projectSessionDir)
for (const file of files) {
if (!file.endsWith(".json")) continue
try {
const sessionPath = path.join(projectSessionDir, file)
const session = (await Bun.file(sessionPath).json()) as Session.Info

if (session.time.updated < cutoff) {
sessionsToDelete.push({ session, project: projectID })

// Estimate size: session file + messages + parts
const sessionSize = (await fs.stat(sessionPath)).size
const msgDir = path.join(storageDir, "message", session.id)
const msgSize = await getDirectorySize(msgDir)
totalSize += sessionSize + msgSize

// Add parts size
try {
const msgFiles = await fs.readdir(msgDir)
for (const msgFile of msgFiles) {
const msgId = msgFile.replace(".json", "")
const partDir = path.join(storageDir, "part", msgId)
totalSize += await getDirectorySize(partDir)
}
} catch {}
}
} catch {}
}
} catch {}
}

if (sessionsToDelete.length === 0) {
console.log(`No sessions older than ${args.duration} found`)
return
}

// Show summary
console.log(`${EOL}Sessions to ${dryRun ? "prune (dry run)" : "prune"}:`)
console.log("─".repeat(80))

for (const { session } of sessionsToDelete) {
const age = Math.floor((Date.now() - session.time.updated) / (24 * 60 * 60 * 1000))
const title = Locale.truncate(session.title, 40)
console.log(` ${session.id} ${title.padEnd(42)} ${age}d ago`)
}

console.log("─".repeat(80))
console.log(`Total: ${sessionsToDelete.length} sessions, ~${formatBytes(totalSize)} estimated`)

// Handle logs
let logFiles: string[] = []
let logSize = 0
if (pruneLogs) {
const logDir = path.join(Global.Path.data, "log")
try {
const files = await fs.readdir(logDir)
for (const file of files) {
const filePath = path.join(logDir, file)
const stat = await fs.stat(filePath)
if (stat.mtime.getTime() < cutoff) {
logFiles.push(filePath)
logSize += stat.size
}
}
if (logFiles.length > 0) {
console.log(`${EOL}Log files to prune: ${logFiles.length} files, ~${formatBytes(logSize)}`)
}
} catch {}
}

// Handle snapshots
let snapshotDirs: string[] = []
let snapshotSize = 0
if (pruneSnapshots) {
const snapshotDir = path.join(Global.Path.data, "snapshot")
const activeSessionIds = new Set<string>()

// Get all active session IDs
for (const projectID of projects) {
try {
const files = await fs.readdir(path.join(sessionDir, projectID))
for (const file of files) {
if (file.endsWith(".json")) {
activeSessionIds.add(file.replace(".json", ""))
}
}
} catch {}
}

// Find orphaned snapshots
try {
const snapshots = await fs.readdir(snapshotDir)
for (const snapshot of snapshots) {
if (!activeSessionIds.has(snapshot)) {
const snapPath = path.join(snapshotDir, snapshot)
snapshotDirs.push(snapPath)
snapshotSize += await getDirectorySize(snapPath)
}
}
if (snapshotDirs.length > 0) {
console.log(`${EOL}Orphaned snapshots to prune: ${snapshotDirs.length} dirs, ~${formatBytes(snapshotSize)}`)
}
} catch {}
}

const grandTotal = totalSize + logSize + snapshotSize
console.log(`${EOL}Total space to free: ~${formatBytes(grandTotal)}`)

if (dryRun) {
console.log(`${EOL}Dry run - no changes made. Remove --dry-run to delete.`)
return
}

// Confirm
if (!force && process.stdin.isTTY) {
process.stdout.write(`${EOL}Proceed with deletion? [y/N] `)
const reader = process.stdin
const response = await new Promise<string>((resolve) => {
let data = ""
const onData = (chunk: Buffer) => {
data += chunk.toString()
if (data.includes("\n")) {
reader.removeListener("data", onData)
resolve(data.trim().toLowerCase())
}
}
reader.on("data", onData)
reader.resume()
})

if (response !== "y" && response !== "yes") {
console.log("Aborted")
return
}
}

// Delete sessions
console.log(`${EOL}Deleting sessions...`)
let deleted = 0
for (const { session, project } of sessionsToDelete) {
try {
// Delete session file
await fs.unlink(path.join(sessionDir, project, session.id + ".json")).catch(() => {})

// Delete messages
const msgDir = path.join(storageDir, "message", session.id)
try {
const msgFiles = await fs.readdir(msgDir)
for (const msgFile of msgFiles) {
const msgId = msgFile.replace(".json", "")
// Delete parts for this message
await fs.rm(path.join(storageDir, "part", msgId), { recursive: true, force: true }).catch(() => {})
}
await fs.rm(msgDir, { recursive: true, force: true })
} catch {}

// Delete session diff
await fs.unlink(path.join(storageDir, "session_diff", session.id + ".json")).catch(() => {})

// Delete todos
await fs.unlink(path.join(storageDir, "todo", session.id + ".json")).catch(() => {})

deleted++
if (deleted % 10 === 0) {
process.stdout.write(` Deleted ${deleted}/${sessionsToDelete.length} sessions\r`)
}
} catch (e) {
console.error(` Failed to delete session ${session.id}:`, e)
}
}
console.log(` Deleted ${deleted}/${sessionsToDelete.length} sessions`)

// Delete logs
if (pruneLogs && logFiles.length > 0) {
console.log(`Deleting log files...`)
for (const file of logFiles) {
await fs.unlink(file).catch(() => {})
}
console.log(` Deleted ${logFiles.length} log files`)
}

// Delete snapshots
if (pruneSnapshots && snapshotDirs.length > 0) {
console.log(`Deleting orphaned snapshots...`)
for (const dir of snapshotDirs) {
await fs.rm(dir, { recursive: true, force: true }).catch(() => {})
}
console.log(` Deleted ${snapshotDirs.length} snapshot directories`)
}

console.log(`${EOL}Done! Freed approximately ${formatBytes(grandTotal)}`)
})
},
})
Loading