@@ -985,6 +985,144 @@ describe('LogPoller', () => {
985985 db . close ( )
986986 } )
987987
988+ test ( 'matches new session to window when existing session is orphaned between polls' , async ( ) => {
989+ const db = initDatabase ( { path : ':memory:' } )
990+ const registry = new SessionRegistry ( )
991+ registry . replaceSessions ( [ baseSession ] )
992+
993+ const tokensA = Array . from ( { length : 60 } , ( _ , i ) => `token${ i } ` ) . join ( ' ' )
994+ setTmuxOutput ( baseSession . tmuxWindow , buildLastExchangeOutput ( tokensA ) )
995+
996+ const projectPath = baseSession . projectPath
997+ const encoded = encodeProjectPath ( projectPath )
998+ const logDir = path . join (
999+ process . env . CLAUDE_CONFIG_DIR ?? '' ,
1000+ 'projects' ,
1001+ encoded
1002+ )
1003+ await fs . mkdir ( logDir , { recursive : true } )
1004+
1005+ // Poll 1: Session A is discovered and matched to the window
1006+ const logPathA = path . join ( logDir , 'session-a.jsonl' )
1007+ const lineA = buildUserLogEntry ( tokensA , {
1008+ sessionId : 'claude-session-a' ,
1009+ cwd : projectPath ,
1010+ } )
1011+ const assistantLineA = JSON . stringify ( {
1012+ type : 'assistant' ,
1013+ message : { content : [ { type : 'text' , text : tokensA } ] } ,
1014+ } )
1015+ await fs . writeFile ( logPathA , `${ lineA } \n${ assistantLineA } \n` )
1016+
1017+ const poller = new LogPoller ( db , registry , {
1018+ matchWorkerClient : new InlineMatchWorkerClient ( ) ,
1019+ } )
1020+ await poller . pollOnce ( )
1021+
1022+ const recordA = db . getSessionById ( 'claude-session-a' )
1023+ expect ( recordA ?. currentWindow ) . toBe ( baseSession . tmuxWindow )
1024+
1025+ // Between polls: Session A's window claim is removed (simulating orphan cleanup)
1026+ db . updateSession ( 'claude-session-a' , { currentWindow : null } )
1027+ const orphanedA = db . getSessionById ( 'claude-session-a' )
1028+ expect ( orphanedA ?. currentWindow ) . toBeNull ( )
1029+
1030+ // Poll 2: Session B appears with different content.
1031+ // The window is now unclaimed, so the optimization should allow matching.
1032+ const tokensB = Array . from ( { length : 60 } , ( _ , i ) => `next${ i } ` ) . join ( ' ' )
1033+ setTmuxOutput ( baseSession . tmuxWindow , buildLastExchangeOutput ( tokensB ) )
1034+
1035+ const logPathB = path . join ( logDir , 'session-b.jsonl' )
1036+ const lineB = buildUserLogEntry ( tokensB , {
1037+ sessionId : 'claude-session-b' ,
1038+ cwd : projectPath ,
1039+ } )
1040+ const assistantLineB = JSON . stringify ( {
1041+ type : 'assistant' ,
1042+ message : { content : [ { type : 'text' , text : tokensB } ] } ,
1043+ } )
1044+ await fs . writeFile ( logPathB , `${ lineB } \n${ assistantLineB } \n` )
1045+
1046+ await poller . pollOnce ( )
1047+
1048+ // Session B should get matched to the now-unclaimed window
1049+ const newRecord = db . getSessionById ( 'claude-session-b' )
1050+ expect ( newRecord ?. currentWindow ) . toBe ( baseSession . tmuxWindow )
1051+
1052+ db . close ( )
1053+ } )
1054+
1055+ test ( 'new session with slug supersedes claimed window without needing match' , async ( ) => {
1056+ const db = initDatabase ( { path : ':memory:' } )
1057+ const registry = new SessionRegistry ( )
1058+ registry . replaceSessions ( [ baseSession ] )
1059+
1060+ const tokensA = Array . from ( { length : 60 } , ( _ , i ) => `token${ i } ` ) . join ( ' ' )
1061+ setTmuxOutput ( baseSession . tmuxWindow , buildLastExchangeOutput ( tokensA ) )
1062+
1063+ const projectPath = baseSession . projectPath
1064+ const encoded = encodeProjectPath ( projectPath )
1065+ const logDir = path . join (
1066+ process . env . CLAUDE_CONFIG_DIR ?? '' ,
1067+ 'projects' ,
1068+ encoded
1069+ )
1070+ await fs . mkdir ( logDir , { recursive : true } )
1071+
1072+ // Poll 1: Session A gets matched to the window (with slug)
1073+ const logPathA = path . join ( logDir , 'session-a.jsonl' )
1074+ const lineA = buildUserLogEntry ( tokensA , {
1075+ sessionId : 'claude-session-a' ,
1076+ cwd : projectPath ,
1077+ slug : 'supersede-slug' ,
1078+ } )
1079+ const assistantLineA = JSON . stringify ( {
1080+ type : 'assistant' ,
1081+ message : { content : [ { type : 'text' , text : tokensA } ] } ,
1082+ } )
1083+ await fs . writeFile ( logPathA , `${ lineA } \n${ assistantLineA } \n` )
1084+
1085+ const poller = new LogPoller ( db , registry , {
1086+ matchWorkerClient : new InlineMatchWorkerClient ( ) ,
1087+ } )
1088+ await poller . pollOnce ( )
1089+
1090+ const recordA = db . getSessionById ( 'claude-session-a' )
1091+ expect ( recordA ?. currentWindow ) . toBe ( baseSession . tmuxWindow )
1092+
1093+ // Poll 2: Session B appears with the same slug + project.
1094+ // The window is still claimed by A, so match-based matching is skipped
1095+ // for this window. However, slug supersede should detach A and give
1096+ // the window to B without needing rg matching.
1097+ const tokensB = Array . from ( { length : 60 } , ( _ , i ) => `next${ i } ` ) . join ( ' ' )
1098+ setTmuxOutput ( baseSession . tmuxWindow , buildLastExchangeOutput ( tokensB ) )
1099+
1100+ const logPathB = path . join ( logDir , 'session-b.jsonl' )
1101+ const lineB = buildUserLogEntry ( tokensB , {
1102+ sessionId : 'claude-session-b' ,
1103+ cwd : projectPath ,
1104+ slug : 'supersede-slug' ,
1105+ } )
1106+ const assistantLineB = JSON . stringify ( {
1107+ type : 'assistant' ,
1108+ message : { content : [ { type : 'text' , text : tokensB } ] } ,
1109+ } )
1110+ await fs . writeFile ( logPathB , `${ lineB } \n${ assistantLineB } \n` )
1111+
1112+ await poller . pollOnce ( )
1113+
1114+ // Session A should be detached (superseded by slug)
1115+ const oldRecord = db . getSessionById ( 'claude-session-a' )
1116+ expect ( oldRecord ?. currentWindow ) . toBeNull ( )
1117+
1118+ // Session B should claim the window via slug supersede,
1119+ // even though the optimization skipped rg matching for claimed windows
1120+ const newRecord = db . getSessionById ( 'claude-session-b' )
1121+ expect ( newRecord ?. currentWindow ) . toBe ( baseSession . tmuxWindow )
1122+
1123+ db . close ( )
1124+ } )
1125+
9881126 test ( 'ignores external windows in name-based orphan fallback' , async ( ) => {
9891127 const db = initDatabase ( { path : ':memory:' } )
9901128 const registry = new SessionRegistry ( )
0 commit comments