From 6cb7028ef742b449a08d40f7c12a1647b4c89a9c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 3 Apr 2026 18:12:33 +0900 Subject: [PATCH 1/2] fix(tmux): reassign anchor pane on first subagent deletion Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/tmux-subagent/manager.test.ts | 98 ++++++++++++++++++++++ src/features/tmux-subagent/manager.ts | 18 ++++ 2 files changed, 116 insertions(+) diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index 40179fe79..2e57c501d 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -1094,6 +1094,104 @@ describe('TmuxSessionManager', () => { expect(Reflect.get(manager, 'isolatedWindowPaneId')).toBeUndefined() }) + test('#given session isolation with another subagent still tracked #when the anchor subagent is deleted first #then it reassigns the anchor and cleans up when the last subagent exits', async () => { + // given + mockIsInsideTmux.mockReturnValue(true) + mockQueryWindowState.mockImplementation(async (paneId) => { + if (paneId === '%isolated-session-ses_first') { + return createWindowState({ + mainPane: { + paneId, + width: 110, + height: 44, + left: 0, + top: 0, + title: 'isolated', + isActive: true, + }, + agentPanes: [ + { + paneId: '%mock', + width: 40, + height: 44, + left: 110, + top: 0, + title: 'omo-subagent-Second Task', + isActive: false, + }, + ], + }) + } + + if (paneId === '%mock') { + return createWindowState({ + mainPane: { + paneId: '%isolated-session-ses_first', + width: 110, + height: 44, + left: 0, + top: 0, + title: 'isolated', + isActive: true, + }, + agentPanes: [ + { + paneId, + width: 40, + height: 44, + left: 110, + top: 0, + title: 'omo-subagent-Second Task', + isActive: false, + }, + ], + }) + } + + return createWindowState() + }) + + const { TmuxSessionManager } = await import('./manager') + const ctx = createMockContext() + const config: TmuxConfig = { + enabled: true, + isolation: 'session', + layout: 'main-vertical', + main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, + } + const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps) + + await manager.onSessionCreated( + createSessionCreatedEvent('ses_first', 'ses_parent', 'First Task') + ) + await manager.onSessionCreated( + createSessionCreatedEvent('ses_second', 'ses_parent', 'Second Task') + ) + + mockExecuteAction.mockClear() + + // when + await manager.onSessionDeleted({ sessionID: 'ses_first' }) + + // then + expect(mockExecuteAction).toHaveBeenCalledTimes(0) + expect(Reflect.get(manager, 'isolatedWindowPaneId')).toBe('%mock') + + // when + await manager.onSessionDeleted({ sessionID: 'ses_second' }) + + // then + expect(mockExecuteAction).toHaveBeenCalledTimes(1) + expect(mockExecuteAction.mock.calls[0]?.[0]).toEqual({ + type: 'close', + paneId: '%mock', + sessionId: 'ses_second', + }) + expect(Reflect.get(manager, 'isolatedWindowPaneId')).toBeUndefined() + }) + test('does nothing when untracked session is deleted', async () => { // given mockIsInsideTmux.mockReturnValue(true) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 2a985223d..5fd39cbe1 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -172,6 +172,20 @@ export class TmuxSessionManager { } } + private reassignIsolatedContainerAnchor(): boolean { + const nextAnchor = this.sessions.values().next().value + if (!nextAnchor) { + return false + } + + this.isolatedWindowPaneId = nextAnchor.paneId + log("[tmux-session-manager] reassigned isolated container anchor pane", { + sessionId: nextAnchor.sessionId, + paneId: nextAnchor.paneId, + }) + return true + } + private async cleanupIsolatedContainerAfterSessionDeletion( tracked: TrackedSession, isolatedPaneAlreadyClosed: boolean, @@ -182,6 +196,10 @@ export class TmuxSessionManager { } if (this.sessions.size > 0) { + if (this.reassignIsolatedContainerAnchor()) { + return + } + return } From 8be39e558d1993951dd6cd3a7a5769b61ad6e78c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 3 Apr 2026 18:42:58 +0900 Subject: [PATCH 2/2] fix(tmux): preserve isolated container cleanup after anchor reassignment --- src/features/tmux-subagent/manager.test.ts | 10 +++++++- src/features/tmux-subagent/manager.ts | 28 +++++++++++++--------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index 2e57c501d..e2052dc28 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -1091,6 +1091,7 @@ describe('TmuxSessionManager', () => { paneId: '%isolated-session-ses_first', sessionId: 'ses_first', }) + expect(Reflect.get(manager, 'isolatedContainerPaneId')).toBeUndefined() expect(Reflect.get(manager, 'isolatedWindowPaneId')).toBeUndefined() }) @@ -1177,18 +1178,25 @@ describe('TmuxSessionManager', () => { // then expect(mockExecuteAction).toHaveBeenCalledTimes(0) + expect(Reflect.get(manager, 'isolatedContainerPaneId')).toBe('%isolated-session-ses_first') expect(Reflect.get(manager, 'isolatedWindowPaneId')).toBe('%mock') // when await manager.onSessionDeleted({ sessionID: 'ses_second' }) // then - expect(mockExecuteAction).toHaveBeenCalledTimes(1) + expect(mockExecuteAction).toHaveBeenCalledTimes(2) expect(mockExecuteAction.mock.calls[0]?.[0]).toEqual({ type: 'close', paneId: '%mock', sessionId: 'ses_second', }) + expect(mockExecuteAction.mock.calls[1]?.[0]).toEqual({ + type: 'close', + paneId: '%isolated-session-ses_first', + sessionId: 'ses_second', + }) + expect(Reflect.get(manager, 'isolatedContainerPaneId')).toBeUndefined() expect(Reflect.get(manager, 'isolatedWindowPaneId')).toBeUndefined() }) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 5fd39cbe1..403efb7c5 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -70,6 +70,7 @@ export class TmuxSessionManager { private nullStateCount = 0 private deps: TmuxUtilDeps private pollingManager: TmuxPollingManager + private isolatedContainerPaneId: string | undefined private isolatedWindowPaneId: string | undefined constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) { this.client = ctx.client @@ -125,6 +126,7 @@ export class TmuxSessionManager { if (this.isolatedWindowPaneId) { const state = await queryWindowState(this.isolatedWindowPaneId).catch(() => null) if (state) return null + this.isolatedContainerPaneId = undefined this.isolatedWindowPaneId = undefined } @@ -136,6 +138,7 @@ export class TmuxSessionManager { : await spawnTmuxWindow(sessionId, title, this.tmuxConfig, this.serverUrl) if (result.success && result.paneId) { + this.isolatedContainerPaneId = result.paneId this.isolatedWindowPaneId = result.paneId log("[tmux-session-manager] isolated container created", { isolation, @@ -172,10 +175,10 @@ export class TmuxSessionManager { } } - private reassignIsolatedContainerAnchor(): boolean { + private reassignIsolatedContainerAnchor(): void { const nextAnchor = this.sessions.values().next().value if (!nextAnchor) { - return false + return } this.isolatedWindowPaneId = nextAnchor.paneId @@ -183,7 +186,6 @@ export class TmuxSessionManager { sessionId: nextAnchor.sessionId, paneId: nextAnchor.paneId, }) - return true } private async cleanupIsolatedContainerAfterSessionDeletion( @@ -196,22 +198,25 @@ export class TmuxSessionManager { } if (this.sessions.size > 0) { - if (this.reassignIsolatedContainerAnchor()) { - return - } - + this.reassignIsolatedContainerAnchor() return } + const isolatedContainerPaneId = this.isolatedContainerPaneId + this.isolatedContainerPaneId = undefined this.isolatedWindowPaneId = undefined - if (isolatedPaneAlreadyClosed) { + if (!isolatedContainerPaneId) { + return + } + + if (isolatedPaneAlreadyClosed && tracked.paneId === isolatedContainerPaneId) { return } try { const result = await executeAction( - { type: "close", paneId: tracked.paneId, sessionId: tracked.sessionId }, + { type: "close", paneId: isolatedContainerPaneId, sessionId: tracked.sessionId }, { config: this.tmuxConfig, serverUrl: this.serverUrl, @@ -223,13 +228,13 @@ export class TmuxSessionManager { if (!result.success) { log("[tmux-session-manager] failed to close isolated container pane after anchor session deletion", { sessionId: tracked.sessionId, - paneId: tracked.paneId, + paneId: isolatedContainerPaneId, }) } } catch (error) { log("[tmux-session-manager] failed to cleanup isolated container pane after anchor session deletion", { sessionId: tracked.sessionId, - paneId: tracked.paneId, + paneId: isolatedContainerPaneId, error: String(error), }) } @@ -855,6 +860,7 @@ export class TmuxSessionManager { } await this.retryPendingCloses() + this.isolatedContainerPaneId = undefined this.isolatedWindowPaneId = undefined log("[tmux-session-manager] cleanup complete")