YeonGyu-Kim 78047dfd7d fix(background-agent): use session status check and prompt() for visible notification
- Replace promptAsync() with session.prompt() for visible TUI updates
- Add main session check to skip subagent sessions
- Add session status idle check before sending prompt
- Add 200ms debounce with re-check to prevent race conditions
- Fallback to pending queue when session is busy

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00

397 lines
12 KiB
TypeScript

import type { PluginInput } from "@opencode-ai/plugin"
import type {
BackgroundTask,
LaunchInput,
} from "./types"
import { log } from "../../shared/logger"
import { getMainSessionID } from "../claude-code-session-state"
type OpencodeClient = PluginInput["client"]
interface MessagePartInfo {
sessionID?: string
type?: string
tool?: string
}
interface EventProperties {
sessionID?: string
info?: { id?: string }
[key: string]: unknown
}
interface Event {
type: string
properties?: EventProperties
}
export class BackgroundManager {
private tasks: Map<string, BackgroundTask>
private notifications: Map<string, BackgroundTask[]>
private client: OpencodeClient
private directory: string
private pollingInterval?: Timer
constructor(ctx: PluginInput) {
this.tasks = new Map()
this.notifications = new Map()
this.client = ctx.client
this.directory = ctx.directory
}
async launch(input: LaunchInput): Promise<BackgroundTask> {
const createResult = await this.client.session.create({
body: {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
},
})
if (createResult.error) {
throw new Error(`Failed to create background session: ${createResult.error}`)
}
const sessionID = createResult.data.id
const task: BackgroundTask = {
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
sessionID,
parentSessionID: input.parentSessionID,
parentMessageID: input.parentMessageID,
description: input.description,
agent: input.agent,
status: "running",
startedAt: new Date(),
progress: {
toolCalls: 0,
lastUpdate: new Date(),
},
}
this.tasks.set(task.id, task)
this.startPolling()
log("[background-agent] Launching task:", { taskId: task.id, sessionID })
this.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: input.agent,
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
log("[background-agent] promptAsync error:", error)
const existingTask = this.findBySession(sessionID)
if (existingTask) {
existingTask.status = "error"
existingTask.error = String(error)
existingTask.completedAt = new Date()
}
})
return task
}
getTask(id: string): BackgroundTask | undefined {
return this.tasks.get(id)
}
getTasksByParentSession(sessionID: string): BackgroundTask[] {
const result: BackgroundTask[] = []
for (const task of this.tasks.values()) {
if (task.parentSessionID === sessionID) {
result.push(task)
}
}
return result
}
findBySession(sessionID: string): BackgroundTask | undefined {
for (const task of this.tasks.values()) {
if (task.sessionID === sessionID) {
return task
}
}
return undefined
}
handleEvent(event: Event): void {
const props = event.properties
if (event.type === "message.part.updated") {
if (!props || typeof props !== "object" || !("sessionID" in props)) return
const partInfo = props as unknown as MessagePartInfo
const sessionID = partInfo?.sessionID
if (!sessionID) return
const task = this.findBySession(sessionID)
if (!task) return
if (partInfo?.type === "tool" || partInfo?.tool) {
if (!task.progress) {
task.progress = {
toolCalls: 0,
lastUpdate: new Date(),
}
}
task.progress.toolCalls += 1
task.progress.lastTool = partInfo.tool
task.progress.lastUpdate = new Date()
}
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const task = this.findBySession(sessionID)
if (!task || task.status !== "running") return
task.status = "completed"
task.completedAt = new Date()
this.markForNotification(task)
this.notifyParentSession(task)
log("[background-agent] Task completed via session.idle event:", task.id)
}
if (event.type === "session.deleted") {
const info = props?.info
if (!info || typeof info.id !== "string") return
const sessionID = info.id
const task = this.findBySession(sessionID)
if (!task) return
if (task.status === "running") {
task.status = "cancelled"
task.completedAt = new Date()
task.error = "Session deleted"
}
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
}
}
markForNotification(task: BackgroundTask): void {
const queue = this.notifications.get(task.parentSessionID) ?? []
queue.push(task)
this.notifications.set(task.parentSessionID, queue)
}
getPendingNotifications(sessionID: string): BackgroundTask[] {
return this.notifications.get(sessionID) ?? []
}
clearNotifications(sessionID: string): void {
this.notifications.delete(sessionID)
}
private clearNotificationsForTask(taskId: string): void {
for (const [sessionID, tasks] of this.notifications.entries()) {
const filtered = tasks.filter((t) => t.id !== taskId)
if (filtered.length === 0) {
this.notifications.delete(sessionID)
} else {
this.notifications.set(sessionID, filtered)
}
}
}
private startPolling(): void {
if (this.pollingInterval) return
this.pollingInterval = setInterval(() => {
this.pollRunningTasks()
}, 2000)
}
private stopPolling(): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
this.pollingInterval = undefined
}
}
private notifyParentSession(task: BackgroundTask): void {
const duration = this.formatDuration(task.startedAt, task.completedAt)
const toolCalls = task.progress?.toolCalls ?? 0
log("[background-agent] notifyParentSession called for task:", task.id)
// 1. Toast 알림 (즉각적 피드백 - 항상 실행)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
if (tuiClient.tui?.showToast) {
tuiClient.tui.showToast({
body: {
title: "Background Task Completed",
message: `Task "${task.description}" finished in ${duration}.`,
variant: "success",
duration: 5000,
},
}).catch(() => {})
}
// 2. Main session 확인
const mainSessionID = getMainSessionID()
const isMainSession = task.parentSessionID === mainSessionID
if (!isMainSession) {
log("[background-agent] Parent is not main session, relying on pending queue")
return
}
// 3. Session status 확인 + 200ms debounce 후 prompt() 호출
this.schedulePromptToParent(task, duration, toolCalls)
}
private schedulePromptToParent(task: BackgroundTask, duration: string, toolCalls: number): void {
this.client.session.status().then((statusResult) => {
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
const parentStatus = allStatuses[task.parentSessionID]
if (!parentStatus || parentStatus.type !== "idle") {
log("[background-agent] Parent session is busy, relying on pending queue")
return
}
// 4. 200ms debounce 후 prompt() 호출
setTimeout(() => {
this.sendPromptToParent(task, duration, toolCalls)
}, 200)
}).catch((error) => {
log("[background-agent] Failed to check session status:", String(error))
})
}
private sendPromptToParent(task: BackgroundTask, duration: string, toolCalls: number): void {
// Re-check status after delay (race condition 방지)
this.client.session.status().then((recheck) => {
const recheckStatuses = (recheck.data ?? {}) as Record<string, { type: string }>
const recheckStatus = recheckStatuses[task.parentSessionID]
if (!recheckStatus || recheckStatus.type !== "idle") {
log("[background-agent] Parent session no longer idle after delay, skipping prompt")
return
}
const message = this.buildNotificationMessage(task, duration, toolCalls)
this.client.session.prompt({
path: { id: task.parentSessionID },
body: {
parts: [{ type: "text", text: message }],
},
query: { directory: this.directory },
}).then(() => {
this.clearNotificationsForTask(task.id)
log("[background-agent] Successfully sent prompt to parent session")
}).catch((error) => {
// 실패해도 pending queue가 있으므로 다음 user 입력 시 전달됨
log("[background-agent] Failed to send prompt, relying on pending queue:", String(error))
})
}).catch((error) => {
log("[background-agent] Failed to recheck session status:", String(error))
})
}
private buildNotificationMessage(task: BackgroundTask, duration: string, toolCalls: number): string {
return `[BACKGROUND TASK COMPLETED]
Task "${task.description}" has finished.
Duration: ${duration}
Tool calls: ${toolCalls}
Use \`background_result\` tool with taskId="${task.id}" to retrieve the full result.`
}
private formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`
}
return `${seconds}s`
}
private hasRunningTasks(): boolean {
for (const task of this.tasks.values()) {
if (task.status === "running") return true
}
return false
}
private async pollRunningTasks(): Promise<void> {
const statusResult = await this.client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
for (const task of this.tasks.values()) {
if (task.status !== "running") continue
try {
const sessionStatus = allStatuses[task.sessionID]
if (!sessionStatus) {
log("[background-agent] Session not found in status:", task.sessionID)
continue
}
if (sessionStatus.type === "idle") {
task.status = "completed"
task.completedAt = new Date()
this.markForNotification(task)
this.notifyParentSession(task)
log("[background-agent] Task completed via polling:", task.id)
continue
}
const messagesResult = await this.client.session.messages({
path: { id: task.sessionID },
})
if (!messagesResult.error && messagesResult.data) {
const messages = messagesResult.data as Array<{
info?: { role?: string }
parts?: Array<{ type?: string; tool?: string; name?: string }>
}>
const assistantMsgs = messages.filter(
(m) => m.info?.role === "assistant"
)
let toolCalls = 0
let lastTool: string | undefined
for (const msg of assistantMsgs) {
const parts = msg.parts ?? []
for (const part of parts) {
if (part.type === "tool_use" || part.tool) {
toolCalls++
lastTool = part.tool || part.name || "unknown"
}
}
}
if (!task.progress) {
task.progress = { toolCalls: 0, lastUpdate: new Date() }
}
task.progress.toolCalls = toolCalls
task.progress.lastTool = lastTool
task.progress.lastUpdate = new Date()
}
} catch (error) {
log("[background-agent] Poll error for task:", { taskId: task.id, error })
}
}
if (!this.hasRunningTasks()) {
this.stopPolling()
}
}
}