/**
* useProactive — React hook that drives tick generation for proactive mode.
*
* Mounted inside REPL.tsx when feature('PROACTIVE') || feature('KAIROS').
* Generates HH:MM:SS prompts at a fixed interval while
* proactive mode is active and not blocked.
*/
import { useEffect, useRef } from 'react'
import type { QueuedCommand } from '../types/textInputTypes.js'
import { TICK_TAG } from '../constants/xml.js'
import { getCwd } from '../utils/cwd.js'
import { cancelQueuedAutonomyCommands } from '../utils/autonomyQueueLifecycle.js'
import { createProactiveAutonomyCommands } from '../utils/autonomyRuns.js'
import { logForDebugging } from '../utils/debug.js'
import {
isProactiveActive,
isProactivePaused,
isContextBlocked,
setNextTickAt,
shouldTick,
} from './index.js'
/** Default interval between ticks (ms). Prompt cache TTL is ~5 min so we
* stay well under that to keep the cache warm. */
const TICK_INTERVAL_MS = 30_000
type UseProactiveOpts = {
isLoading: boolean
queuedCommandsLength: number
hasActiveLocalJsxUI: boolean
isInPlanMode: boolean
onQueueTick: (command: QueuedCommand) => void
}
export function useProactive(opts: UseProactiveOpts): void {
const optsRef = useRef(opts)
optsRef.current = opts
useEffect(() => {
if (!isProactiveActive()) return
let timer: ReturnType | null = null
let disposed = false
let generating = false
function scheduleTick(): void {
const nextTs = Date.now() + TICK_INTERVAL_MS
setNextTickAt(nextTs)
timer = setTimeout(() => {
timer = null
// Guard: skip tick if any blocking condition is met
if (!shouldTick()) {
// Reschedule — conditions may clear later
scheduleTick()
return
}
const {
isLoading,
queuedCommandsLength,
hasActiveLocalJsxUI,
isInPlanMode,
} = optsRef.current
// Don't fire while a query is in-flight, plan mode is active,
// a local JSX UI is showing, or commands are queued
if (
isLoading ||
isInPlanMode ||
hasActiveLocalJsxUI ||
queuedCommandsLength > 0 ||
generating
) {
scheduleTick()
return
}
generating = true
void (async () => {
const commands = await createProactiveAutonomyCommands({
basePrompt: `<${TICK_TAG}>${new Date().toLocaleTimeString()}${TICK_TAG}>`,
currentDir: getCwd(),
shouldCreate: () => !disposed,
})
if (disposed) {
await cancelQueuedAutonomyCommands({ commands })
return
}
const queuedCommands: QueuedCommand[] = []
try {
for (const command of commands) {
// Always queue proactive turns. This avoids races where the prompt
// is built asynchronously, a user turn starts meanwhile, and a
// direct-submit path would silently drop the autonomy turn after
// consuming its heartbeat due-state.
optsRef.current.onQueueTick(command)
queuedCommands.push(command)
}
} catch (error) {
await cancelQueuedAutonomyCommands({
commands: commands.filter(
command => !queuedCommands.includes(command),
),
})
throw error
}
})()
.catch(error =>
logForDebugging(`[Proactive] failed to create tick: ${error}`, {
level: 'error',
}),
)
.finally(() => {
generating = false
})
// Schedule next tick
scheduleTick()
}, TICK_INTERVAL_MS)
}
scheduleTick()
return () => {
disposed = true
if (timer !== null) {
clearTimeout(timer)
timer = null
}
setNextTickAt(null)
}
}, [
// Re-mount when proactive state changes
isProactiveActive(),
isProactivePaused(),
isContextBlocked(),
])
}