diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 8c0b7c28d..91bee5dfd 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -405,7 +405,7 @@ export async function runNonInteractive( prompt_id, { type: cronIsFirstTurn - ? SendMessageType.UserQuery + ? SendMessageType.Cron : SendMessageType.ToolResult, }, ); @@ -415,7 +415,11 @@ export async function runNonInteractive( for await (const event of cronStream) { if (abortController.signal.aborted) { + const summary = scheduler.getExitSummary(); scheduler.stop(); + if (summary) { + process.stderr.write(summary + '\n'); + } resolve(); return; } diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 188fd91e7..7979a9ba5 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1176,7 +1176,10 @@ export const useGeminiStream = ( } // Check image format support for non-continuations - if (submitType === SendMessageType.UserQuery) { + if ( + submitType === SendMessageType.UserQuery || + submitType === SendMessageType.Cron + ) { const formatCheck = checkImageFormatsSupport(queryToSend); if (formatCheck.hasUnsupportedFormats) { addItem( @@ -1193,7 +1196,10 @@ export const useGeminiStream = ( lastPromptRef.current = finalQueryToSend; lastPromptErroredRef.current = false; - if (submitType === SendMessageType.UserQuery) { + if ( + submitType === SendMessageType.UserQuery || + submitType === SendMessageType.Cron + ) { // trigger new prompt event for session stats in CLI startNewPrompt(); @@ -1651,7 +1657,11 @@ export const useGeminiStream = ( setCronTrigger((n) => n + 1); }); return () => { + const summary = scheduler.getExitSummary(); scheduler.stop(); + if (summary) { + process.stderr.write(summary + '\n'); + } }; }, [config]); @@ -1662,7 +1672,7 @@ export const useGeminiStream = ( cronQueueRef.current.length > 0 ) { const prompt = cronQueueRef.current.shift()!; - submitQuery(prompt, SendMessageType.UserQuery); + submitQuery(prompt, SendMessageType.Cron); } }, [streamingState, submitQuery, cronTrigger]); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index dfbcc38ea..e275b3681 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -92,6 +92,8 @@ export enum SendMessageType { ToolResult = 'toolResult', Retry = 'retry', Hook = 'hook', + /** Cron-fired prompt. Behaves like UserQuery but skips UserPromptSubmit hook. */ + Cron = 'cron', } export interface SendMessageOptions { @@ -465,7 +467,12 @@ export class GeminiClient { // Fire UserPromptSubmit hook through MessageBus (only if hooks are enabled) const hooksEnabled = this.config.getEnableHooks(); const messageBus = this.config.getMessageBus(); - if (messageType !== SendMessageType.Retry && hooksEnabled && messageBus) { + if ( + messageType !== SendMessageType.Retry && + messageType !== SendMessageType.Cron && + hooksEnabled && + messageBus + ) { const promptText = partToString(request); const response = await messageBus.request< HookExecutionRequest, @@ -507,7 +514,10 @@ export class GeminiClient { } } - if (messageType === SendMessageType.UserQuery) { + if ( + messageType === SendMessageType.UserQuery || + messageType === SendMessageType.Cron + ) { this.loopDetector.reset(prompt_id); this.lastPromptId = prompt_id; @@ -605,7 +615,10 @@ export class GeminiClient { // append system reminders to the request let requestToSent = await flatMapTextParts(request, async (text) => [text]); - if (messageType === SendMessageType.UserQuery) { + if ( + messageType === SendMessageType.UserQuery || + messageType === SendMessageType.Cron + ) { const systemReminders = []; // add subagent system reminder if there are subagents diff --git a/packages/core/src/services/cronScheduler.test.ts b/packages/core/src/services/cronScheduler.test.ts index 6f106e67f..e7673f00e 100644 --- a/packages/core/src/services/cronScheduler.test.ts +++ b/packages/core/src/services/cronScheduler.test.ts @@ -203,6 +203,44 @@ describe('CronScheduler', () => { }); }); + describe('getExitSummary', () => { + it('returns null when no jobs', () => { + expect(scheduler.getExitSummary()).toBeNull(); + }); + + it('returns summary with single job', () => { + scheduler.create('*/5 * * * *', 'check the build', true); + const summary = scheduler.getExitSummary()!; + expect(summary).toContain('1 active loop cancelled:'); + expect(summary).toContain('Every 5 minutes'); + expect(summary).toContain('check the build'); + }); + + it('returns summary with multiple jobs', () => { + scheduler.create('*/5 * * * *', 'check the build', true); + scheduler.create('*/30 * * * *', 'check PR reviews', true); + const summary = scheduler.getExitSummary()!; + expect(summary).toContain('2 active loops cancelled:'); + expect(summary).toContain('check the build'); + expect(summary).toContain('check PR reviews'); + }); + + it('truncates long prompts', () => { + const longPrompt = 'a'.repeat(100); + scheduler.create('*/1 * * * *', longPrompt, true); + const summary = scheduler.getExitSummary()!; + expect(summary).toContain('...'); + // Should not contain the full 100-char prompt + expect(summary).not.toContain(longPrompt); + }); + + it('returns null after all jobs are deleted', () => { + const job = scheduler.create('*/1 * * * *', 'temp', true); + scheduler.delete(job.id); + expect(scheduler.getExitSummary()).toBeNull(); + }); + }); + describe('destroy', () => { it('stops and clears all jobs', () => { scheduler.create('*/1 * * * *', 'a', true); diff --git a/packages/core/src/services/cronScheduler.ts b/packages/core/src/services/cronScheduler.ts index 6b4739517..e58c811b4 100644 --- a/packages/core/src/services/cronScheduler.ts +++ b/packages/core/src/services/cronScheduler.ts @@ -4,6 +4,7 @@ */ import { matches, nextFireTime } from '../utils/cronParser.js'; +import { humanReadableCron } from '../utils/cronDisplay.js'; const MAX_JOBS = 50; const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; @@ -224,6 +225,27 @@ export class CronScheduler { } } + /** + * Returns a human-readable summary of active jobs for display on session + * exit. Returns null if there are no active jobs. + */ + getExitSummary(): string | null { + if (this.jobs.size === 0) return null; + + const count = this.jobs.size; + const lines = [ + `Session ending. ${count} active loop${count === 1 ? '' : 's'} cancelled:`, + ]; + for (const job of this.jobs.values()) { + const schedule = humanReadableCron(job.cronExpr); + // Truncate long prompts + const prompt = + job.prompt.length > 60 ? job.prompt.slice(0, 57) + '...' : job.prompt; + lines.push(` - [${job.id}] ${schedule}: ${prompt}`); + } + return lines.join('\n'); + } + /** * Clears all jobs and stops the scheduler. */