import type { PluginInput } from "@opencode-ai/plugin" import { completeBoulder, formatDurationHuman, getPlanProgress, getWorkForSession, getTaskSessionState, readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath, } from "../../features/boulder-state" import { getSessionAgent, isAgentRegistered, resolveRegisteredAgentName, } from "../../features/claude-code-session-state" import { getLastAgentFromSession } from "./session-last-agent" import { isSessionInBoulderLineage } from "./boulder-session-lineage" import { createInternalAgentContinuationTextPart } from "../../shared" import { getAgentConfigKey } from "../../shared/agent-display-names" import { log } from "../../shared/logger" import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle" import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate" import { injectBoulderContinuation } from "./boulder-continuation-injector" import { HOOK_NAME } from "./hook-name" import { resolveActiveBoulderSession } from "./resolve-active-boulder-session" import { BOULDER_COMPLETE_PROMPT } from "./system-reminder-templates" import type { AtlasHookOptions, SessionState } from "./types" const CONTINUATION_COOLDOWN_MS = 5000 const FAILURE_BACKOFF_MS = 5 * 60 * 1000 const MAX_CONSECUTIVE_PROMPT_FAILURES = 10 const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000 function getTaskLabelSortValue(taskLabel: string): number { const parsed = Number.parseInt(taskLabel.replace(/[^0-9]/g, ""), 10) return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed } function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean { const backgroundManager = options?.backgroundManager return backgroundManager ? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running") : false } async function injectContinuation(input: { ctx: PluginInput sessionID: string sessionState: SessionState options?: AtlasHookOptions planName: string progress: { total: number; completed: number } agent?: string worktreePath?: string idleSettleMs?: number }): Promise { const remaining = input.progress.total - input.progress.completed if (input.sessionState.isInjectingContinuation) { scheduleRetry({ ctx: input.ctx, sessionID: input.sessionID, sessionState: input.sessionState, options: input.options, }) return } input.sessionState.isInjectingContinuation = true try { const currentBoulder = readBoulderState(input.ctx.directory) const currentPlanPath = currentBoulder ? resolveBoulderPlanPath(input.ctx.directory, currentBoulder) : null const currentTask = currentBoulder && currentPlanPath ? readCurrentTopLevelTask(currentPlanPath) : null const preferredTaskSession = currentTask ? getTaskSessionState(input.ctx.directory, currentTask.key) : null if (!currentBoulder) { return } const canContinueSession = await canContinueTrackedBoulderSession({ client: input.ctx.client, sessionID: input.sessionID, sessionOrigin: currentBoulder.session_origins?.[input.sessionID], boulderSessionIDs: currentBoulder.session_ids, requiredAgent: currentBoulder.agent, }) if (!canContinueSession) { log(`[${HOOK_NAME}] Skipped: tracked descendant agent does not match boulder agent`, { sessionID: input.sessionID, requiredAgent: currentBoulder.agent ?? "atlas", }) return } const result = await injectBoulderContinuation({ ctx: input.ctx, sessionID: input.sessionID, planName: input.planName, remaining, total: input.progress.total, agent: input.agent, worktreePath: input.worktreePath, preferredTaskSessionId: preferredTaskSession?.session_id, preferredTaskTitle: preferredTaskSession?.task_title, backgroundManager: input.options?.backgroundManager, sessionState: input.sessionState, idleSettleMs: input.idleSettleMs, }) if (result === "injected") { if (input.sessionState.pendingRetryTimer) { clearTimeout(input.sessionState.pendingRetryTimer) input.sessionState.pendingRetryTimer = undefined } input.sessionState.lastContinuationInjectedAt = Date.now() return } if (result === "skipped_background_tasks") { scheduleRetry({ ctx: input.ctx, sessionID: input.sessionID, sessionState: input.sessionState, options: input.options, }) return } if (result === "failed") { scheduleRetry({ ctx: input.ctx, sessionID: input.sessionID, sessionState: input.sessionState, options: input.options, }) } } catch (error) { log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID: input.sessionID, error }) input.sessionState.promptFailureCount += 1 input.sessionState.lastFailureAt = Date.now() scheduleRetry({ ctx: input.ctx, sessionID: input.sessionID, sessionState: input.sessionState, options: input.options, }) } finally { input.sessionState.isInjectingContinuation = false } } function scheduleRetry(input: { ctx: PluginInput sessionID: string sessionState: SessionState options?: AtlasHookOptions }): void { const { ctx, sessionID, sessionState, options } = input if (sessionState.pendingRetryTimer) { return } sessionState.pendingRetryTimer = setTimeout(async () => { sessionState.pendingRetryTimer = undefined if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) return if (sessionState.waitingForFinalWaveApproval) return const now = Date.now() if ( sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS ) { return } const currentBoulder = readBoulderState(ctx.directory) if (!currentBoulder) return if (!currentBoulder.session_ids?.includes(sessionID)) return const currentProgress = getPlanProgress(resolveBoulderPlanPath(ctx.directory, currentBoulder)) if (currentProgress.isComplete) return if (options?.isContinuationStopped?.(sessionID)) return const canContinueSession = await canContinueTrackedBoulderSession({ client: ctx.client, sessionID, sessionOrigin: currentBoulder.session_origins?.[sessionID], boulderSessionIDs: currentBoulder.session_ids, requiredAgent: currentBoulder.agent, }) if (!canContinueSession) return if (hasRunningBackgroundTasks(sessionID, options)) { scheduleRetry({ ctx, sessionID, sessionState, options }) return } await injectContinuation({ ctx, sessionID, sessionState, options, planName: currentBoulder.plan_name, progress: currentProgress, agent: currentBoulder.agent, worktreePath: currentBoulder.worktree_path, }) }, RETRY_DELAY_MS) } export async function handleAtlasSessionIdle(input: { ctx: PluginInput options?: AtlasHookOptions getState: (sessionID: string) => SessionState sessionID: string }): Promise { const { ctx, options, getState, sessionID } = input const sessionState = getState(sessionID) log(`[${HOOK_NAME}] session.idle`, { sessionID }) const activeBoulderSession = await resolveActiveBoulderSession({ client: ctx.client, directory: ctx.directory, sessionID, }) if (!activeBoulderSession) { log(`[${HOOK_NAME}] Skipped: session not registered in active boulder`, { sessionID }) return } const { boulderState, progress, appendedSession } = activeBoulderSession if (progress.isComplete) { const work = getWorkForSession(ctx.directory, sessionID) if (work) { completeBoulder(ctx.directory, work.work_id) } else { completeBoulder(ctx.directory, boulderState.active_work_id) } if (!work || work.status === "abandoned") { log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) return } if (sessionState.boulderCompletionNudgedAt?.[work.work_id]) { log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) return } const elapsedMilliseconds = work.elapsed_ms ?? (Date.now() - new Date(work.started_at).getTime()) const elapsedHuman = formatDurationHuman(elapsedMilliseconds) const taskBreakdown = Object.values(work.task_sessions ?? {}) .sort((left, right) => { const leftSortValue = getTaskLabelSortValue(left.task_label) const rightSortValue = getTaskLabelSortValue(right.task_label) if (leftSortValue !== rightSortValue) { return leftSortValue - rightSortValue } return left.task_label.localeCompare(right.task_label) }) .map((task) => { if (typeof task.elapsed_ms === "number") { return `- ${task.task_label} ${task.task_title}: ${formatDurationHuman(task.elapsed_ms)}` } return `- ${task.task_label} ${task.task_title}: (no timing)` }) .join("\n") const prompt = BOULDER_COMPLETE_PROMPT .replace(/{PLAN_NAME}/g, work.plan_name) .replace(/{ELAPSED_HUMAN}/g, elapsedHuman) .replace(/{TASK_BREAKDOWN}/g, taskBreakdown.length > 0 ? taskBreakdown : "- (no task timings)") const atlasAgent = resolveRegisteredAgentName( boulderState.agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined), ) if (atlasAgent && isAgentRegistered(atlasAgent)) { if (!(await shouldPromptAfterSessionIdle(ctx.client, sessionID, options?.idleSettleMs))) { log(`[${HOOK_NAME}] Boulder completion nudge skipped because session is active`, { sessionID }) return } const promptResult = await promptAsyncAfterSessionIdle({ client: ctx.client, sessionID, source: HOOK_NAME, settleMs: options?.idleSettleMs, input: { path: { id: sessionID }, body: { agent: atlasAgent, parts: [createInternalAgentContinuationTextPart(prompt)], }, query: { directory: ctx.directory }, }, }) if (promptResult.status !== "dispatched") { log(`[${HOOK_NAME}] Boulder completion nudge skipped by promptAsync gate`, { sessionID, status: promptResult.status, }) return } sessionState.boulderCompletionNudgedAt = { ...(sessionState.boulderCompletionNudgedAt ?? {}), [work.work_id]: Date.now(), } } log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) return } if (appendedSession) { log(`[${HOOK_NAME}] Appended subagent session to boulder during idle`, { sessionID, plan: boulderState.plan_name, }) } const canContinueSession = await canContinueTrackedBoulderSession({ client: ctx.client, sessionID, sessionOrigin: boulderState.session_origins?.[sessionID], boulderSessionIDs: boulderState.session_ids, requiredAgent: boulderState.agent, }) if (!canContinueSession) { log(`[${HOOK_NAME}] Skipped: tracked descendant agent does not match boulder agent`, { sessionID, requiredAgent: boulderState.agent ?? "atlas", }) return } const now = Date.now() if (sessionState.waitingForFinalWaveApproval) { log(`[${HOOK_NAME}] Skipped: waiting for explicit final-wave approval`, { sessionID }) return } if (sessionState.lastEventWasAbortError) { sessionState.lastEventWasAbortError = false log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID }) return } if (sessionState.skipNextIdleAfterRuntimeErrorRetry) { sessionState.skipNextIdleAfterRuntimeErrorRetry = false log(`[${HOOK_NAME}] Skipped: stale idle after runtime error retry`, { sessionID }) return } if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) { const timeSinceLastFailure = sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY if (timeSinceLastFailure < FAILURE_BACKOFF_MS) { log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, { sessionID, promptFailureCount: sessionState.promptFailureCount, backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure, }) return } sessionState.promptFailureCount = 0 sessionState.lastFailureAt = undefined } if (hasRunningBackgroundTasks(sessionID, options)) { scheduleRetry({ ctx, sessionID, sessionState, options }) log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) return } if (options?.isContinuationStopped?.(sessionID)) { log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) return } if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) { scheduleRetry({ ctx, sessionID, sessionState, options }) log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { sessionID, cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - sessionState.lastContinuationInjectedAt), pendingRetry: !!sessionState.pendingRetryTimer, }) return } if (!(await shouldPromptAfterSessionIdle(ctx.client, sessionID, options?.idleSettleMs))) { log(`[${HOOK_NAME}] Skipped: session became active during idle settle`, { sessionID }) return } await injectContinuation({ ctx, sessionID, sessionState, options, planName: boulderState.plan_name, progress, agent: boulderState.agent, worktreePath: boulderState.worktree_path, idleSettleMs: options?.idleSettleMs ?? 0, }) } async function canContinueTrackedBoulderSession(input: { client: PluginInput["client"] sessionID: string sessionOrigin?: "direct" | "appended" boulderSessionIDs: string[] requiredAgent?: string }): Promise { const ancestorSessionIDs = input.boulderSessionIDs.filter((trackedSessionID) => trackedSessionID !== input.sessionID) if (ancestorSessionIDs.length === 0) { return true } const isTrackedDescendant = await isSessionInBoulderLineage({ client: input.client, sessionID: input.sessionID, boulderSessionIDs: ancestorSessionIDs, }) if (input.sessionOrigin === "direct") { return true } if (!isTrackedDescendant) { return false } const sessionAgent = await getLastAgentFromSession(input.sessionID, input.client) ?? getSessionAgent(input.sessionID) if (!sessionAgent) { return false } const requiredAgentKey = getAgentConfigKey(input.requiredAgent ?? "atlas") const sessionAgentKey = getAgentConfigKey(sessionAgent) return sessionAgentKey === requiredAgentKey || (requiredAgentKey === getAgentConfigKey("atlas") && sessionAgentKey === getAgentConfigKey("sisyphus")) }