From db23533adf6b0e5db34443085228551184b72d05 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 1 Apr 2026 18:18:32 -0700 Subject: [PATCH] fix(tmux): properly cleanup isolated container 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 | 69 ++++++++++++++++++++++ src/features/tmux-subagent/manager.ts | 54 +++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index 6853349a3..40179fe79 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -1025,6 +1025,75 @@ describe('TmuxSessionManager', () => { }) }) + test('#given session isolation with a spawned container #when the first isolated subagent is deleted #then it cleans up the isolated container and clears the anchor pane id', async () => { + // given + mockIsInsideTmux.mockReturnValue(true) + + let stateCallCount = 0 + mockQueryWindowState.mockImplementation(async (paneId) => { + stateCallCount++ + + if (paneId === '%isolated-session-ses_first') { + return createWindowState({ + mainPane: { + paneId, + width: 110, + height: 44, + left: 0, + top: 0, + title: 'isolated', + isActive: true, + }, + }) + } + + if (stateCallCount === 1) { + return createWindowState() + } + + return createWindowState({ + mainPane: { + paneId: '%isolated-session-ses_first', + width: 110, + height: 44, + left: 0, + top: 0, + title: 'isolated', + isActive: true, + }, + }) + }) + + 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') + ) + mockExecuteAction.mockClear() + + // when + await manager.onSessionDeleted({ sessionID: 'ses_first' }) + + // then + expect(mockExecuteAction).toHaveBeenCalledTimes(1) + expect(mockExecuteAction.mock.calls[0]?.[0]).toEqual({ + type: 'close', + paneId: '%isolated-session-ses_first', + sessionId: 'ses_first', + }) + 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 a61b1433d..2a985223d 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -172,6 +172,51 @@ export class TmuxSessionManager { } } + private async cleanupIsolatedContainerAfterSessionDeletion( + tracked: TrackedSession, + isolatedPaneAlreadyClosed: boolean, + state: WindowState, + ): Promise { + if (tracked.paneId !== this.isolatedWindowPaneId) { + return + } + + if (this.sessions.size > 0) { + return + } + + this.isolatedWindowPaneId = undefined + + if (isolatedPaneAlreadyClosed) { + return + } + + try { + const result = await executeAction( + { type: "close", paneId: tracked.paneId, sessionId: tracked.sessionId }, + { + config: this.tmuxConfig, + serverUrl: this.serverUrl, + windowState: state, + sourcePaneId: this.sourcePaneId ?? tracked.paneId, + }, + ) + + if (!result.success) { + log("[tmux-session-manager] failed to close isolated container pane after anchor session deletion", { + sessionId: tracked.sessionId, + paneId: tracked.paneId, + }) + } + } catch (error) { + log("[tmux-session-manager] failed to cleanup isolated container pane after anchor session deletion", { + sessionId: tracked.sessionId, + paneId: tracked.paneId, + error: String(error), + }) + } + } + private markSessionClosePending(sessionId: string): void { const tracked = this.sessions.get(sessionId) if (!tracked) return @@ -698,9 +743,13 @@ export class TmuxSessionManager { const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings()) if (!closeAction) { this.removeTrackedSession(event.sessionID) + await this.cleanupIsolatedContainerAfterSessionDeletion(tracked, false, state) return } + const isolatedPaneAlreadyClosed = + closeAction.type === "close" && closeAction.paneId === tracked.paneId + try { const result = await executeAction(closeAction, { config: this.tmuxConfig, @@ -723,6 +772,11 @@ export class TmuxSessionManager { } this.removeTrackedSession(event.sessionID) + await this.cleanupIsolatedContainerAfterSessionDeletion( + tracked, + isolatedPaneAlreadyClosed, + state, + ) }