@@ -309,6 +309,99 @@ end tell`);
309309 }
310310} ;
311311
312+ /**
313+ * cmux cross-reference: refine PID-session mapping using per-surface TTY + title from tree output.
314+ * Same concept as iTerm2 cross-reference but uses cmux CLI instead of AppleScript.
315+ * Requires cmux build with TTY support in tree output (tty= field in surface lines).
316+ */
317+ const refineDetectionWithCmux = async (
318+ activeMap : Map < string , number > ,
319+ claimedSessionIds : Set < string > ,
320+ cwdProcesses : { pid : number ; line : string } [ ] ,
321+ allSessions : ClaudeSession [ ] ,
322+ execPromise : ( cmd : string ) => Promise < string > ,
323+ ) : Promise < void > => {
324+ if ( cwdProcesses . length === 0 ) return ;
325+
326+ // Quick check: is cmux running?
327+ const cmuxCheck = await execPromise ( 'pgrep -x cmux 2>/dev/null' ) ;
328+ if ( ! cmuxCheck . trim ( ) ) return ;
329+
330+ // Get tree output with TTY info
331+ const treeOutput = await execPromise ( `${ CMUX_CLI } tree --all 2>/dev/null` ) ;
332+ if ( ! treeOutput . trim ( ) ) return ;
333+
334+ // Parse surfaces with TTY: look for "tty=ttysNNN" in surface lines
335+ const cmuxSurfaces : { tty : string ; title : string } [ ] = [ ] ;
336+ for ( const line of treeOutput . split ( '\n' ) ) {
337+ const surfaceMatch = line . match ( / s u r f a c e ( s u r f a c e : \d + ) / ) ;
338+ if ( ! surfaceMatch ) continue ;
339+ const ttyMatch = line . match ( / t t y = ( \S + ) / ) ;
340+ if ( ! ttyMatch ) continue ;
341+ const titleMatch = line . match ( / \[ t e r m i n a l \] \s + " ( .+ ?) " \s * ( \[ | ◀ | t t y = | $ ) / ) ;
342+ cmuxSurfaces . push ( {
343+ tty : ttyMatch [ 1 ] ,
344+ title : titleMatch ? titleMatch [ 1 ] : '' ,
345+ } ) ;
346+ }
347+ if ( cmuxSurfaces . length === 0 ) return ;
348+
349+ // Load custom titles lazily per-cwd
350+ const claudeDir = path . join ( os . homedir ( ) , '.claude' , 'projects' ) ;
351+ const titleCache = new Map < string , Map < string , string > > ( ) ;
352+
353+ const getTitlesForCwd = async ( cwd : string ) : Promise < Map < string , string > > => {
354+ if ( titleCache . has ( cwd ) ) return titleCache . get ( cwd ) ! ;
355+ const titles = new Map < string , string > ( ) ;
356+ const candidates = allSessions . filter ( s => s . project === cwd ) ;
357+ const encodedProject = cwd . replace ( / [ ^ a - z A - Z 0 - 9 - ] / g, '-' ) ;
358+ await Promise . all ( candidates . map ( async ( session ) => {
359+ const jsonlPath = path . join ( claudeDir , encodedProject , `${ session . sessionId } .jsonl` ) ;
360+ if ( ! fs . existsSync ( jsonlPath ) ) return ;
361+ const out = await execPromise ( `grep '"type":"custom-title"' "${ jsonlPath } " 2>/dev/null | tail -1` ) ;
362+ try {
363+ const parsed = JSON . parse ( out . trim ( ) ) ;
364+ const title = ( parsed . customTitle || '' ) . replace ( / ^ " | " $ / g, '' ) . trim ( ) ;
365+ if ( title ) titles . set ( session . sessionId , title ) ;
366+ } catch { }
367+ } ) ) ;
368+ titleCache . set ( cwd , titles ) ;
369+ return titles ;
370+ } ;
371+
372+ // Cross-reference: for each cwd-fallback PID, match TTY → cmux surface → title → session
373+ for ( const { pid } of cwdProcesses ) {
374+ const ttyOutput = ( await execPromise ( `ps -o tty= -p ${ pid } 2>/dev/null` ) ) . trim ( ) ;
375+ if ( ! ttyOutput ) continue ;
376+
377+ const cmuxSurface = cmuxSurfaces . find ( s => s . tty . endsWith ( ttyOutput ) ) ;
378+ if ( ! cmuxSurface ) continue ;
379+
380+ const cwdOutput = await execPromise ( `lsof -p ${ pid } -Fn 2>/dev/null | grep "^n/" | head -1` ) ;
381+ const cwdMatch = cwdOutput . match ( / ^ n ( .+ ) $ / m) ;
382+ if ( ! cwdMatch ) continue ;
383+ const cwd = cwdMatch [ 1 ] ;
384+
385+ const sessionTitles = await getTitlesForCwd ( cwd ) ;
386+ if ( sessionTitles . size === 0 ) continue ;
387+
388+ const tabName = cmuxSurface . title ;
389+ for ( const [ sessionId , title ] of sessionTitles ) {
390+ if ( title && tabName . includes ( title ) && ! claimedSessionIds . has ( sessionId ) ) {
391+ const currentSessionId = [ ...activeMap . entries ( ) ] . find ( ( [ , p ] ) => p === pid ) ?. [ 0 ] ;
392+ if ( currentSessionId && currentSessionId !== sessionId ) {
393+ activeMap . delete ( currentSessionId ) ;
394+ claimedSessionIds . delete ( currentSessionId ) ;
395+ console . log ( `[cross-ref-cmux] corrected PID ${ pid } : ${ currentSessionId } → ${ sessionId } (surface: "${ tabName } ")` ) ;
396+ }
397+ activeMap . set ( sessionId , pid ) ;
398+ claimedSessionIds . add ( sessionId ) ;
399+ break ;
400+ }
401+ }
402+ }
403+ } ;
404+
312405export const detectActiveSessions = async ( ) : Promise < Map < string , number > > => {
313406 const now = Date . now ( ) ;
314407 if ( cachedActiveMap && ( now - activeCacheTimestamp ) < ACTIVE_CACHE_TTL_MS ) {
@@ -417,11 +510,11 @@ export const detectActiveSessions = async (): Promise<Map<string, number>> => {
417510 }
418511 }
419512
420- // Third pass: iTerm2 cross-reference for processes that were matched by cwd fallback.
421- // Uses iTerm2 AppleScript to get TTY + tab name for each session , then cross-references
513+ // Third pass: cross-reference for processes that were matched by cwd fallback.
514+ // Uses terminal-specific APIs to get TTY + tab name, then cross-references
422515 // with claude process TTYs to build a definitive PID → session ID mapping.
423- // Only runs when there are same-cwd processes that used cwd fallback (no UUID/title in args).
424516 await refineDetectionWithITerm2 ( activeMap , claimedSessionIds , cwdProcesses , allSessions , execPromise ) ;
517+ await refineDetectionWithCmux ( activeMap , claimedSessionIds , cwdProcesses , allSessions , execPromise ) ;
425518 } catch {
426519 // ignore
427520 }
0 commit comments