feat: introduce Forever Mode (Sisyphus, Confucius, and Bicameral Voice)

This commit is contained in:
Sandy Tao
2026-02-25 17:27:05 -08:00
parent 28c9a907de
commit 1ca7c35027
42 changed files with 2555 additions and 685 deletions
@@ -207,6 +207,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
proQuotaRequest: null,
validationRequest: null,
},
sisyphusSecondsRemaining: null,
...overrides,
}) as UIState;
@@ -38,6 +38,7 @@ import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.j
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
import { ForeverModeOnboardingDialog } from './ForeverModeOnboardingDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -109,6 +110,13 @@ export const DialogManager = ({
);
}
if (uiState.isOnboardingForeverMode) {
return (
<ForeverModeOnboardingDialog
onComplete={() => uiActions.setIsOnboardingForeverMode(false)}
/>
);
}
if (uiState.adminSettingsChanged) {
return <AdminSettingsChangedDialog />;
}
@@ -0,0 +1,323 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useState } from 'react';
import { theme } from '../semantic-colors.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { relaunchApp } from '../../utils/processUtils.js';
import { GEMINI_DIR, DEFAULT_CONTEXT_FILENAME } from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
import { useTextBuffer } from './shared/text-buffer.js';
import { TextInput } from './shared/TextInput.js';
enum Step {
MISSION,
FIRST_STEPS,
SISYPHUS_CONFIG,
SAVING,
ERROR,
}
export const ForeverModeOnboardingDialog = ({
onComplete,
}: {
onComplete: () => void;
}) => {
const config = useConfig();
const [step, setStep] = useState(Step.MISSION);
const [sisyphusFocus, setSisyphusFocus] = useState<'timeout' | 'prompt'>(
'timeout',
);
const [error, setError] = useState<string | null>(null);
const missionBuffer = useTextBuffer({
initialText: '',
viewport: { width: 80, height: 3 },
singleLine: false,
});
const firstStepsBuffer = useTextBuffer({
initialText: '',
viewport: { width: 80, height: 5 },
singleLine: false,
});
const sisyphusTimeoutBuffer = useTextBuffer({
initialText: '',
viewport: { width: 50, height: 1 },
singleLine: true,
});
const sisyphusPromptBuffer = useTextBuffer({
initialText: 'continue',
viewport: { width: 50, height: 1 },
singleLine: true,
});
const handleMissionSubmit = () => {
if (missionBuffer.text.trim()) setStep(Step.FIRST_STEPS);
};
const handleFirstStepsSubmit = () => {
if (firstStepsBuffer.text.trim()) setStep(Step.SISYPHUS_CONFIG);
};
const handleSisyphusTimeoutSubmit = (value: string) => {
const num = parseInt(value, 10);
if (!isNaN(num) && num > 0) {
setSisyphusFocus('prompt');
} else {
void handleSaveSettings();
}
};
const handleSisyphusPromptSubmit = () => {
void handleSaveSettings();
};
const handleSaveSettings = async () => {
setStep(Step.SAVING);
try {
const timeoutNum = parseInt(sisyphusTimeoutBuffer.text, 10);
const hasSisyphus = !isNaN(timeoutNum) && timeoutNum > 0;
let frontmatter = '---\n';
frontmatter += 'sisyphus:\n';
frontmatter += ` enabled: ${hasSisyphus}\n`;
if (hasSisyphus) {
frontmatter += ` idleTimeout: ${timeoutNum}\n`;
if (sisyphusPromptBuffer.text.trim()) {
frontmatter += ` prompt: "${sisyphusPromptBuffer.text.trim()}"\n`;
}
}
frontmatter += '---\n\n';
let content = frontmatter;
if (missionBuffer.text.trim()) {
content += `# Mission\n${missionBuffer.text.trim()}\n\n`;
}
const geminiDir = path.join(config.getTargetDir(), GEMINI_DIR);
await fs.mkdir(geminiDir, { recursive: true });
await fs.writeFile(
path.join(geminiDir, DEFAULT_CONTEXT_FILENAME),
content,
'utf-8',
);
if (firstStepsBuffer.text.trim()) {
await fs.writeFile(
path.join(geminiDir, '.onboarding_prompt'),
firstStepsBuffer.text.trim(),
'utf-8',
);
}
try {
execSync('git init', { cwd: geminiDir, stdio: 'ignore' });
execSync('git add .', { cwd: geminiDir, stdio: 'ignore' });
execSync('git commit -m "chore(memory): initialize gemini memory"', {
cwd: geminiDir,
stdio: 'ignore',
});
} catch (_e) {
// Ignore git errors if git is not installed or user has no git config
}
onComplete(); // Before relaunch
await relaunchApp();
} catch (e: unknown) {
if (e instanceof Error) {
setError(e.message);
} else {
setError(String(e));
}
setStep(Step.ERROR);
}
};
if (step === Step.ERROR) {
return (
<Box
flexDirection="column"
padding={1}
borderStyle="round"
borderColor={theme.border.default}
>
<Text color={theme.status.error} bold>
Failed to generate config
</Text>
<Text>{error}</Text>
<Text color={theme.text.secondary}>
Please create the .gemini/GEMINI.md file manually and try again.
</Text>
</Box>
);
}
if (step === Step.SAVING) {
return (
<Box padding={1} borderStyle="round" borderColor={theme.border.default}>
<Text color={theme.text.accent}>
Saving your configuration... please wait.
</Text>
</Box>
);
}
if (step === Step.MISSION) {
return (
<Box
flexDirection="column"
padding={1}
borderStyle="round"
borderColor={theme.border.default}
>
<Text color={theme.status.success} bold>
Welcome to Forever Mode!
</Text>
<Text>
You launched the CLI with <Text bold>--forever</Text>, which runs the
agent continuously.
</Text>
<Text>
To get started, we need to set up your{' '}
<Text bold>.gemini/GEMINI.md</Text> configuration file.
</Text>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.primary} bold>
What is the primary mission of the agent?
</Text>
<Text color={theme.text.secondary}>
(e.g. &quot;Refactor the authentication module to use OAuth2&quot;)
</Text>
<Box marginTop={1}>
<Text color={theme.text.primary}> </Text>
<TextInput
buffer={missionBuffer}
onSubmit={handleMissionSubmit}
focus={true}
/>
</Box>
</Box>
</Box>
);
}
if (step === Step.FIRST_STEPS) {
return (
<Box
flexDirection="column"
padding={1}
borderStyle="round"
borderColor={theme.border.default}
>
<Text color={theme.text.primary} bold>
What are the immediate first steps?
</Text>
<Text color={theme.text.secondary}>
(e.g. &quot;Investigate src/auth.ts and propose changes&quot;)
</Text>
<Box marginTop={1}>
<Text color={theme.text.primary}> </Text>
<TextInput
buffer={firstStepsBuffer}
onSubmit={handleFirstStepsSubmit}
focus={true}
/>
</Box>
</Box>
);
}
if (step === Step.SISYPHUS_CONFIG) {
return (
<Box
flexDirection="column"
padding={1}
borderStyle="round"
borderColor={theme.border.default}
>
<Text color={theme.text.primary} bold>
Sisyphus Mode (Auto-resume)
</Text>
<Text>
If the agent completes a task and remains idle, it can automatically
resume itself by sending a specific prompt.
</Text>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
Enter idle timeout in minutes before the agent automatically resumes
(leave blank to disable):
</Text>
<Box>
<Text
color={
sisyphusFocus === 'timeout'
? theme.text.primary
: theme.text.secondary
}
>
{' '}
</Text>
<TextInput
buffer={sisyphusTimeoutBuffer}
onSubmit={handleSisyphusTimeoutSubmit}
focus={sisyphusFocus === 'timeout'}
/>
</Box>
</Box>
{sisyphusFocus === 'prompt' && (
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
What prompt should be sent when Sisyphus triggers?
</Text>
<Box>
<Text color={theme.text.primary}> </Text>
<TextInput
buffer={sisyphusPromptBuffer}
onSubmit={handleSisyphusPromptSubmit}
focus={sisyphusFocus === 'prompt'}
/>
</Box>
</Box>
)}
</Box>
);
}
if (step === Step.SAVING) {
return (
<Box padding={1} borderStyle="round" borderColor={theme.border.default}>
<Text>Saving your settings and launching the agent...</Text>
</Box>
);
}
if (step === Step.ERROR && error) {
return (
<Box
flexDirection="column"
padding={1}
borderStyle="round"
borderColor={theme.status.error}
>
<Text color={theme.status.error} bold>
Error
</Text>
<Text>{error}</Text>
<Text color={theme.text.secondary}>
Press Ctrl+C to exit and try again.
</Text>
</Box>
);
}
return null;
};
@@ -54,6 +54,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
backgroundShellCount: 0,
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
sisyphusSecondsRemaining: null,
...overrides,
}) as UIState;
@@ -170,4 +171,16 @@ describe('StatusDisplay', () => {
expect(lastFrame()).toContain('Shells: 3');
unmount();
});
it('renders Sisyphus countdown timer when active', async () => {
const uiState = createMockUIState({
sisyphusSecondsRemaining: 65, // 01:05
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toContain('✦ Resuming work in 01:05');
unmount();
});
});
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { Text } from 'ink';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
@@ -24,18 +24,36 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
const settings = useSettings();
const config = useConfig();
const items: React.ReactNode[] = [];
if (process.env['GEMINI_SYSTEM_MD']) {
return <Text color={theme.status.error}>|_|</Text>;
items.push(<Text color={theme.status.error}>|_|</Text>);
}
if (
uiState.activeHooks.length > 0 &&
settings.merged.hooksConfig.notifications
) {
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
items.push(<HookStatusDisplay activeHooks={uiState.activeHooks} />);
}
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
if (uiState.sisyphusSecondsRemaining !== null) {
const mins = Math.floor(uiState.sisyphusSecondsRemaining / 60);
const secs = uiState.sisyphusSecondsRemaining % 60;
const timerStr = `${mins.toString().padStart(2, '0')}:${secs
.toString()
.padStart(2, '0')}`;
items.push(
<Text color={theme.text.accent}> Resuming work in {timerStr}</Text>,
);
}
if (
items.length === 0 &&
uiState.sisyphusSecondsRemaining === null &&
!settings.merged.ui.hideContextSummary &&
!hideContextSummary
) {
return (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
@@ -51,5 +69,17 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
);
}
return null;
if (items.length === 0) {
return null;
}
return (
<Box flexDirection="row">
{items.map((item, index) => (
<Box key={index} marginRight={index < items.length - 1 ? 1 : 0}>
{item}
</Box>
))}
</Box>
);
};
@@ -27,6 +27,7 @@ export function CompressionMessage({
const originalTokens = originalTokenCount ?? 0;
const newTokens = newTokenCount ?? 0;
const archivePath = compression.archivePath;
const getCompressionText = () => {
if (isPending) {
@@ -36,6 +37,8 @@ export function CompressionMessage({
switch (compressionStatus) {
case CompressionStatus.COMPRESSED:
return `Chat history compressed from ${originalTokens} to ${newTokens} tokens.`;
case CompressionStatus.ARCHIVED:
return `Chat history archived to ${archivePath} (${originalTokens} to ${newTokens} tokens).`;
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
// For smaller histories (< 50k tokens), compression overhead likely exceeds benefits
if (originalTokens < 50000) {