diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 331ec0c018..7a94852b57 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -708,12 +708,13 @@ export async function main() {
} catch (error) {
if (
error instanceof SessionError &&
- error.code === 'NO_SESSIONS_FOUND'
+ (error.code === 'NO_SESSIONS_FOUND' ||
+ error.code === 'INVALID_SESSION_IDENTIFIER')
) {
- // No sessions to resume — start a fresh session with a warning
+ // No sessions to resume or invalid session ID — start a fresh session with a warning
startupWarnings.push({
- id: 'resume-no-sessions',
- message: error.message,
+ id: 'resume-failure',
+ message: 'Could not resume session. Started a new session.',
priority: WarningPriority.High,
});
} else {
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 67f2d5dd84..b8d5f5c62a 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -791,7 +791,7 @@ export const AppContainer = (props: AppContainerProps) => {
Logging in with Google... Restarting Gemini CLI to continue.
----------------------------------------------------------------
`);
- await relaunchApp();
+ await relaunchApp(config.getSessionId());
}
}
setAuthState(AuthState.Authenticated);
@@ -2466,7 +2466,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
});
}
}
- await relaunchApp();
+ await relaunchApp(config.getSessionId());
},
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) {
diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx
index 94ca359b59..c63fc12f38 100644
--- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx
+++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx
@@ -35,7 +35,7 @@ export const LoginWithGoogleRestartDialog = ({
});
}
}
- await relaunchApp();
+ await relaunchApp(config.getSessionId());
}, 100);
return true;
}
diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx
index 5119c1b343..11349ccdcd 100644
--- a/packages/cli/src/ui/components/DialogManager.tsx
+++ b/packages/cli/src/ui/components/DialogManager.tsx
@@ -230,7 +230,7 @@ export const DialogManager = ({
uiActions.closeSettingsDialog()}
- onRestartRequest={relaunchApp}
+ onRestartRequest={() => relaunchApp(config.getSessionId())}
availableTerminalHeight={terminalHeight - staticExtraHeight}
/>
diff --git a/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx b/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx
index 32e451a542..63b5f114b1 100644
--- a/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx
+++ b/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx
@@ -8,6 +8,7 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { relaunchApp } from '../../utils/processUtils.js';
+import { useConfig } from '../contexts/ConfigContext.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import { debugLogger } from '@google/gemini-cli-core';
@@ -16,11 +17,12 @@ interface IdeTrustChangeDialogProps {
}
export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => {
+ const config = useConfig();
useKeypress(
(key) => {
if (key.name === 'r' || key.name === 'R') {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
- relaunchApp();
+ relaunchApp(config.getSessionId());
return true;
}
return false;
diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx
index d555ee2fed..441091d9c2 100644
--- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx
+++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx
@@ -11,6 +11,7 @@ import * as path from 'node:path';
import { TrustLevel } from '../../config/trustedFolders.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
+import { useConfig } from '../contexts/ConfigContext.js';
import { theme } from '../semantic-colors.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { relaunchApp } from '../../utils/processUtils.js';
@@ -33,6 +34,7 @@ export function PermissionsModifyTrustDialog({
const currentDirectory = targetDirectory ?? process.cwd();
const dirName = path.basename(currentDirectory);
const parentFolder = path.basename(path.dirname(currentDirectory));
+ const config = useConfig();
const TRUST_LEVEL_ITEMS = [
{
@@ -72,7 +74,7 @@ export function PermissionsModifyTrustDialog({
void (async () => {
const success = await commitTrustLevelChange();
if (success) {
- void relaunchApp();
+ void relaunchApp(config.getSessionId());
} else {
onExit();
}
diff --git a/packages/cli/src/utils/processUtils.test.ts b/packages/cli/src/utils/processUtils.test.ts
index 3e6b7913e9..e633825d55 100644
--- a/packages/cli/src/utils/processUtils.test.ts
+++ b/packages/cli/src/utils/processUtils.test.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { vi } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
RELAUNCH_EXIT_CODE,
relaunchApp,
@@ -29,10 +29,35 @@ describe('processUtils', () => {
afterEach(() => vi.clearAllMocks());
- it('should wait for updates, run cleanup, and exit with the relaunch code', async () => {
+ it('should wait for updates, run cleanup, send resume session ID, and exit with the relaunch code', async () => {
+ const originalSend = process.send;
+ process.send = vi.fn();
+
await relaunchApp();
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
expect(runExitCleanup).toHaveBeenCalledTimes(1);
+ expect(process.send).toHaveBeenCalledWith({
+ type: 'relaunch-resume-session',
+ sessionId: expect.any(String),
+ });
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
+
+ process.send = originalSend;
+ });
+
+ it('should wait for updates, run cleanup, send override resume session ID, and exit with the relaunch code', async () => {
+ const originalSend = process.send;
+ process.send = vi.fn();
+
+ await relaunchApp('custom-session-id');
+ expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
+ expect(runExitCleanup).toHaveBeenCalledTimes(1);
+ expect(process.send).toHaveBeenCalledWith({
+ type: 'relaunch-resume-session',
+ sessionId: 'custom-session-id',
+ });
+ expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
+
+ process.send = originalSend;
});
});
diff --git a/packages/cli/src/utils/processUtils.ts b/packages/cli/src/utils/processUtils.ts
index c43f5c54fd..fd2886c30d 100644
--- a/packages/cli/src/utils/processUtils.ts
+++ b/packages/cli/src/utils/processUtils.ts
@@ -6,6 +6,7 @@
import { runExitCleanup } from './cleanup.js';
import { waitForUpdateCompletion } from './handleAutoUpdate.js';
+import { sessionId } from '@google/gemini-cli-core';
/**
* Exit code used to signal that the CLI should be relaunched.
@@ -22,10 +23,18 @@ export function _resetRelaunchStateForTesting(): void {
isRelaunching = false;
}
-export async function relaunchApp(): Promise {
+export async function relaunchApp(sessionIdOverride?: string): Promise {
if (isRelaunching) return;
isRelaunching = true;
await waitForUpdateCompletion();
await runExitCleanup();
+
+ if (process.send) {
+ process.send({
+ type: 'relaunch-resume-session',
+ sessionId: sessionIdOverride ?? sessionId,
+ });
+ }
+
process.exit(RELAUNCH_EXIT_CODE);
}
diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts
index 2ad5e06a73..3497ea72fb 100644
--- a/packages/cli/src/utils/relaunch.test.ts
+++ b/packages/cli/src/utils/relaunch.test.ts
@@ -315,6 +315,99 @@ describe('relaunchAppInChildProcess', () => {
// Should default to exit code 1
expect(processExitSpy).toHaveBeenCalledWith(1);
});
+
+ it('should append --resume on the next spawn if relaunch-resume-session message is received', async () => {
+ process.argv = ['/usr/bin/node', '/app/cli.js', '--some-flag'];
+
+ let spawnCount = 0;
+ mockedSpawn.mockImplementation(() => {
+ spawnCount++;
+ const mockChild = createMockChildProcess(0, false);
+
+ if (spawnCount === 1) {
+ // First run: send the resume session ID, then exit with RELAUNCH_EXIT_CODE
+ setImmediate(() => {
+ mockChild.emit('message', {
+ type: 'relaunch-resume-session',
+ sessionId: 'test-session-123',
+ });
+ mockChild.emit('close', RELAUNCH_EXIT_CODE);
+ });
+ } else if (spawnCount === 2) {
+ // Second run: exit normally
+ setImmediate(() => {
+ mockChild.emit('close', 0);
+ });
+ }
+
+ return mockChild;
+ });
+
+ const promise = relaunchAppInChildProcess([], []);
+ await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED');
+
+ expect(mockedSpawn).toHaveBeenCalledTimes(2);
+
+ // First spawn should not have --resume
+ expect(mockedSpawn.mock.calls[0][1]).not.toContain('--resume');
+
+ // Second spawn should have --resume test-session-123 appended
+ const secondArgs = mockedSpawn.mock.calls[1][1];
+ expect(secondArgs).toContain('--resume');
+ expect(secondArgs).toContain('test-session-123');
+
+ // Check exact order at the end of arguments
+ expect(secondArgs.slice(-2)).toEqual(['--resume', 'test-session-123']);
+ });
+
+ it('should strip existing --resume flags when appending new one', async () => {
+ process.argv = [
+ '/usr/bin/node',
+ '/app/cli.js',
+ '--resume',
+ 'old-session',
+ '--resume=other-session',
+ '--flag',
+ ];
+
+ let spawnCount = 0;
+ mockedSpawn.mockImplementation(() => {
+ spawnCount++;
+ const mockChild = createMockChildProcess(0, false);
+
+ if (spawnCount === 1) {
+ setImmediate(() => {
+ mockChild.emit('message', {
+ type: 'relaunch-resume-session',
+ sessionId: 'new-session-456',
+ });
+ mockChild.emit('close', RELAUNCH_EXIT_CODE);
+ });
+ } else if (spawnCount === 2) {
+ setImmediate(() => {
+ mockChild.emit('close', 0);
+ });
+ }
+
+ return mockChild;
+ });
+
+ const promise = relaunchAppInChildProcess([], []);
+ await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED');
+
+ expect(mockedSpawn).toHaveBeenCalledTimes(2);
+
+ const secondArgs = mockedSpawn.mock.calls[1][1] as string[];
+
+ // Should not contain the old resumes
+ expect(secondArgs).not.toContain('old-session');
+ expect(secondArgs).not.toContain('--resume=other-session');
+
+ // Should contain the new resume at the end
+ expect(secondArgs.slice(-2)).toEqual(['--resume', 'new-session-456']);
+ // Should still contain other flags
+ expect(secondArgs).toContain('--flag');
+ });
});
});
diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts
index 7e287e4565..a9801c0f10 100644
--- a/packages/cli/src/utils/relaunch.ts
+++ b/packages/cli/src/utils/relaunch.ts
@@ -41,12 +41,28 @@ export async function relaunchAppInChildProcess(
}
let latestAdminSettings = remoteAdminSettings;
+ let resumeSessionId: string | undefined = undefined;
const runner = () => {
// process.argv is [node, script, ...args]
// We want to construct [ ...nodeArgs, script, ...scriptArgs]
const script = process.argv[1];
- const scriptArgs = process.argv.slice(2);
+ let scriptArgs = process.argv.slice(2);
+
+ if (resumeSessionId) {
+ const filteredArgs: string[] = [];
+ for (let i = 0; i < scriptArgs.length; i++) {
+ if (scriptArgs[i] === '--resume') {
+ i++; // Skip the next argument as well
+ continue;
+ }
+ if (scriptArgs[i].startsWith('--resume=')) {
+ continue;
+ }
+ filteredArgs.push(scriptArgs[i]);
+ }
+ scriptArgs = [...filteredArgs, '--resume', resumeSessionId];
+ }
const nodeArgs = [
...process.execArgv,
@@ -69,11 +85,16 @@ export async function relaunchAppInChildProcess(
child.send({ type: 'admin-settings', settings: latestAdminSettings });
}
- child.on('message', (msg: { type?: string; settings?: unknown }) => {
- if (msg.type === 'admin-settings-update' && msg.settings) {
- latestAdminSettings = msg.settings as AdminControlsSettings;
- }
- });
+ child.on(
+ 'message',
+ (msg: { type?: string; settings?: unknown; sessionId?: string }) => {
+ if (msg.type === 'admin-settings-update' && msg.settings) {
+ latestAdminSettings = msg.settings as AdminControlsSettings;
+ } else if (msg.type === 'relaunch-resume-session' && msg.sessionId) {
+ resumeSessionId = msg.sessionId;
+ }
+ },
+ );
return new Promise((resolve, reject) => {
child.on('error', reject);