@@ -13,6 +13,7 @@ import { subagentSessions } from "../claude-code-session-state"
1313import { getTaskToastManager } from "../task-toast-manager"
1414
1515const TASK_TTL_MS = 30 * 60 * 1000
16+ const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in
1617
1718type OpencodeClient = PluginInput [ "client" ]
1819
@@ -40,9 +41,18 @@ interface Todo {
4041 id : string
4142}
4243
44+ export interface PendingNotification {
45+ taskId : string
46+ description : string
47+ duration : string
48+ status : "completed" | "error"
49+ error ?: string
50+ }
51+
4352export class BackgroundManager {
4453 private tasks : Map < string , BackgroundTask >
4554 private notifications : Map < string , BackgroundTask [ ] >
55+ private pendingNotifications : Map < string , PendingNotification [ ] >
4656 private client : OpencodeClient
4757 private directory : string
4858 private pollingInterval ?: ReturnType < typeof setInterval >
@@ -51,6 +61,7 @@ export class BackgroundManager {
5161 constructor ( ctx : PluginInput , config ?: BackgroundTaskConfig ) {
5262 this . tasks = new Map ( )
5363 this . notifications = new Map ( )
64+ this . pendingNotifications = new Map ( )
5465 this . client = ctx . client
5566 this . directory = ctx . directory
5667 this . concurrencyManager = new ConcurrencyManager ( config )
@@ -380,10 +391,21 @@ export class BackgroundManager {
380391 return this . notifications . get ( sessionID ) ?? [ ]
381392 }
382393
383- clearNotifications ( sessionID : string ) : void {
394+ clearNotifications ( sessionID : string ) : void {
384395 this . notifications . delete ( sessionID )
385396 }
386397
398+ hasPendingNotifications ( sessionID : string ) : boolean {
399+ const pending = this . pendingNotifications . get ( sessionID )
400+ return pending !== undefined && pending . length > 0
401+ }
402+
403+ consumePendingNotifications ( sessionID : string ) : PendingNotification [ ] {
404+ const pending = this . pendingNotifications . get ( sessionID ) ?? [ ]
405+ this . pendingNotifications . delete ( sessionID )
406+ return pending
407+ }
408+
387409 private clearNotificationsForTask ( taskId : string ) : void {
388410 for ( const [ sessionID , tasks ] of this . notifications . entries ( ) ) {
389411 const filtered = tasks . filter ( ( t ) => t . id !== taskId )
@@ -411,13 +433,14 @@ export class BackgroundManager {
411433 }
412434 }
413435
414- cleanup ( ) : void {
436+ cleanup ( ) : void {
415437 this . stopPolling ( )
416438 this . tasks . clear ( )
417439 this . notifications . clear ( )
440+ this . pendingNotifications . clear ( )
418441 }
419442
420- private notifyParentSession ( task : BackgroundTask ) : void {
443+ private notifyParentSession ( task : BackgroundTask ) : void {
421444 const duration = this . formatDuration ( task . startedAt , task . completedAt )
422445
423446 log ( "[background-agent] notifyParentSession called for task:" , task . id )
@@ -431,47 +454,34 @@ export class BackgroundManager {
431454 } )
432455 }
433456
434- const message = `[BACKGROUND TASK COMPLETED] Task "${ task . description } " finished in ${ duration } . Use background_output with task_id="${ task . id } " to get results.`
457+ // Store notification for silent injection via tool.execute.after hook
458+ const notification : PendingNotification = {
459+ taskId : task . id ,
460+ description : task . description ,
461+ duration,
462+ status : task . status === "error" ? "error" : "completed" ,
463+ error : task . error ,
464+ }
465+
466+ const existing = this . pendingNotifications . get ( task . parentSessionID ) ?? [ ]
467+ existing . push ( notification )
468+ this . pendingNotifications . set ( task . parentSessionID , existing )
435469
436- log ( "[background-agent] Sending notification to parent session:" , { parentSessionID : task . parentSessionID } )
470+ log ( "[background-agent] Stored pending notification for parent session:" , {
471+ parentSessionID : task . parentSessionID ,
472+ taskId : task . id
473+ } )
437474
438475 const taskId = task . id
439- setTimeout ( async ( ) => {
476+ setTimeout ( ( ) => {
440477 if ( task . concurrencyKey ) {
441478 this . concurrencyManager . release ( task . concurrencyKey )
479+ task . concurrencyKey = undefined // Prevent double-release
442480 }
443-
444- try {
445- const body : {
446- agent ?: string
447- model ?: { providerID : string ; modelID : string }
448- parts : Array < { type : "text" ; text : string } >
449- } = {
450- parts : [ { type : "text" , text : message } ] ,
451- }
452-
453- if ( task . parentAgent !== undefined ) {
454- body . agent = task . parentAgent
455- }
456-
457- if ( task . parentModel ?. providerID && task . parentModel ?. modelID ) {
458- body . model = { providerID : task . parentModel . providerID , modelID : task . parentModel . modelID }
459- }
460-
461- await this . client . session . prompt ( {
462- path : { id : task . parentSessionID } ,
463- body,
464- query : { directory : this . directory } ,
465- } )
466- log ( "[background-agent] Successfully sent prompt to parent session:" , { parentSessionID : task . parentSessionID } )
467- } catch ( error ) {
468- log ( "[background-agent] prompt failed:" , String ( error ) )
469- } finally {
470- this . clearNotificationsForTask ( taskId )
471- this . tasks . delete ( taskId )
472- log ( "[background-agent] Removed completed task from memory:" , taskId )
473- }
474- } , 200 )
481+ this . clearNotificationsForTask ( taskId )
482+ this . tasks . delete ( taskId )
483+ log ( "[background-agent] Removed completed task from memory:" , taskId )
484+ } , 5 * 60 * 1000 ) // 5 minutes retention for background_output retrieval
475485 }
476486
477487 private formatDuration ( start : Date , end ?: Date ) : string {
@@ -540,15 +550,11 @@ export class BackgroundManager {
540550 for ( const task of this . tasks . values ( ) ) {
541551 if ( task . status !== "running" ) continue
542552
543- try {
553+ try {
544554 const sessionStatus = allStatuses [ task . sessionID ]
545555
546- if ( ! sessionStatus ) {
547- log ( "[background-agent] Session not found in status:" , task . sessionID )
548- continue
549- }
550-
551- if ( sessionStatus . type === "idle" ) {
556+ // Don't skip if session not in status - fall through to message-based detection
557+ if ( sessionStatus ?. type === "idle" ) {
552558 const hasIncompleteTodos = await this . checkSessionTodos ( task . sessionID )
553559 if ( hasIncompleteTodos ) {
554560 log ( "[background-agent] Task has incomplete todos via polling, waiting:" , task . id )
@@ -599,10 +605,34 @@ export class BackgroundManager {
599605 task . progress . toolCalls = toolCalls
600606 task . progress . lastTool = lastTool
601607 task . progress . lastUpdate = new Date ( )
602- if ( lastMessage ) {
608+ if ( lastMessage ) {
603609 task . progress . lastMessage = lastMessage
604610 task . progress . lastMessageAt = new Date ( )
605611 }
612+
613+ // Stability detection: complete when message count unchanged for 3 polls
614+ const currentMsgCount = messages . length
615+ const elapsedMs = Date . now ( ) - task . startedAt . getTime ( )
616+
617+ if ( elapsedMs >= MIN_STABILITY_TIME_MS ) {
618+ if ( task . lastMsgCount === currentMsgCount ) {
619+ task . stablePolls = ( task . stablePolls ?? 0 ) + 1
620+ if ( task . stablePolls >= 3 ) {
621+ const hasIncompleteTodos = await this . checkSessionTodos ( task . sessionID )
622+ if ( ! hasIncompleteTodos ) {
623+ task . status = "completed"
624+ task . completedAt = new Date ( )
625+ this . markForNotification ( task )
626+ this . notifyParentSession ( task )
627+ log ( "[background-agent] Task completed via stability detection:" , task . id )
628+ continue
629+ }
630+ }
631+ } else {
632+ task . stablePolls = 0
633+ }
634+ }
635+ task . lastMsgCount = currentMsgCount
606636 }
607637 } catch ( error ) {
608638 log ( "[background-agent] Poll error for task:" , { taskId : task . id , error } )
0 commit comments