import { resolveRegisteredAgentName } from "../claude-code-session-state" import { createInternalAgentTextPart, log, messagesInDirectory, normalizeSDKResponse } from "../../shared" import { isSessionActive as isOpenCodeSessionActive, settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle" import { promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate" import type { PluginInput } from "@opencode-ai/plugin" type OpencodeClient = PluginInput["client"] export type ParentWakePromptContext = { agent?: string model?: { providerID: string; modelID: string } variant?: string tools?: Record } export type PendingParentWake = { promptContext: ParentWakePromptContext notifications: string[] shouldReply: boolean dispatchedAt?: number toolCallDeferralStartedAt?: number } type ParentWakeSessionMessage = { info?: { role?: string finish?: string time?: { created?: unknown } } role?: string finish?: string time?: { created?: unknown } parts?: Array<{ type?: string text?: string content?: unknown state?: { status?: unknown } }> } type ParentWakeNotifierDeps = { client: OpencodeClient directory: string enqueueNotificationForParent: (parentSessionID: string | undefined, operation: () => Promise) => Promise } type ParentWakeNotifierOptions = { pendingRetryMs: number acceptedMessageSkewMs: number toolCallDeferMaxMs: number failureRequeueWindowMs: number } export class ParentWakeNotifier { private pendingParentWakes: Map = new Map() private pendingParentWakeTimers: Map> = new Map() private dispatchedParentWakes: Map = new Map() private dispatchedParentWakeTimers: Map> = new Map() constructor( private readonly deps: ParentWakeNotifierDeps, private readonly options: ParentWakeNotifierOptions, ) {} getPendingParentWakes(): Map { return this.pendingParentWakes } getPendingParentWakeTimers(): Map> { return this.pendingParentWakeTimers } getDispatchedParentWakes(): Map { return this.dispatchedParentWakes } getDispatchedParentWakeTimers(): Map> { return this.dispatchedParentWakeTimers } queuePendingParentWake( sessionID: string, notification: string, promptContext: ParentWakePromptContext, shouldReply: boolean, delayMs?: number, ): void { const resolvedPromptContext = this.resolveParentWakePromptContext(promptContext) const pendingWake = this.pendingParentWakes.get(sessionID) if (pendingWake) { pendingWake.notifications.push(notification) pendingWake.promptContext = resolvedPromptContext pendingWake.shouldReply = pendingWake.shouldReply || shouldReply } else { this.pendingParentWakes.set(sessionID, { promptContext: resolvedPromptContext, notifications: [notification], shouldReply, }) } this.schedulePendingParentWakeFlush(sessionID, delayMs) } async flushPendingParentWake(sessionID: string): Promise { if (!this.pendingParentWakes.has(sessionID)) { this.clearPendingParentWakeTimer(sessionID) return } if (await this.isSessionActive(sessionID)) { this.schedulePendingParentWakeFlush(sessionID) return } this.clearPendingParentWakeTimer(sessionID) await settleAfterSessionIdle() if (await this.isSessionActive(sessionID)) { this.schedulePendingParentWakeFlush(sessionID) return } const latestWake = this.pendingParentWakes.get(sessionID) if (!latestWake) { return } if (await this.shouldDeferParentWakeForSessionHistory(sessionID, latestWake)) { this.schedulePendingParentWakeFlush(sessionID) return } this.pendingParentWakes.delete(sessionID) const notificationContent = latestWake.notifications.join("\n\n") try { const promptResult = await promptAsyncAfterSessionIdle({ client: this.deps.client, sessionID, source: "background-agent-parent-wake", settleMs: 0, postDispatchHoldMs: 250, input: { path: { id: sessionID }, body: { noReply: !latestWake.shouldReply, ...latestWake.promptContext, parts: [createInternalAgentTextPart(notificationContent)], }, query: { directory: this.deps.directory }, }, }) if (promptResult.status === "failed") { throw promptResult.error } if (promptResult.status !== "dispatched") { this.requeueWake(sessionID, latestWake) this.schedulePendingParentWakeFlush(sessionID) log("[background-agent] Deferred parent wake skipped by promptAsync gate:", { sessionID, status: promptResult.status, }) return } log("[background-agent] Sent deferred parent wake:", { sessionID }) this.trackDispatchedParentWake(sessionID, latestWake) } catch (error) { this.requeueWake(sessionID, latestWake) this.schedulePendingParentWakeFlush(sessionID) log("[background-agent] Failed to send deferred parent wake:", { sessionID, error }) } } clearDispatchedParentWake(sessionID: string): void { const timer = this.dispatchedParentWakeTimers.get(sessionID) if (timer) { clearTimeout(timer) this.dispatchedParentWakeTimers.delete(sessionID) } this.dispatchedParentWakes.delete(sessionID) } async requeueDispatchedParentWake(sessionID: string, reason: string): Promise { const wake = this.dispatchedParentWakes.get(sessionID) if (!wake) { return false } await settleAfterSessionIdle() if (await this.hasAcceptedMessageAfterDispatchedParentWake(sessionID, wake)) { this.clearDispatchedParentWake(sessionID) log("[background-agent] Ignored late parent wake failure after assistant output:", { sessionID, reason, }) return false } this.clearDispatchedParentWake(sessionID) this.requeueWake(sessionID, wake) this.schedulePendingParentWakeFlush(sessionID) log("[background-agent] Requeued dispatched parent wake after prompt failure:", { sessionID, reason, }) return true } schedulePendingParentWakeFlush(sessionID: string, delayMs?: number): void { if (this.pendingParentWakeTimers.has(sessionID)) { return } const timer = setTimeout(() => { this.pendingParentWakeTimers.delete(sessionID) void this.deps.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => { log("[background-agent] Failed to retry pending parent wake:", { sessionID, error }) }) }, delayMs ?? this.options.pendingRetryMs) this.pendingParentWakeTimers.set(sessionID, timer) } clearPendingParentWakeTimer(sessionID: string): void { const timer = this.pendingParentWakeTimers.get(sessionID) if (!timer) { return } clearTimeout(timer) this.pendingParentWakeTimers.delete(sessionID) } shutdown(): void { for (const timer of this.pendingParentWakeTimers.values()) { clearTimeout(timer) } this.pendingParentWakeTimers.clear() for (const timer of this.dispatchedParentWakeTimers.values()) { clearTimeout(timer) } this.dispatchedParentWakeTimers.clear() this.pendingParentWakes.clear() this.dispatchedParentWakes.clear() } private async isSessionActive(sessionID: string): Promise { return isOpenCodeSessionActive(this.deps.client, sessionID) } private resolveParentWakePromptContext(promptContext: ParentWakePromptContext): ParentWakePromptContext { const resolvedAgent = resolveRegisteredAgentName(promptContext.agent) return { ...promptContext, ...(resolvedAgent ? { agent: resolvedAgent } : {}), ...(promptContext.model ? { model: { ...promptContext.model } } : {}), ...(promptContext.tools ? { tools: { ...promptContext.tools } } : {}), } } private cloneParentWake(wake: PendingParentWake): PendingParentWake { const promptContext = this.resolveParentWakePromptContext(wake.promptContext) return { promptContext, notifications: [...wake.notifications], shouldReply: wake.shouldReply, ...(wake.dispatchedAt !== undefined ? { dispatchedAt: wake.dispatchedAt } : {}), ...(wake.toolCallDeferralStartedAt !== undefined ? { toolCallDeferralStartedAt: wake.toolCallDeferralStartedAt } : {}), } } private trackDispatchedParentWake(sessionID: string, wake: PendingParentWake): void { this.clearDispatchedParentWake(sessionID) const dispatchedWake = this.cloneParentWake(wake) dispatchedWake.dispatchedAt = Date.now() this.dispatchedParentWakes.set(sessionID, dispatchedWake) const timer = setTimeout(() => { this.dispatchedParentWakeTimers.delete(sessionID) this.dispatchedParentWakes.delete(sessionID) }, this.options.failureRequeueWindowMs) this.dispatchedParentWakeTimers.set(sessionID, timer) } private async loadParentWakeSessionMessages(sessionID: string): Promise { try { const messagesResp = await messagesInDirectory(this.deps.client, { path: { id: sessionID }, }, this.deps.directory) return normalizeSDKResponse(messagesResp, [] as ParentWakeSessionMessage[]) } catch (error) { log("[background-agent] Failed to inspect parent session messages for wake safety:", { sessionID, error, }) return [] } } private getParentWakeMessageRole(message: ParentWakeSessionMessage): string | undefined { return message.info?.role ?? message.role } private getParentWakeMessageFinish(message: ParentWakeSessionMessage): string | undefined { return message.info?.finish ?? message.finish } private getParentWakeMessageCreatedAt(message: ParentWakeSessionMessage): number | undefined { const value = message.info?.time?.created ?? message.time?.created if (typeof value === "number" && Number.isFinite(value)) { return value } if (typeof value === "string") { const parsed = Date.parse(value) return Number.isFinite(parsed) ? parsed : undefined } if (value instanceof Date) { return value.getTime() } return undefined } private parentWakePartIsWaitingOnTool(part: NonNullable[number]): boolean { if (part.type !== "tool" && part.type !== "tool_use") { return false } const status = part.state?.status return status === "pending" || status === "running" } private latestAssistantTurnIsWaitingOnTools(messages: ParentWakeSessionMessage[]): boolean { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index] if (!message) { continue } const role = this.getParentWakeMessageRole(message) if (role === "assistant") { return this.getParentWakeMessageFinish(message) === "tool-calls" || message.parts?.some((part) => this.parentWakePartIsWaitingOnTool(part)) === true } if (role === "user") { return false } } return false } private parentWakeMessageHasOutput(message: ParentWakeSessionMessage): boolean { const role = this.getParentWakeMessageRole(message) if (role !== "assistant" && role !== "tool") { return false } if (!message.parts || message.parts.length === 0) { return role === "assistant" } return message.parts.some((part) => { if (part.type === "text" || part.type === "reasoning") { return typeof part.text === "string" && part.text.trim().length > 0 } if (part.type === "tool" || part.type === "tool_result") { return true } if (part.content !== undefined) { if (typeof part.content === "string") { return part.content.trim().length > 0 } if (Array.isArray(part.content)) { return part.content.length > 0 } return true } return false }) } private parentWakeMessageContainsNotification(message: ParentWakeSessionMessage, wake: PendingParentWake): boolean { if (this.getParentWakeMessageRole(message) !== "user") { return false } return message.parts?.some((part) => typeof part.text === "string" && wake.notifications.some((notification) => part.text?.includes(notification)) ) ?? false } private async shouldDeferParentWakeForSessionHistory(sessionID: string, wake: PendingParentWake): Promise { const messages = await this.loadParentWakeSessionMessages(sessionID) if (!this.latestAssistantTurnIsWaitingOnTools(messages)) { delete wake.toolCallDeferralStartedAt return false } const now = Date.now() wake.toolCallDeferralStartedAt ??= now if (wake.shouldReply && now - wake.toolCallDeferralStartedAt >= this.options.toolCallDeferMaxMs) { log("[background-agent] Sending parent wake after stale tool-call deferral window:", { sessionID, }) return false } log("[background-agent] Deferred parent wake because latest assistant turn is waiting on tool results:", { sessionID, }) return true } private async hasAcceptedMessageAfterDispatchedParentWake(sessionID: string, wake: PendingParentWake): Promise { if (wake.dispatchedAt === undefined) { return false } const dispatchedAt = wake.dispatchedAt const messages = await this.loadParentWakeSessionMessages(sessionID) return messages.some((message) => { const createdAt = this.getParentWakeMessageCreatedAt(message) if (createdAt === undefined) { return false } if ( createdAt >= dispatchedAt - this.options.acceptedMessageSkewMs && this.parentWakeMessageContainsNotification(message, wake) ) { return true } return createdAt >= dispatchedAt && this.parentWakeMessageHasOutput(message) }) } private requeueWake(sessionID: string, latestWake: PendingParentWake): void { const pendingWake = this.pendingParentWakes.get(sessionID) if (pendingWake) { pendingWake.notifications.unshift(...latestWake.notifications) pendingWake.shouldReply = pendingWake.shouldReply || latestWake.shouldReply pendingWake.promptContext = latestWake.promptContext pendingWake.toolCallDeferralStartedAt ??= latestWake.toolCallDeferralStartedAt return } this.pendingParentWakes.set(sessionID, this.cloneParentWake(latestWake)) } }