diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index f07f82c8b..3f9ca54e7 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -137,6 +137,7 @@ export class TmuxSessionManager { this.retryPendingCloses.bind(this), this.queryWindowStateSafely.bind(this), this.activateTrackedSessionPane.bind(this), + this.canAutoActivatePane.bind(this), ) this.deps.log("[tmux-session-manager] initialized", { configEnabled: this.tmuxConfig.enabled, @@ -385,6 +386,11 @@ export class TmuxSessionManager { return true } + private canAutoActivatePane(state: WindowState): boolean { + if (!this.isIsolated()) return true + return state.windowActive && state.sessionAttached + } + private async closeTrackedSessionPane(args: { tracked: TrackedSession state: WindowState diff --git a/src/features/tmux-subagent/pane-state-parser.test.ts b/src/features/tmux-subagent/pane-state-parser.test.ts index 991c3fd95..87839203e 100644 --- a/src/features/tmux-subagent/pane-state-parser.test.ts +++ b/src/features/tmux-subagent/pane-state-parser.test.ts @@ -6,7 +6,7 @@ import { parsePaneStateOutput } from "./pane-state-parser" describe("parsePaneStateOutput", () => { it("rejects malformed integer fields", () => { // given - const stdout = "%0\t120oops\t40\t0\t0\t1\t120\t40\n" + const stdout = "%0\t120oops\t40\t0\t0\t1\t120\t40\t1\t1\n" // when const result = parsePaneStateOutput(stdout) @@ -17,7 +17,7 @@ describe("parsePaneStateOutput", () => { it("rejects negative integer fields", () => { // given - const stdout = "%0\t-1\t40\t0\t0\t1\t120\t40\n" + const stdout = "%0\t-1\t40\t0\t0\t1\t120\t40\t1\t1\n" // when const result = parsePaneStateOutput(stdout) @@ -28,7 +28,7 @@ describe("parsePaneStateOutput", () => { it("rejects empty integer fields", () => { // given - const stdout = "%0\t\t40\t0\t0\t1\t120\t40\n" + const stdout = "%0\t\t40\t0\t0\t1\t120\t40\t1\t1\n" // when const result = parsePaneStateOutput(stdout) @@ -39,7 +39,7 @@ describe("parsePaneStateOutput", () => { it("rejects non-binary active flags", () => { // given - const stdout = "%0\t120\t40\t0\t0\tx\t120\t40\n" + const stdout = "%0\t120\t40\t0\t0\tx\t120\t40\t1\t1\n" // when const result = parsePaneStateOutput(stdout) @@ -50,7 +50,7 @@ describe("parsePaneStateOutput", () => { it("rejects numeric active flags other than zero or one", () => { // given - const stdout = "%0\t120\t40\t0\t0\t2\t120\t40\n" + const stdout = "%0\t120\t40\t0\t0\t2\t120\t40\t1\t1\n" // when const result = parsePaneStateOutput(stdout) @@ -61,7 +61,18 @@ describe("parsePaneStateOutput", () => { it("rejects empty active flags", () => { // given - const stdout = "%0\t120\t40\t0\t0\t\t120\t40\n" + const stdout = "%0\t120\t40\t0\t0\t\t120\t40\t1\t1\n" + + // when + const result = parsePaneStateOutput(stdout) + + // then + expect(result).toBe(null) + }) + + it("rejects malformed session attached field", () => { + // given + const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t1\tnope\n" // when const result = parsePaneStateOutput(stdout) diff --git a/src/features/tmux-subagent/pane-state-parser.ts b/src/features/tmux-subagent/pane-state-parser.ts index 3ae6579d8..97e240ee2 100644 --- a/src/features/tmux-subagent/pane-state-parser.ts +++ b/src/features/tmux-subagent/pane-state-parser.ts @@ -1,10 +1,12 @@ import type { TmuxPaneInfo } from "./types" -const MANDATORY_PANE_FIELD_COUNT = 8 +const MANDATORY_PANE_FIELD_COUNT = 10 type ParsedPaneState = { windowWidth: number windowHeight: number + windowActive: boolean + sessionAttached: boolean panes: TmuxPaneInfo[] } @@ -12,6 +14,8 @@ type ParsedPaneLine = { pane: TmuxPaneInfo windowWidth: number windowHeight: number + windowActive: boolean + sessionAttached: boolean } type MandatoryPaneFields = [ @@ -23,6 +27,8 @@ type MandatoryPaneFields = [ activeString: string, windowWidthString: string, windowHeightString: string, + windowActiveString: string, + sessionAttachedString: string, ] export function parsePaneStateOutput(stdout: string): ParsedPaneState | null { @@ -45,6 +51,8 @@ export function parsePaneStateOutput(stdout: string): ParsedPaneState | null { return { windowWidth: latestPaneLine.windowWidth, windowHeight: latestPaneLine.windowHeight, + windowActive: latestPaneLine.windowActive, + sessionAttached: latestPaneLine.sessionAttached, panes: parsedPaneLines.map(({ pane }) => pane), } } @@ -54,7 +62,7 @@ function parsePaneLine(line: string): ParsedPaneLine | null { const mandatoryFields = getMandatoryPaneFields(fields) if (!mandatoryFields) return null - const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = mandatoryFields + const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString, windowActiveString, sessionAttachedString] = mandatoryFields const width = parseInteger(widthString) const height = parseInteger(heightString) @@ -63,6 +71,8 @@ function parsePaneLine(line: string): ParsedPaneLine | null { const isActive = parseActiveValue(activeString) const windowWidth = parseInteger(windowWidthString) const windowHeight = parseInteger(windowHeightString) + const windowActive = parseActiveValue(windowActiveString) + const sessionAttached = parseAttachedValue(sessionAttachedString) if ( width === null || @@ -71,7 +81,9 @@ function parsePaneLine(line: string): ParsedPaneLine | null { top === null || isActive === null || windowWidth === null || - windowHeight === null + windowHeight === null || + windowActive === null || + sessionAttached === null ) { return null } @@ -88,13 +100,15 @@ function parsePaneLine(line: string): ParsedPaneLine | null { }, windowWidth, windowHeight, + windowActive, + sessionAttached, } } function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null { if (fields.length < MANDATORY_PANE_FIELD_COUNT) return null - const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = fields + const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString, windowActiveString, sessionAttachedString] = fields if ( paneId === undefined || @@ -104,7 +118,9 @@ function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null { topString === undefined || activeString === undefined || windowWidthString === undefined || - windowHeightString === undefined + windowHeightString === undefined || + windowActiveString === undefined || + sessionAttachedString === undefined ) { return null } @@ -118,6 +134,8 @@ function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null { activeString, windowWidthString, windowHeightString, + windowActiveString, + sessionAttachedString, ] } @@ -133,3 +151,8 @@ function parseActiveValue(value: string): boolean | null { if (value === "0") return false return null } + +function parseAttachedValue(value: string): boolean | null { + if (!/^\d+$/.test(value)) return null + return Number.parseInt(value, 10) > 0 +} diff --git a/src/features/tmux-subagent/pane-state-querier.test.ts b/src/features/tmux-subagent/pane-state-querier.test.ts index 708889246..da3a6a45c 100644 --- a/src/features/tmux-subagent/pane-state-querier.test.ts +++ b/src/features/tmux-subagent/pane-state-querier.test.ts @@ -6,7 +6,7 @@ import { parsePaneStateOutput } from "./pane-state-parser" describe("parsePaneStateOutput", () => { it("accepts a single pane when tmux omits the empty trailing title field", () => { // given - const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\n" + const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t1\t1\n" // when const result = parsePaneStateOutput(stdout) @@ -16,6 +16,8 @@ describe("parsePaneStateOutput", () => { expect(result).toEqual({ windowWidth: 120, windowHeight: 40, + windowActive: true, + sessionAttached: true, panes: [ { paneId: "%0", @@ -32,7 +34,7 @@ describe("parsePaneStateOutput", () => { it("handles CRLF line endings without dropping panes", () => { // given - const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\r\n%1\t60\t40\t60\t0\t0\t120\t40\tagent\r\n" + const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t1\t1\r\n%1\t60\t40\t60\t0\t0\t120\t40\t1\t1\tagent\r\n" // when const result = parsePaneStateOutput(stdout) @@ -63,13 +65,15 @@ describe("parsePaneStateOutput", () => { it("preserves tabs inside pane titles", () => { // given - const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\ttitle\twith\ttabs\n" + const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t0\t0\ttitle\twith\ttabs\n" // when const result = parsePaneStateOutput(stdout) // then expect(result).not.toBe(null) + expect(result?.windowActive).toBe(false) + expect(result?.sessionAttached).toBe(false) expect(result?.panes[0]?.title).toBe("title\twith\ttabs") }) }) diff --git a/src/features/tmux-subagent/pane-state-querier.ts b/src/features/tmux-subagent/pane-state-querier.ts index 3dfa911ef..1b0c1a104 100644 --- a/src/features/tmux-subagent/pane-state-querier.ts +++ b/src/features/tmux-subagent/pane-state-querier.ts @@ -19,7 +19,7 @@ export async function queryWindowStateWithDeps(sourcePaneId: string, deps: Query "-t", sourcePaneId, "-F", - "#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{pane_title}", + "#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{window_active}\t#{session_attached}\t#{pane_title}", ]) if (result.exitCode !== 0) { @@ -38,6 +38,8 @@ export async function queryWindowStateWithDeps(sourcePaneId: string, deps: Query const { panes } = parsedPaneState const windowWidth = parsedPaneState.windowWidth const windowHeight = parsedPaneState.windowHeight + const windowActive = parsedPaneState.windowActive + const sessionAttached = parsedPaneState.sessionAttached panes.sort((a, b) => a.left - b.left || a.top - b.top) @@ -71,7 +73,7 @@ export async function queryWindowStateWithDeps(sourcePaneId: string, deps: Query agentPaneCount: agentPanes.length, }) - return { windowWidth, windowHeight, mainPane, agentPanes } + return { windowWidth, windowHeight, windowActive, sessionAttached, mainPane, agentPanes } } export async function queryWindowState(sourcePaneId: string): Promise { diff --git a/src/features/tmux-subagent/polling-manager.test.ts b/src/features/tmux-subagent/polling-manager.test.ts index 69526610c..f9dd28359 100644 --- a/src/features/tmux-subagent/polling-manager.test.ts +++ b/src/features/tmux-subagent/polling-manager.test.ts @@ -269,6 +269,8 @@ describe("TmuxPollingManager overlap", () => { const windowState: WindowState = { windowWidth: 160, windowHeight: 48, + windowActive: true, + sessionAttached: true, mainPane: null, agentPanes: [ { paneId: "%1", width: 80, height: 24, left: 0, top: 0, title: "agent", isActive: true }, diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts index 9c457a20c..5d22297c1 100644 --- a/src/features/tmux-subagent/polling-manager.ts +++ b/src/features/tmux-subagent/polling-manager.ts @@ -24,6 +24,7 @@ export class TmuxPollingManager { private retryPendingCloses?: () => Promise, private getWindowState?: () => Promise, private activateSessionPane?: (tracked: TrackedSession) => Promise, + private canActivatePane: (state: WindowState) => boolean = (state) => state.windowActive !== false && state.sessionAttached !== false, ) {} handleEvent(event: { type: string; properties?: Record }): void { @@ -220,6 +221,13 @@ export class TmuxPollingManager { const state = await this.getWindowState().catch(() => null) if (!state) return + if (this.canActivatePane && !this.canActivatePane(state)) { + log("[tmux-session-manager] activation gate blocked auto-attach", { + windowActive: state.windowActive, + sessionAttached: state.sessionAttached, + }) + return + } const panes = [state.mainPane, ...state.agentPanes].filter((pane): pane is NonNullable => Boolean(pane)) const activePaneIds = new Set(panes.filter((pane) => pane.isActive).map((pane) => pane.paneId)) diff --git a/src/features/tmux-subagent/types.ts b/src/features/tmux-subagent/types.ts index 27567afa5..9d120088a 100644 --- a/src/features/tmux-subagent/types.ts +++ b/src/features/tmux-subagent/types.ts @@ -31,6 +31,8 @@ export interface TmuxPaneInfo { export interface WindowState { windowWidth: number windowHeight: number + windowActive?: boolean + sessionAttached?: boolean mainPane: TmuxPaneInfo | null agentPanes: TmuxPaneInfo[] }