2025-12-15 19:02:31 +09:00
import { existsSync , readdirSync } from "node:fs"
import { join } from "node:path"
2025-12-11 15:45:37 +09:00
import type { PluginInput } from "@opencode-ai/plugin"
import type {
BackgroundTask ,
LaunchInput ,
} from "./types"
2025-12-11 17:29:20 +09:00
import { log } from "../../shared/logger"
2025-12-15 19:02:31 +09:00
import {
findNearestMessageWithFields ,
MESSAGE_STORAGE ,
} from "../hook-message-injector"
2025-12-16 23:01:48 +09:00
import { subagentSessions } from "../claude-code-session-state"
2025-12-11 15:45:37 +09:00
2026-01-02 22:25:49 +09:00
const TASK_TTL_MS = 30 * 60 * 1000
2025-12-11 15:45:37 +09:00
type OpencodeClient = PluginInput [ "client" ]
interface MessagePartInfo {
sessionID? : string
type ? : string
tool? : string
}
interface EventProperties {
sessionID? : string
2025-12-11 17:42:33 +09:00
info ? : { id? : string }
2025-12-11 15:45:37 +09:00
[ key : string ] : unknown
}
interface Event {
type : string
properties? : EventProperties
}
2025-12-15 23:54:59 +09:00
interface Todo {
content : string
status : string
priority : string
id : string
}
2025-12-15 19:02:31 +09:00
function getMessageDir ( sessionID : string ) : string | null {
if ( ! existsSync ( MESSAGE_STORAGE ) ) return null
const directPath = join ( MESSAGE_STORAGE , sessionID )
if ( existsSync ( directPath ) ) return directPath
for ( const dir of readdirSync ( MESSAGE_STORAGE ) ) {
const sessionPath = join ( MESSAGE_STORAGE , dir , sessionID )
if ( existsSync ( sessionPath ) ) return sessionPath
}
return null
}
2025-12-11 15:45:37 +09:00
export class BackgroundManager {
private tasks : Map < string , BackgroundTask >
private notifications : Map < string , BackgroundTask [ ] >
private client : OpencodeClient
2025-12-11 18:13:02 +09:00
private directory : string
2025-12-27 23:06:44 +09:00
private pollingInterval? : ReturnType < typeof setInterval >
2025-12-11 15:45:37 +09:00
2025-12-11 18:13:02 +09:00
constructor ( ctx : PluginInput ) {
2025-12-11 15:45:37 +09:00
this . tasks = new Map ( )
this . notifications = new Map ( )
2025-12-11 18:13:02 +09:00
this . client = ctx . client
this . directory = ctx . directory
2025-12-11 15:45:37 +09:00
}
async launch ( input : LaunchInput ) : Promise < BackgroundTask > {
2025-12-14 01:22:28 +09:00
if ( ! input . agent || input . agent . trim ( ) === "" ) {
throw new Error ( "Agent parameter is required" )
}
2025-12-11 15:45:37 +09:00
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
2025-12-16 23:01:48 +09:00
subagentSessions . add ( sessionID )
2025-12-11 15:45:37 +09:00
const task : BackgroundTask = {
id : ` bg_ ${ crypto . randomUUID ( ) . slice ( 0 , 8 ) } ` ,
sessionID ,
parentSessionID : input.parentSessionID ,
parentMessageID : input.parentMessageID ,
description : input.description ,
2025-12-13 13:05:12 +09:00
prompt : input.prompt ,
2025-12-11 15:45:37 +09:00
agent : input.agent ,
status : "running" ,
startedAt : new Date ( ) ,
progress : {
toolCalls : 0 ,
lastUpdate : new Date ( ) ,
} ,
2025-12-25 22:36:06 +09:00
parentModel : input.parentModel ,
2025-12-11 15:45:37 +09:00
}
this . tasks . set ( task . id , task )
2025-12-11 17:12:45 +09:00
this . startPolling ( )
2025-12-11 15:45:37 +09:00
2025-12-14 01:22:28 +09:00
log ( "[background-agent] Launching task:" , { taskId : task.id , sessionID , agent : input.agent } )
2025-12-11 17:42:33 +09:00
2025-12-11 15:45:37 +09:00
this . client . session . promptAsync ( {
path : { id : sessionID } ,
body : {
agent : input.agent ,
2025-12-12 11:48:39 +09:00
tools : {
2025-12-14 11:54:36 +09:00
task : false ,
2025-12-12 11:48:39 +09:00
background_task : false ,
} ,
2025-12-11 15:45:37 +09:00
parts : [ { type : "text" , text : input.prompt } ] ,
} ,
} ) . catch ( ( error ) = > {
2025-12-11 17:29:20 +09:00
log ( "[background-agent] promptAsync error:" , error )
2025-12-11 15:45:37 +09:00
const existingTask = this . findBySession ( sessionID )
if ( existingTask ) {
existingTask . status = "error"
2025-12-14 01:22:28 +09:00
const errorMessage = error instanceof Error ? error.message : String ( error )
if ( errorMessage . includes ( "agent.name" ) || errorMessage . includes ( "undefined" ) ) {
existingTask . error = ` Agent " ${ input . agent } " not found. Make sure the agent is registered in your opencode.json or provided by a plugin. `
} else {
existingTask . error = errorMessage
}
2025-12-11 15:45:37 +09:00
existingTask . completedAt = new Date ( )
2025-12-14 01:22:28 +09:00
this . markForNotification ( existingTask )
this . notifyParentSession ( existingTask )
2025-12-11 15:45:37 +09:00
}
} )
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
}
2025-12-19 01:56:38 +09:00
getAllDescendantTasks ( sessionID : string ) : BackgroundTask [ ] {
const result : BackgroundTask [ ] = [ ]
const directChildren = this . getTasksByParentSession ( sessionID )
for ( const child of directChildren ) {
result . push ( child )
const descendants = this . getAllDescendantTasks ( child . sessionID )
result . push ( . . . descendants )
}
return result
}
2025-12-11 15:45:37 +09:00
findBySession ( sessionID : string ) : BackgroundTask | undefined {
for ( const task of this . tasks . values ( ) ) {
if ( task . sessionID === sessionID ) {
return task
}
}
return undefined
}
2025-12-15 23:54:59 +09:00
private async checkSessionTodos ( sessionID : string ) : Promise < boolean > {
try {
const response = await this . client . session . todo ( {
path : { id : sessionID } ,
} )
const todos = ( response . data ? ? response ) as Todo [ ]
if ( ! todos || todos . length === 0 ) return false
const incomplete = todos . filter (
( t ) = > t . status !== "completed" && t . status !== "cancelled"
)
return incomplete . length > 0
} catch {
return false
}
}
2025-12-11 15:45:37 +09:00
handleEvent ( event : Event ) : void {
const props = event . properties
if ( event . type === "message.part.updated" ) {
2025-12-11 16:56:16 +09:00
if ( ! props || typeof props !== "object" || ! ( "sessionID" in props ) ) return
2025-12-11 15:45:37 +09:00
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 ( )
}
}
2025-12-11 17:42:33 +09:00
if ( event . type === "session.idle" ) {
const sessionID = props ? . sessionID as string | undefined
if ( ! sessionID ) return
2025-12-11 15:45:37 +09:00
const task = this . findBySession ( sessionID )
2025-12-11 17:42:33 +09:00
if ( ! task || task . status !== "running" ) return
2025-12-11 15:45:37 +09:00
2025-12-15 23:54:59 +09:00
this . checkSessionTodos ( sessionID ) . then ( ( hasIncompleteTodos ) = > {
if ( hasIncompleteTodos ) {
log ( "[background-agent] Task has incomplete todos, waiting for todo-continuation:" , task . id )
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 )
} )
2025-12-11 15:45:37 +09:00
}
if ( event . type === "session.deleted" ) {
2025-12-11 17:42:33 +09:00
const info = props ? . info
2025-12-11 16:56:16 +09:00
if ( ! info || typeof info . id !== "string" ) return
const sessionID = info . id
2025-12-11 15:45:37 +09:00
const task = this . findBySession ( sessionID )
if ( ! task ) return
if ( task . status === "running" ) {
task . status = "cancelled"
task . completedAt = new Date ( )
2025-12-11 17:42:33 +09:00
task . error = "Session deleted"
2025-12-11 15:45:37 +09:00
}
this . tasks . delete ( task . id )
this . clearNotificationsForTask ( task . id )
2025-12-16 23:01:48 +09:00
subagentSessions . delete ( sessionID )
2025-12-11 15:45:37 +09:00
}
}
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 )
}
}
}
2025-12-11 17:12:45 +09:00
private startPolling ( ) : void {
if ( this . pollingInterval ) return
this . pollingInterval = setInterval ( ( ) = > {
this . pollRunningTasks ( )
} , 2000 )
2025-12-27 23:06:44 +09:00
this . pollingInterval . unref ( )
2025-12-11 17:12:45 +09:00
}
private stopPolling ( ) : void {
if ( this . pollingInterval ) {
clearInterval ( this . pollingInterval )
this . pollingInterval = undefined
}
}
2025-12-27 23:06:44 +09:00
cleanup ( ) : void {
this . stopPolling ( )
this . tasks . clear ( )
this . notifications . clear ( )
}
2025-12-11 17:15:02 +09:00
private notifyParentSession ( task : BackgroundTask ) : void {
const duration = this . formatDuration ( task . startedAt , task . completedAt )
2025-12-11 18:36:36 +09:00
log ( "[background-agent] notifyParentSession called for task:" , task . id )
2025-12-11 18:13:02 +09:00
// 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" ,
2025-12-11 18:36:36 +09:00
message : ` Task " ${ task . description } " finished in ${ duration } . ` ,
2025-12-11 18:13:02 +09:00
variant : "success" ,
duration : 5000 ,
} ,
} ) . catch ( ( ) = > { } )
}
2025-12-12 11:48:39 +09:00
const message = ` [BACKGROUND TASK COMPLETED] Task " ${ task . description } " finished in ${ duration } . Use background_output with task_id=" ${ task . id } " to get results. `
2025-12-12 10:15:46 +09:00
2025-12-13 13:36:31 +09:00
log ( "[background-agent] Sending notification to parent session:" , { parentSessionID : task.parentSessionID } )
2025-12-12 10:15:46 +09:00
2025-12-28 14:59:06 +09:00
const taskId = task . id
2025-12-12 10:15:46 +09:00
setTimeout ( async ( ) = > {
try {
2025-12-15 19:02:31 +09:00
const messageDir = getMessageDir ( task . parentSessionID )
const prevMessage = messageDir ? findNearestMessageWithFields ( messageDir ) : null
2025-12-25 22:36:06 +09:00
const modelContext = task . parentModel ? ? prevMessage ? . model
const modelField = modelContext ? . providerID && modelContext ? . modelID
? { providerID : modelContext.providerID , modelID : modelContext.modelID }
: undefined
2025-12-12 18:39:53 +09:00
await this . client . session . prompt ( {
2025-12-13 13:36:31 +09:00
path : { id : task.parentSessionID } ,
2025-12-12 10:24:31 +09:00
body : {
2025-12-15 19:02:31 +09:00
agent : prevMessage?.agent ,
2025-12-25 22:36:06 +09:00
model : modelField ,
2025-12-12 10:24:31 +09:00
parts : [ { type : "text" , text : message } ] ,
} ,
2025-12-12 10:15:46 +09:00
query : { directory : this.directory } ,
} )
2025-12-13 13:36:31 +09:00
log ( "[background-agent] Successfully sent prompt to parent session:" , { parentSessionID : task.parentSessionID } )
2025-12-12 10:15:46 +09:00
} catch ( error ) {
2025-12-12 18:39:53 +09:00
log ( "[background-agent] prompt failed:" , String ( error ) )
2025-12-28 14:59:06 +09:00
} finally {
2026-01-02 22:25:49 +09:00
// Always clean up both maps to prevent memory leaks
this . clearNotificationsForTask ( taskId )
2025-12-28 14:59:06 +09:00
this . tasks . delete ( taskId )
log ( "[background-agent] Removed completed task from memory:" , taskId )
2025-12-12 10:15:46 +09:00
}
2025-12-11 18:39:20 +09:00
} , 200 )
2025-12-11 18:36:36 +09:00
}
2025-12-11 17:15:02 +09:00
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 `
}
2025-12-11 17:12:45 +09:00
private hasRunningTasks ( ) : boolean {
for ( const task of this . tasks . values ( ) ) {
if ( task . status === "running" ) return true
}
return false
}
2026-01-02 22:25:49 +09:00
private pruneStaleTasksAndNotifications ( ) : void {
const now = Date . now ( )
for ( const [ taskId , task ] of this . tasks . entries ( ) ) {
const age = now - task . startedAt . getTime ( )
if ( age > TASK_TTL_MS ) {
log ( "[background-agent] Pruning stale task:" , { taskId , age : Math.round ( age / 1000 ) + "s" } )
task . status = "error"
task . error = "Task timed out after 30 minutes"
task . completedAt = new Date ( )
this . clearNotificationsForTask ( taskId )
this . tasks . delete ( taskId )
subagentSessions . delete ( task . sessionID )
}
}
for ( const [ sessionID , notifications ] of this . notifications . entries ( ) ) {
if ( notifications . length === 0 ) {
this . notifications . delete ( sessionID )
continue
}
const validNotifications = notifications . filter ( ( task ) = > {
const age = now - task . startedAt . getTime ( )
return age <= TASK_TTL_MS
} )
if ( validNotifications . length === 0 ) {
this . notifications . delete ( sessionID )
} else if ( validNotifications . length !== notifications . length ) {
this . notifications . set ( sessionID , validNotifications )
}
}
}
2025-12-11 17:12:45 +09:00
private async pollRunningTasks ( ) : Promise < void > {
2026-01-02 22:25:49 +09:00
this . pruneStaleTasksAndNotifications ( )
2025-12-11 17:38:01 +09:00
const statusResult = await this . client . session . status ( )
const allStatuses = ( statusResult . data ? ? { } ) as Record < string , { type : string } >
2025-12-11 17:12:45 +09:00
for ( const task of this . tasks . values ( ) ) {
if ( task . status !== "running" ) continue
try {
2025-12-11 17:38:01 +09:00
const sessionStatus = allStatuses [ task . sessionID ]
if ( ! sessionStatus ) {
2025-12-11 17:42:33 +09:00
log ( "[background-agent] Session not found in status:" , task . sessionID )
2025-12-11 17:12:45 +09:00
continue
}
2025-12-11 17:38:01 +09:00
if ( sessionStatus . type === "idle" ) {
2025-12-15 23:54:59 +09:00
const hasIncompleteTodos = await this . checkSessionTodos ( task . sessionID )
if ( hasIncompleteTodos ) {
log ( "[background-agent] Task has incomplete todos via polling, waiting:" , task . id )
continue
}
2025-12-11 17:12:45 +09:00
task . status = "completed"
task . completedAt = new Date ( )
this . markForNotification ( task )
2025-12-11 17:15:02 +09:00
this . notifyParentSession ( task )
2025-12-11 17:42:33 +09:00
log ( "[background-agent] Task completed via polling:" , task . id )
2025-12-11 17:12:45 +09:00
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 }
2025-12-13 13:05:12 +09:00
parts? : Array < { type ? : string ; tool? : string ; name? : string ; text? : string } >
2025-12-11 17:12:45 +09:00
} >
const assistantMsgs = messages . filter (
( m ) = > m . info ? . role === "assistant"
)
let toolCalls = 0
let lastTool : string | undefined
2025-12-13 13:05:12 +09:00
let lastMessage : string | undefined
2025-12-11 17:12:45 +09:00
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"
}
2025-12-13 13:05:12 +09:00
if ( part . type === "text" && part . text ) {
lastMessage = part . text
}
2025-12-11 17:12:45 +09:00
}
}
2025-12-11 17:23:40 +09:00
if ( ! task . progress ) {
task . progress = { toolCalls : 0 , lastUpdate : new Date ( ) }
2025-12-11 17:12:45 +09:00
}
2025-12-11 17:23:40 +09:00
task . progress . toolCalls = toolCalls
task . progress . lastTool = lastTool
task . progress . lastUpdate = new Date ( )
2025-12-13 13:05:12 +09:00
if ( lastMessage ) {
task . progress . lastMessage = lastMessage
task . progress . lastMessageAt = new Date ( )
}
2025-12-11 17:12:45 +09:00
}
2025-12-11 17:42:33 +09:00
} catch ( error ) {
log ( "[background-agent] Poll error for task:" , { taskId : task.id , error } )
2025-12-11 17:12:45 +09:00
}
}
if ( ! this . hasRunningTasks ( ) ) {
this . stopPolling ( )
}
}
2025-12-11 15:45:37 +09:00
}