From da3f24b8b146095ed98e20b775f5b6bcd3728f5e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 17 Feb 2026 03:40:37 +0900 Subject: [PATCH 1/3] fix: align split targeting with configured pane width Use the configured agent pane width consistently in split target selection and avoid close+spawn churn by replacing the oldest pane when eviction is required. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../tmux-subagent/decision-engine.test.ts | 30 ++++++++++++++++--- .../tmux-subagent/pane-split-availability.ts | 16 ++++++++-- .../tmux-subagent/spawn-action-decider.ts | 15 ++++------ .../tmux-subagent/spawn-target-finder.ts | 17 +++++++---- 4 files changed, 55 insertions(+), 23 deletions(-) diff --git a/src/features/tmux-subagent/decision-engine.test.ts b/src/features/tmux-subagent/decision-engine.test.ts index c0fa2ccc4..9573b69de 100644 --- a/src/features/tmux-subagent/decision-engine.test.ts +++ b/src/features/tmux-subagent/decision-engine.test.ts @@ -228,7 +228,7 @@ describe("decideSpawnActions", () => { expect(result.actions[0].type).toBe("spawn") }) - it("closes oldest pane when existing panes are too small to split", () => { + it("replaces oldest pane when existing panes are too small to split", () => { // given - existing pane is below minimum splittable size const state = createWindowState(220, 30, [ { paneId: "%1", width: 50, height: 15, left: 110, top: 0 }, @@ -242,9 +242,8 @@ describe("decideSpawnActions", () => { // then expect(result.canSpawn).toBe(true) - expect(result.actions.length).toBe(2) - expect(result.actions[0].type).toBe("close") - expect(result.actions[1].type).toBe("spawn") + expect(result.actions.length).toBe(1) + expect(result.actions[0].type).toBe("replace") }) it("can spawn when existing pane is large enough to split", () => { @@ -394,4 +393,27 @@ describe("decideSpawnActions with custom agentPaneWidth", () => { expect(defaultResult.canSpawn).toBe(false) expect(customResult.canSpawn).toBe(true) }) + + it("#given custom agentPaneWidth and splittable existing pane #when deciding spawn #then uses spawn without eviction", () => { + //#given + const customConfig: CapacityConfig = { mainPaneMinWidth: 120, agentPaneWidth: 40 } + const state = createWindowState(220, 44, [ + { paneId: "%1", width: 90, height: 30, left: 110, top: 0 }, + ]) + const mappings: SessionMapping[] = [ + { sessionId: "old-ses", paneId: "%1", createdAt: new Date("2024-01-01") }, + ] + + //#when + const result = decideSpawnActions(state, "ses1", "test", customConfig, mappings) + + //#then + expect(result.canSpawn).toBe(true) + expect(result.actions.length).toBe(1) + expect(result.actions[0].type).toBe("spawn") + if (result.actions[0].type === "spawn") { + expect(result.actions[0].targetPaneId).toBe("%1") + expect(result.actions[0].splitDirection).toBe("-h") + } + }) }) diff --git a/src/features/tmux-subagent/pane-split-availability.ts b/src/features/tmux-subagent/pane-split-availability.ts index ac2794923..68d437e45 100644 --- a/src/features/tmux-subagent/pane-split-availability.ts +++ b/src/features/tmux-subagent/pane-split-availability.ts @@ -57,11 +57,21 @@ export function canSplitPane( } export function canSplitPaneAnyDirection(pane: TmuxPaneInfo): boolean { - return pane.width >= MIN_SPLIT_WIDTH || pane.height >= MIN_SPLIT_HEIGHT + return canSplitPaneAnyDirectionWithMinWidth(pane, MIN_PANE_WIDTH) } -export function getBestSplitDirection(pane: TmuxPaneInfo): SplitDirection | null { - const canH = pane.width >= MIN_SPLIT_WIDTH +export function canSplitPaneAnyDirectionWithMinWidth( + pane: TmuxPaneInfo, + minPaneWidth: number = MIN_PANE_WIDTH, +): boolean { + return pane.width >= minSplitWidthFor(minPaneWidth) || pane.height >= MIN_SPLIT_HEIGHT +} + +export function getBestSplitDirection( + pane: TmuxPaneInfo, + minPaneWidth: number = MIN_PANE_WIDTH, +): SplitDirection | null { + const canH = pane.width >= minSplitWidthFor(minPaneWidth) const canV = pane.height >= MIN_SPLIT_HEIGHT if (!canH && !canV) return null diff --git a/src/features/tmux-subagent/spawn-action-decider.ts b/src/features/tmux-subagent/spawn-action-decider.ts index 9a2f71ff5..7dec369ff 100644 --- a/src/features/tmux-subagent/spawn-action-decider.ts +++ b/src/features/tmux-subagent/spawn-action-decider.ts @@ -62,7 +62,7 @@ export function decideSpawnActions( } if (isSplittableAtCount(agentAreaWidth, currentCount, minPaneWidth)) { - const spawnTarget = findSpawnTarget(state) + const spawnTarget = findSpawnTarget(state, minPaneWidth) if (spawnTarget) { return { canSpawn: true, @@ -85,19 +85,14 @@ export function decideSpawnActions( canSpawn: true, actions: [ { - type: "close", + type: "replace", paneId: oldestPane.paneId, - sessionId: oldestMapping?.sessionId || "", - }, - { - type: "spawn", - sessionId, + oldSessionId: oldestMapping?.sessionId || "", + newSessionId: sessionId, description, - targetPaneId: state.mainPane.paneId, - splitDirection: "-h", }, ], - reason: "closed 1 pane to make room for split", + reason: "replaced oldest pane to avoid split churn", } } diff --git a/src/features/tmux-subagent/spawn-target-finder.ts b/src/features/tmux-subagent/spawn-target-finder.ts index 592f4c2fb..61f69e4ea 100644 --- a/src/features/tmux-subagent/spawn-target-finder.ts +++ b/src/features/tmux-subagent/spawn-target-finder.ts @@ -2,6 +2,7 @@ import type { SplitDirection, TmuxPaneInfo, WindowState } from "./types" import { MAIN_PANE_RATIO } from "./tmux-grid-constants" import { computeGridPlan, mapPaneToSlot } from "./grid-planning" import { canSplitPane, getBestSplitDirection } from "./pane-split-availability" +import { MIN_PANE_WIDTH } from "./types" export interface SpawnTarget { targetPaneId: string @@ -37,6 +38,7 @@ function findFirstEmptySlot( function findSplittableTarget( state: WindowState, + minPaneWidth: number, _preferredDirection?: SplitDirection, ): SpawnTarget | null { if (!state.mainPane) return null @@ -44,7 +46,7 @@ function findSplittableTarget( if (existingCount === 0) { const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth } - if (canSplitPane(virtualMainPane, "-h")) { + if (canSplitPane(virtualMainPane, "-h", minPaneWidth)) { return { targetPaneId: state.mainPane.paneId, splitDirection: "-h" } } return null @@ -56,17 +58,17 @@ function findSplittableTarget( const targetSlot = findFirstEmptySlot(occupancy, plan) const leftPane = occupancy.get(`${targetSlot.row}:${targetSlot.col - 1}`) - if (leftPane && canSplitPane(leftPane, "-h")) { + if (leftPane && canSplitPane(leftPane, "-h", minPaneWidth)) { return { targetPaneId: leftPane.paneId, splitDirection: "-h" } } const abovePane = occupancy.get(`${targetSlot.row - 1}:${targetSlot.col}`) - if (abovePane && canSplitPane(abovePane, "-v")) { + if (abovePane && canSplitPane(abovePane, "-v", minPaneWidth)) { return { targetPaneId: abovePane.paneId, splitDirection: "-v" } } const splittablePanes = state.agentPanes - .map((pane) => ({ pane, direction: getBestSplitDirection(pane) })) + .map((pane) => ({ pane, direction: getBestSplitDirection(pane, minPaneWidth) })) .filter( (item): item is { pane: TmuxPaneInfo; direction: SplitDirection } => item.direction !== null, @@ -81,6 +83,9 @@ function findSplittableTarget( return null } -export function findSpawnTarget(state: WindowState): SpawnTarget | null { - return findSplittableTarget(state) +export function findSpawnTarget( + state: WindowState, + minPaneWidth: number = MIN_PANE_WIDTH, +): SpawnTarget | null { + return findSplittableTarget(state, minPaneWidth) } From 17da22704e3467a15656cce54e7cc58694a67f77 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 17 Feb 2026 03:40:46 +0900 Subject: [PATCH 2/3] fix: size main pane using configured layout percentage Main pane resize now uses main_pane_size instead of a hardcoded 50 percent fallback so post-split layout remains stable and predictable. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../tmux-subagent/action-executor-core.ts | 16 ++++++++++++---- .../tmux-subagent/action-executor.test.ts | 1 + src/shared/tmux/tmux-utils/layout.ts | 5 ++++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/features/tmux-subagent/action-executor-core.ts b/src/features/tmux-subagent/action-executor-core.ts index a1ae9c139..466cbe957 100644 --- a/src/features/tmux-subagent/action-executor-core.ts +++ b/src/features/tmux-subagent/action-executor-core.ts @@ -22,9 +22,17 @@ export interface ActionExecutorDeps { enforceMainPaneWidth: typeof enforceMainPaneWidth } -async function enforceMainPane(windowState: WindowState, deps: ActionExecutorDeps): Promise { +async function enforceMainPane( + windowState: WindowState, + config: TmuxConfig, + deps: ActionExecutorDeps, +): Promise { if (!windowState.mainPane) return - await deps.enforceMainPaneWidth(windowState.mainPane.paneId, windowState.windowWidth) + await deps.enforceMainPaneWidth( + windowState.mainPane.paneId, + windowState.windowWidth, + config.main_pane_size, + ) } export async function executeActionWithDeps( @@ -35,7 +43,7 @@ export async function executeActionWithDeps( if (action.type === "close") { const success = await deps.closeTmuxPane(action.paneId) if (success) { - await enforceMainPane(ctx.windowState, deps) + await enforceMainPane(ctx.windowState, ctx.config, deps) } return { success } } @@ -65,7 +73,7 @@ export async function executeActionWithDeps( if (result.success) { await deps.applyLayout(ctx.config.layout, ctx.config.main_pane_size) - await enforceMainPane(ctx.windowState, deps) + await enforceMainPane(ctx.windowState, ctx.config, deps) } return { diff --git a/src/features/tmux-subagent/action-executor.test.ts b/src/features/tmux-subagent/action-executor.test.ts index 90a85ca5c..f5ab3d746 100644 --- a/src/features/tmux-subagent/action-executor.test.ts +++ b/src/features/tmux-subagent/action-executor.test.ts @@ -86,6 +86,7 @@ describe("executeAction", () => { expect(mockApplyLayout).toHaveBeenCalledTimes(1) expect(mockApplyLayout).toHaveBeenCalledWith("main-horizontal", 55) expect(mockEnforceMainPaneWidth).toHaveBeenCalledTimes(1) + expect(mockEnforceMainPaneWidth).toHaveBeenCalledWith("%0", 220, 55) }) test("does not apply layout when spawn fails", async () => { diff --git a/src/shared/tmux/tmux-utils/layout.ts b/src/shared/tmux/tmux-utils/layout.ts index 7aeeae024..355b598ec 100644 --- a/src/shared/tmux/tmux-utils/layout.ts +++ b/src/shared/tmux/tmux-utils/layout.ts @@ -29,13 +29,15 @@ export async function applyLayout( export async function enforceMainPaneWidth( mainPaneId: string, windowWidth: number, + mainPaneSize: number, ): Promise { const { log } = await import("../../logger") const tmux = await getTmuxPath() if (!tmux) return const dividerWidth = 1 - const mainWidth = Math.floor((windowWidth - dividerWidth) / 2) + const boundedMainPaneSize = Math.max(20, Math.min(80, mainPaneSize)) + const mainWidth = Math.floor(((windowWidth - dividerWidth) * boundedMainPaneSize) / 100) const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], { stdout: "ignore", @@ -47,5 +49,6 @@ export async function enforceMainPaneWidth( mainPaneId, mainWidth, windowWidth, + mainPaneSize: boundedMainPaneSize, }) } From 84a83922c3662a0497f48896dbf05a6bce2b5bd6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 17 Feb 2026 03:40:55 +0900 Subject: [PATCH 3/3] fix: stop tracking sessions that never become ready When session readiness times out, immediately close the spawned pane and skip tracking to prevent stale mappings from causing reopen and close anomalies. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/features/tmux-subagent/manager.test.ts | 54 ++++++++++++++++++- src/features/tmux-subagent/manager.ts | 9 +++- .../tmux-subagent/session-created-handler.ts | 13 ++++- src/features/tmux-subagent/session-spawner.ts | 13 ++++- 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index b28c2cf72..e1bf6e516 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -434,6 +434,53 @@ describe('TmuxSessionManager', () => { }) describe('onSessionDeleted', () => { + test('does not track session when readiness timed out', async () => { + // given + mockIsInsideTmux.mockReturnValue(true) + let stateCallCount = 0 + mockQueryWindowState.mockImplementation(async () => { + stateCallCount++ + if (stateCallCount === 1) { + return createWindowState() + } + return createWindowState({ + agentPanes: [ + { + paneId: '%mock', + width: 40, + height: 44, + left: 100, + top: 0, + title: 'omo-subagent-Timeout Task', + isActive: false, + }, + ], + }) + }) + + const { TmuxSessionManager } = await import('./manager') + const ctx = createMockContext({ sessionStatusResult: { data: {} } }) + const config: TmuxConfig = { + enabled: true, + 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_timeout', 'ses_parent', 'Timeout Task') + ) + mockExecuteAction.mockClear() + + // when + await manager.onSessionDeleted({ sessionID: 'ses_timeout' }) + + // then + expect(mockExecuteAction).toHaveBeenCalledTimes(0) + }) + test('closes pane when tracked session is deleted', async () => { // given mockIsInsideTmux.mockReturnValue(true) @@ -521,8 +568,13 @@ describe('TmuxSessionManager', () => { mockIsInsideTmux.mockReturnValue(true) let callCount = 0 - mockExecuteActions.mockImplementation(async () => { + mockExecuteActions.mockImplementation(async (actions) => { callCount++ + for (const action of actions) { + if (action.type === 'spawn') { + trackedSessions.add(action.sessionId) + } + } return { success: true, spawnedPaneId: `%${callCount}`, diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 4ab167d5c..2c40f7c94 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -213,10 +213,17 @@ export class TmuxSessionManager { const sessionReady = await this.waitForSessionReady(sessionId) if (!sessionReady) { - log("[tmux-session-manager] session not ready after timeout, tracking anyway", { + log("[tmux-session-manager] session not ready after timeout, closing spawned pane", { sessionId, paneId: result.spawnedPaneId, }) + + await executeAction( + { type: "close", paneId: result.spawnedPaneId, sessionId }, + { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state } + ) + + return } const now = Date.now() diff --git a/src/features/tmux-subagent/session-created-handler.ts b/src/features/tmux-subagent/session-created-handler.ts index 18afb0d94..a72816ea0 100644 --- a/src/features/tmux-subagent/session-created-handler.ts +++ b/src/features/tmux-subagent/session-created-handler.ts @@ -135,10 +135,21 @@ export async function handleSessionCreated( const sessionReady = await deps.waitForSessionReady(sessionId) if (!sessionReady) { - log("[tmux-session-manager] session not ready after timeout, tracking anyway", { + log("[tmux-session-manager] session not ready after timeout, closing spawned pane", { sessionId, paneId: result.spawnedPaneId, }) + + await executeActions( + [{ type: "close", paneId: result.spawnedPaneId, sessionId }], + { + config: deps.tmuxConfig, + serverUrl: deps.serverUrl, + windowState: state, + }, + ) + + return } const now = Date.now() diff --git a/src/features/tmux-subagent/session-spawner.ts b/src/features/tmux-subagent/session-spawner.ts index 433a163f1..4c43b653e 100644 --- a/src/features/tmux-subagent/session-spawner.ts +++ b/src/features/tmux-subagent/session-spawner.ts @@ -129,10 +129,21 @@ export class SessionSpawner { const sessionReady = await this.waitForSessionReady(sessionId) if (!sessionReady) { - log("[tmux-session-manager] session not ready after timeout, tracking anyway", { + log("[tmux-session-manager] session not ready after timeout, closing spawned pane", { sessionId, paneId: result.spawnedPaneId, }) + + await executeActions( + [{ type: "close", paneId: result.spawnedPaneId, sessionId }], + { + config: this.tmuxConfig, + serverUrl: this.serverUrl, + windowState: state, + }, + ) + + return } const now = Date.now()