import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { loadPiToolsSuiteConfig, type TodoThinkingLevel as ConfigTodoThinkingLevel } from "../config.js"; import { isAgentBusyRaceError } from "../context-usage.js"; import { hasAvailableIndexedProjectRoot } from "../lib/project.js"; import { autoClearCompletedTodos } from "./state/auto-clear.js"; import { loadPersistedPlan, syncPersistedPlan } from "./state/persistence.js"; import { replayFromBranch } from "./state/replay.js"; import { ACTIVE_STATUSES, isTaskBlocked, selectVisibleTasks } from "./state/selectors.js"; import { applyTaskMutation } from "./state/state-reducer.js"; import { getState, replaceState } from "./state/store.js"; import { activateTodoStateScope, appendTodoStateSnapshot, DEFAULT_PROMPT_GUIDELINES, DEFAULT_PROMPT_SNIPPET, publishTodoState, registerTodosCommand, registerTodoTool, todoStateScopeFromContext, } from "./todo.js"; import { TODO_THINKING_LEVEL_VALUES, todoParamsSchemaForThinkingLevels, type Task, type TaskMutationParams, type TodoThinkingLevel, } from "./tool/types.js"; type AgentMessageLike = { role?: unknown; stopReason?: unknown; content?: unknown }; const TODO_NUDGE_LIMIT = 8; const TODO_NUDGE_INITIAL_DELAY_MS = 0; const TODO_NUDGE_IDLE_RETRY_DELAY_MS = 100; const TODO_NUDGE_MAX_IDLE_ATTEMPTS = 40; const ASK_USER_TOOL_NAMES = new Set(["ask_user", "ask_user_question", "question"]); const TODO_THINKING_RESTORE_METADATA_KEY = "__piTodoRestoreThinking"; const REPO_KNOWLEDGE_FINALIZATION_REMINDER = "πŸ“š Before completing the final todo: if behavior/contracts changed, reconcile affected specs and repo knowledge; skip for mechanical changes."; function completesTodo(info: { action: string; params: TaskMutationParams }): boolean { if (info.action === "update") return info.params.status === "completed"; if (info.action !== "batch_update") return false; return (info.params.items ?? []).some((item) => item.status === "completed"); } function repoKnowledgeFinalizationReminder( state: ReturnType, ctx: ExtensionContext, info: { action: string; params: TaskMutationParams }, ): string | undefined { if (!hasAvailableIndexedProjectRoot(ctx.cwd)) return undefined; if (!completesTodo(info)) return undefined; const remainingActive = selectVisibleTasks(state).filter((task) => ACTIVE_STATUSES.has(task.status)); return remainingActive.length === 1 ? REPO_KNOWLEDGE_FINALIZATION_REMINDER : undefined; } function isStaleExtensionContextError(error: unknown): boolean { return error instanceof Error && /ctx is stale|stale ctx|stale after session replacement|stale after.*reload/i.test(error.message); } type ModelLike = { provider?: string; providerId?: string; id?: string; modelId?: string; reasoning?: boolean; thinkingLevelMap?: Partial>; compat?: { thinkingFormat?: unknown }; }; function escapeRegExp(text: string): string { return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); } function modelPatternMatches(pattern: string, candidate: string): boolean { let source = "^"; for (const char of pattern) { if (char === "*") source += ".*"; else if (char === "?") source += "."; else source += escapeRegExp(char); } return new RegExp(`${source}$`, "i").test(candidate); } function modelKeys(model: unknown): { bare?: string; full?: string } { const candidate = model as ModelLike | undefined; const provider = candidate?.provider ?? candidate?.providerId; const rawId = candidate?.modelId ?? candidate?.id; if (!rawId) return {}; if (rawId.includes("/")) { const slash = rawId.indexOf("/"); return { bare: rawId.slice(slash + 1), full: rawId }; } return { bare: rawId, ...(provider ? { full: `${provider}/${rawId}` } : {}) }; } function resolveTodoThinkingOverride( model: unknown, overrides: Record, ): TodoThinkingLevel | undefined { const keys = modelKeys(model); let best: { level: TodoThinkingLevel; score: number } | undefined; for (const [rawPattern, level] of Object.entries(overrides)) { const pattern = rawPattern.trim(); const isFull = pattern.includes("/"); const candidate = isFull ? keys.full : keys.bare; if (!candidate || !modelPatternMatches(pattern, candidate)) continue; const exact = !pattern.includes("*") && !pattern.includes("?"); const literalLength = pattern.replace(/[?*]/g, "").length; const score = (isFull ? 30_000 : 10_000) + (exact ? 10_000 : 0) + literalLength; if (!best || score >= best.score) best = { level, score }; } return best?.level; } function isTodoThinkingLevel(value: unknown): value is TodoThinkingLevel { return TODO_THINKING_LEVEL_VALUES.includes(value as TodoThinkingLevel); } function isGlm53TodoModel(model: ModelLike | undefined): boolean { if (!model) return false; const id = model.modelId ?? model.id; if (id !== "glm-5.3") return false; return model.provider === "zai" || model.provider === "zai-coding-cn" || model.compat?.thinkingFormat === "zai"; } function getAvailableTodoThinkingLevels(model: unknown): TodoThinkingLevel[] { const m = model as ModelLike | undefined; if (!m?.reasoning) return ["off"]; if (isGlm53TodoModel(m)) return ["low", "high", "max"]; const map = m.thinkingLevelMap; return TODO_THINKING_LEVEL_VALUES.filter((level) => { const mapped = map?.[level]; if (mapped === null) return false; if (level === "xhigh" || level === "max") return mapped !== undefined; return true; }); } function buildThinkingPromptParts(model: unknown): { promptSnippet?: string; promptGuidelines?: string[] } { const levels = getAvailableTodoThinkingLevels(model); if (levels.length <= 1) return {}; const lowEffortWording = levels.includes("off") ? "lower/off" : "lower"; return { promptSnippet: `${DEFAULT_PROMPT_SNIPPET} Set per-item thinking: ${levels.join("|")}.`.trim(), promptGuidelines: [ ...DEFAULT_PROMPT_GUIDELINES, `If todoThinking is enabled, set \`thinking\` on every planned task during create/batch_create (or update); choose from ${levels.join(", ")}. Use higher thinking for investigation, hard debugging, risky edits, or review; use ${lowEffortWording} for mechanical steps and the final report. Never leave it unset in a non-trivial plan.`, ], }; } function normalizeTodoThinkingLevelForModel(model: unknown, level: TodoThinkingLevel): TodoThinkingLevel { const available = getAvailableTodoThinkingLevels(model); if (available.includes(level)) return level; const requestedIndex = TODO_THINKING_LEVEL_VALUES.indexOf(level); for (let index = requestedIndex; index < TODO_THINKING_LEVEL_VALUES.length; index += 1) { const candidate = TODO_THINKING_LEVEL_VALUES[index]; if (candidate && available.includes(candidate)) return candidate; } for (let index = requestedIndex - 1; index >= 0; index -= 1) { const candidate = TODO_THINKING_LEVEL_VALUES[index]; if (candidate && available.includes(candidate)) return candidate; } return available[0] ?? "off"; } function isAskUserToolName(toolName: string): boolean { return ASK_USER_TOOL_NAMES.has(toolName); } function getUnfinishedTodoNudge(): { signature: string; message: string } | undefined { const visible = selectVisibleTasks(getState()); const byId = new Map(visible.map((task) => [task.id, task])); const unfinished = visible.filter((task) => ACTIVE_STATUSES.has(task.status) && !isTaskBlocked(task, byId)); if (unfinished.length === 0) return undefined; const signature = JSON.stringify( unfinished.map((task) => ({ id: task.id, status: task.status, subject: task.subject, activeForm: task.activeForm, blockedBy: task.blockedBy ?? [], })), ); const shown = unfinished.slice(0, TODO_NUDGE_LIMIT); const hidden = unfinished.length - shown.length; const lines = shown.map((task) => { const activeForm = task.activeForm ? ` β€” ${task.activeForm}` : ""; const blockedBy = task.blockedBy && task.blockedBy.length > 0 ? ` (blocked by #${task.blockedBy.join(", #")})` : ""; return `- #${task.id} [${task.status}] ${task.subject}${activeForm}${blockedBy}`; }); if (hidden > 0) lines.push(`- …and ${hidden} more unfinished todo item${hidden === 1 ? "" : "s"}.`); return { signature, message: [ "Todo auto-nudge: unfinished todo items remain after your last response.", "Continue working on them now. Pick exactly one pending/in_progress item, mark it in_progress if needed, make concrete progress, and update or complete todos immediately as work changes.", "If the user added/removed/canceled requirements or changed goal/scope/approach, or if discovered facts make the current plan stale/incomplete/impossible, synchronize todos first: update still-relevant items, defer/delete obsolete ones, add new tasks, and adjust blockers/order.", "If progress is waiting on user-supplied data, clarification, or a decision, defer the affected plan/todos before your final response instead of leaving them pending/in_progress, so auto-nudge stops until the user replies.", "For non-user blockers, leave the current item in_progress and create/update a blocker task instead of stopping.", "", ...lines, ].join("\n"), }; } function getPersistedPlanPrompt(path: string): string | undefined { const unfinished = selectVisibleTasks(getState()).filter((task) => task.status !== "completed"); if (unfinished.length === 0) return undefined; const lines = unfinished.slice(0, TODO_NUDGE_LIMIT).map((task) => { const activeForm = task.activeForm ? ` β€” ${task.activeForm}` : ""; const blockedBy = task.blockedBy?.length ? ` (blocked by #${task.blockedBy.join(", #")})` : ""; return `- #${task.id} [${task.status}] ${task.subject}${activeForm}${blockedBy}`; }); if (unfinished.length > TODO_NUDGE_LIMIT) lines.push(`- …and ${unfinished.length - TODO_NUDGE_LIMIT} more unfinished todo item(s).`); return [ `Persisted todo plan loaded from ${path}.`, "Ask the user which item(s) to continue before implementing. Offer a compact choice list based on these active tasks; do not dump the full plan unless asked.", "After the user chooses a scope, run /todos scope so out-of-scope active tasks become deferred and stop triggering auto-nudges.", "Before resuming implementation, synchronize the loaded plan if the user's current goal/scope/requirements changed or discovered facts make it stale/incomplete: update still-relevant items, defer/delete obsolete ones, add new tasks, and adjust blockers/order.", "Use /todos persist off (or clear) when the plan should be discarded; when all visible tasks are completed, persistence will be disabled and the plan file removed automatically.", "", ...lines, ].join("\n"); } function emitPersistedPlanPrompt(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string): void { if (ctx.hasUI) { try { pi.sendUserMessage(prompt); } catch (error) { if (isStaleExtensionContextError(error)) return; throw error; } return; } console.log(prompt); } export default function (pi: ExtensionAPI) { let currentModel: unknown; const todoConfig = loadPiToolsSuiteConfig(["todo"]); const todoThinkingEnabled = todoConfig.todoThinking; const todoThinkingOverrides = todoConfig.todoThinkingOverrides; const baselineThinkingByScope = new Map(); let lastNudgedSignature: string | undefined; let nudgeTimer: ReturnType | undefined; const pendingAskUserToolCallIds = new Set(); let suppressNextNudgeForThinkingSwitch = false; let inProgressAtAgentStart = new Set(); // agent_end may be followed by an automatic retry, compaction, or queued // continuation. Remember only whether its terminal assistant reply was a // successful visible response; schedule a nudge later on agent_settled. let settledNudgeEligible = false; function registerTodoToolWithCurrentPrompt(): void { const availableThinkingLevels = todoThinkingEnabled ? getAvailableTodoThinkingLevels(currentModel) : undefined; const thinkingPrompt = todoThinkingEnabled ? buildThinkingPromptParts(currentModel) : {}; registerTodoTool(pi, { ...thinkingPrompt, ...(availableThinkingLevels ? { parameters: todoParamsSchemaForThinkingLevels(availableThinkingLevels) } : {}), resultReminder: repoKnowledgeFinalizationReminder, prepareMutation: (state, ctx, info) => { if (!todoThinkingEnabled) return info.params; if (info.action === "create" || info.action === "update") return prepareTodoThinkingMutation(state, ctx, info.params); if (info.action === "batch_create" || info.action === "batch_update") { return { ...info.params, items: (info.params.items ?? []).map((item) => prepareTodoThinkingMutation(state, ctx, item)), }; } return info.params; }, afterCommit: async (state, ctx, info) => { if (todoThinkingEnabled) applyTodoThinkingAfterCommit(state, ctx, info); try { const sync = syncPersistedPlan(ctx.cwd, info.committedState); if (sync?.completed) console.log(`rpiv-todo: completed persisted plan and removed ${sync.path}`); } catch (err) { console.warn(`rpiv-todo: failed to sync persisted plan β€” ${(err as Error).message}`); } }, }); } function applyTodoThinkingAfterCommit( state: ReturnType, ctx: ExtensionContext, info: { action: string; params: TaskMutationParams }, ): void { const mutations = getTodoThinkingMutations(info.action, info.params); const activeTasks = state.tasks.filter((task) => task.status === "in_progress" && task.thinking !== undefined); if (activeTasks.length === 0) { restoreTodoThinkingBaseline(state, ctx); return; } rememberTodoThinkingBaseline(state, ctx); const explicitlyActivated = [...mutations].reverse().find((mutation) => { if (mutation.id === undefined || (mutation.status !== "in_progress" && mutation.thinking === undefined)) return false; return activeTasks.some((task) => task.id === mutation.id); }); let target: Task | undefined; if (explicitlyActivated) target = activeTasks.find((task) => task.id === explicitlyActivated.id); else if (activeTasks.length === 1) [target] = activeTasks; if (target?.thinking) switchToTaskThinking(target.thinking); } function getTodoThinkingMutations(action: string, params: TaskMutationParams): TaskMutationParams[] { if (action === "update") return [params]; if (action === "batch_update") return params.items ?? []; return []; } function getCurrentThinkingLevel(): TodoThinkingLevel | undefined { const level = (pi as { getThinkingLevel?: () => unknown }).getThinkingLevel?.(); return isTodoThinkingLevel(level) ? level : undefined; } function thinkingScopeKey(ctx: ExtensionContext): string { return todoStateScopeFromContext(ctx) ?? "__default__"; } function storedRestoreThinking(task: Task): TodoThinkingLevel | undefined { const stored = task.metadata?.[TODO_THINKING_RESTORE_METADATA_KEY]; return isTodoThinkingLevel(stored) ? stored : undefined; } function storedTodoThinkingBaseline(state: ReturnType, activeOnly: boolean): TodoThinkingLevel | undefined { for (const task of state.tasks) { if (activeOnly && task.status !== "in_progress") continue; const stored = storedRestoreThinking(task); if (stored) return stored; } return undefined; } function getTodoThinkingBaseline(state: ReturnType, ctx: ExtensionContext): TodoThinkingLevel | undefined { return baselineThinkingByScope.get(thinkingScopeKey(ctx)) ?? storedTodoThinkingBaseline(state, true); } function rememberTodoThinkingBaseline(state: ReturnType, ctx: ExtensionContext): void { const scopeKey = thinkingScopeKey(ctx); if (baselineThinkingByScope.has(scopeKey)) return; const baseline = storedTodoThinkingBaseline(state, true); if (baseline) baselineThinkingByScope.set(scopeKey, baseline); } function syncTodoThinkingBaseline(state: ReturnType, ctx: ExtensionContext): void { const scopeKey = thinkingScopeKey(ctx); const baseline = storedTodoThinkingBaseline(state, true); if (baseline) baselineThinkingByScope.set(scopeKey, baseline); else baselineThinkingByScope.delete(scopeKey); } function prepareTodoThinkingMutation(state: ReturnType, ctx: ExtensionContext, params: TaskMutationParams): TaskMutationParams { let nextParams = params; const configuredOverride = resolveTodoThinkingOverride(currentModel, todoThinkingOverrides); if (configuredOverride !== undefined) { const forced = normalizeTodoThinkingLevelForModel(currentModel, configuredOverride); nextParams = { ...nextParams, thinking: forced }; } else if (params.thinking !== undefined) { const normalized = normalizeTodoThinkingLevelForModel(currentModel, params.thinking); if (normalized !== params.thinking) nextParams = { ...nextParams, thinking: normalized }; } if (nextParams.id === undefined) return nextParams; const current = state.tasks.find((task) => task.id === nextParams.id); if (!current) return nextParams; const nextStatus = nextParams.status ?? current.status; if (nextStatus === "in_progress" && nextParams.thinking === undefined && current.thinking !== undefined) { const normalized = normalizeTodoThinkingLevelForModel(currentModel, current.thinking); if (normalized !== current.thinking) nextParams = { ...nextParams, thinking: normalized }; } const nextThinking = nextParams.thinking ?? current.thinking; const shouldCapturePreviousThinking = nextStatus === "in_progress" && nextThinking !== undefined && (current.status !== "in_progress" || nextParams.thinking !== undefined); if (!shouldCapturePreviousThinking) return nextParams; const baselineThinking = getTodoThinkingBaseline(state, ctx) ?? getCurrentThinkingLevel(); if (!baselineThinking) return nextParams; return { ...nextParams, metadata: { ...(nextParams.metadata ?? {}), [TODO_THINKING_RESTORE_METADATA_KEY]: baselineThinking, }, }; } function switchToTaskThinking(level: TodoThinkingLevel): void { if (!getAvailableTodoThinkingLevels(currentModel).includes(level)) return; const current = getCurrentThinkingLevel(); if (!current) return; if (current !== level) setTodoThinkingLevel(level); } function restoreTodoThinkingBaseline(state: ReturnType, ctx: ExtensionContext): void { const scopeKey = thinkingScopeKey(ctx); const previous = baselineThinkingByScope.get(scopeKey) ?? storedTodoThinkingBaseline(state, false); if (!previous) return; baselineThinkingByScope.delete(scopeKey); const restored = normalizeTodoThinkingLevelForModel(currentModel, previous); if (getCurrentThinkingLevel() !== restored) setTodoThinkingLevel(restored); } function setTodoThinkingLevel(level: TodoThinkingLevel): void { const setter = (pi as { setThinkingLevel?: (level: TodoThinkingLevel) => void }).setThinkingLevel; if (!setter) return; suppressNextNudgeForThinkingSwitch = true; setter.call(pi, level); } function hasVisibleAssistantContent(content: unknown): boolean { if (typeof content === "string") return content.trim().length > 0; if (!Array.isArray(content)) return false; return content.some((block) => { const candidate = block as { type?: unknown; text?: unknown }; return candidate.type === "text" && typeof candidate.text === "string" && candidate.text.trim().length > 0; }); } function isCompletedAssistantReply(message: AgentMessageLike | undefined): boolean { if (message?.role !== "assistant") return false; if (message.stopReason === "aborted" || message.stopReason === "error" || message.stopReason === "length" || message.stopReason === "toolUse") return false; return hasVisibleAssistantContent(message.content); } function hasCompletedAssistantReply(messages: readonly unknown[] | undefined): boolean { if (!Array.isArray(messages)) return false; for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] as AgentMessageLike | undefined; if (message?.role !== "assistant") continue; return isCompletedAssistantReply(message); } return false; } function findOptimisticallyCompletableTask(tasks: readonly Task[]): Task | undefined { const visible = tasks.filter((task) => task.status !== "deleted"); const byId = new Map(visible.map((task) => [task.id, task])); const unfinished = visible.filter((task) => ACTIVE_STATUSES.has(task.status) && !isTaskBlocked(task, byId)); if (unfinished.length !== 1) return undefined; const [task] = unfinished; if (task.status !== "in_progress") return undefined; if (inProgressAtAgentStart.has(task.id)) return undefined; return task; } function applyInternalTodoMutation(action: "update", params: TaskMutationParams, ctx: ExtensionContext): boolean { activateTodoStateScope(ctx); const result = applyTaskMutation(getState(), action, params); if (result.op.kind === "error") { console.warn(`rpiv-todo: failed internal ${action} mutation β€” ${result.op.message}`); return false; } const autoClear = autoClearCompletedTodos(result.state); replaceState(autoClear.state); appendTodoStateSnapshot(pi as any, action, params as Record); publishTodoState(pi as any, ctx, action, params as Record); if (todoThinkingEnabled) applyTodoThinkingAfterCommit(result.state, ctx, { action, params }); try { const sync = syncPersistedPlan(ctx.cwd, autoClear.state); if (sync?.completed) console.log(`rpiv-todo: completed persisted plan and removed ${sync.path}`); } catch (err) { console.warn(`rpiv-todo: failed to sync persisted plan β€” ${(err as Error).message}`); } return true; } function maybeRecoverCompletedCurrentTask(messages: readonly unknown[] | undefined, ctx: ExtensionContext): boolean { activateTodoStateScope(ctx); if (!hasCompletedAssistantReply(messages)) return false; const task = findOptimisticallyCompletableTask(getState().tasks); if (!task) return false; return applyInternalTodoMutation("update", { action: "update", id: task.id, status: "completed" }, ctx); } function clearNudgeTimer(): void { if (!nudgeTimer) return; clearTimeout(nudgeTimer); nudgeTimer = undefined; } function scheduleTodoNudge(ctx: ExtensionContext, attempt = 0): void { clearNudgeTimer(); const delayMs = attempt === 0 ? TODO_NUDGE_INITIAL_DELAY_MS : TODO_NUDGE_IDLE_RETRY_DELAY_MS; nudgeTimer = setTimeout(() => { nudgeTimer = undefined; try { activateTodoStateScope(ctx); if (!ctx.isIdle()) { if (attempt < TODO_NUDGE_MAX_IDLE_ATTEMPTS) scheduleTodoNudge(ctx, attempt + 1); return; } if (ctx.hasPendingMessages()) return; const nudge = getUnfinishedTodoNudge(); if (!nudge) { lastNudgedSignature = undefined; return; } // Avoid an infinite self-nudge loop when the previous nudge did not change // the pending/in_progress todo snapshot. if (nudge.signature === lastNudgedSignature) return; lastNudgedSignature = nudge.signature; // agent_settled means retries, compaction, and queued continuations are // finished. Send on the next idle tick as a fresh turn. pi.sendUserMessage(nudge.message); } catch (err) { if (isAgentBusyRaceError(err)) { if (attempt < TODO_NUDGE_MAX_IDLE_ATTEMPTS) scheduleTodoNudge(ctx, attempt + 1); return; } if (isStaleExtensionContextError(err)) return; console.warn(`rpiv-todo: failed to auto-nudge unfinished todos β€” ${(err as Error).message}`); } }, delayMs); } registerTodoToolWithCurrentPrompt(); registerTodosCommand(pi); pi.on("session_start", async (_event, ctx) => { activateTodoStateScope(ctx); currentModel = ctx.model; registerTodoToolWithCurrentPrompt(); const persisted = loadPersistedPlan(ctx.cwd); const loaded = autoClearCompletedTodos(persisted?.state ?? replayFromBranch(ctx)); replaceState(loaded.state); if (todoThinkingEnabled) syncTodoThinkingBaseline(loaded.state, ctx); publishTodoState(pi as any, ctx); if (persisted && loaded.cleared) syncPersistedPlan(ctx.cwd, loaded.state); lastNudgedSignature = undefined; if (persisted) { const prompt = getPersistedPlanPrompt(persisted.path); if (prompt) { emitPersistedPlanPrompt(pi, ctx, prompt); lastNudgedSignature = getUnfinishedTodoNudge()?.signature; } } }); pi.on("session_compact", async (_event, ctx) => { activateTodoStateScope(ctx); const state = autoClearCompletedTodos(loadPersistedPlan(ctx.cwd)?.state ?? replayFromBranch(ctx)).state; replaceState(state); if (todoThinkingEnabled) syncTodoThinkingBaseline(state, ctx); publishTodoState(pi as any, ctx); lastNudgedSignature = undefined; }); pi.on("session_tree", async (_event, ctx) => { activateTodoStateScope(ctx); const state = autoClearCompletedTodos(loadPersistedPlan(ctx.cwd)?.state ?? replayFromBranch(ctx)).state; replaceState(state); if (todoThinkingEnabled) syncTodoThinkingBaseline(state, ctx); publishTodoState(pi as any, ctx); lastNudgedSignature = undefined; }); pi.on("session_shutdown", async () => { clearNudgeTimer(); }); pi.on("model_select", async (event) => { currentModel = event.model; if (todoThinkingEnabled) registerTodoToolWithCurrentPrompt(); }); // Reads getTodos() at render time; do NOT call replayFromBranch here // (branch is stale β€” message_end runs after tool_execution_end). pi.on("tool_execution_start", async (event) => { if (!isAskUserToolName(event.toolName)) return; pendingAskUserToolCallIds.add(event.toolCallId); clearNudgeTimer(); }); pi.on("tool_execution_end", async (event) => { if (isAskUserToolName(event.toolName)) pendingAskUserToolCallIds.delete(event.toolCallId); }); pi.on("agent_start", async (_event, ctx) => { activateTodoStateScope(ctx); pendingAskUserToolCallIds.clear(); settledNudgeEligible = false; inProgressAtAgentStart = new Set(selectVisibleTasks(getState()).filter((task) => task.status === "in_progress").map((task) => task.id)); }); pi.on("message_end", async (event, ctx) => { activateTodoStateScope(ctx); if (!isCompletedAssistantReply((event as { message?: AgentMessageLike } | undefined)?.message)) return; if (maybeRecoverCompletedCurrentTask([(event as { message?: AgentMessageLike }).message], ctx)) { lastNudgedSignature = undefined; clearNudgeTimer(); } }); pi.on("agent_end", async (event, ctx) => { activateTodoStateScope(ctx); const completedAssistantReply = hasCompletedAssistantReply((event as { messages?: readonly unknown[] } | undefined)?.messages); settledNudgeEligible = completedAssistantReply; if (suppressNextNudgeForThinkingSwitch) { suppressNextNudgeForThinkingSwitch = false; if (!completedAssistantReply) { settledNudgeEligible = false; clearNudgeTimer(); return; } } if (pendingAskUserToolCallIds.size > 0) { settledNudgeEligible = false; clearNudgeTimer(); return; } if (completedAssistantReply && maybeRecoverCompletedCurrentTask((event as { messages?: readonly unknown[] } | undefined)?.messages, ctx)) { settledNudgeEligible = false; lastNudgedSignature = undefined; clearNudgeTimer(); return; } }); pi.on("agent_settled", async (_event, ctx) => { activateTodoStateScope(ctx); if (!settledNudgeEligible) { clearNudgeTimer(); return; } settledNudgeEligible = false; if (pendingAskUserToolCallIds.size > 0) { clearNudgeTimer(); return; } const nudge = getUnfinishedTodoNudge(); if (!nudge) { lastNudgedSignature = undefined; return; } scheduleTodoNudge(ctx); }); }