🎯 Use case
This file lives under “hooks/”, which covers reusable UI or integration hooks. On the API surface it exposes useScheduledTasks — mainly functions, hooks, or classes. Dependencies touch React UI. It composes internal code from state, Task, tasks, tools, and types (relative imports).
Generated from folder role, exports, dependency roots, and inline comments — not hand-reviewed for every path.
🧠 Inline summary
import { useEffect, useRef } from 'react' import { useAppStateStore, useSetAppState } from '../state/AppState.js' import { isTerminalTaskStatus } from '../Task.js' import { findTeammateTaskByAgentId,
📤 Exports (heuristic)
useScheduledTasks
📚 External import roots
Package roots from from "…" (relative paths omitted).
react
🖥️ Source preview
import { useEffect, useRef } from 'react'
import { useAppStateStore, useSetAppState } from '../state/AppState.js'
import { isTerminalTaskStatus } from '../Task.js'
import {
findTeammateTaskByAgentId,
injectUserMessageToTeammate,
} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
import { isKairosCronEnabled } from '../tools/ScheduleCronTool/prompt.js'
import type { Message } from '../types/message.js'
import { getCronJitterConfig } from '../utils/cronJitterConfig.js'
import { createCronScheduler } from '../utils/cronScheduler.js'
import { removeCronTasks } from '../utils/cronTasks.js'
import { logForDebugging } from '../utils/debug.js'
import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
import { createScheduledTaskFireMessage } from '../utils/messages.js'
import { WORKLOAD_CRON } from '../utils/workloadContext.js'
type Props = {
isLoading: boolean
/**
* When true, bypasses the isLoading gate so tasks can enqueue while a
* query is streaming rather than deferring to the next 1s check tick
* after the turn ends. Assistant mode no longer forces --proactive
* (#20425) so isLoading drops between turns like a normal REPL — this
* bypass is now a latency nicety, not a starvation fix. The prompt is
* enqueued at 'later' priority either way and drains between turns.
*/
assistantMode?: boolean
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
}
/**
* REPL wrapper for the cron scheduler. Mounts the scheduler once and tears
* it down on unmount. Fired prompts go into the command queue as 'later'
* priority, which the REPL drains via useCommandQueue between turns.
*
* Scheduler core (timer, file watcher, fire logic) lives in cronScheduler.ts
* so SDK/-p mode can share it — see print.ts for the headless wiring.
*/
export function useScheduledTasks({
isLoading,
assistantMode = false,
setMessages,
}: Props): void {
// Latest-value ref so the scheduler's isLoading() getter doesn't capture
// a stale closure. The effect mounts once; isLoading changes every turn.
const isLoadingRef = useRef(isLoading)
isLoadingRef.current = isLoading
const store = useAppStateStore()
const setAppState = useSetAppState()
useEffect(() => {
// Runtime gate checked here (not at the hook call site) so the hook
// stays unconditionally mounted — rules-of-hooks forbid wrapping the
// call in a dynamic condition. getFeatureValue_CACHED_WITH_REFRESH
// reads from disk; the 5-min TTL fires a background refetch but the
// effect won't re-run on value flip (assistantMode is the only dep),
// so this guard alone is launch-grain. The mid-session killswitch is
// the isKilled option below — check() polls it every tick.
if (!isKairosCronEnabled()) return
// System-generated — hidden from queue preview and transcript UI.
// In brief mode, executeForkedSlashCommand runs as a background
// subagent and returns no visible messages. In normal mode,
// isMeta is only propagated for plain-text prompts (via
// processTextPrompt); slash commands like /context:fork do not
// forward isMeta, so their messages remain visible in the
// transcript. This is acceptable since normal mode is not the
// primary use case for scheduled tasks.
const enqueueForLead = (prompt: string) =>
enqueuePendingNotification({
value: prompt,
mode: 'prompt',
priority: 'later',
isMeta: true,
// Threaded through to cc_workload= in the billing-header
// attribution block so the API can serve cron-initiated requests
// at lower QoS when capacity is tight. No human is actively
// waiting on this response.
workload: WORKLOAD_CRON,
})
const scheduler = createCronScheduler({
// Missed-task surfacing (onFire fallback). Teammate crons are always
// session-only (durable:false) so they never appear in the missed list,
// which is populated from disk at scheduler startup — this path only
// handles team-lead durable crons.
onFire: enqueueForLead,
// Normal fires receive the full CronTask so we can route by agentId.
onFireTask: task => {
if (task.agentId) {
const teammate = findTeammateTaskByAgentId(
task.agentId,
store.getState().tasks,
)
if (teammate && !isTerminalTaskStatus(teammate.status)) {
injectUserMessageToTeammate(teammate.id, task.prompt, setAppState)
return
}
// Teammate is gone — clean up the orphaned cron so it doesn't keep
// firing into nowhere every tick. One-shots would auto-delete on
// fire anyway, but recurring crons would loop until auto-expiry.
logForDebugging(
`[ScheduledTasks] teammate ${task.agentId} gone, removing orphaned cron ${task.id}`,
)
void removeCronTasks([task.id])
return
}
const msg = createScheduledTaskFireMessage(
`Running scheduled task (${formatCronFireTime(new Date())})`,
)
setMessages(prev => [...prev, msg])
enqueueForLead(task.prompt)
},
isLoading: () => isLoadingRef.current,
assistantMode,
getJitterConfig: getCronJitterConfig,
isKilled: () => !isKairosCronEnabled(),
})
scheduler.start()
return () => scheduler.stop()
// assistantMode is stable for the session lifetime; store/setAppState are
// stable refs from useSyncExternalStore; setMessages is a stable useCallback.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assistantMode])
}
function formatCronFireTime(d: Date): string {
return d
.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
.replace(/,? at |, /, ' ')
.replace(/ ([AP]M)/, (_, ampm) => ampm.toLowerCase())
}