Compare commits

...

6 Commits

33 changed files with 1473 additions and 290 deletions
+1
View File
@@ -38,6 +38,7 @@ These commands are available within the interactive REPL.
| `/mcp reload` | Restart and reload MCP servers |
| `/extensions reload` | Reload all active extensions |
| `/help` | Show help for all commands |
| `/loop` | Schedule a recurring task |
| `/quit` | Exit the interactive session |
## CLI Options
+3 -1
View File
@@ -39,7 +39,9 @@ To start Plan Mode while using Gemini CLI:
the rotation when Gemini CLI is actively processing or showing confirmation
dialogs.
- **Command:** Type `/plan` in the input box.
- **Command:** Type `/plan [goal]` in the input box. The `[goal]` is optional;
for example, `/plan implement authentication` will switch to Plan Mode and
immediately submit the prompt to the model.
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
calls the
+106
View File
@@ -0,0 +1,106 @@
# Scheduled tasks
Scheduled tasks let you run prompts repeatedly, poll for status, or set one-time
reminders within a Gemini CLI session. You can use them to check the status of a
deployment, monitor a long-running build, or remind yourself to perform a task
later in your workflow.
<!-- prettier-ignore -->
> [!NOTE]
> Scheduled tasks are session-scoped. They only run while your Gemini CLI
> session is open and are cleared when you exit the CLI. For persistent
> scheduling, use your operating system's native scheduling tools (like `cron`
> or Task Scheduler).
## Schedule a recurring prompt with /loop
The `/loop` command is the fastest way to schedule a recurring prompt. You can
provide an optional interval and a prompt, and Gemini CLI schedules the task to
run in the background.
```text
/loop 5m check if the deployment finished
```
If you don't provide an interval, the command defaults to 10 minutes.
### Interval syntax
Intervals use a simple numeric value followed by a unit character. Supported
units are `s` for seconds, `m` for minutes, `h` for hours, and `d` for days.
| Form | Example | Interval |
| :------------ | :-------------------------- | :--------------- |
| Leading token | `/loop 30m check the build` | Every 30 minutes |
| No interval | `/loop check the build` | Every 10 minutes |
### Loop over another command
A scheduled prompt can be a slash command or a skill invocation. This lets you
automate existing workflows.
```text
/loop 20m /git:status
```
Gemini CLI executes the command as if you had typed it yourself.
## Set a one-time reminder
To set a single reminder, describe what you want in natural language. Gemini CLI
uses its internal scheduling tools to set a one-time task that removes itself
after firing.
```text
In 15 minutes, remind me to push my changes
```
```text
Remind me in 1h to check the integration tests
```
## Manage scheduled tasks
You can ask Gemini CLI to list or cancel your active tasks using natural
language.
```text
What scheduled tasks do I have?
```
```text
Cancel the deployment check task
```
Under the hood, the agent uses these tools to manage your schedule:
| Tool | Purpose |
| :---------------------- | :------------------------------------------------- |
| `schedule_task` | Schedules a new recurring or one-time task. |
| `list_scheduled_tasks` | Lists all active tasks with their IDs and prompts. |
| `cancel_scheduled_task` | Cancels a specific task using its ID. |
## How scheduled tasks run
Scheduled tasks trigger only when Gemini CLI is idle.
- **Non-disruptive:** If a task becomes due while the agent is busy generating a
response or executing a tool, the prompt is queued.
- **Sequential:** The queued prompt executes immediately after the current turn
finishes.
- **Shared context:** Scheduled tasks run within your active session and have
access to the full conversation history and any files you have added to the
context.
## Limitations
Session-scoped scheduling has the following constraints:
- **Active session required:** Tasks only fire while Gemini CLI is running.
Closing your terminal or exiting the session cancels all tasks.
- **Shared context window:** Every task execution adds to your session's history
and consumes tokens in the context window. High-frequency loops may trigger
[context compression](./token-caching.md) sooner.
- **No persistence:** Restarting Gemini CLI clears all tasks.
- **Simple intervals:** Only simple time intervals are supported (e.g., `5m`).
Complex cron expressions (e.g., `0 9 * * 1-5`) are not supported.
+1
View File
@@ -98,6 +98,7 @@
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{ "label": "Scheduled tasks", "slug": "docs/cli/scheduled-tasks" },
{
"label": "Git worktrees",
"badge": "🔬",
+117 -19
View File
@@ -13,8 +13,21 @@ import { evalTest, TEST_AGENTS } from './test-helper.js';
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
// A minimal package.json is used to provide a realistic workspace anchor.
// This prevents the agent from making incorrect assumptions about the environment
// and helps it properly navigate or act as if it is in a standard Node.js project.
const MOCK_PACKAGE_JSON = JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
);
function readProjectFile(
rig: { testDir?: string },
rig: { testDir: string | null },
relativePath: string,
): string {
return fs.readFileSync(path.join(rig.testDir!, relativePath), 'utf8');
@@ -117,15 +130,7 @@ describe('subagent eval test cases', () => {
files: {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
@@ -164,15 +169,7 @@ describe('subagent eval test cases', () => {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
@@ -190,4 +187,105 @@ describe('subagent eval test cases', () => {
);
},
});
/**
* Checks that the main agent can correctly select the appropriate subagent
* from a large pool of available subagents (10 total).
*/
evalTest('USUALLY_PASSES', {
name: 'should select the correct subagent from a pool of 10 different agents',
prompt: 'Please add a new SQL table migration for a user profile.',
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
...TEST_AGENTS.DATABASE_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
...TEST_AGENTS.MOBILE_AGENT.asFile(),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['database-agent']);
// Ensure the generalist and other irrelevant specialists were not invoked
const uncalledAgents = [
'generalist',
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.DEVOPS_AGENT.name,
TEST_AGENTS.ANALYTICS_AGENT.name,
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
TEST_AGENTS.MOBILE_AGENT.name,
];
for (const agentName of uncalledAgents) {
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
false,
);
}
},
});
/**
* Checks that the main agent can correctly select the appropriate subagent
* from a large pool of available subagents, even when many irrelevant MCP tools are present.
*
* This test includes stress tests the subagent delegation with ~80 tools.
*/
evalTest('USUALLY_PASSES', {
name: 'should select the correct subagent from a pool of 10 different agents with MCP tools present',
prompt: 'Please add a new SQL table migration for a user profile.',
setup: async (rig) => {
rig.addTestMcpServer('workspace-server', 'google-workspace');
},
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
...TEST_AGENTS.DATABASE_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
...TEST_AGENTS.MOBILE_AGENT.asFile(),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['database-agent']);
// Ensure the generalist and other irrelevant specialists were not invoked
const uncalledAgents = [
'generalist',
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.DEVOPS_AGENT.name,
TEST_AGENTS.ANALYTICS_AGENT.name,
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
TEST_AGENTS.MOBILE_AGENT.name,
];
for (const agentName of uncalledAgents) {
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
false,
);
}
},
});
});
+5
View File
@@ -61,6 +61,10 @@ export async function internalEvalTest(evalCase: EvalCase) {
try {
rig.setup(evalCase.name, evalCase.params);
if (evalCase.setup) {
await evalCase.setup(rig);
}
if (evalCase.files) {
await setupTestFiles(rig, evalCase.files);
}
@@ -371,6 +375,7 @@ export interface EvalCase {
prompt: string;
timeout?: number;
files?: Record<string, string>;
setup?: (rig: TestRig) => Promise<void> | void;
/** Conversation history to pre-load via --resume. Each entry is a message object with type, content, etc. */
messages?: Record<string, unknown>[];
/** Session ID for the resumed session. Auto-generated if not provided. */
+49 -139
View File
@@ -424,7 +424,22 @@ describe('loadConfig', () => {
});
});
describe('authentication fallback', () => {
describe('authentication logic', () => {
const setupConfigMock = (refreshAuthMock: ReturnType<typeof vi.fn>) => {
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
};
beforeEach(() => {
vi.stubEnv('USE_CCPA', 'true');
vi.stubEnv('GEMINI_API_KEY', '');
@@ -434,182 +449,77 @@ describe('loadConfig', () => {
vi.unstubAllEnvs();
});
it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
// Update the mock implementation for this test
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow('Non-interactive session');
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(true);
it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => {
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC failed'));
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => {
it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC not found'));
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.',
'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
);
expect(refreshAuthMock).not.toHaveBeenCalled();
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
if (authType === AuthType.COMPUTE_ADC) {
throw new Error('ADC failed');
}
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
return Promise.resolve();
});
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
setupConfigMock(refreshAuthMock);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
);
});
});
+28 -54
View File
@@ -25,7 +25,6 @@ import {
ExperimentFlags,
isHeadlessMode,
FatalAuthenticationError,
isCloudShell,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
type TelemetryTarget,
@@ -43,7 +42,6 @@ export async function loadConfig(
taskId: string,
): Promise<Config> {
const workspaceDir = process.cwd();
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
const folderTrust =
settings.folderTrust === true ||
@@ -192,7 +190,7 @@ export async function loadConfig(
await config.waitForMcpInit();
startupProfiler.flush(config);
await refreshAuthentication(config, adcFilePath, 'Config');
await refreshAuthentication(config, 'Config');
return config;
}
@@ -263,75 +261,51 @@ function findEnvFile(startDir: string): string | null {
async function refreshAuthentication(
config: Config,
adcFilePath: string | undefined,
logPrefix: string,
): Promise<void> {
if (process.env['USE_CCPA']) {
logger.info(`[${logPrefix}] Using CCPA Auth:`);
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
try {
if (adcFilePath) {
path.resolve(adcFilePath);
}
} catch (e) {
logger.error(
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
logger.info(
`[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`,
);
}
const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
const shouldSkipOauth = isHeadless || useComputeAdc;
const useComputeAdc =
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
if (shouldSkipOauth) {
if (isCloudShell() || useComputeAdc) {
logger.info(
`[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`,
);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
throw new FatalAuthenticationError(
`COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`,
);
}
} else {
if (isHeadless || useComputeAdc) {
const reason = isHeadless
? 'headless mode'
: 'GEMINI_CLI_USE_COMPUTE_ADC=true';
throw new FatalAuthenticationError(
`Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`,
`COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
);
}
} else {
logger.info(
`[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
);
try {
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
} catch (e) {
if (
e instanceof FatalAuthenticationError &&
(isCloudShell() || useComputeAdc)
) {
logger.warn(
`[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`,
if (e instanceof FatalAuthenticationError) {
const originalMessage = e instanceof Error ? e.message : String(e);
throw new FatalAuthenticationError(
`${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`,
);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`);
} catch (adcError) {
logger.error(
`[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`,
);
const originalMessage = e instanceof Error ? e.message : String(e);
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
throw new FatalAuthenticationError(
`${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`,
);
}
} else {
throw e;
}
throw e;
}
}
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
@@ -32,6 +32,7 @@ import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { loopCommand } from '../ui/commands/loopCommand.js';
import { footerCommand } from '../ui/commands/footerCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
@@ -155,6 +156,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
]
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
helpCommand,
loopCommand,
footerCommand,
shortcutsCommand,
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
+26
View File
@@ -83,6 +83,8 @@ import {
logBillingEvent,
ApiKeyUpdatedEvent,
type InjectionSource,
cronSchedulerService,
type ScheduledTask,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
@@ -1198,6 +1200,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { isMcpReady } = useMcpStatus(config);
const [scheduledTasks, setScheduledTasks] = useState<ScheduledTask[]>([]);
useEffect(() => {
const updateTasks = () => {
setScheduledTasks(cronSchedulerService.listTasks());
};
updateTasks();
// Poor man's sync: polling for list updates since it's mostly local
const interval = setInterval(updateTasks, 2000);
return () => clearInterval(interval);
}, []);
const {
messageQueue,
addMessage,
@@ -1211,6 +1225,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
isMcpReady,
});
useEffect(() => {
const handleTaskDue = (task: ScheduledTask) => {
addMessage(task.prompt);
};
cronSchedulerService.on('task_due', handleTaskDue);
return () => {
cronSchedulerService.off('task_due', handleTaskDue);
};
}, [addMessage]);
cancelHandlerRef.current = useCallback(
(shouldRestorePrompt: boolean = true) => {
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
@@ -2325,6 +2349,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
terminalBackgroundColor: config.getTerminalBackground(),
settingsNonce,
backgroundTasks,
scheduledTasks,
activeBackgroundTaskPid,
backgroundTaskHeight,
isBackgroundTaskListOpen,
@@ -2452,6 +2477,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
settingsNonce,
backgroundTaskHeight,
isBackgroundTaskListOpen,
scheduledTasks,
activeBackgroundTaskPid,
backgroundTasks,
adminSettingsChanged,
+15 -4
View File
@@ -75,6 +75,7 @@ const listCommand: SlashCommand = {
description: 'List saved manual conversation checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async (context): Promise<void> => {
const chatDetails = await getSavedChatTags(context, false);
@@ -406,14 +407,24 @@ export const chatResumeSubCommands: SlashCommand[] = [
checkpointCompatibilityCommand,
];
import { parseSlashCommand } from '../../utils/commands.js';
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Browse auto-saved conversations and manage chat checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async () => ({
type: 'dialog',
dialog: 'sessionBrowser',
}),
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
return {
type: 'dialog',
dialog: 'sessionBrowser',
};
},
subCommands: chatResumeSubCommands,
};
@@ -789,6 +789,7 @@ const listExtensionsCommand: SlashCommand = {
description: 'List active extensions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: listAction,
};
@@ -849,6 +850,7 @@ const exploreExtensionsCommand: SlashCommand = {
description: 'Open extensions page in your browser',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: exploreAction,
};
@@ -870,6 +872,8 @@ const configCommand: SlashCommand = {
action: configAction,
};
import { parseSlashCommand } from '../../utils/commands.js';
export function extensionsCommand(
enableExtensionReloading?: boolean,
): SlashCommand {
@@ -883,20 +887,29 @@ export function extensionsCommand(
configCommand,
]
: [];
const subCommands = [
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
reloadCommand,
...conditionalCommands,
];
return {
name: 'extensions',
description: 'Manage extensions',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
reloadCommand,
...conditionalCommands,
],
action: (context, args) =>
subCommands,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, subCommands);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
// Default to list if no subcommand is provided
listExtensionsCommand.action!(context, args),
return listExtensionsCommand.action!(context, args);
},
};
}
@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { loopCommand } from './loopCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { cronSchedulerService } from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
cronSchedulerService: {
scheduleTask: vi.fn(),
},
};
});
describe('loopCommand', () => {
let mockContext: ReturnType<typeof createMockCommandContext>;
beforeEach(() => {
mockContext = createMockCommandContext();
vi.clearAllMocks();
});
it('should print an error if no args are provided', async () => {
mockContext.invocation!.args = ' ';
await loopCommand.action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Please provide a prompt'),
}),
);
});
it('should default to 10m if no interval is provided', async () => {
mockContext.invocation!.args = 'check the build';
vi.mocked(cronSchedulerService.scheduleTask).mockReturnValue('abc12345');
await loopCommand.action!(mockContext, '');
expect(cronSchedulerService.scheduleTask).toHaveBeenCalledWith(
'10m',
'check the build',
true,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('every 10m'),
}),
);
});
it('should parse leading interval', async () => {
mockContext.invocation!.args = '5m check the build';
vi.mocked(cronSchedulerService.scheduleTask).mockReturnValue('def56789');
await loopCommand.action!(mockContext, '');
expect(cronSchedulerService.scheduleTask).toHaveBeenCalledWith(
'5m',
'check the build',
true,
);
});
it('should handle scheduling errors', async () => {
mockContext.invocation!.args = 'invalid check the build';
vi.mocked(cronSchedulerService.scheduleTask).mockImplementation(() => {
throw new Error('Invalid format');
});
await loopCommand.action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining(
'Failed to schedule task: Invalid format',
),
}),
);
});
});
@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
import { cronSchedulerService } from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
export const loopCommand: SlashCommand = {
name: 'loop',
kind: CommandKind.BUILT_IN,
description: 'Schedules a repeating prompt (e.g., /loop 5m check the build)',
autoExecute: true,
action: async (context) => {
const args = context.invocation?.args?.trim() || '';
if (!args) {
context.ui.addItem({
type: MessageType.INFO,
text: 'Please provide a prompt to loop. Example: /loop 5m check the build',
});
return;
}
// Default to 10 minutes if no interval is provided
let intervalString = '10m';
// Check if the first word is an interval
const match = args.match(/^(\d+[smhd])\s+(.*)/i);
let prompt = args;
if (match) {
intervalString = match[1].toLowerCase();
prompt = match[2].trim();
}
try {
const id = cronSchedulerService.scheduleTask(
intervalString,
prompt,
true,
);
context.ui.addItem({
type: MessageType.INFO,
text: `Scheduled recurring task \`${id}\` to run \`${prompt}\` every ${intervalString}.`,
});
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
context.ui.addItem({
type: MessageType.INFO,
text: `Failed to schedule task: ${message}`,
});
}
},
};
@@ -17,7 +17,7 @@ import {
} from '@google/gemini-cli-core';
import type { CallableTool } from '@google/genai';
import { MessageType } from '../types.js';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -280,5 +280,41 @@ describe('mcpCommand', () => {
}),
);
});
it('should filter servers by name when an argument is provided to list', async () => {
await mcpCommand.action!(mockContext, 'list server1');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
servers: expect.objectContaining({
server1: expect.any(Object),
}),
}),
);
// Should NOT contain server2 or server3
const call = vi.mocked(mockContext.ui.addItem).mock
.calls[0][0] as HistoryItemMcpStatus;
expect(Object.keys(call.servers)).toEqual(['server1']);
});
it('should filter servers by name and show descriptions when an argument is provided to desc', async () => {
await mcpCommand.action!(mockContext, 'desc server2');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
showDescriptions: true,
servers: expect.objectContaining({
server2: expect.any(Object),
}),
}),
);
const call = vi.mocked(mockContext.ui.addItem).mock
.calls[0][0] as HistoryItemMcpStatus;
expect(Object.keys(call.servers)).toEqual(['server2']);
});
});
});
+36 -6
View File
@@ -31,6 +31,7 @@ import {
canLoadServer,
} from '../../config/mcp/mcpServerEnablement.js';
import { loadSettings } from '../../config/settings.js';
import { parseSlashCommand } from '../../utils/commands.js';
const authCommand: SlashCommand = {
name: 'auth',
@@ -177,6 +178,7 @@ const listAction = async (
context: CommandContext,
showDescriptions = false,
showSchema = false,
serverNameFilter?: string,
): Promise<void | MessageActionReturn> => {
const agentContext = context.services.agentContext;
const config = agentContext?.config;
@@ -199,11 +201,25 @@ const listAction = async (
};
}
const mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
const serverNames = Object.keys(mcpServers);
let mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
const blockedMcpServers =
config.getMcpClientManager()?.getBlockedMcpServers() || [];
if (serverNameFilter) {
const filter = serverNameFilter.trim().toLowerCase();
if (filter) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(
([name]) =>
name.toLowerCase().includes(filter) ||
normalizeServerId(name).includes(filter),
),
);
}
}
const serverNames = Object.keys(mcpServers);
const connectingServers = serverNames.filter(
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
);
@@ -306,7 +322,7 @@ const listCommand: SlashCommand = {
description: 'List configured MCP servers and tools',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => listAction(context),
action: (context, args) => listAction(context, false, false, args),
};
const descCommand: SlashCommand = {
@@ -315,7 +331,7 @@ const descCommand: SlashCommand = {
description: 'List configured MCP servers and tools with descriptions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => listAction(context, true),
action: (context, args) => listAction(context, true, false, args),
};
const schemaCommand: SlashCommand = {
@@ -324,7 +340,7 @@ const schemaCommand: SlashCommand = {
'List configured MCP servers and tools with descriptions and schemas',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => listAction(context, true, true),
action: (context, args) => listAction(context, true, true, args),
};
const reloadCommand: SlashCommand = {
@@ -333,6 +349,7 @@ const reloadCommand: SlashCommand = {
description: 'Reloads MCP servers',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
@@ -530,5 +547,18 @@ export const mcpCommand: SlashCommand = {
enableCommand,
disableCommand,
],
action: async (context: CommandContext) => listAction(context),
action: async (
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
// If no subcommand matches, treat the whole args as a filter for list
return listAction(context, false, false, args);
}
return listAction(context);
},
};
@@ -104,6 +104,47 @@ describe('planCommand', () => {
);
});
it('should not return a submit_prompt action if arguments are empty', async () => {
vi.mocked(
mockContext.services.agentContext!.config.isPlanEnabled,
).mockReturnValue(true);
mockContext.invocation = {
raw: '/plan',
name: 'plan',
args: '',
};
if (!planCommand.action) throw new Error('Action missing');
const result = await planCommand.action(mockContext, '');
expect(result).toBeUndefined();
expect(
mockContext.services.agentContext!.config.setApprovalMode,
).toHaveBeenCalledWith(ApprovalMode.PLAN);
});
it('should return a submit_prompt action if arguments are provided', async () => {
vi.mocked(
mockContext.services.agentContext!.config.isPlanEnabled,
).mockReturnValue(true);
mockContext.invocation = {
raw: '/plan implement auth',
name: 'plan',
args: 'implement auth',
};
if (!planCommand.action) throw new Error('Action missing');
const result = await planCommand.action(mockContext, 'implement auth');
expect(result).toEqual({
type: 'submit_prompt',
content: 'implement auth',
});
expect(
mockContext.services.agentContext!.config.setApprovalMode,
).toHaveBeenCalledWith(ApprovalMode.PLAN);
});
it('should display the approved plan from config', async () => {
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
vi.mocked(
@@ -66,6 +66,13 @@ export const planCommand: SlashCommand = {
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
}
if (context.invocation?.args) {
return {
type: 'submit_prompt',
content: context.invocation.args,
};
}
const approvedPlanPath = config.getApprovedPlanPath();
if (!approvedPlanPath) {
@@ -86,12 +93,14 @@ export const planCommand: SlashCommand = {
type: MessageType.GEMINI,
text: partToString(content.llmContent),
});
return;
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
error,
);
return;
}
},
subCommands: [
@@ -100,6 +109,7 @@ export const planCommand: SlashCommand = {
description: 'Copy the currently approved plan to your clipboard',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: copyAction,
},
],
+11 -1
View File
@@ -357,6 +357,8 @@ function enableCompletion(
.map((s) => s.name);
}
import { parseSlashCommand } from '../../utils/commands.js';
export const skillsCommand: SlashCommand = {
name: 'skills',
description:
@@ -402,5 +404,13 @@ export const skillsCommand: SlashCommand = {
action: reloadAction,
},
],
action: listAction,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
return listAction(context, args);
},
};
+9
View File
@@ -240,5 +240,14 @@ export interface SlashCommand {
*/
showCompletionLoading?: boolean;
/**
* Whether the command expects arguments.
* If false, and the command is a subcommand, the command parser may treat
* any following text as arguments for the parent command instead of this subcommand,
* provided the parent command has an action.
* Defaults to true.
*/
takesArgs?: boolean;
subCommands?: SlashCommand[];
}
@@ -149,6 +149,7 @@ describe('<BackgroundTaskDisplay />', () => {
<ScrollProvider>
<BackgroundTaskDisplay
shells={mockShells}
scheduledTasks={[]}
activePid={shell1.pid}
width={width}
height={24}
@@ -169,6 +170,7 @@ describe('<BackgroundTaskDisplay />', () => {
<ScrollProvider>
<BackgroundTaskDisplay
shells={mockShells}
scheduledTasks={[]}
activePid={shell1.pid}
width={width}
height={24}
@@ -189,6 +191,7 @@ describe('<BackgroundTaskDisplay />', () => {
<ScrollProvider>
<BackgroundTaskDisplay
shells={mockShells}
scheduledTasks={[]}
activePid={shell1.pid}
width={width}
height={24}
@@ -209,6 +212,7 @@ describe('<BackgroundTaskDisplay />', () => {
<ScrollProvider>
<BackgroundTaskDisplay
shells={mockShells}
scheduledTasks={[]}
activePid={shell1.pid}
width={width}
height={24}
@@ -229,6 +233,7 @@ describe('<BackgroundTaskDisplay />', () => {
<ScrollProvider>
<BackgroundTaskDisplay
shells={mockShells}
scheduledTasks={[]}
activePid={shell1.pid}
width={100}
height={30}
@@ -252,6 +257,7 @@ describe('<BackgroundTaskDisplay />', () => {
<ScrollProvider>
<BackgroundTaskDisplay
shells={mockShells}
scheduledTasks={[]}
activePid={shell1.pid}
width={width}
height={24}
@@ -272,6 +278,7 @@ describe('<BackgroundTaskDisplay />', () => {
<ScrollProvider>
<BackgroundTaskDisplay
shells={mockShells}
scheduledTasks={[]}
activePid={shell1.pid}
width={width}
height={24}
@@ -303,6 +310,7 @@ describe('<BackgroundTaskDisplay />', () => {
<ScrollProvider>
<BackgroundTaskDisplay
shells={mockShells}
scheduledTasks={[]}
activePid={shell1.pid}
width={width}
height={24}
@@ -335,6 +343,7 @@ describe('<BackgroundTaskDisplay />', () => {
<ScrollProvider>
<BackgroundTaskDisplay
shells={mockShells}
scheduledTasks={[]}
activePid={shell1.pid}
width={width}
height={24}
@@ -360,6 +369,7 @@ describe('<BackgroundTaskDisplay />', () => {
<ScrollProvider>
<BackgroundTaskDisplay
shells={mockShells}
scheduledTasks={[]}
activePid={shell2.pid}
width={width}
height={24}
@@ -391,6 +401,7 @@ describe('<BackgroundTaskDisplay />', () => {
<ScrollProvider>
<BackgroundTaskDisplay
shells={mockShells}
scheduledTasks={[]}
activePid={exitedShell.pid}
width={width}
height={24}
@@ -15,6 +15,7 @@ import {
type AnsiOutput,
type AnsiLine,
type AnsiToken,
type ScheduledTask,
} from '@google/gemini-cli-core';
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
@@ -36,6 +37,7 @@ import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface BackgroundTaskDisplayProps {
shells: Map<number, BackgroundTask>;
scheduledTasks: ScheduledTask[];
activePid: number;
width: number;
height: number;
@@ -63,6 +65,7 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
export const BackgroundTaskDisplay = ({
shells,
scheduledTasks,
activePid,
width,
height,
@@ -335,60 +338,74 @@ export const BackgroundTaskDisplay = ({
</Text>
</Box>
<Box flexGrow={1} width="100%">
<RadioButtonSelect
items={items}
initialIndex={initialIndex >= 0 ? initialIndex : 0}
onSelect={(pid) => {
setActiveBackgroundTaskPid(pid);
setIsBackgroundTaskListOpen(false);
}}
onHighlight={(pid) => setHighlightedPid(pid)}
isFocused={isFocused}
maxItemsToShow={Math.max(
1,
height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT,
)}
renderItem={(
item,
{ isSelected: _isSelected, titleColor: _titleColor },
) => {
// Custom render to handle exit code coloring if needed,
// or just use default. The default RadioButtonSelect renderer
// handles standard label.
// But we want to color exit code differently?
// The previous implementation colored exit code green/red.
// Let's reimplement that.
{shells.size > 0 && (
<RadioButtonSelect
items={items}
initialIndex={initialIndex >= 0 ? initialIndex : 0}
onSelect={(pid) => {
setActiveBackgroundTaskPid(pid);
setIsBackgroundTaskListOpen(false);
}}
onHighlight={(pid) => setHighlightedPid(pid)}
isFocused={isFocused}
maxItemsToShow={Math.max(
1,
height -
TOTAL_OVERHEAD_HEIGHT -
PROCESS_LIST_HEADER_HEIGHT -
(scheduledTasks.length > 0 ? scheduledTasks.length + 2 : 0),
)}
renderItem={(
item,
{ isSelected: _isSelected, titleColor: _titleColor },
) => {
const shell = shells.get(item.value);
if (!shell) return <Text>{item.label}</Text>;
// We need access to shell details here.
// We can put shell details in the item or lookup.
// Lookup from shells map.
const shell = shells.get(item.value);
if (!shell) return <Text>{item.label}</Text>;
const truncatedCommand = formatShellCommandForDisplay(
shell.command,
maxCommandLength,
);
const truncatedCommand = formatShellCommandForDisplay(
shell.command,
maxCommandLength,
);
return (
<Text>
{truncatedCommand} (PID: {shell.pid})
{shell.status === 'exited' ? (
<Text
color={
shell.exitCode === 0
? theme.status.success
: theme.status.error
}
>
{' '}
(Exit Code: {shell.exitCode})
</Text>
) : null}
return (
<Text>
{truncatedCommand} (PID: {shell.pid})
{shell.status === 'exited' ? (
<Text
color={
shell.exitCode === 0
? theme.status.success
: theme.status.error
}
>
{' '}
(Exit Code: {shell.exitCode})
</Text>
) : null}
</Text>
);
}}
/>
)}
{scheduledTasks.length > 0 && (
<Box flexDirection="column" marginTop={shells.size > 0 ? 1 : 0}>
<Box
borderStyle="single"
borderBottom={false}
borderLeft={false}
borderRight={false}
paddingTop={1}
marginBottom={1}
>
<Text bold>Scheduled Loops</Text>
</Box>
{scheduledTasks.map((task) => (
<Text key={task.id}>
{` ● [${task.id}] every ${task.intervalMs! / 1000}s: "${task.prompt}"`}
</Text>
);
}}
/>
))}
</Box>
)}
</Box>
</Box>
);
@@ -29,6 +29,7 @@ import type {
AgentDefinition,
FolderDiscoveryResults,
PolicyUpdateConfirmationRequest,
ScheduledTask,
} from '@google/gemini-cli-core';
import { type TransientMessageType } from '../../utils/events.js';
import type { DOMElement } from 'ink';
@@ -216,6 +217,7 @@ export interface UIState {
terminalBackgroundColor: TerminalBackgroundColor;
settingsNonce: number;
backgroundTasks: Map<number, BackgroundTask>;
scheduledTasks: ScheduledTask[];
activeBackgroundTaskPid: number | null;
backgroundTaskHeight: number;
isBackgroundTaskListOpen: boolean;
@@ -40,14 +40,15 @@ export const DefaultAppLayout: React.FC = () => {
<MainContent />
{uiState.isBackgroundTaskVisible &&
uiState.backgroundTasks.size > 0 &&
uiState.activeBackgroundTaskPid &&
(uiState.backgroundTasks.size > 0 ||
uiState.scheduledTasks.length > 0) &&
uiState.backgroundTaskHeight > 0 &&
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
<Box height={uiState.backgroundTaskHeight} flexShrink={0}>
<BackgroundTaskDisplay
shells={uiState.backgroundTasks}
activePid={uiState.activeBackgroundTaskPid}
scheduledTasks={uiState.scheduledTasks}
activePid={uiState.activeBackgroundTaskPid ?? 0}
width={uiState.terminalWidth}
height={uiState.backgroundTaskHeight}
isFocused={
+101
View File
@@ -137,4 +137,105 @@ describe('parseSlashCommand', () => {
expect(result.args).toBe('');
expect(result.canonicalPath).toEqual([]);
});
describe('backtracking', () => {
const backtrackingCommands: readonly SlashCommand[] = [
{
name: 'parent',
description: 'Parent command',
kind: CommandKind.BUILT_IN,
action: async () => {},
subCommands: [
{
name: 'notakes',
description: 'Subcommand that does not take arguments',
kind: CommandKind.BUILT_IN,
takesArgs: false,
action: async () => {},
},
{
name: 'takes',
description: 'Subcommand that takes arguments',
kind: CommandKind.BUILT_IN,
takesArgs: true,
action: async () => {},
},
],
},
];
it('should backtrack to parent if subcommand has takesArgs: false and args are provided', () => {
const result = parseSlashCommand(
'/parent notakes some prompt',
backtrackingCommands,
);
expect(result.commandToExecute?.name).toBe('parent');
expect(result.args).toBe('notakes some prompt');
expect(result.canonicalPath).toEqual(['parent']);
});
it('should NOT backtrack if subcommand has takesArgs: false but NO args are provided', () => {
const result = parseSlashCommand('/parent notakes', backtrackingCommands);
expect(result.commandToExecute?.name).toBe('notakes');
expect(result.args).toBe('');
expect(result.canonicalPath).toEqual(['parent', 'notakes']);
});
it('should NOT backtrack if subcommand has takesArgs: true and args are provided', () => {
const result = parseSlashCommand(
'/parent takes some args',
backtrackingCommands,
);
expect(result.commandToExecute?.name).toBe('takes');
expect(result.args).toBe('some args');
expect(result.canonicalPath).toEqual(['parent', 'takes']);
});
it('should NOT backtrack if parent has NO action', () => {
const noActionCommands: readonly SlashCommand[] = [
{
name: 'parent',
description: 'Parent without action',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'notakes',
description: 'Subcommand without args',
kind: CommandKind.BUILT_IN,
takesArgs: false,
action: async () => {},
},
],
},
];
const result = parseSlashCommand(
'/parent notakes some args',
noActionCommands,
);
// It stays with the subcommand because parent can't handle it
expect(result.commandToExecute?.name).toBe('notakes');
expect(result.args).toBe('some args');
expect(result.canonicalPath).toEqual(['parent', 'notakes']);
});
it('should NOT backtrack if subcommand is NOT marked with takesArgs: false', () => {
const result = parseSlashCommand(
'/parent takes some args',
backtrackingCommands,
);
expect(result.commandToExecute?.name).toBe('takes');
expect(result.args).toBe('some args');
expect(result.canonicalPath).toEqual(['parent', 'takes']);
});
it('should backtrack if subcommand has takesArgs: false and args are provided (like /plan copy foo)', () => {
const result = parseSlashCommand(
'/parent notakes some prompt',
backtrackingCommands,
);
expect(result.commandToExecute?.name).toBe('parent');
expect(result.args).toBe('notakes some prompt');
expect(result.canonicalPath).toEqual(['parent']);
});
});
});
+18
View File
@@ -33,6 +33,7 @@ export const parseSlashCommand = (
let commandToExecute: SlashCommand | undefined;
let pathIndex = 0;
const canonicalPath: string[] = [];
let parentCommand: SlashCommand | undefined;
for (const part of commandPath) {
// TODO: For better performance and architectural clarity, this two-pass
@@ -52,6 +53,7 @@ export const parseSlashCommand = (
}
if (foundCommand) {
parentCommand = commandToExecute;
commandToExecute = foundCommand;
canonicalPath.push(foundCommand.name);
pathIndex++;
@@ -67,5 +69,21 @@ export const parseSlashCommand = (
const args = parts.slice(pathIndex).join(' ');
// Backtrack if the matched (sub)command doesn't take arguments but some were provided,
// AND the parent command is capable of handling them.
if (
commandToExecute &&
commandToExecute.takesArgs === false &&
args.length > 0 &&
parentCommand &&
parentCommand.action
) {
return {
commandToExecute: parentCommand,
args: parts.slice(pathIndex - 1).join(' '),
canonicalPath: canonicalPath.slice(0, -1),
};
}
return { commandToExecute, args, canonicalPath };
};
+3 -2
View File
@@ -119,7 +119,8 @@ async function initOauthClient(
credentials &&
typeof credentials === 'object' &&
'type' in credentials &&
credentials.type === 'external_account_authorized_user'
(credentials.type === 'external_account_authorized_user' ||
credentials.type === 'service_account')
) {
const auth = new GoogleAuth({
scopes: OAUTH_SCOPE,
@@ -130,7 +131,7 @@ async function initOauthClient(
});
const token = await byoidClient.getAccessToken();
if (token) {
debugLogger.debug('Created BYOID auth client.');
debugLogger.debug(`Created ${credentials.type} auth client.`);
return byoidClient;
}
}
+14
View File
@@ -78,6 +78,11 @@ import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
import { ideContextStore } from '../ide/ideContext.js';
import { WriteTodosTool } from '../tools/write-todos.js';
import {
ScheduleTaskTool,
ListTasksTool,
CancelTaskTool,
} from '../tools/cronTools.js';
import {
StandardFileSystemService,
type FileSystemService,
@@ -3439,6 +3444,15 @@ export class Config implements McpContext, AgentLoopContext {
maybeRegister(AskUserTool, () =>
registry.registerTool(new AskUserTool(this.messageBus)),
);
maybeRegister(ScheduleTaskTool, () =>
registry.registerTool(new ScheduleTaskTool(this.messageBus)),
);
maybeRegister(ListTasksTool, () =>
registry.registerTool(new ListTasksTool(this.messageBus)),
);
maybeRegister(CancelTaskTool, () =>
registry.registerTool(new CancelTaskTool(this.messageBus)),
);
if (this.getUseWriteTodos()) {
maybeRegister(WriteTodosTool, () =>
registry.registerTool(new WriteTodosTool(this.messageBus)),
+1
View File
@@ -137,6 +137,7 @@ export * from './services/sessionSummaryUtils.js';
export * from './services/contextManager.js';
export * from './services/trackerService.js';
export * from './services/trackerTypes.js';
export * from './services/cronSchedulerService.js';
export * from './services/keychainService.js';
export * from './services/keychainTypes.js';
export * from './skills/skillManager.js';
@@ -0,0 +1,99 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
CronSchedulerService,
type ScheduledTask,
} from './cronSchedulerService.js';
describe('CronSchedulerService', () => {
let service: CronSchedulerService;
beforeEach(() => {
vi.useFakeTimers();
service = new CronSchedulerService();
});
afterEach(() => {
service.stop();
vi.useRealTimers();
});
it('should parse intervals and schedule a task', () => {
const id = service.scheduleTask('5m', 'test prompt');
const tasks = service.listTasks();
expect(tasks).toHaveLength(1);
expect(tasks[0].id).toBe(id);
expect(tasks[0].prompt).toBe('test prompt');
expect(tasks[0].intervalMs).toBe(5 * 60 * 1000);
});
it('should throw on invalid interval', () => {
expect(() => service.scheduleTask('invalid', 'test prompt')).toThrow(
/Invalid interval format/,
);
});
it('should emit event when task is due', () => {
const callback = vi.fn();
service.on('task_due', callback);
service.scheduleTask('10s', 'test prompt');
// Advance 9 seconds, shouldn't fire
vi.advanceTimersByTime(9000);
expect(callback).not.toHaveBeenCalled();
// Advance 1 more second, should fire
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
const taskArg = callback.mock.calls[0][0] as ScheduledTask;
expect(taskArg.prompt).toBe('test prompt');
});
it('should handle recurring tasks correctly', () => {
const callback = vi.fn();
service.on('task_due', callback);
service.scheduleTask('10s', 'test prompt', true);
// First run
vi.advanceTimersByTime(10000);
expect(callback).toHaveBeenCalledTimes(1);
// Second run
vi.advanceTimersByTime(10000);
expect(callback).toHaveBeenCalledTimes(2);
});
it('should handle one-shot tasks correctly', () => {
const callback = vi.fn();
service.on('task_due', callback);
service.scheduleTask('10s', 'test prompt', false);
// First run
vi.advanceTimersByTime(10000);
expect(callback).toHaveBeenCalledTimes(1);
expect(service.listTasks()).toHaveLength(0); // Task should be removed
// Advance again, shouldn't fire
vi.advanceTimersByTime(10000);
expect(callback).toHaveBeenCalledTimes(1);
});
it('should cancel a task', () => {
const id = service.scheduleTask('10s', 'test prompt');
expect(service.listTasks()).toHaveLength(1);
service.cancelTask(id);
expect(service.listTasks()).toHaveLength(0);
});
});
@@ -0,0 +1,152 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { EventEmitter } from 'node:events';
export interface ScheduledTask {
id: string;
intervalMs: number | null; // null if one-shot (though this basic implementation focuses on recurring)
prompt: string;
createdAt: number;
nextRunAt: number;
isRecurring: boolean;
}
export class CronSchedulerService extends EventEmitter {
private tasks: Map<string, ScheduledTask> = new Map();
private tickInterval: NodeJS.Timeout | null = null;
private isRunning = false;
constructor() {
super();
}
/**
* Starts the background interval that checks for due tasks.
*/
start() {
if (this.isRunning) return;
this.isRunning = true;
this.tickInterval = setInterval(() => this.tick(), 1000);
// Don't prevent process exit if only the scheduler is running
this.tickInterval.unref();
}
/**
* Stops the background interval.
*/
stop() {
this.isRunning = false;
if (this.tickInterval) {
clearInterval(this.tickInterval);
this.tickInterval = null;
}
}
/**
* Schedules a new task.
* @param intervalString An interval string like "5m", "10s", "2h".
* @param prompt The prompt to execute.
* @param isRecurring Whether it's a recurring task or one-shot.
* @returns The generated task ID.
*/
scheduleTask(
intervalString: string,
prompt: string,
isRecurring: boolean = true,
): string {
const intervalMs = this.parseInterval(intervalString);
if (!intervalMs) {
throw new Error(
`Invalid interval format: ${intervalString}. Supported formats: 10s, 5m, 2h.`,
);
}
const id = Math.random().toString(36).substring(2, 10); // 8-character ID
const now = Date.now();
const task: ScheduledTask = {
id,
intervalMs,
prompt,
createdAt: now,
nextRunAt: now + intervalMs,
isRecurring,
};
this.tasks.set(id, task);
if (!this.isRunning) {
this.start();
}
return id;
}
/**
* Lists all active scheduled tasks.
*/
listTasks(): ScheduledTask[] {
return Array.from(this.tasks.values());
}
/**
* Cancels a scheduled task by ID.
*/
cancelTask(id: string): boolean {
return this.tasks.delete(id);
}
private tick() {
const now = Date.now();
for (const [id, task] of this.tasks.entries()) {
if (now >= task.nextRunAt) {
// Emit the event so the REPL can pick it up
this.emit('task_due', task);
if (task.isRecurring && task.intervalMs) {
// Calculate next run time
task.nextRunAt = now + task.intervalMs;
} else {
// One-shot, remove it
this.tasks.delete(id);
}
}
}
// Auto-stop if no tasks
if (this.tasks.size === 0) {
this.stop();
}
}
/**
* Parses strings like "10s", "5m", "2h", "1d" into milliseconds.
*/
private parseInterval(interval: string): number | null {
const match = interval.match(/^(\d+)([smhd])$/);
if (!match) return null;
const value = parseInt(match[1], 10);
const unit = match[2];
switch (unit) {
case 's':
return value * 1000;
case 'm':
return value * 60 * 1000;
case 'h':
return value * 60 * 60 * 1000;
case 'd':
return value * 24 * 60 * 60 * 1000;
default:
return null;
}
}
}
// Singleton instance
export const cronSchedulerService = new CronSchedulerService();
+254
View File
@@ -0,0 +1,254 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type ToolResult,
} from './tools.js';
import { cronSchedulerService } from '../services/cronSchedulerService.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { Type, type FunctionDeclaration } from '@google/genai';
// --- Declarations ---
export const SCHEDULE_TASK_TOOL_NAME = 'schedule_task';
export const LIST_TASKS_TOOL_NAME = 'list_scheduled_tasks';
export const CANCEL_TASK_TOOL_NAME = 'cancel_scheduled_task';
export const SCHEDULE_TASK_DECLARATION: FunctionDeclaration = {
name: SCHEDULE_TASK_TOOL_NAME,
description: 'Schedules a prompt to run after a specified interval.',
parameters: {
type: Type.OBJECT,
properties: {
interval: {
type: Type.STRING,
description: 'Interval string like "10s", "5m", "1h".',
},
prompt: {
type: Type.STRING,
description: 'The prompt to run when the task triggers.',
},
recurring: {
type: Type.BOOLEAN,
description: 'Whether the task should run repeatedly.',
},
},
required: ['interval', 'prompt'],
},
};
export const LIST_TASKS_DECLARATION: FunctionDeclaration = {
name: LIST_TASKS_TOOL_NAME,
description: 'Lists all currently scheduled tasks.',
parameters: {
type: Type.OBJECT,
properties: {},
},
};
export const CANCEL_TASK_DECLARATION: FunctionDeclaration = {
name: CANCEL_TASK_TOOL_NAME,
description: 'Cancels a scheduled task by ID.',
parameters: {
type: Type.OBJECT,
properties: {
id: {
type: Type.STRING,
description: 'The ID of the task to cancel.',
},
},
required: ['id'],
},
};
// --- Invocations ---
interface ScheduleTaskParams {
interval: string;
prompt: string;
recurring?: boolean;
}
class ScheduleTaskInvocation extends BaseToolInvocation<
ScheduleTaskParams,
ToolResult
> {
constructor(
params: ScheduleTaskParams,
messageBus: MessageBus,
toolName: string,
) {
super(params, messageBus, toolName);
}
override getDescription(): string {
return `Schedule task: ${this.params.prompt} (Interval: ${this.params.interval})`;
}
async execute(): Promise<ToolResult> {
try {
const isRecurring = this.params.recurring !== false;
const id = cronSchedulerService.scheduleTask(
this.params.interval,
this.params.prompt,
isRecurring,
);
return {
llmContent: `Task scheduled successfully. ID: ${id}`,
returnDisplay: `Task scheduled successfully. ID: ${id}`,
};
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
return {
llmContent: `Error scheduling task: ${message}`,
returnDisplay: `Error scheduling task: ${message}`,
};
}
}
override async getConfirmationDetails() {
return false as const;
}
}
class ListTasksInvocation extends BaseToolInvocation<object, ToolResult> {
constructor(params: object, messageBus: MessageBus, toolName: string) {
super(params, messageBus, toolName);
}
override getDescription(): string {
return 'List scheduled tasks';
}
async execute(): Promise<ToolResult> {
const tasks = cronSchedulerService.listTasks();
if (tasks.length === 0) {
return {
llmContent: 'No scheduled tasks.',
returnDisplay: 'No scheduled tasks.',
};
}
const lines = tasks.map(
(t) =>
`- ID: ${t.id}, Interval: ${t.intervalMs}ms, Prompt: "${t.prompt}", Recurring: ${t.isRecurring}`,
);
return { llmContent: lines.join('\n'), returnDisplay: lines.join('\n') };
}
override async getConfirmationDetails() {
return false as const;
}
}
interface CancelTaskParams {
id: string;
}
class CancelTaskInvocation extends BaseToolInvocation<
CancelTaskParams,
ToolResult
> {
constructor(
params: CancelTaskParams,
messageBus: MessageBus,
toolName: string,
) {
super(params, messageBus, toolName);
}
override getDescription(): string {
return `Cancel task ID: ${this.params.id}`;
}
async execute(): Promise<ToolResult> {
const success = cronSchedulerService.cancelTask(this.params.id);
if (success) {
return {
llmContent: `Task ${this.params.id} cancelled successfully.`,
returnDisplay: `Task ${this.params.id} cancelled successfully.`,
};
}
return {
llmContent: `Task ${this.params.id} not found.`,
returnDisplay: `Task ${this.params.id} not found.`,
};
}
override async getConfirmationDetails() {
return false as const;
}
}
// --- Tools ---
export class ScheduleTaskTool extends BaseDeclarativeTool<
ScheduleTaskParams,
ToolResult
> {
constructor(messageBus: MessageBus) {
super(
SCHEDULE_TASK_TOOL_NAME,
'ScheduleTask',
SCHEDULE_TASK_DECLARATION.description ?? '',
Kind.Other,
SCHEDULE_TASK_DECLARATION.parameters,
messageBus,
);
}
protected createInvocation(
params: ScheduleTaskParams,
messageBus: MessageBus,
): ScheduleTaskInvocation {
return new ScheduleTaskInvocation(params, messageBus, this.name);
}
}
export class ListTasksTool extends BaseDeclarativeTool<object, ToolResult> {
constructor(messageBus: MessageBus) {
super(
LIST_TASKS_TOOL_NAME,
'ListTasks',
LIST_TASKS_DECLARATION.description ?? '',
Kind.Other,
LIST_TASKS_DECLARATION.parameters,
messageBus,
);
}
protected createInvocation(
params: object,
messageBus: MessageBus,
): ListTasksInvocation {
return new ListTasksInvocation(params, messageBus, this.name);
}
}
export class CancelTaskTool extends BaseDeclarativeTool<
CancelTaskParams,
ToolResult
> {
constructor(messageBus: MessageBus) {
super(
CANCEL_TASK_TOOL_NAME,
'CancelTask',
CANCEL_TASK_DECLARATION.description ?? '',
Kind.Other,
CANCEL_TASK_DECLARATION.parameters,
messageBus,
);
}
protected createInvocation(
params: CancelTaskParams,
messageBus: MessageBus,
): CancelTaskInvocation {
return new CancelTaskInvocation(params, messageBus, this.name);
}
}
@@ -69,4 +69,84 @@ export const TEST_AGENTS = {
tools: ['read_file', 'write_file'],
body: 'You are the test agent. Add or update tests.',
}),
/**
* An agent with expertise in database schemas, SQL, and creating database migrations.
*/
DATABASE_AGENT: createAgent({
name: 'database-agent',
description:
'An expert in database schemas, SQL, and creating database migrations.',
tools: ['read_file', 'write_file'],
body: 'You are the database agent. Create and update SQL migrations.',
}),
/**
* An agent with expertise in CSS, styling, and UI design.
*/
CSS_AGENT: createAgent({
name: 'css-agent',
description: 'An expert in CSS, styling, and UI design.',
tools: ['read_file', 'write_file'],
body: 'You are the CSS agent.',
}),
/**
* An agent with expertise in internationalization and translations.
*/
I18N_AGENT: createAgent({
name: 'i18n-agent',
description: 'An expert in internationalization and translations.',
tools: ['read_file', 'write_file'],
body: 'You are the i18n agent.',
}),
/**
* An agent with expertise in security audits and vulnerability patches.
*/
SECURITY_AGENT: createAgent({
name: 'security-agent',
description: 'An expert in security audits and vulnerability patches.',
tools: ['read_file', 'write_file'],
body: 'You are the security agent.',
}),
/**
* An agent with expertise in CI/CD, Docker, and deployment scripts.
*/
DEVOPS_AGENT: createAgent({
name: 'devops-agent',
description: 'An expert in CI/CD, Docker, and deployment scripts.',
tools: ['read_file', 'write_file'],
body: 'You are the devops agent.',
}),
/**
* An agent with expertise in tracking, analytics, and metrics.
*/
ANALYTICS_AGENT: createAgent({
name: 'analytics-agent',
description: 'An expert in tracking, analytics, and metrics.',
tools: ['read_file', 'write_file'],
body: 'You are the analytics agent.',
}),
/**
* An agent with expertise in web accessibility and ARIA roles.
*/
ACCESSIBILITY_AGENT: createAgent({
name: 'accessibility-agent',
description: 'An expert in web accessibility and ARIA roles.',
tools: ['read_file', 'write_file'],
body: 'You are the accessibility agent.',
}),
/**
* An agent with expertise in React Native and mobile app development.
*/
MOBILE_AGENT: createAgent({
name: 'mobile-agent',
description: 'An expert in React Native and mobile app development.',
tools: ['read_file', 'write_file'],
body: 'You are the mobile agent.',
}),
} as const;