diff --git a/packages/core/src/goals/goalLoop.integration.test.ts b/packages/core/src/goals/goalLoop.integration.test.ts index bcb93d029f..fad2b56eea 100644 --- a/packages/core/src/goals/goalLoop.integration.test.ts +++ b/packages/core/src/goals/goalLoop.integration.test.ts @@ -56,6 +56,8 @@ function makeStopInput(lastAssistantText: string): StopInput { timestamp: new Date().toISOString(), stop_hook_active: true, last_assistant_message: lastAssistantText, + background_tasks: [], + crons: [], }; } diff --git a/packages/core/src/hooks/hookEventHandler.test.ts b/packages/core/src/hooks/hookEventHandler.test.ts index 9f86b8ecb6..9fd22f70e3 100644 --- a/packages/core/src/hooks/hookEventHandler.test.ts +++ b/packages/core/src/hooks/hookEventHandler.test.ts @@ -410,6 +410,125 @@ describe('HookEventHandler', () => { expect(result.success).toBe(true); expect(result.finalOutput).toBeUndefined(); }); + + it('should inject background_tasks and crons into Stop input', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + const mockRegistry = { + getAll: vi.fn().mockReturnValue([ + { + id: 'bg-1', + status: 'running', + subagentType: 'Explorer', + startTime: 1720000000000, + description: 'test task', + }, + ]), + }; + const mockScheduler = { + list: vi.fn().mockReturnValue([ + { + id: 'cron-1', + cronExpr: '0 */2 * * *', + prompt: 'check status', + recurring: true, + fireAtMs: 1720007200000, + lastFiredAt: 1720000000000, + }, + ]), + }; + const configWithMocks = mockConfig as unknown as { + getBackgroundTaskRegistry: () => typeof mockRegistry; + getCronScheduler: () => typeof mockScheduler; + }; + configWithMocks.getBackgroundTaskRegistry = () => mockRegistry; + configWithMocks.getCronScheduler = () => mockScheduler; + + // Re-create handler with updated config mocks + const handler = new HookEventHandler( + mockConfig, + mockHookPlanner, + mockHookRunner, + mockHookAggregator, + mockSessionHooksManager, + ); + + await handler.fireStopEvent(true, 'msg'); + + const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock + .calls; + const input = mockCalls[0][2] as { + background_tasks: Array<{ + id: string; + status: string; + agent_type: string; + }>; + crons: Array<{ id: string; schedule: string; prompt: string }>; + }; + + expect(input.background_tasks).toHaveLength(1); + expect(input.background_tasks[0].id).toBe('bg-1'); + expect(input.background_tasks[0].status).toBe('running'); + expect(input.background_tasks[0].agent_type).toBe('Explorer'); + + expect(input.crons).toHaveLength(1); + expect(input.crons[0].id).toBe('cron-1'); + expect(input.crons[0].schedule).toBe('0 */2 * * *'); + expect(input.crons[0].prompt).toBe('check status'); + }); + + it('should return empty arrays when registry/scheduler unavailable', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + // Config without registry/scheduler methods + const bareConfig = { + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'), + getWorkingDir: vi.fn().mockReturnValue('/test/cwd'), + } as unknown as Config; + + const handler = new HookEventHandler( + bareConfig, + mockHookPlanner, + mockHookRunner, + mockHookAggregator, + mockSessionHooksManager, + ); + + await handler.fireStopEvent(true, 'msg'); + + const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock + .calls; + const input = mockCalls[0][2] as { + background_tasks: unknown[]; + crons: unknown[]; + }; + + expect(input.background_tasks).toEqual([]); + expect(input.crons).toEqual([]); + }); }); describe('fireSessionStartEvent', () => { diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts index 17f7c097f4..ca8b5b99b9 100644 --- a/packages/core/src/hooks/hookEventHandler.ts +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -52,11 +52,14 @@ import type { InstructionsLoadedInput, InstructionMemoryType, InstructionLoadReason, + BackgroundTaskInfo, + CronJobInfo, } from './types.js'; import { HookPhase, PermissionMode } from './types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { logHookCall } from '../telemetry/loggers.js'; import { HookCallEvent } from '../telemetry/types.js'; +import type { CronJob } from '../services/cronScheduler.js'; const debugLogger = createDebugLogger('TRUSTED_HOOKS'); @@ -102,6 +105,50 @@ export class HookEventHandler { return this.messagesProvider; } + /** + * Snapshot of current background tasks for hook payloads. + * Non-blocking: reads registry state synchronously. + */ + private getBackgroundTaskSnapshot(): BackgroundTaskInfo[] { + try { + const registry = this.config.getBackgroundTaskRegistry(); + return registry.getAll().map((task) => ({ + id: task.id, + status: task.status, + agent_type: task.subagentType ?? 'unknown', + started_at: new Date(task.startTime).toISOString(), + description: task.description, + })); + } catch { + return []; + } + } + + /** + * Snapshot of current cron jobs for hook payloads. + * Non-blocking: reads scheduler state synchronously. + */ + private getCronJobSnapshot(): CronJobInfo[] { + try { + const scheduler = this.config.getCronScheduler(); + return scheduler.list().map((job: CronJob) => ({ + id: job.id, + schedule: job.cronExpr, + prompt: job.prompt, + recurring: job.recurring, + next_run: job.fireAtMs + ? new Date(job.fireAtMs).toISOString() + : undefined, + last_run: job.lastFiredAt + ? new Date(job.lastFiredAt).toISOString() + : undefined, + enabled: true, + })); + } catch { + return []; + } + } + /** * Fire a UserPromptSubmit event * Called by handleHookExecutionRequest - executes hooks directly @@ -196,6 +243,8 @@ export class HookEventHandler { ...this.createBaseInput(HookEventName.Stop), stop_hook_active: stopHookActive, last_assistant_message: lastAssistantMessage, + background_tasks: this.getBackgroundTaskSnapshot(), + crons: this.getCronJobSnapshot(), ...contextUsage, }; @@ -546,6 +595,8 @@ export class HookEventHandler { agent_type: agentType, agent_transcript_path: agentTranscriptPath, last_assistant_message: lastAssistantMessage, + background_tasks: this.getBackgroundTaskSnapshot(), + crons: this.getCronJobSnapshot(), }; // Pass agentType as context for matcher filtering diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index da76ed2e17..6448fac632 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -5,6 +5,7 @@ */ import type { ChildProcess } from 'child_process'; import type { ToolArtifact } from '../tools/tools.js'; +import type { TaskStatus } from '../agents/tasks/types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('TRUSTED_HOOKS'); @@ -923,12 +924,38 @@ export interface ContextUsageData { input_tokens: number; } +/** + * Background task info for hook payloads + */ +export interface BackgroundTaskInfo { + id: string; + status: TaskStatus; + agent_type: string; + started_at: string; + description?: string; +} + +/** + * Cron job info for hook payloads + */ +export interface CronJobInfo { + id: string; + schedule: string; + prompt: string; + recurring: boolean; + next_run?: string; + last_run?: string; + enabled: boolean; +} + /** * Stop hook input */ export interface StopInput extends HookInput, Partial { stop_hook_active: boolean; last_assistant_message: string; + background_tasks: BackgroundTaskInfo[]; + crons: CronJobInfo[]; } /** @@ -1102,6 +1129,8 @@ export interface SubagentStopInput extends HookInput { agent_type: AgentType | string; agent_transcript_path: string; last_assistant_message: string; + background_tasks: BackgroundTaskInfo[]; + crons: CronJobInfo[]; } /**