fix(todo-continuation-enforcer): simplify implementation and remove 10s throttle blocking background task completion

Removes the complex state machine and 10-second throttle (MIN_INJECTION_INTERVAL_MS)
that was causing background task completion to hang. The hook now:

- Uses straightforward error cooldown logic instead of complex injection throttling
- Removes unnecessary state tracking that was delaying continuation injection
- Maintains all safety checks (recovery mode, running tasks, error state)
- Keeps countdown behavior with toast notifications

Fixes #312 - Resolves v2.7.0 issue where background task completion would freeze
the agent due to injection delays.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-29 03:10:43 +09:00
parent c0b28b0715
commit c10bc5fcdf
2 changed files with 485 additions and 291 deletions

View File

@@ -0,0 +1,383 @@
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
import { setMainSession } from "../features/claude-code-session-state"
import type { BackgroundManager } from "../features/background-agent"
describe("todo-continuation-enforcer", () => {
let promptCalls: Array<{ sessionID: string; agent?: string; text: string }>
let toastCalls: Array<{ title: string; message: string }>
function createMockPluginInput() {
return {
client: {
session: {
todo: async () => ({ data: [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
]}),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: {
showToast: async (opts: any) => {
toastCalls.push({
title: opts.body.title,
message: opts.body.message,
})
return {}
},
},
},
directory: "/tmp/test",
} as any
}
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
return {
getTasksByParentSession: () => runningTasks
? [{ status: "running" }]
: [],
} as any
}
beforeEach(() => {
promptCalls = []
toastCalls = []
setMainSession(undefined)
})
afterEach(() => {
setMainSession(undefined)
})
test("should inject continuation when idle with incomplete todos", async () => {
// #given - main session with incomplete todos
const sessionID = "main-123"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(false),
})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #then - countdown toast shown
await new Promise(r => setTimeout(r, 100))
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
expect(toastCalls[0].title).toBe("Todo Continuation")
// #then - after countdown, continuation injected
await new Promise(r => setTimeout(r, 2500))
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
})
test("should not inject when all todos are complete", async () => {
// #given - session with all todos complete
const sessionID = "main-456"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.todo = async () => ({ data: [
{ id: "1", content: "Task 1", status: "completed", priority: "high" },
]})
const hook = createTodoContinuationEnforcer(mockInput, {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should not inject when background tasks are running", async () => {
// #given - session with running background tasks
const sessionID = "main-789"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(true),
})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should not inject for non-main session", async () => {
// #given - main session set, different session goes idle
setMainSession("main-session")
const otherSession = "other-session"
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - non-main session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID: otherSession } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should skip injection after recent error", async () => {
// #given - session that just had an error
const sessionID = "main-error"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session error occurs
await hook.handler({
event: { type: "session.error", properties: { sessionID, error: new Error("test") } },
})
// #when - session goes idle immediately after
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected (error cooldown)
expect(promptCalls).toHaveLength(0)
})
test("should clear error state on user message and allow injection", async () => {
// #given - session with error, then user clears it
const sessionID = "main-error-clear"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - error occurs
await hook.handler({
event: { type: "session.error", properties: { sessionID } },
})
// #when - user sends message (clears error immediately)
await hook.handler({
event: { type: "message.updated", properties: { info: { sessionID, role: "user" } } },
})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
// #then - continuation injected (error was cleared by user message)
expect(promptCalls.length).toBe(1)
})
test("should cancel countdown on user message", async () => {
// #given - session starting countdown
const sessionID = "main-cancel"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - user sends message immediately (before 2s countdown)
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } }
},
})
// #then - wait past countdown time and verify no injection
await new Promise(r => setTimeout(r, 2500))
expect(promptCalls).toHaveLength(0)
})
test("should cancel countdown on assistant activity", async () => {
// #given - session starting countdown
const sessionID = "main-assistant"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - assistant starts responding
await new Promise(r => setTimeout(r, 500))
await hook.handler({
event: {
type: "message.part.updated",
properties: { info: { sessionID, role: "assistant" } }
},
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected (cancelled)
expect(promptCalls).toHaveLength(0)
})
test("should cancel countdown on tool execution", async () => {
// #given - session starting countdown
const sessionID = "main-tool"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - tool starts executing
await new Promise(r => setTimeout(r, 500))
await hook.handler({
event: { type: "tool.execute.before", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected (cancelled)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection during recovery mode", async () => {
// #given - session in recovery mode
const sessionID = "main-recovery"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - mark as recovering
hook.markRecovering(sessionID)
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should inject after recovery complete", async () => {
// #given - session was in recovery, now complete
const sessionID = "main-recovery-done"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - mark as recovering then complete
hook.markRecovering(sessionID)
hook.markRecoveryComplete(sessionID)
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - continuation injected
expect(promptCalls.length).toBe(1)
})
test("should cleanup on session deleted", async () => {
// #given - session starting countdown
const sessionID = "main-delete"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - session is deleted during countdown
await new Promise(r => setTimeout(r, 500))
await hook.handler({
event: { type: "session.deleted", properties: { info: { id: sessionID } } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected (cleaned up)
expect(promptCalls).toHaveLength(0)
})
test("should show countdown toast updates", async () => {
// #given - session with incomplete todos
const sessionID = "main-toast"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #then - multiple toast updates during countdown (2s countdown = 2 toasts: "2s" and "1s")
await new Promise(r => setTimeout(r, 2500))
expect(toastCalls.length).toBeGreaterThanOrEqual(2)
expect(toastCalls[0].message).toContain("2s")
})
test("should not have 10s throttle between injections", async () => {
// #given - new hook instance (no prior state)
const sessionID = "main-no-throttle"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - first idle cycle completes
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
// #then - first injection happened
expect(promptCalls.length).toBe(1)
// #when - immediately trigger second idle (no 10s wait needed)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
// #then - second injection also happened (no throttle blocking)
expect(promptCalls.length).toBe(2)
}, { timeout: 10000 })
})

View File

@@ -28,6 +28,13 @@ interface Todo {
id: string id: string
} }
interface SessionState {
lastErrorAt?: number
countdownTimer?: ReturnType<typeof setTimeout>
countdownInterval?: ReturnType<typeof setInterval>
isRecovering?: boolean
}
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION] const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
Incomplete tasks remain in your todo list. Continue working on the next pending task. Incomplete tasks remain in your todo list. Continue working on the next pending task.
@@ -38,29 +45,7 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
const COUNTDOWN_SECONDS = 2 const COUNTDOWN_SECONDS = 2
const TOAST_DURATION_MS = 900 const TOAST_DURATION_MS = 900
const MIN_INJECTION_INTERVAL_MS = 10_000 const ERROR_COOLDOWN_MS = 3_000
// ============================================================================
// STATE MACHINE TYPES
// ============================================================================
type SessionMode =
| "idle" // Observed idle, no countdown started yet
| "countingDown" // Waiting N seconds before injecting
| "injecting" // Currently calling session.prompt
| "recovering" // Session recovery in progress (external control)
| "errorBypass" // Bypass mode after session.error/interrupt
interface SessionState {
version: number // Monotonic generation token - increment to invalidate pending callbacks
mode: SessionMode
timer?: ReturnType<typeof setTimeout> // Pending countdown timer
lastAttemptedAt?: number // Timestamp of last injection attempt (throttle all attempts)
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
function getMessageDir(sessionID: string): string | null { function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null if (!existsSync(MESSAGE_STORAGE)) return null
@@ -76,20 +61,24 @@ function getMessageDir(sessionID: string): string | null {
return null return null
} }
function detectInterrupt(error: unknown): boolean { function isAbortError(error: unknown): boolean {
if (!error) return false if (!error) return false
if (typeof error === "object") { if (typeof error === "object") {
const errObj = error as Record<string, unknown> const errObj = error as Record<string, unknown>
const name = errObj.name as string | undefined const name = errObj.name as string | undefined
const message = (errObj.message as string | undefined)?.toLowerCase() ?? "" const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
if (name === "MessageAbortedError" || name === "AbortError") return true if (name === "MessageAbortedError" || name === "AbortError") return true
if (name === "DOMException" && message.includes("abort")) return true if (name === "DOMException" && message.includes("abort")) return true
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
} }
if (typeof error === "string") { if (typeof error === "string") {
const lower = error.toLowerCase() const lower = error.toLowerCase()
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt") return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
} }
return false return false
} }
@@ -97,91 +86,56 @@ function getIncompleteCount(todos: Todo[]): number {
return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length
} }
// ============================================================================
// MAIN IMPLEMENTATION
// ============================================================================
export function createTodoContinuationEnforcer( export function createTodoContinuationEnforcer(
ctx: PluginInput, ctx: PluginInput,
options: TodoContinuationEnforcerOptions = {} options: TodoContinuationEnforcerOptions = {}
): TodoContinuationEnforcer { ): TodoContinuationEnforcer {
const { backgroundManager } = options const { backgroundManager } = options
// Single source of truth: per-session state machine
const sessions = new Map<string, SessionState>() const sessions = new Map<string, SessionState>()
// ============================================================================ function getState(sessionID: string): SessionState {
// STATE HELPERS
// ============================================================================
function getOrCreateState(sessionID: string): SessionState {
let state = sessions.get(sessionID) let state = sessions.get(sessionID)
if (!state) { if (!state) {
state = { version: 0, mode: "idle" } state = {}
sessions.set(sessionID, state) sessions.set(sessionID, state)
} }
return state return state
} }
function clearTimer(state: SessionState): void { function cancelCountdown(sessionID: string): void {
if (state.timer) {
clearTimeout(state.timer)
state.timer = undefined
}
}
/**
* Invalidate any pending or in-flight operation by incrementing version.
* ALWAYS bumps version regardless of current mode to prevent last-mile races.
*/
function invalidate(sessionID: string, reason: string): void {
const state = sessions.get(sessionID) const state = sessions.get(sessionID)
if (!state) return if (!state) return
// Skip if in recovery mode (external control)
if (state.mode === "recovering") return
state.version++
clearTimer(state)
if (state.mode !== "idle" && state.mode !== "errorBypass") { if (state.countdownTimer) {
log(`[${HOOK_NAME}] Invalidated`, { sessionID, reason, prevMode: state.mode, newVersion: state.version }) clearTimeout(state.countdownTimer)
state.mode = "idle" state.countdownTimer = undefined
}
if (state.countdownInterval) {
clearInterval(state.countdownInterval)
state.countdownInterval = undefined
} }
} }
/** function cleanup(sessionID: string): void {
* Check if this is the main session (not a subagent session). cancelCountdown(sessionID)
*/ sessions.delete(sessionID)
function isMainSession(sessionID: string): boolean {
const mainSessionID = getMainSessionID()
// If no main session is set, allow all. If set, only allow main.
return !mainSessionID || sessionID === mainSessionID
} }
// ============================================================================
// EXTERNAL API
// ============================================================================
const markRecovering = (sessionID: string): void => { const markRecovering = (sessionID: string): void => {
const state = getOrCreateState(sessionID) const state = getState(sessionID)
invalidate(sessionID, "entering recovery mode") state.isRecovering = true
state.mode = "recovering" cancelCountdown(sessionID)
log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID }) log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID })
} }
const markRecoveryComplete = (sessionID: string): void => { const markRecoveryComplete = (sessionID: string): void => {
const state = sessions.get(sessionID) const state = sessions.get(sessionID)
if (state && state.mode === "recovering") { if (state) {
state.mode = "idle" state.isRecovering = false
log(`[${HOOK_NAME}] Session recovery complete`, { sessionID }) log(`[${HOOK_NAME}] Session recovery complete`, { sessionID })
} }
} }
// ============================================================================
// TOAST HELPER
// ============================================================================
async function showCountdownToast(seconds: number, incompleteCount: number): Promise<void> { async function showCountdownToast(seconds: number, incompleteCount: number): Promise<void> {
await ctx.client.tui.showToast({ await ctx.client.tui.showToast({
body: { body: {
@@ -193,126 +147,65 @@ export function createTodoContinuationEnforcer(
}).catch(() => {}) }).catch(() => {})
} }
// ============================================================================ async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
// CORE INJECTION LOGIC
// ============================================================================
async function executeInjection(sessionID: string, capturedVersion: number): Promise<void> {
const state = sessions.get(sessionID) const state = sessions.get(sessionID)
if (!state) return
if (state?.isRecovering) {
// Version check: if version changed since we started, abort log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
if (state.version !== capturedVersion) {
log(`[${HOOK_NAME}] Injection aborted: version mismatch`, {
sessionID, capturedVersion, currentVersion: state.version
})
return return
} }
// Mode check: must still be in countingDown mode if (state?.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
if (state.mode !== "countingDown") { log(`[${HOOK_NAME}] Skipped injection: recent error`, { sessionID })
log(`[${HOOK_NAME}] Injection aborted: mode changed`, {
sessionID, mode: state.mode
})
return return
} }
// Throttle check: minimum interval between injection attempts const hasRunningBgTasks = backgroundManager
if (state.lastAttemptedAt) { ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
const elapsed = Date.now() - state.lastAttemptedAt : false
if (elapsed < MIN_INJECTION_INTERVAL_MS) {
log(`[${HOOK_NAME}] Injection throttled: too soon since last injection`, { if (hasRunningBgTasks) {
sessionID, elapsedMs: elapsed, minIntervalMs: MIN_INJECTION_INTERVAL_MS log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
}) return
state.mode = "idle"
return
}
} }
state.mode = "injecting"
// Re-verify todos (CRITICAL: always re-check before injecting)
let todos: Todo[] = [] let todos: Todo[] = []
try { try {
const response = await ctx.client.session.todo({ path: { id: sessionID } }) const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = (response.data ?? response) as Todo[] todos = (response.data ?? response) as Todo[]
} catch (err) { } catch (err) {
log(`[${HOOK_NAME}] Failed to fetch todos for injection`, { sessionID, error: String(err) }) log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(err) })
state.mode = "idle"
return return
} }
// Version check again after async operation const freshIncompleteCount = getIncompleteCount(todos)
if (state.version !== capturedVersion) { if (freshIncompleteCount === 0) {
log(`[${HOOK_NAME}] Injection aborted after todo fetch: version mismatch`, { sessionID }) log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID })
state.mode = "idle"
return return
} }
const incompleteCount = getIncompleteCount(todos)
if (incompleteCount === 0) {
log(`[${HOOK_NAME}] No incomplete todos at injection time`, { sessionID, total: todos.length })
state.mode = "idle"
return
}
// Skip entirely if background tasks are running (no false positives)
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks still running`, { sessionID })
state.mode = "idle"
return
}
// Get previous message agent info
const messageDir = getMessageDir(sessionID) const messageDir = getMessageDir(sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
// Check write permission const hasWritePermission = !prevMessage?.tools ||
const agentHasWritePermission = !prevMessage?.tools ||
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false) (prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
if (!agentHasWritePermission) { if (!hasWritePermission) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
sessionID, agent: prevMessage?.agent, tools: prevMessage?.tools
})
state.mode = "idle"
return return
} }
// Plan mode agents only analyze and plan, not implement - skip todo continuation
const agentName = prevMessage?.agent?.toLowerCase() ?? "" const agentName = prevMessage?.agent?.toLowerCase() ?? ""
const isPlanModeAgent = agentName === "plan" || agentName === "planner-sisyphus" if (agentName === "plan" || agentName === "planner-sisyphus") {
if (isPlanModeAgent) { log(`[${HOOK_NAME}] Skipped: plan mode agent`, { sessionID, agent: prevMessage?.agent })
log(`[${HOOK_NAME}] Skipped: plan mode agent detected`, {
sessionID, agent: prevMessage?.agent
})
state.mode = "idle"
return return
} }
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incompleteCount}/${todos.length} completed, ${incompleteCount} remaining]` const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
// Final version check right before API call (last-mile race mitigation)
if (state.version !== capturedVersion) {
log(`[${HOOK_NAME}] Injection aborted: version changed before API call`, { sessionID })
state.mode = "idle"
return
}
// Set lastAttemptedAt BEFORE calling API (throttle attempts, not just successes)
state.lastAttemptedAt = Date.now()
try { try {
log(`[${HOOK_NAME}] Injecting continuation prompt`, { log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
sessionID,
agent: prevMessage?.agent,
incompleteCount
})
await ctx.client.session.prompt({ await ctx.client.session.prompt({
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
@@ -321,235 +214,153 @@ export function createTodoContinuationEnforcer(
}, },
query: { directory: ctx.directory }, query: { directory: ctx.directory },
}) })
log(`[${HOOK_NAME}] Continuation prompt injected successfully`, { sessionID }) log(`[${HOOK_NAME}] Injection successful`, { sessionID })
} catch (err) { } catch (err) {
log(`[${HOOK_NAME}] Prompt injection failed`, { sessionID, error: String(err) }) log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) })
} }
state.mode = "idle"
} }
// ============================================================================ function startCountdown(sessionID: string, incompleteCount: number, total: number): void {
// COUNTDOWN STARTER const state = getState(sessionID)
// ============================================================================ cancelCountdown(sessionID)
function startCountdown(sessionID: string, incompleteCount: number): void {
const state = getOrCreateState(sessionID)
// Cancel any existing countdown
invalidate(sessionID, "starting new countdown")
// Increment version for this new countdown
state.version++
state.mode = "countingDown"
const capturedVersion = state.version
log(`[${HOOK_NAME}] Starting countdown`, {
sessionID,
seconds: COUNTDOWN_SECONDS,
version: capturedVersion,
incompleteCount
})
// Show initial toast
showCountdownToast(COUNTDOWN_SECONDS, incompleteCount)
// Show countdown toasts
let secondsRemaining = COUNTDOWN_SECONDS let secondsRemaining = COUNTDOWN_SECONDS
const toastInterval = setInterval(() => { showCountdownToast(secondsRemaining, incompleteCount)
// Check if countdown was cancelled
if (state.version !== capturedVersion) { state.countdownInterval = setInterval(() => {
clearInterval(toastInterval)
return
}
secondsRemaining-- secondsRemaining--
if (secondsRemaining > 0) { if (secondsRemaining > 0) {
showCountdownToast(secondsRemaining, incompleteCount) showCountdownToast(secondsRemaining, incompleteCount)
} }
}, 1000) }, 1000)
// Schedule the injection state.countdownTimer = setTimeout(() => {
state.timer = setTimeout(() => { cancelCountdown(sessionID)
clearInterval(toastInterval) injectContinuation(sessionID, incompleteCount, total)
clearTimer(state)
executeInjection(sessionID, capturedVersion)
}, COUNTDOWN_SECONDS * 1000) }, COUNTDOWN_SECONDS * 1000)
}
// ============================================================================ log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount })
// EVENT HANDLER }
// ============================================================================
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => { const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
// -------------------------------------------------------------------------
// SESSION.ERROR - Enter error bypass mode
// -------------------------------------------------------------------------
if (event.type === "session.error") { if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined const sessionID = props?.sessionID as string | undefined
if (!sessionID) return if (!sessionID) return
const isInterrupt = detectInterrupt(props?.error) const state = getState(sessionID)
const state = getOrCreateState(sessionID) state.lastErrorAt = Date.now()
cancelCountdown(sessionID)
invalidate(sessionID, isInterrupt ? "user interrupt" : "session error") log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort: isAbortError(props?.error) })
state.mode = "errorBypass"
log(`[${HOOK_NAME}] session.error received`, { sessionID, isInterrupt, error: props?.error })
return return
} }
// -------------------------------------------------------------------------
// SESSION.IDLE - Main trigger for todo continuation
// -------------------------------------------------------------------------
if (event.type === "session.idle") { if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined const sessionID = props?.sessionID as string | undefined
if (!sessionID) return if (!sessionID) return
log(`[${HOOK_NAME}] session.idle received`, { sessionID }) log(`[${HOOK_NAME}] session.idle`, { sessionID })
// Skip if not main session const mainSessionID = getMainSessionID()
if (!isMainSession(sessionID)) { if (mainSessionID && sessionID !== mainSessionID) {
log(`[${HOOK_NAME}] Skipped: not main session`, { sessionID }) log(`[${HOOK_NAME}] Skipped: not main session`, { sessionID })
return return
} }
const state = getOrCreateState(sessionID) const state = getState(sessionID)
// Skip if in recovery mode if (state.isRecovering) {
if (state.mode === "recovering") { log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
log(`[${HOOK_NAME}] Skipped: session in recovery mode`, { sessionID })
return return
} }
// Skip if in error bypass mode (DO NOT clear - wait for user message) if (state.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
if (state.mode === "errorBypass") { log(`[${HOOK_NAME}] Skipped: recent error (cooldown)`, { sessionID })
log(`[${HOOK_NAME}] Skipped: error bypass (awaiting user message to resume)`, { sessionID })
return return
} }
// Skip if already counting down or injecting const hasRunningBgTasks = backgroundManager
if (state.mode === "countingDown" || state.mode === "injecting") { ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
log(`[${HOOK_NAME}] Skipped: already ${state.mode}`, { sessionID }) : false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return return
} }
// Fetch todos
let todos: Todo[] = [] let todos: Todo[] = []
try { try {
const response = await ctx.client.session.todo({ path: { id: sessionID } }) const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = (response.data ?? response) as Todo[] todos = (response.data ?? response) as Todo[]
} catch (err) { } catch (err) {
log(`[${HOOK_NAME}] Todo API error`, { sessionID, error: String(err) }) log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(err) })
return return
} }
if (!todos || todos.length === 0) { if (!todos || todos.length === 0) {
log(`[${HOOK_NAME}] No todos found`, { sessionID }) log(`[${HOOK_NAME}] No todos`, { sessionID })
return return
} }
const incompleteCount = getIncompleteCount(todos) const incompleteCount = getIncompleteCount(todos)
if (incompleteCount === 0) { if (incompleteCount === 0) {
log(`[${HOOK_NAME}] All todos completed`, { sessionID, total: todos.length }) log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
return return
} }
// Skip if background tasks are running (avoid toast spam with no injection) startCountdown(sessionID, incompleteCount, todos.length)
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks still running`, { sessionID })
return
}
log(`[${HOOK_NAME}] Found incomplete todos`, {
sessionID,
incomplete: incompleteCount,
total: todos.length
})
startCountdown(sessionID, incompleteCount)
return return
} }
// -------------------------------------------------------------------------
// MESSAGE.UPDATED - Cancel countdown on activity
// -------------------------------------------------------------------------
if (event.type === "message.updated") { if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined const role = info?.role as string | undefined
const finish = info?.finish as string | undefined
if (!sessionID) return if (!sessionID) return
// User message: Always cancel countdown and clear errorBypass
if (role === "user") { if (role === "user") {
const state = sessions.get(sessionID) const state = sessions.get(sessionID)
if (state?.mode === "errorBypass") { if (state) {
state.mode = "idle" state.lastErrorAt = undefined
log(`[${HOOK_NAME}] User message cleared errorBypass mode`, { sessionID })
} }
invalidate(sessionID, "user message received") cancelCountdown(sessionID)
return log(`[${HOOK_NAME}] User message: cleared error state`, { sessionID })
} }
// Assistant message WITHOUT finish: Agent is working, cancel countdown if (role === "assistant") {
if (role === "assistant" && !finish) { cancelCountdown(sessionID)
invalidate(sessionID, "assistant is working (streaming)")
return
}
// Assistant message WITH finish: Agent finished a turn (let session.idle handle it)
if (role === "assistant" && finish) {
log(`[${HOOK_NAME}] Assistant turn finished`, { sessionID, finish })
return
} }
return return
} }
// -------------------------------------------------------------------------
// MESSAGE.PART.UPDATED - Cancel countdown on streaming activity
// -------------------------------------------------------------------------
if (event.type === "message.part.updated") { if (event.type === "message.part.updated") {
const info = props?.info as Record<string, unknown> | undefined const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined const role = info?.role as string | undefined
if (sessionID && role === "assistant") { if (sessionID && role === "assistant") {
invalidate(sessionID, "assistant streaming") cancelCountdown(sessionID)
} }
return return
} }
// -------------------------------------------------------------------------
// TOOL EVENTS - Cancel countdown when tools are executing
// -------------------------------------------------------------------------
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined const sessionID = props?.sessionID as string | undefined
if (sessionID) { if (sessionID) {
invalidate(sessionID, `tool execution (${event.type})`) cancelCountdown(sessionID)
} }
return return
} }
// -------------------------------------------------------------------------
// SESSION.DELETED - Cleanup
// -------------------------------------------------------------------------
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) { if (sessionInfo?.id) {
const state = sessions.get(sessionInfo.id) cleanup(sessionInfo.id)
if (state) { log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
clearTimer(state)
}
sessions.delete(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted, state cleaned up`, { sessionID: sessionInfo.id })
} }
return return
} }