@@ -183,33 +183,205 @@ export const SessionPlugin: Plugin = async (ctx) => {
183183 } )
184184 . join ( "\n" )
185185
186- // Store pending messages for agent relay communication
187- // Map<sessionID, { agent?, text }>
186+ // State machine for compaction flow
187+ type CompactionState = {
188+ phase : 'waiting_for_first_abort' | 'compacting' | 'waiting_for_compaction_complete' | 'compaction_done' | 'ready_to_send'
189+ providerID : string
190+ modelID : string
191+ agent ?: string
192+ text : string
193+ }
194+ const compactionState = new Map < string , CompactionState > ( )
195+
196+ // Store pending messages for agent relay communication (message mode only)
188197 const pendingMessages = new Map < string , { agent ?: string , text : string } > ( )
189198
199+ // Store tool call IDs for compact mode
200+ const compactCalls = new Map < string , string > ( ) // callID -> sessionID
201+
202+ // Store metadata for compact mode
203+ const compactMetadata = new Map < string , { providerID : string , modelID : string , agent ?: string , text : string } > ( )
204+
190205 return {
191206 // Hook: Before tool execution - save args for message and compact modes
192207 "tool.execute.before" : async ( input , output ) => {
193208 if ( input . tool === "session" ) {
194209 const args = output . args as { mode : string , text : string , agent ?: string }
195210
196- if ( args . mode === "message" || args . mode === "compact" ) {
211+ if ( args . mode === "message" ) {
197212 // Store message for later - will be sent on session.idle event
198213 pendingMessages . set ( input . sessionID , {
199214 agent : args . agent ,
200215 text : args . text
201216 } )
217+ log ( '[tool.execute.before] Message mode: stored pending message' , {
218+ sessionID : input . sessionID ,
219+ agent : args . agent
220+ } )
221+ } else if ( args . mode === "compact" ) {
222+ // Track compact calls for tool.execute.after
223+ compactCalls . set ( input . callID , input . sessionID )
224+ log ( '[tool.execute.before] Compact mode: tracked call' , {
225+ callID : input . callID ,
226+ sessionID : input . sessionID
227+ } )
228+ }
229+ }
230+ } ,
231+
232+ // Hook: After tool execution - handle compact mode with abort
233+ "tool.execute.after" : async ( input , output ) => {
234+ const sessionID = compactCalls . get ( input . callID )
235+
236+ if ( sessionID ) {
237+ const metadata = compactMetadata . get ( sessionID )
238+
239+ if ( metadata ) {
240+ log ( '=== TOOL.EXECUTE.AFTER: Compact mode detected ===' , {
241+ callID : input . callID ,
242+ sessionID,
243+ metadata
244+ } )
245+
246+ // Store compaction state
247+ compactionState . set ( sessionID , {
248+ phase : 'waiting_for_first_abort' ,
249+ providerID : metadata . providerID ,
250+ modelID : metadata . modelID ,
251+ agent : metadata . agent ,
252+ text : metadata . text
253+ } )
254+
255+ log ( '[tool.execute.after] Stored compaction state' , compactionState . get ( sessionID ) )
256+
257+ // Clean up call tracking and metadata
258+ compactCalls . delete ( input . callID )
259+ compactMetadata . delete ( sessionID )
260+
261+ // Wait 100ms for agent to finish processing tool result
262+ log ( '[tool.execute.after] Waiting 100ms before first abort...' )
263+ await new Promise ( resolve => setTimeout ( resolve , 100 ) )
264+
265+ // Abort #1: Stop the agent
266+ log ( '[tool.execute.after] Calling first abort to stop agent...' )
267+ try {
268+ await ctx . client . session . abort ( {
269+ path : { id : sessionID }
270+ } )
271+ log ( '[tool.execute.after] First abort succeeded, waiting for session.idle #1' )
272+ } catch ( error ) {
273+ log ( '[tool.execute.after] First abort failed' , error )
274+ // Clean up state on error
275+ compactionState . delete ( sessionID )
276+ }
202277 }
203278 }
204279 } ,
205280
206- // Hook: Listen for session.idle event to send queued messages
281+ // Hook: Listen for session.idle and session.compacted events
207282 event : async ( { event } ) => {
283+ // Type guard for events with sessionID
284+ if ( ! ( 'properties' in event ) || ! ( 'sessionID' in event . properties ) ) {
285+ return
286+ }
287+
288+ const sessionID = event . properties . sessionID as string
289+
290+ // ===== COMPACTION FLOW: Handle session.idle =====
291+ if ( event . type === "session.idle" ) {
292+ const state = compactionState . get ( sessionID )
293+
294+ // IDLE #1: After first abort - start compaction
295+ if ( state ?. phase === 'waiting_for_first_abort' ) {
296+ log ( '=== EVENT: session.idle #1 - Starting compaction ===' , { sessionID } )
297+ state . phase = 'compacting'
298+
299+ try {
300+ log ( '[event] Calling session.summarize' , {
301+ providerID : state . providerID ,
302+ modelID : state . modelID
303+ } )
304+
305+ await ctx . client . session . summarize ( {
306+ path : { id : sessionID } ,
307+ body : {
308+ providerID : state . providerID ,
309+ modelID : state . modelID
310+ }
311+ } )
312+
313+ state . phase = 'waiting_for_compaction_complete'
314+ log ( '[event] Compaction started, waiting for session.compacted event' )
315+ } catch ( error ) {
316+ log ( '[event] Compaction failed' , error )
317+ compactionState . delete ( sessionID )
318+ }
319+ return
320+ }
321+
322+ // IDLE #2: After second abort - send message
323+ if ( state ?. phase === 'ready_to_send' ) {
324+ log ( '=== EVENT: session.idle #2 - Sending message ===' , {
325+ sessionID,
326+ agent : state . agent ,
327+ text : state . text
328+ } )
329+
330+ try {
331+ await ctx . client . session . prompt ( {
332+ path : { id : sessionID } ,
333+ body : {
334+ agent : state . agent ,
335+ parts : [ { type : "text" , text : state . text } ]
336+ }
337+ } )
338+ log ( '[event] Message sent successfully, cleaning up state' )
339+ } catch ( error ) {
340+ log ( '[event] Failed to send message' , error )
341+ } finally {
342+ compactionState . delete ( sessionID )
343+ }
344+ return
345+ }
346+ }
347+
348+ // ===== COMPACTION FLOW: Handle session.compacted =====
349+ if ( event . type === "session.compacted" ) {
350+ const state = compactionState . get ( sessionID )
351+
352+ if ( state ?. phase === 'waiting_for_compaction_complete' ) {
353+ log ( '=== EVENT: session.compacted - Compaction done! ===' , { sessionID } )
354+ state . phase = 'compaction_done'
355+
356+ // Wait 100ms for lock to fully release
357+ log ( '[event] Waiting 100ms for lock to fully release...' )
358+ await new Promise ( resolve => setTimeout ( resolve , 100 ) )
359+
360+ // Abort #2: Ensure clean state
361+ log ( '[event] Calling second abort immediately after compaction' )
362+ try {
363+ await ctx . client . session . abort ( {
364+ path : { id : sessionID }
365+ } )
366+ state . phase = 'ready_to_send'
367+ log ( '[event] Second abort succeeded, ready to send message on next idle' )
368+ } catch ( error ) {
369+ log ( '[event] Second abort failed' , error )
370+ compactionState . delete ( sessionID )
371+ }
372+ }
373+ }
374+
375+ // ===== MESSAGE MODE: Handle session.idle for normal message relay =====
208376 if ( event . type === "session.idle" ) {
209- const sessionID = event . properties . sessionID
210377 const pending = pendingMessages . get ( sessionID )
211378
212379 if ( pending ) {
380+ log ( '=== EVENT: session.idle - Message mode ===' , {
381+ sessionID,
382+ agent : pending . agent
383+ } )
384+
213385 // Remove from queue
214386 pendingMessages . delete ( sessionID )
215387
@@ -222,8 +394,9 @@ export const SessionPlugin: Plugin = async (ctx) => {
222394 parts : [ { type : "text" , text : pending . text } ]
223395 }
224396 } )
397+ log ( '[event] Message mode: message sent successfully' )
225398 } catch ( error ) {
226- console . error ( '[session-plugin] Failed to send message on session.idle: ' , error )
399+ log ( '[event] Message mode: failed to send message' , error )
227400 }
228401 }
229402 }
@@ -391,73 +564,29 @@ EXAMPLES:
391564 } ]
392565 }
393566 } )
394- log ( 'Context message injected successfully' )
567+ log ( '[tool.execute] Context message injected successfully' )
395568
396- // Trigger compaction with model parameters
397- log ( 'Calling session.summarize with model parameters...' )
398- log ( 'Request body:' , { providerID, modelID } )
399- const startTime = Date . now ( )
400-
401- const result = await ctx . client . session . summarize ( {
402- path : { id : toolCtx . sessionID } ,
403- body : {
404- providerID,
405- modelID
406- }
407- } )
408-
409- const duration = Date . now ( ) - startTime
410- log ( 'session.summarize returned' , {
411- result,
412- duration_ms : duration
413- } )
414-
415- // Check if summarize returned an error
416- if ( result . error ) {
417- log ( 'session.summarize returned error:' , result . error )
418-
419- // Check for SessionLockedError using type-safe approach
420- const errorObj = result . error as any
421- if ( errorObj . name === 'SessionLockedError' || errorObj . data ?. message ?. includes ( 'locked' ) ) {
422- log ( '=== COMPACT MODE END (FAILED - SESSION LOCKED) ===\n' )
423- return "Error: Cannot compact session while it is active. Session compaction can only be performed when the session is idle."
424- }
425-
426- log ( '=== COMPACT MODE END (FAILED - API ERROR) ===\n' )
427- const errorMessage = errorObj . data ?. message || errorObj . message || 'Unknown error'
428- throw new Error ( `Compaction failed: ${ errorObj . name || 'APIError' } - ${ errorMessage } ` )
429- }
430-
431- // Verify compaction happened
432- log ( 'Fetching messages to verify compaction...' )
433- const msgsAfter = await ctx . client . session . messages ( {
434- path : { id : toolCtx . sessionID }
435- } )
436-
437- log ( 'Post-compaction state' , {
438- total_messages : msgsAfter . data . length ,
439- assistant_messages : msgsAfter . data . filter ( m => m . info . role === "assistant" ) . length ,
440- user_messages : msgsAfter . data . filter ( m => m . info . role === "user" ) . length ,
441- message_count_before : msgs . data . length ,
442- message_count_after : msgsAfter . data . length ,
443- compaction_occurred : msgs . data . length > msgsAfter . data . length
569+ // Store metadata in map for tool.execute.after hook (using sessionID as key)
570+ // DO NOT call session.summarize() here - causes SessionLockedError!
571+ compactMetadata . set ( toolCtx . sessionID , {
572+ providerID,
573+ modelID,
574+ agent : args . agent ,
575+ text : args . text
444576 } )
445577
446- // Check for compacted messages
447- const compactedMsgs = msgsAfter . data . filter ( m =>
448- m . info . role === "assistant" && ( m . info as any ) . summary === true
449- )
450- log ( 'Compacted messages found:' , {
451- count : compactedMsgs . length ,
452- message_ids : compactedMsgs . map ( m => m . info . id )
578+ log ( '[tool.execute] Stored metadata for tool.execute.after' , {
579+ sessionID : toolCtx . sessionID ,
580+ providerID,
581+ modelID,
582+ agent : args . agent
453583 } )
584+ log ( '=== COMPACT MODE END (returning) ===\n' )
454585
455- log ( '=== COMPACT MODE END (SUCCESS) ===\n' )
456-
457- // Message stored in tool.execute.before will be sent via session.idle
586+ // Return immediately - compaction will happen asynchronously via events
458587 return args . agent
459- ? `Compacting session with ${ providerID } / ${ modelID } ... ${ args . agent } agent will respond after completion .`
460- : `Compacting session with ${ providerID } / ${ modelID } ... response will continue after completion .`
588+ ? `Compacting session and handing off to ${ args . agent } . This will complete shortly .`
589+ : `Compacting session. This will complete shortly .`
461590
462591 } catch ( error ) {
463592 log ( '=== COMPACT MODE ERROR ===' , {
0 commit comments