Skip to content

Commit 536a965

Browse files
BlocksOrgclaude
andcommitted
feat: Add --resume functionality for non-interactive sessions
This commit implements comprehensive session resumption capabilities: - Add --resume flag to CLI arguments with support for: * Index numbers (--resume 1) * UUID identifiers (--resume <full-uuid>) * Latest session (--resume latest) - Add --list-sessions flag to display available sessions - Add --delete-session flag to clean up sessions - Automatic session recording in non-interactive mode via ChatRecordingService - Session cleanup functionality with configurable retention policies - Full integration with existing chat recording infrastructure The implementation enables users to seamlessly continue conversations across multiple CLI invocations, maintaining context and history. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 13d39ef commit 536a965

File tree

8 files changed

+724
-2
lines changed

8 files changed

+724
-2
lines changed

packages/cli/src/config/config.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ export interface CliArgs {
8181
useSmartEdit: boolean | undefined;
8282
sessionSummary: string | undefined;
8383
promptWords: string[] | undefined;
84+
resume: string | undefined;
85+
listSessions: boolean | undefined;
86+
deleteSession: string | undefined;
8487
}
8588

8689
export async function parseArguments(settings: Settings): Promise<CliArgs> {
@@ -234,6 +237,19 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
234237
type: 'string',
235238
description: 'File to write session summary to.',
236239
})
240+
.option('resume', {
241+
alias: 'r',
242+
type: 'string',
243+
description: 'Resume a previous session. Use "latest" for most recent or index number (e.g. --resume 5)',
244+
})
245+
.option('list-sessions', {
246+
type: 'boolean',
247+
description: 'List available sessions for the current project and exit.',
248+
})
249+
.option('delete-session', {
250+
type: 'string',
251+
description: 'Delete a session by index number (use --list-sessions to see available sessions).',
252+
})
237253
.deprecateOption(
238254
'telemetry',
239255
'Use settings.json instead. This flag will be removed in a future version.',
@@ -299,6 +315,16 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
299315
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
300316
);
301317
}
318+
if (argv.resume && (argv['listSessions'] || argv['deleteSession'])) {
319+
throw new Error(
320+
'Cannot use --resume with --list-sessions or --delete-session',
321+
);
322+
}
323+
if (argv.resume && !argv.prompt && !promptWords?.length && !process.stdin.isTTY) {
324+
throw new Error(
325+
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
326+
);
327+
}
302328
return true;
303329
}),
304330
)

packages/cli/src/config/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
4646
}
4747

4848
export type { Settings, MemoryImportFormat };
49+
export type { SessionRetentionSettings } from './settingsSchema.js';
4950

5051
export const SETTINGS_DIRECTORY_NAME = '.gemini';
5152

packages/cli/src/config/settingsSchema.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,48 @@ export const SETTINGS_SCHEMA = {
152152
description: 'Enable debug logging of keystrokes to the console.',
153153
showInDialog: true,
154154
},
155+
sessionRetention: {
156+
type: 'object',
157+
label: 'Session Retention',
158+
category: 'General',
159+
requiresRestart: false,
160+
default: {
161+
enabled: false,
162+
maxAge: '30d',
163+
maxCount: 100,
164+
},
165+
description: 'Automatic cleanup of old session files.',
166+
showInDialog: false,
167+
properties: {
168+
enabled: {
169+
type: 'boolean',
170+
label: 'Enable Session Cleanup',
171+
category: 'General',
172+
requiresRestart: false,
173+
default: false,
174+
description: 'Enable automatic cleanup of old session files',
175+
showInDialog: true,
176+
},
177+
maxAge: {
178+
type: 'string',
179+
label: 'Maximum Age',
180+
category: 'General',
181+
requiresRestart: false,
182+
default: '30d',
183+
description: 'Maximum age of sessions to keep (e.g., "30d", "7d", "24h")',
184+
showInDialog: true,
185+
},
186+
maxCount: {
187+
type: 'number',
188+
label: 'Maximum Count',
189+
category: 'General',
190+
requiresRestart: false,
191+
default: 100,
192+
description: 'Maximum number of sessions to keep (most recent)',
193+
showInDialog: true,
194+
},
195+
},
196+
},
155197
},
156198
},
157199

@@ -917,3 +959,9 @@ export interface FooterSettings {
917959
hideSandboxStatus?: boolean;
918960
hideModelInfo?: boolean;
919961
}
962+
963+
export interface SessionRetentionSettings {
964+
enabled?: boolean;
965+
maxAge?: string;
966+
maxCount?: number;
967+
}

packages/cli/src/gemini.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ describe('gemini.tsx main function kitty protocol', () => {
235235
useSmartEdit: undefined,
236236
sessionSummary: undefined,
237237
promptWords: undefined,
238+
resume: undefined,
239+
listSessions: undefined,
240+
deleteSession: undefined,
238241
});
239242

240243
await main();

packages/cli/src/gemini.tsx

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
5555
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
5656
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
5757
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
58+
import { SessionSelector, formatRelativeTime } from './utils/sessionUtils.js';
59+
import { cleanupExpiredSessions } from './utils/sessionCleanup.js';
60+
import fs from 'node:fs/promises';
61+
import path from 'node:path';
5862

5963
export function validateDnsResolutionOrder(
6064
order: string | undefined,
@@ -248,6 +252,15 @@ export async function main() {
248252
argv,
249253
);
250254

255+
// Clean up expired sessions on startup
256+
try {
257+
await cleanupExpiredSessions(config, settings.merged);
258+
} catch (error) {
259+
if (config.getDebugMode()) {
260+
console.debug('Session cleanup error:', error);
261+
}
262+
}
263+
251264
const wasRaw = process.stdin.isRaw;
252265
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
253266
if (config.isInteractive() && !wasRaw) {
@@ -302,6 +315,58 @@ export async function main() {
302315
process.exit(0);
303316
}
304317

318+
// Handle session management commands
319+
if (argv.listSessions) {
320+
try {
321+
const sessionSelector = new SessionSelector(config);
322+
const sessions = await sessionSelector.listSessions();
323+
324+
if (sessions.length === 0) {
325+
console.log('No previous sessions found for this project.');
326+
} else {
327+
console.log(`Available sessions for this project (${sessions.length}):\n`);
328+
329+
for (const session of sessions) {
330+
const timeAgo = formatRelativeTime(session.lastUpdated);
331+
console.log(` ${session.index}. ${session.firstUserMessage} (${timeAgo}) [${session.id}]`);
332+
}
333+
}
334+
} catch (error) {
335+
console.error('Error listing sessions:', error instanceof Error ? error.message : 'Unknown error');
336+
process.exit(1);
337+
}
338+
process.exit(0);
339+
}
340+
341+
if (argv.deleteSession) {
342+
try {
343+
const sessionSelector = new SessionSelector(config);
344+
const sessions = await sessionSelector.listSessions();
345+
346+
if (sessions.length === 0) {
347+
console.error('No sessions found for this project.');
348+
process.exit(1);
349+
}
350+
351+
const index = parseInt(argv.deleteSession, 10);
352+
if (isNaN(index) || index < 1 || index > sessions.length) {
353+
console.error(`Invalid session index "${argv.deleteSession}". Use --list-sessions to see available sessions.`);
354+
process.exit(1);
355+
}
356+
357+
const session = sessions[index - 1];
358+
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
359+
const sessionPath = path.join(chatsDir, session.fileName);
360+
361+
await fs.unlink(sessionPath);
362+
console.log(`Deleted session ${index}: ${session.firstUserMessage}`);
363+
} catch (error) {
364+
console.error('Error deleting session:', error instanceof Error ? error.message : 'Unknown error');
365+
process.exit(1);
366+
}
367+
process.exit(0);
368+
}
369+
305370
// Set a default auth type if one isn't set.
306371
if (!settings.merged.security?.auth?.selectedType) {
307372
if (process.env['CLOUD_SHELL'] === 'true') {
@@ -482,7 +547,24 @@ export async function main() {
482547
console.log('Session ID: %s', sessionId);
483548
}
484549

485-
await runNonInteractive(nonInteractiveConfig, input, prompt_id);
550+
// Handle resume functionality
551+
let resumedSessionData: any = undefined;
552+
if (argv.resume) {
553+
try {
554+
const sessionSelector = new SessionSelector(nonInteractiveConfig);
555+
const { sessionData, displayInfo } = await sessionSelector.resolveSession(argv.resume);
556+
resumedSessionData = { conversation: sessionData, filePath: '' };
557+
558+
if (nonInteractiveConfig.getDebugMode()) {
559+
console.debug(`Resuming ${displayInfo}`);
560+
}
561+
} catch (error) {
562+
console.error('Error resuming session:', error instanceof Error ? error.message : 'Unknown error');
563+
process.exit(1);
564+
}
565+
}
566+
567+
await runNonInteractive(nonInteractiveConfig, input, prompt_id, resumedSessionData);
486568
// Call cleanup before process.exit, which causes cleanup to not run
487569
await runExitCleanup();
488570
process.exit(0);

packages/cli/src/nonInteractiveCli.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import type { Config, ToolCallRequestInfo } from '@google/gemini-cli-core';
7+
import type { Config, ToolCallRequestInfo, ResumedSessionData } from '@google/gemini-cli-core';
88
import {
99
executeToolCall,
1010
shutdownTelemetry,
@@ -23,6 +23,7 @@ export async function runNonInteractive(
2323
config: Config,
2424
input: string,
2525
prompt_id: string,
26+
resumedSessionData?: ResumedSessionData,
2627
): Promise<void> {
2728
const consolePatcher = new ConsolePatcher({
2829
stderr: true,
@@ -40,6 +41,29 @@ export async function runNonInteractive(
4041
});
4142

4243
const geminiClient = config.getGeminiClient();
44+
45+
// Initialize chat recording service and handle resumed session
46+
if (resumedSessionData) {
47+
const chatRecordingService = geminiClient.getChatRecordingService();
48+
if (chatRecordingService) {
49+
chatRecordingService.initialize(resumedSessionData);
50+
51+
// Convert resumed session messages to chat history
52+
const geminiChat = await geminiClient.getChat();
53+
if (geminiChat && resumedSessionData.conversation.messages.length > 0) {
54+
// Load the conversation history into the chat
55+
const historyContent: Content[] = resumedSessionData.conversation.messages.map(msg => ({
56+
role: msg.type === 'user' ? 'user' : 'model' as const,
57+
parts: Array.isArray(msg.content)
58+
? msg.content.map(part => typeof part === 'string' ? { text: part } : part)
59+
: [{ text: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) }]
60+
}));
61+
62+
// Set the chat history
63+
geminiChat.setHistory(historyContent);
64+
}
65+
}
66+
}
4367

4468
const abortController = new AbortController();
4569

0 commit comments

Comments
 (0)