diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index 8e9ff7380f..677fc565bb 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -181,6 +181,9 @@ export interface SessionRetentionSettings {
/** Minimum retention period (safety limit, defaults to "1d") */
minRetention?: string;
+
+ /** Timestamp (ISO string or ms) to start age checks from for existing files. */
+ gracePeriodStart?: string;
}
export interface SettingsError {
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 2d2fd01067..34e2f0dd55 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -319,6 +319,16 @@ const SETTINGS_SCHEMA = {
description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`,
showInDialog: false,
},
+ gracePeriodStart: {
+ type: 'string',
+ label: 'Grace Period Start',
+ category: 'General',
+ requiresRestart: false,
+ default: undefined as string | undefined,
+ description:
+ 'Timestamp (ISO string) to start age checks from for existing files.',
+ showInDialog: false,
+ },
},
description: 'Settings for automatic session cleanup.',
},
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index c18b9f24e8..c577546351 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -141,6 +141,10 @@ import {
QUEUE_ERROR_DISPLAY_DURATION_MS,
} from './constants.js';
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
+import {
+ type SessionRetentionChoice,
+ SessionRetentionWarningDialog,
+} from './components/SessionRetentionWarningDialog.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
@@ -242,6 +246,49 @@ export const AppContainer = (props: AppContainerProps) => {
const [warningBannerText, setWarningBannerText] = useState('');
const [bannerVisible, setBannerVisible] = useState(true);
+ const [showRetentionWarning, setShowRetentionWarning] = useState(false);
+ const retentionCheckDone = useRef(false);
+
+ useEffect(() => {
+ if (retentionCheckDone.current) return;
+ retentionCheckDone.current = true;
+
+ // We only want to show this if the user hasn't explicitly set it in User or Workspace scope.
+ const userSettings = settings.user.settings;
+ const workspaceSettings = settings.workspace.settings;
+
+ const userRetention = userSettings.general?.sessionRetention;
+ const workspaceRetention = workspaceSettings.general?.sessionRetention;
+
+ // Check if it's completely undefined in both explicit scopes
+ if (userRetention === undefined && workspaceRetention === undefined) {
+ setShowRetentionWarning(true);
+ }
+ }, [settings]);
+
+ const handleRetentionConfirm = useCallback(
+ (choice: SessionRetentionChoice) => {
+ const now = new Date().toISOString();
+ const retentionSettings: any = {
+ enabled: true,
+ maxAge: '60d',
+ };
+
+ if (choice === 'graceful') {
+ retentionSettings.gracePeriodStart = now;
+ }
+
+ // Store in Workspace scope as requested
+ settings.setValue(
+ SettingScope.Workspace,
+ 'general.sessionRetention',
+ retentionSettings,
+ );
+ setShowRetentionWarning(false);
+ },
+ [settings],
+ );
+
const bannerData = useMemo(
() => ({
defaultText: defaultBannerText,
@@ -2231,6 +2278,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
);
}
+ if (showRetentionWarning) {
+ return ;
+ }
+
return (
diff --git a/packages/cli/src/ui/components/SessionRetentionWarningDialog.test.tsx b/packages/cli/src/ui/components/SessionRetentionWarningDialog.test.tsx
new file mode 100644
index 0000000000..7945d2475a
--- /dev/null
+++ b/packages/cli/src/ui/components/SessionRetentionWarningDialog.test.tsx
@@ -0,0 +1,66 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { renderWithProviders } from '../../test-utils/render.js';
+import { waitFor } from '../../test-utils/async.js';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
+import { act } from 'react';
+
+describe('SessionRetentionWarningDialog', () => {
+ const onConfirm = vi.fn();
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should render correctly', () => {
+ const { lastFrame } = renderWithProviders(
+ ,
+ );
+
+ expect(lastFrame()).toMatchSnapshot();
+ expect(lastFrame()).toContain('Session Retention Policy');
+ expect(lastFrame()).toContain('Graceful Cleanup');
+ expect(lastFrame()).toContain('Strict Cleanup');
+ });
+
+ it('should confirm with "graceful" when selecting "Graceful Cleanup"', async () => {
+ const { stdin } = renderWithProviders(
+ ,
+ );
+
+ // Default selection is "Graceful Cleanup", so just press Enter
+ act(() => {
+ stdin.write('\r');
+ });
+
+ await waitFor(() => {
+ expect(onConfirm).toHaveBeenCalledWith('graceful');
+ });
+ });
+
+ it('should confirm with "strict" when selecting "Strict Cleanup"', async () => {
+ const { stdin } = renderWithProviders(
+ ,
+ );
+
+ // Move down to "Strict Cleanup"
+ act(() => {
+ stdin.write('\x1b[B'); // Arrow Down
+ });
+
+ // Press Enter
+ act(() => {
+ stdin.write('\r');
+ });
+
+ await waitFor(() => {
+ expect(onConfirm).toHaveBeenCalledWith('strict');
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx b/packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx
new file mode 100644
index 0000000000..9e96162d05
--- /dev/null
+++ b/packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx
@@ -0,0 +1,67 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import {
+ DescriptiveRadioButtonSelect,
+ type DescriptiveRadioSelectItem,
+} from './shared/DescriptiveRadioButtonSelect.js';
+
+export type SessionRetentionChoice = 'graceful' | 'strict';
+
+interface SessionRetentionWarningDialogProps {
+ onConfirm: (choice: SessionRetentionChoice) => void;
+}
+
+export const SessionRetentionWarningDialog = ({
+ onConfirm,
+}: SessionRetentionWarningDialogProps): React.JSX.Element => {
+ const items: Array> = [
+ {
+ key: 'graceful',
+ title: 'Graceful Cleanup (Recommended)',
+ description:
+ 'Start 60-day limit from today. Existing sessions are safe for now.',
+ value: 'graceful',
+ },
+ {
+ key: 'strict',
+ title: 'Strict Cleanup',
+ description:
+ 'Apply 60-day limit immediately. Older sessions will be deleted.',
+ value: 'strict',
+ },
+ ];
+
+ return (
+
+
+
+ Session Retention Policy
+
+
+
+
+ To save disk space, we recommend enabling automatic cleanup of old
+ sessions. Please choose how to apply the 60-day retention policy:
+
+
+
+
+ );
+};
diff --git a/packages/cli/src/utils/sessionCleanup.ts b/packages/cli/src/utils/sessionCleanup.ts
index 6004cb8c5d..818d5000f1 100644
--- a/packages/cli/src/utils/sessionCleanup.ts
+++ b/packages/cli/src/utils/sessionCleanup.ts
@@ -198,7 +198,66 @@ async function identifySessionsToDelete(
if (retentionConfig.maxAge) {
try {
const maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge);
- cutoffDate = new Date(now.getTime() - maxAgeMs);
+ const nowMs = now.getTime();
+
+ // If gracePeriodStart is set, we treat files as if they were created at
+ // max(file.mtime, gracePeriodStart) for the purpose of age check?
+ // actually, simpler:
+ // The deletion condition is:
+ // age > maxAge AND (now > gracePeriodStart + maxAge)
+ // Wait, if I have a file from 2020.
+ // gracePeriodStart = Today.
+ // I want it to survive for 60 days.
+ // So effectively, the file's "effective mtime" is max(real_mtime, gracePeriodStart).
+ // We check if (now - effective_mtime) > maxAge.
+
+ if (retentionConfig.gracePeriodStart) {
+ const graceStart = new Date(retentionConfig.gracePeriodStart).getTime();
+ if (!isNaN(graceStart)) {
+ // We can't set a single cutoff date because it depends on the file's mtime vs graceStart?
+ // Actually, we can.
+ // effectiveAge = now - max(mtime, graceStart)
+ // We want effectiveAge > maxAge
+ // now - max(mtime, graceStart) > maxAge
+ // max(mtime, graceStart) < now - maxAge
+ // mtime < now - maxAge AND graceStart < now - maxAge
+ // So, BOTH must be true.
+ // If graceStart is recent (today), then `graceStart < now - maxAge` is FALSE.
+ // So nothing gets deleted.
+ // This works perfectly!
+ // So checking `graceStart < now - maxAge` is a global gate.
+ // If we are within the grace period (globally), we delete NOTHING based on age?
+ // Wait, what if a file was created *after* graceStart?
+ // mtime (Post-Grace) < now - maxAge.
+ // If graceStart is today.
+ // File created tomorrow.
+ // now is tomorrow + 61 days.
+ // mtime < now - maxAge (True).
+ // graceStart < now - maxAge (True, because graceStart was 61 days ago).
+ // So it deletes.
+ // This logic holds up.
+ // We just need to ensure we use the SAME cutoff for all files?
+ // Yes, `cutoffDate` is `now - maxAge`.
+ // And we also verify `graceStart < cutoffDate`.
+ if (graceStart > nowMs - maxAgeMs) {
+ // We are still within the global grace period.
+ // UNLESS the file is somehow older than maxAge AND older than graceStart?
+ // No, if `graceStart > cutoffDate`, then `max(mtime, graceStart)` is at least `graceStart`.
+ // `graceStart > cutoffDate` means `graceStart > now - maxAge`.
+ // So `now - graceStart < maxAge`.
+ // So effective age is ALWAYS < maxAge.
+ // So NOTHING deletes.
+ cutoffDate = null; // Disable age cleanup
+ } else {
+ cutoffDate = new Date(nowMs - maxAgeMs);
+ }
+ } else {
+ cutoffDate = new Date(nowMs - maxAgeMs);
+ }
+ } else {
+ cutoffDate = new Date(nowMs - maxAgeMs);
+ }
+
} catch {
// This should not happen as validation should have caught it,
// but handle gracefully just in case