diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index c1cbd5621e..bc3c47bd95 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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] : []), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 9942e24e48..b78052bbf2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -83,7 +83,9 @@ import { logBillingEvent, ApiKeyUpdatedEvent, type InjectionSource, -} from '@google/gemini-cli-core'; + + cronSchedulerService, + type ScheduledTask} from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; import { useHistory } from './hooks/useHistoryManager.js'; @@ -1211,6 +1213,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)) { diff --git a/packages/cli/src/ui/commands/loopCommand.test.ts b/packages/cli/src/ui/commands/loopCommand.test.ts new file mode 100644 index 0000000000..0443d5dbe0 --- /dev/null +++ b/packages/cli/src/ui/commands/loopCommand.test.ts @@ -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(); + return { + ...actual, + cronSchedulerService: { + scheduleTask: vi.fn(), + }, + }; +}); + +describe('loopCommand', () => { + let mockContext: ReturnType; + + 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', + ), + }), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/loopCommand.ts b/packages/cli/src/ui/commands/loopCommand.ts new file mode 100644 index 0000000000..01ce99c2b6 --- /dev/null +++ b/packages/cli/src/ui/commands/loopCommand.ts @@ -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}`, + }); + } + }, +}; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 075c5439ad..8af287cee8 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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)), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4633b5f4c3..d5c1cc27f1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/services/cronSchedulerService.test.ts b/packages/core/src/services/cronSchedulerService.test.ts new file mode 100644 index 0000000000..6f4a09b6eb --- /dev/null +++ b/packages/core/src/services/cronSchedulerService.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/services/cronSchedulerService.ts b/packages/core/src/services/cronSchedulerService.ts new file mode 100644 index 0000000000..2e5ebb64a1 --- /dev/null +++ b/packages/core/src/services/cronSchedulerService.ts @@ -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 = 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(); diff --git a/packages/core/src/tools/cronTools.ts b/packages/core/src/tools/cronTools.ts new file mode 100644 index 0000000000..fd74efdfb7 --- /dev/null +++ b/packages/core/src/tools/cronTools.ts @@ -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 { + 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 { + constructor(params: object, messageBus: MessageBus, toolName: string) { + super(params, messageBus, toolName); + } + + override getDescription(): string { + return 'List scheduled tasks'; + } + + async execute(): Promise { + 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 { + 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 { + 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); + } +}