From 341a4c8a76f912aa11ef10689c2e02d7ccf9eb27 Mon Sep 17 00:00:00 2001 From: coder999999999 Date: Mon, 6 Apr 2026 21:46:48 +0000 Subject: [PATCH] feat(sessions): add prune command to delete closed sessions Adds `acpx sessions prune` to delete closed sessions and free disk space. Supports filtering by agent, date cutoff, and age threshold, with a dry-run mode to preview what would be removed. Flags: --dry-run Preview what would be pruned without deleting --before Prune sessions closed before a given date --older-than Prune sessions closed more than N days ago --include-history Also delete event stream files (.stream.ndjson) When both --before and --older-than are supplied, --before takes precedence. Output is available in text, json, and quiet formats. Co-authored-by: Claude --- src/cli/command-handlers.ts | 27 ++ src/cli/command-registration.ts | 15 + src/cli/flags.ts | 25 ++ src/cli/output/render.ts | 58 ++++ src/session/persistence.ts | 2 + src/session/persistence/repository.ts | 96 +++++++ src/session/session.ts | 2 + test/sessions-prune.test.ts | 395 ++++++++++++++++++++++++++ 8 files changed, 620 insertions(+) create mode 100644 test/sessions-prune.test.ts diff --git a/src/cli/command-handlers.ts b/src/cli/command-handlers.ts index daf8c4f..04c5f34 100644 --- a/src/cli/command-handlers.ts +++ b/src/cli/command-handlers.ts @@ -33,6 +33,7 @@ import { type PromptFlags, type SessionsHistoryFlags, type SessionsNewFlags, + type SessionsPruneFlags, type StatusFlags, } from "./flags.js"; import { emitJsonResult } from "./output/json-output.js"; @@ -874,4 +875,30 @@ export async function handleSessionsHistory( printSessionHistoryByFormat(record, flags.limit, globalFlags.format); } +export async function handleSessionsPrune( + explicitAgentName: string | undefined, + flags: SessionsPruneFlags, + command: Command, + config: ResolvedAcpxConfig, +): Promise { + const globalFlags = resolveGlobalFlags(command, config); + const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config); + const [{ pruneSessions }, { printPruneResultByFormat }] = await Promise.all([ + loadSessionModule(), + loadOutputRenderModule(), + ]); + + const olderThanMs = flags.olderThan != null ? flags.olderThan * 24 * 60 * 60 * 1000 : undefined; + + const result = await pruneSessions({ + agentCommand: agent.agentCommand, + before: flags.before, + olderThanMs, + includeHistory: flags.includeHistory, + dryRun: flags.dryRun, + }); + + printPruneResultByFormat(result, globalFlags.format); +} + export { parseHistoryLimit, NoSessionError, loadSessionModule }; diff --git a/src/cli/command-registration.ts b/src/cli/command-registration.ts index b5188a8..f0035d2 100644 --- a/src/cli/command-registration.ts +++ b/src/cli/command-registration.ts @@ -9,6 +9,7 @@ import { handleSessionsHistory, handleSessionsList, handleSessionsNew, + handleSessionsPrune, handleSessionsShow, handleSetConfigOption, handleSetMode, @@ -20,11 +21,14 @@ import { addPromptInputOption, addSessionNameOption, addSessionOption, + parseDaysOlderThan, parseNonEmptyValue, + parsePruneBeforeDate, parseSessionName, type PromptFlags, type SessionsHistoryFlags, type SessionsNewFlags, + type SessionsPruneFlags, type StatusFlags, } from "./flags.js"; import { registerStatusCommand } from "./status-command.js"; @@ -134,6 +138,17 @@ export function registerSessionsCommand( config, ); }); + + sessionsCommand + .command("prune") + .description("Delete closed sessions and free disk space") + .option("--dry-run", "Preview what would be pruned without deleting anything") + .option("--before ", "Prune sessions closed before this date", parsePruneBeforeDate) + .option("--older-than ", "Prune sessions closed more than N days ago", parseDaysOlderThan) + .option("--include-history", "Also delete event stream files (.stream.ndjson)") + .action(async function (this: Command, flags: SessionsPruneFlags) { + await handleSessionsPrune(explicitAgentName, flags, this, config); + }); } export function registerSharedAgentSubcommands( diff --git a/src/cli/flags.ts b/src/cli/flags.ts index af1cc02..c087c19 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -68,6 +68,13 @@ export type StatusFlags = { session?: string; }; +export type SessionsPruneFlags = { + dryRun?: boolean; + before?: Date; + olderThan?: number; + includeHistory?: boolean; +}; + export function parseOutputFormat(value: string): OutputFormat { if (!OUTPUT_FORMATS.includes(value as OutputFormat)) { throw new InvalidArgumentError( @@ -135,6 +142,24 @@ export function parseHistoryLimit(value: string): number { return parsed; } +export function parseDaysOlderThan(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new InvalidArgumentError("--older-than must be a positive integer number of days"); + } + return parsed; +} + +export function parsePruneBeforeDate(value: string): Date { + const date = new Date(value); + if (isNaN(date.getTime())) { + throw new InvalidArgumentError( + `--before must be a valid date (e.g. 2026-01-01 or 2026-01-01T00:00:00Z)`, + ); + } + return date; +} + export function parseAllowedTools(value: string): string[] { const trimmed = value.trim(); if (trimmed.length === 0) { diff --git a/src/cli/output/render.ts b/src/cli/output/render.ts index f43d1e0..2808b5a 100644 --- a/src/cli/output/render.ts +++ b/src/cli/output/render.ts @@ -205,6 +205,64 @@ export function printCreatedSessionBanner( process.stderr.write(`[acpx] cwd: ${record.cwd}\n`); } +function formatBytes(bytes: number): string { + if (bytes >= 1_073_741_824) { + return `${(bytes / 1_073_741_824).toFixed(1)} GB`; + } + if (bytes >= 1_048_576) { + return `${(bytes / 1_048_576).toFixed(1)} MB`; + } + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${bytes} B`; +} + +export function printPruneResultByFormat( + result: { pruned: SessionRecord[]; bytesFreed: number; dryRun: boolean }, + format: OutputFormat, +): void { + const count = result.pruned.length; + + if ( + emitJsonResult(format, { + action: result.dryRun ? "sessions_prune_dry_run" : "sessions_pruned", + dryRun: result.dryRun, + count, + bytesFreed: result.bytesFreed, + pruned: result.pruned.map((r) => r.acpxRecordId), + }) + ) { + return; + } + + if (format === "quiet") { + for (const record of result.pruned) { + process.stdout.write(`${record.acpxRecordId}\n`); + } + return; + } + + if (count === 0) { + process.stdout.write( + result.dryRun ? "[DRY RUN] No sessions to prune\n" : "No sessions pruned\n", + ); + return; + } + + const prefix = result.dryRun ? "[DRY RUN] Would prune" : "Pruned"; + const bytesSuffix = + !result.dryRun && result.bytesFreed > 0 ? `, freed ${formatBytes(result.bytesFreed)}` : ""; + process.stdout.write(`${prefix} ${count} session${count === 1 ? "" : "s"}${bytesSuffix}\n`); + + for (const record of result.pruned) { + const label = record.name ? ` (${record.name})` : ""; + process.stdout.write( + ` ${record.acpxRecordId}${label}\t${record.closedAt ?? record.lastUsedAt}\n`, + ); + } +} + export function agentSessionIdPayload(agentSessionId: string | undefined): { agentSessionId?: string; } { diff --git a/src/session/persistence.ts b/src/session/persistence.ts index 79ad8e4..2d1eba5 100644 --- a/src/session/persistence.ts +++ b/src/session/persistence.ts @@ -10,6 +10,8 @@ export { listSessions, listSessionsForAgent, normalizeName, + pruneSessions, resolveSessionRecord, writeSessionRecord, } from "./persistence/repository.js"; +export type { PruneOptions, PruneResult } from "./persistence/repository.js"; diff --git a/src/session/persistence/repository.ts b/src/session/persistence/repository.ts index 9552cd4..fa44e0a 100644 --- a/src/session/persistence/repository.ts +++ b/src/session/persistence/repository.ts @@ -297,6 +297,102 @@ function killSignalCandidates(signal: NodeJS.Signals | undefined): NodeJS.Signal return [normalized, "SIGKILL"]; } +export type PruneOptions = { + agentCommand?: string; + before?: Date; + olderThanMs?: number; + includeHistory?: boolean; + dryRun?: boolean; +}; + +export type PruneResult = { + pruned: SessionRecord[]; + bytesFreed: number; + dryRun: boolean; +}; + +export async function pruneSessions(options: PruneOptions = {}): Promise { + await ensureSessionDir(); + const entries = await loadSessionIndexEntries(); + + let eligible = entries.filter((entry) => entry.closed); + + if (options.agentCommand) { + eligible = eligible.filter((entry) => entry.agentCommand === options.agentCommand); + } + + const cutoff = + options.before ?? + (options.olderThanMs != null ? new Date(Date.now() - options.olderThanMs) : undefined); + + if (cutoff) { + const cutoffIso = cutoff.toISOString(); + eligible = eligible.filter((entry) => entry.lastUsedAt < cutoffIso); + } + + const records: SessionRecord[] = []; + for (const entry of eligible) { + const record = await loadRecordFromIndexEntry(entry); + if (record) { + records.push(record); + } + } + + if (options.dryRun) { + return { pruned: records, bytesFreed: 0, dryRun: true }; + } + + const sessionDir = sessionBaseDir(); + let bytesFreed = 0; + + // Read the directory once upfront so stream-file matching doesn't re-read + // it for every session in the loop. + let dirEntries: string[] = []; + if (options.includeHistory) { + try { + dirEntries = await fs.readdir(sessionDir); + } catch { + // ignore + } + } + + for (const record of records) { + const safeId = encodeURIComponent(record.acpxRecordId); + const jsonFile = path.join(sessionDir, `${safeId}.json`); + + try { + const stat = await fs.stat(jsonFile); + bytesFreed += stat.size; + } catch { + // file already gone + } + await fs.unlink(jsonFile).catch(() => undefined); + + if (options.includeHistory) { + const prefix = `${safeId}.stream`; + for (const name of dirEntries) { + if (!name.startsWith(prefix)) { + continue; + } + const filePath = path.join(sessionDir, name); + try { + const stat = await fs.stat(filePath); + bytesFreed += stat.size; + } catch { + // ignore + } + await fs.unlink(filePath).catch(() => undefined); + } + } + } + + await rebuildSessionIndex(sessionDir).catch(() => { + // best effort cache rebuild + }); + + return { pruned: records, bytesFreed, dryRun: false }; +} + export async function closeSession(id: string): Promise { const record = await resolveSessionRecord(id); const now = isoNow(); diff --git a/src/session/session.ts b/src/session/session.ts index 6676d62..cce1254 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -10,5 +10,7 @@ export { findSessionByDirectoryWalk, listSessions, listSessionsForAgent, + pruneSessions, } from "./persistence.js"; +export type { PruneOptions, PruneResult } from "./persistence.js"; export { isProcessAlive } from "../cli/queue/ipc.js"; diff --git a/test/sessions-prune.test.ts b/test/sessions-prune.test.ts new file mode 100644 index 0000000..a0314d2 --- /dev/null +++ b/test/sessions-prune.test.ts @@ -0,0 +1,395 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { serializeSessionRecordForDisk } from "../src/session/persistence.js"; +import type { SessionRecord } from "../src/types.js"; + +type SessionModule = typeof import("../src/session/session.js"); + +const SESSION_MODULE_URL = new URL("../src/session/session.js", import.meta.url); + +async function loadSessionModule(): Promise { + const cacheBuster = `${Date.now()}-${Math.random()}`; + return (await import(`${SESSION_MODULE_URL.href}?prune_test=${cacheBuster}`)) as SessionModule; +} + +async function withTempHome(run: (homeDir: string) => Promise): Promise { + const originalHome = process.env.HOME; + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-prune-test-")); + process.env.HOME = tempHome; + + try { + await run(tempHome); + } finally { + if (originalHome == null) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + await fs.rm(tempHome, { recursive: true, force: true }); + } +} + +function makeSessionRecord( + overrides: Partial & { + acpxRecordId: string; + acpSessionId: string; + agentCommand: string; + cwd: string; + }, +): SessionRecord { + const timestamp = "2026-01-01T00:00:00.000Z"; + return { + schema: "acpx.session.v1", + acpxRecordId: overrides.acpxRecordId, + acpSessionId: overrides.acpSessionId, + agentSessionId: overrides.agentSessionId, + agentCommand: overrides.agentCommand, + cwd: path.resolve(overrides.cwd), + name: overrides.name, + createdAt: overrides.createdAt ?? timestamp, + lastUsedAt: overrides.lastUsedAt ?? timestamp, + lastSeq: overrides.lastSeq ?? 0, + lastRequestId: overrides.lastRequestId, + eventLog: overrides.eventLog ?? { + active_path: `.stream.ndjson`, + segment_count: 1, + max_segment_bytes: 1024, + max_segments: 1, + last_write_at: overrides.lastUsedAt ?? timestamp, + last_write_error: null, + }, + closed: overrides.closed ?? false, + closedAt: overrides.closedAt, + pid: overrides.pid, + agentStartedAt: overrides.agentStartedAt, + lastPromptAt: overrides.lastPromptAt, + lastAgentExitCode: overrides.lastAgentExitCode, + lastAgentExitSignal: overrides.lastAgentExitSignal, + lastAgentExitAt: overrides.lastAgentExitAt, + lastAgentDisconnectReason: overrides.lastAgentDisconnectReason, + protocolVersion: overrides.protocolVersion, + agentCapabilities: overrides.agentCapabilities, + title: overrides.title ?? null, + messages: overrides.messages ?? [], + updated_at: overrides.updated_at ?? overrides.lastUsedAt ?? timestamp, + cumulative_token_usage: overrides.cumulative_token_usage ?? {}, + request_token_usage: overrides.request_token_usage ?? {}, + acpx: overrides.acpx, + }; +} + +function sessionFilePath(homeDir: string, acpxRecordId: string): string { + return path.join(homeDir, ".acpx", "sessions", `${encodeURIComponent(acpxRecordId)}.json`); +} + +async function writeSessionRecord(homeDir: string, record: SessionRecord): Promise { + const filePath = sessionFilePath(homeDir, record.acpxRecordId); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + `${JSON.stringify(serializeSessionRecordForDisk(record), null, 2)}\n`, + "utf8", + ); +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +test("pruneSessions returns empty result when no closed sessions exist", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const cwd = path.join(homeDir, "workspace"); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "open-session", + acpSessionId: "open-session", + agentCommand: "agent-a", + cwd, + closed: false, + }), + ); + + const result = await session.pruneSessions({ agentCommand: "agent-a" }); + assert.equal(result.pruned.length, 0); + assert.equal(result.bytesFreed, 0); + assert.equal(result.dryRun, false); + }); +}); + +test("pruneSessions deletes closed session files and removes them from the index", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const cwd = path.join(homeDir, "workspace"); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "closed-session", + acpSessionId: "closed-session", + agentCommand: "agent-a", + cwd, + closed: true, + closedAt: "2026-01-01T00:00:00.000Z", + }), + ); + + const filePath = sessionFilePath(homeDir, "closed-session"); + assert.ok(await fileExists(filePath)); + + const result = await session.pruneSessions({ agentCommand: "agent-a" }); + assert.equal(result.pruned.length, 1); + assert.equal(result.pruned[0].acpxRecordId, "closed-session"); + assert.ok(result.bytesFreed > 0); + assert.equal(result.dryRun, false); + assert.ok(!(await fileExists(filePath))); + }); +}); + +test("pruneSessions --dry-run does not delete files but returns correct count", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const cwd = path.join(homeDir, "workspace"); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "dry-run-session", + acpSessionId: "dry-run-session", + agentCommand: "agent-a", + cwd, + closed: true, + closedAt: "2026-01-01T00:00:00.000Z", + }), + ); + + const filePath = sessionFilePath(homeDir, "dry-run-session"); + assert.ok(await fileExists(filePath)); + + const result = await session.pruneSessions({ agentCommand: "agent-a", dryRun: true }); + assert.equal(result.pruned.length, 1); + assert.equal(result.bytesFreed, 0); + assert.equal(result.dryRun, true); + assert.ok(await fileExists(filePath)); + }); +}); + +test("pruneSessions --before only prunes sessions with lastUsedAt before the cutoff", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const cwd = path.join(homeDir, "workspace"); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "old-session", + acpSessionId: "old-session", + agentCommand: "agent-a", + cwd, + closed: true, + closedAt: "2025-06-01T00:00:00.000Z", + lastUsedAt: "2025-06-01T00:00:00.000Z", + }), + ); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "recent-session", + acpSessionId: "recent-session", + agentCommand: "agent-a", + cwd, + closed: true, + closedAt: "2026-03-01T00:00:00.000Z", + lastUsedAt: "2026-03-01T00:00:00.000Z", + }), + ); + + const result = await session.pruneSessions({ + agentCommand: "agent-a", + before: new Date("2026-01-01T00:00:00.000Z"), + }); + assert.equal(result.pruned.length, 1); + assert.equal(result.pruned[0].acpxRecordId, "old-session"); + assert.ok(!(await fileExists(sessionFilePath(homeDir, "old-session")))); + assert.ok(await fileExists(sessionFilePath(homeDir, "recent-session"))); + }); +}); + +test("pruneSessions --older-than prunes sessions beyond the day threshold", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const cwd = path.join(homeDir, "workspace"); + + // Session with lastUsedAt far in the past + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "ancient-session", + acpSessionId: "ancient-session", + agentCommand: "agent-a", + cwd, + closed: true, + closedAt: "2020-01-01T00:00:00.000Z", + lastUsedAt: "2020-01-01T00:00:00.000Z", + }), + ); + + // Session with lastUsedAt very recently (should not be pruned) + const now = new Date().toISOString(); + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "fresh-session", + acpSessionId: "fresh-session", + agentCommand: "agent-a", + cwd, + closed: true, + closedAt: now, + lastUsedAt: now, + }), + ); + + // Prune sessions older than 1 day + const result = await session.pruneSessions({ + agentCommand: "agent-a", + olderThanMs: 1 * 24 * 60 * 60 * 1000, + }); + assert.equal(result.pruned.length, 1); + assert.equal(result.pruned[0].acpxRecordId, "ancient-session"); + assert.ok(await fileExists(sessionFilePath(homeDir, "fresh-session"))); + }); +}); + +test("pruneSessions scoped to agentCommand only prunes that agent's sessions", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const cwd = path.join(homeDir, "workspace"); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "agent-a-session", + acpSessionId: "agent-a-session", + agentCommand: "agent-a", + cwd, + closed: true, + closedAt: "2026-01-01T00:00:00.000Z", + }), + ); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "agent-b-session", + acpSessionId: "agent-b-session", + agentCommand: "agent-b", + cwd, + closed: true, + closedAt: "2026-01-01T00:00:00.000Z", + }), + ); + + const result = await session.pruneSessions({ agentCommand: "agent-a" }); + assert.equal(result.pruned.length, 1); + assert.equal(result.pruned[0].acpxRecordId, "agent-a-session"); + assert.ok(!(await fileExists(sessionFilePath(homeDir, "agent-a-session")))); + assert.ok(await fileExists(sessionFilePath(homeDir, "agent-b-session"))); + }); +}); + +test("pruneSessions --include-history deletes stream files", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const cwd = path.join(homeDir, "workspace"); + const sessionsDir = path.join(homeDir, ".acpx", "sessions"); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "stream-session", + acpSessionId: "stream-session", + agentCommand: "agent-a", + cwd, + closed: true, + closedAt: "2026-01-01T00:00:00.000Z", + }), + ); + + const safeId = encodeURIComponent("stream-session"); + const streamFile = path.join(sessionsDir, `${safeId}.stream.ndjson`); + const streamSegment = path.join(sessionsDir, `${safeId}.stream.0.ndjson`); + const streamLock = path.join(sessionsDir, `${safeId}.stream.lock`); + await fs.writeFile(streamFile, "event-data\n", "utf8"); + await fs.writeFile(streamSegment, "segment-data\n", "utf8"); + await fs.writeFile(streamLock, "", "utf8"); + + const result = await session.pruneSessions({ + agentCommand: "agent-a", + includeHistory: true, + }); + assert.equal(result.pruned.length, 1); + assert.ok(result.bytesFreed > 0); + assert.ok(!(await fileExists(streamFile))); + assert.ok(!(await fileExists(streamSegment))); + assert.ok(!(await fileExists(streamLock))); + }); +}); + +test("pruneSessions without agentCommand prunes all closed sessions across all agents", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const cwd = path.join(homeDir, "workspace"); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "all-a", + acpSessionId: "all-a", + agentCommand: "agent-a", + cwd, + closed: true, + closedAt: "2026-01-01T00:00:00.000Z", + }), + ); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "all-b", + acpSessionId: "all-b", + agentCommand: "agent-b", + cwd, + closed: true, + closedAt: "2026-01-01T00:00:00.000Z", + }), + ); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "all-open", + acpSessionId: "all-open", + agentCommand: "agent-a", + cwd, + closed: false, + }), + ); + + const result = await session.pruneSessions({}); + assert.equal(result.pruned.length, 2); + const prunedIds = result.pruned.map((r) => r.acpxRecordId).toSorted(); + assert.deepEqual(prunedIds, ["all-a", "all-b"]); + assert.ok(await fileExists(sessionFilePath(homeDir, "all-open"))); + }); +});