Skip to content
Open
Show file tree
Hide file tree
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
27 changes: 27 additions & 0 deletions src/cli/command-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> {
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 };
15 changes: 15 additions & 0 deletions src/cli/command-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
handleSessionsHistory,
handleSessionsList,
handleSessionsNew,
handleSessionsPrune,
handleSessionsShow,
handleSetConfigOption,
handleSetMode,
Expand All @@ -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";
Expand Down Expand Up @@ -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 <date>", "Prune sessions closed before this date", parsePruneBeforeDate)
.option("--older-than <days>", "Prune sessions closed more than N days ago", parseDaysOlderThan)
Comment on lines +146 to +147
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The help text says "Prune sessions closed before this date" / "closed more than N days ago", but pruning is implemented using lastUsedAt (not closedAt). This is user-facing behavior documentation; either update the descriptions to match (e.g., "last used before"), or change the pruning logic to use closedAt if that's the intended cutoff.

Suggested change
.option("--before <date>", "Prune sessions closed before this date", parsePruneBeforeDate)
.option("--older-than <days>", "Prune sessions closed more than N days ago", parseDaysOlderThan)
.option("--before <date>", "Prune sessions last used before this date", parsePruneBeforeDate)
.option("--older-than <days>", "Prune sessions last used more than N days ago", parseDaysOlderThan)

Copilot uses AI. Check for mistakes.
.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(
Expand Down
25 changes: 25 additions & 0 deletions src/cli/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
58 changes: 58 additions & 0 deletions src/cli/output/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
} {
Expand Down
2 changes: 2 additions & 0 deletions src/session/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export {
listSessions,
listSessionsForAgent,
normalizeName,
pruneSessions,
resolveSessionRecord,
writeSessionRecord,
} from "./persistence/repository.js";
export type { PruneOptions, PruneResult } from "./persistence/repository.js";
96 changes: 96 additions & 0 deletions src/session/persistence/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PruneResult> {
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) {
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pruneSessions determines eligibility from the cached index entry (entry.closed) but does not re-check the loaded record before deleting. If index.json is stale/corrupt and marks an active session as closed, this can delete an open session file. Consider filtering again on record.closed === true (and optionally record.pid == null) after loadRecordFromIndexEntry before adding it to records / deleting.

Suggested change
if (record) {
if (record?.closed === true) {

Copilot uses AI. Check for mistakes.
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);

Comment on lines +363 to +370
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bytesFreed is incremented based on stat.size before deletion, but unlink failures are swallowed. If unlink fails (permissions, transient FS errors), the command will report bytes freed even though the file remains, and still include the record in pruned. Consider only adding to bytesFreed after a successful delete and surfacing/returning deletion failures so the CLI output is accurate.

Copilot uses AI. Check for mistakes.
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);
}
Comment on lines +371 to +385
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When --include-history is set, this loops over the full dirEntries list for every pruned session, making deletion O(prunedSessions × filesInDir). With many sessions/stream segments this can get slow. Consider pre-indexing dirEntries by safeId/prefix once (or filtering to matching names and removing them from a Set as you delete) to avoid repeated full scans.

Copilot uses AI. Check for mistakes.
}
}

await rebuildSessionIndex(sessionDir).catch(() => {
// best effort cache rebuild
});

return { pruned: records, bytesFreed, dryRun: false };
}

export async function closeSession(id: string): Promise<SessionRecord> {
const record = await resolveSessionRecord(id);
const now = isoNow();
Expand Down
2 changes: 2 additions & 0 deletions src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading
Loading