diff --git a/src/features/background-agent/index.ts b/src/features/background-agent/index.ts index 26fece81f..49ea2b265 100644 --- a/src/features/background-agent/index.ts +++ b/src/features/background-agent/index.ts @@ -1,3 +1,3 @@ export * from "./types" -export { BackgroundManager } from "./manager" +export { BackgroundManager, type PendingNotification } from "./manager" export { ConcurrencyManager } from "./concurrency" diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index ccc7ddc63..652ad9a6d 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -13,6 +13,7 @@ import { subagentSessions } from "../claude-code-session-state" import { getTaskToastManager } from "../task-toast-manager" const TASK_TTL_MS = 30 * 60 * 1000 +const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in type OpencodeClient = PluginInput["client"] @@ -40,9 +41,18 @@ interface Todo { id: string } +export interface PendingNotification { + taskId: string + description: string + duration: string + status: "completed" | "error" + error?: string +} + export class BackgroundManager { private tasks: Map private notifications: Map + private pendingNotifications: Map private client: OpencodeClient private directory: string private pollingInterval?: ReturnType @@ -51,6 +61,7 @@ export class BackgroundManager { constructor(ctx: PluginInput, config?: BackgroundTaskConfig) { this.tasks = new Map() this.notifications = new Map() + this.pendingNotifications = new Map() this.client = ctx.client this.directory = ctx.directory this.concurrencyManager = new ConcurrencyManager(config) @@ -380,10 +391,21 @@ export class BackgroundManager { return this.notifications.get(sessionID) ?? [] } - clearNotifications(sessionID: string): void { +clearNotifications(sessionID: string): void { this.notifications.delete(sessionID) } + hasPendingNotifications(sessionID: string): boolean { + const pending = this.pendingNotifications.get(sessionID) + return pending !== undefined && pending.length > 0 + } + + consumePendingNotifications(sessionID: string): PendingNotification[] { + const pending = this.pendingNotifications.get(sessionID) ?? [] + this.pendingNotifications.delete(sessionID) + return pending + } + private clearNotificationsForTask(taskId: string): void { for (const [sessionID, tasks] of this.notifications.entries()) { const filtered = tasks.filter((t) => t.id !== taskId) @@ -411,13 +433,14 @@ export class BackgroundManager { } } - cleanup(): void { +cleanup(): void { this.stopPolling() this.tasks.clear() this.notifications.clear() + this.pendingNotifications.clear() } - private notifyParentSession(task: BackgroundTask): void { +private notifyParentSession(task: BackgroundTask): void { const duration = this.formatDuration(task.startedAt, task.completedAt) log("[background-agent] notifyParentSession called for task:", task.id) @@ -431,47 +454,34 @@ export class BackgroundManager { }) } - const message = `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished in ${duration}. Use background_output with task_id="${task.id}" to get results.` + // Store notification for silent injection via tool.execute.after hook + const notification: PendingNotification = { + taskId: task.id, + description: task.description, + duration, + status: task.status === "error" ? "error" : "completed", + error: task.error, + } + + const existing = this.pendingNotifications.get(task.parentSessionID) ?? [] + existing.push(notification) + this.pendingNotifications.set(task.parentSessionID, existing) - log("[background-agent] Sending notification to parent session:", { parentSessionID: task.parentSessionID }) + log("[background-agent] Stored pending notification for parent session:", { + parentSessionID: task.parentSessionID, + taskId: task.id + }) const taskId = task.id - setTimeout(async () => { + setTimeout(() => { if (task.concurrencyKey) { this.concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined // Prevent double-release } - - try { - const body: { - agent?: string - model?: { providerID: string; modelID: string } - parts: Array<{ type: "text"; text: string }> - } = { - parts: [{ type: "text", text: message }], - } - - if (task.parentAgent !== undefined) { - body.agent = task.parentAgent - } - - if (task.parentModel?.providerID && task.parentModel?.modelID) { - body.model = { providerID: task.parentModel.providerID, modelID: task.parentModel.modelID } - } - - await this.client.session.prompt({ - path: { id: task.parentSessionID }, - body, - query: { directory: this.directory }, - }) - log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID }) - } catch (error) { - log("[background-agent] prompt failed:", String(error)) - } finally { - this.clearNotificationsForTask(taskId) - this.tasks.delete(taskId) - log("[background-agent] Removed completed task from memory:", taskId) - } - }, 200) + this.clearNotificationsForTask(taskId) + this.tasks.delete(taskId) + log("[background-agent] Removed completed task from memory:", taskId) + }, 5 * 60 * 1000) // 5 minutes retention for background_output retrieval } private formatDuration(start: Date, end?: Date): string { @@ -540,15 +550,11 @@ export class BackgroundManager { for (const task of this.tasks.values()) { if (task.status !== "running") continue - try { +try { const sessionStatus = allStatuses[task.sessionID] - if (!sessionStatus) { - log("[background-agent] Session not found in status:", task.sessionID) - continue - } - - if (sessionStatus.type === "idle") { + // Don't skip if session not in status - fall through to message-based detection + if (sessionStatus?.type === "idle") { const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID) if (hasIncompleteTodos) { log("[background-agent] Task has incomplete todos via polling, waiting:", task.id) @@ -599,10 +605,34 @@ export class BackgroundManager { task.progress.toolCalls = toolCalls task.progress.lastTool = lastTool task.progress.lastUpdate = new Date() - if (lastMessage) { +if (lastMessage) { task.progress.lastMessage = lastMessage task.progress.lastMessageAt = new Date() } + + // Stability detection: complete when message count unchanged for 3 polls + const currentMsgCount = messages.length + const elapsedMs = Date.now() - task.startedAt.getTime() + + if (elapsedMs >= MIN_STABILITY_TIME_MS) { + if (task.lastMsgCount === currentMsgCount) { + task.stablePolls = (task.stablePolls ?? 0) + 1 + if (task.stablePolls >= 3) { + const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID) + if (!hasIncompleteTodos) { + task.status = "completed" + task.completedAt = new Date() + this.markForNotification(task) + this.notifyParentSession(task) + log("[background-agent] Task completed via stability detection:", task.id) + continue + } + } + } else { + task.stablePolls = 0 + } + } + task.lastMsgCount = currentMsgCount } } catch (error) { log("[background-agent] Poll error for task:", { taskId: task.id, error }) diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index b7e68cdd7..a77766f8a 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -32,6 +32,10 @@ export interface BackgroundTask { concurrencyKey?: string /** Parent session's agent name for notification */ parentAgent?: string + /** Last message count for stability detection */ + lastMsgCount?: number + /** Number of consecutive polls with stable message count */ + stablePolls?: number } export interface LaunchInput { diff --git a/src/hooks/background-notification/index.ts b/src/hooks/background-notification/index.ts index 21944a6b3..e530bf926 100644 --- a/src/hooks/background-notification/index.ts +++ b/src/hooks/background-notification/index.ts @@ -9,13 +9,49 @@ interface EventInput { event: Event } +interface ToolExecuteInput { + sessionID?: string + tool: string +} + +interface ToolExecuteOutput { + title: string + output: string + metadata: unknown +} + export function createBackgroundNotificationHook(manager: BackgroundManager) { const eventHandler = async ({ event }: EventInput) => { manager.handleEvent(event) } + const toolExecuteAfterHandler = async ( + input: ToolExecuteInput, + output: ToolExecuteOutput + ) => { + const sessionID = input.sessionID + if (!sessionID) return + + if (!manager.hasPendingNotifications(sessionID)) return + + const notifications = manager.consumePendingNotifications(sessionID) + if (notifications.length === 0) return + + const messages = notifications.map((n) => { + if (n.status === "error") { + return `[BACKGROUND TASK FAILED] Task "${n.description}" failed after ${n.duration}. Error: ${n.error || "Unknown error"}. Use background_output with task_id="${n.taskId}" for details.` + } + return `[BACKGROUND TASK COMPLETED] Task "${n.description}" finished in ${n.duration}. Use background_output with task_id="${n.taskId}" to get results.` + }) + + const injection = "\n\n---\n" + messages.join("\n") + "\n---" + + output.output = output.output + injection + } + return { event: eventHandler, + "tool.execute.after": toolExecuteAfterHandler, } } diff --git a/src/index.ts b/src/index.ts index 79c631af9..da68fe14a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -522,9 +522,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await emptyTaskResponseDetector?.["tool.execute.after"](input, output); await agentUsageReminder?.["tool.execute.after"](input, output); await interactiveBashSession?.["tool.execute.after"](input, output); - await editErrorRecovery?.["tool.execute.after"](input, output); +await editErrorRecovery?.["tool.execute.after"](input, output); await sisyphusOrchestrator?.["tool.execute.after"]?.(input, output); await taskResumeInfo["tool.execute.after"](input, output); + await backgroundNotificationHook?.["tool.execute.after"]?.(input, output); }, }; }; diff --git a/src/tools/background-task/tools.ts b/src/tools/background-task/tools.ts index 1f9169378..5f3cd73c5 100644 --- a/src/tools/background-task/tools.ts +++ b/src/tools/background-task/tools.ts @@ -193,7 +193,7 @@ Session ID: ${task.sessionID} (No messages found)` } - const assistantMessages = messages.filter( +const assistantMessages = messages.filter( (m) => m.info?.role === "assistant" ) @@ -210,8 +210,15 @@ Session ID: ${task.sessionID} (No assistant response found)` } - const lastMessage = assistantMessages[assistantMessages.length - 1] - const textParts = lastMessage?.parts?.filter( + // Sort by time descending (newest first), take first result - matches sync pattern + const sortedMessages = [...assistantMessages].sort((a, b) => { + const timeA = String((a as { info?: { time?: string } }).info?.time ?? "") + const timeB = String((b as { info?: { time?: string } }).info?.time ?? "") + return timeB.localeCompare(timeA) + }) + + const lastMessage = sortedMessages[0] + const textParts = lastMessage.parts?.filter( (p) => p.type === "text" ) ?? [] const textContent = textParts diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index abb6d1c1a..24e0f548c 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -194,4 +194,4 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition }) } -export const skill = createSkillTool() +export const skill: ToolDefinition = createSkillTool() diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts index 335d4428b..4866a6765 100644 --- a/src/tools/slashcommand/tools.ts +++ b/src/tools/slashcommand/tools.ts @@ -249,4 +249,4 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T } // Default instance for backward compatibility (lazy loading) -export const slashcommand = createSlashcommandTool() +export const slashcommand: ToolDefinition = createSlashcommandTool()