Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/features/background-agent/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export * from "./types"
export { BackgroundManager } from "./manager"
export { BackgroundManager, type PendingNotification } from "./manager"
export { ConcurrencyManager } from "./concurrency"
122 changes: 76 additions & 46 deletions src/features/background-agent/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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<string, BackgroundTask>
private notifications: Map<string, BackgroundTask[]>
private pendingNotifications: Map<string, PendingNotification[]>
private client: OpencodeClient
private directory: string
private pollingInterval?: ReturnType<typeof setInterval>
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 })
Expand Down
4 changes: 4 additions & 0 deletions src/features/background-agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions src/hooks/background-notification/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
};
};
Expand Down
13 changes: 10 additions & 3 deletions src/tools/background-task/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ Session ID: ${task.sessionID}
(No messages found)`
}

const assistantMessages = messages.filter(
const assistantMessages = messages.filter(
(m) => m.info?.role === "assistant"
)

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/tools/skill/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,4 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
})
}

export const skill = createSkillTool()
export const skill: ToolDefinition = createSkillTool()
2 changes: 1 addition & 1 deletion src/tools/slashcommand/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()