Skip to content

Commit 04f2b51

Browse files
committed
feat(tmux-subagent): add replace action to prevent mass eviction
- Add column-based splittable calculation (getColumnCount, getColumnWidth) - New decision tree: splittable → split, k=1 eviction → close+spawn, else → replace - Add 'replace' action type using tmux respawn-pane (preserves layout) - Replace oldest pane in-place instead of closing all panes when unsplittable - Prevents scenario where all agent panes get closed leaving only 1
1 parent 8ebc933 commit 04f2b51

File tree

7 files changed

+188
-66
lines changed

7 files changed

+188
-66
lines changed

src/agents/utils.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { createMetisAgent } from "./metis"
1010
import { createAtlasAgent } from "./atlas"
1111
import { createMomusAgent } from "./momus"
1212
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
13-
import { deepMerge, fetchAvailableModels, resolveModelWithFallback, AGENT_MODEL_REQUIREMENTS, findCaseInsensitive, includesCaseInsensitive } from "../shared"
13+
import { deepMerge, fetchAvailableModels, resolveModelWithFallback, AGENT_MODEL_REQUIREMENTS, findCaseInsensitive, includesCaseInsensitive, readConnectedProvidersCache } from "../shared"
1414
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
1515
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
1616
import { createBuiltinSkills } from "../features/builtin-skills"
@@ -155,8 +155,10 @@ export async function createBuiltinAgents(
155155
throw new Error("createBuiltinAgents requires systemDefaultModel")
156156
}
157157

158-
// Fetch available models at plugin init
159-
const availableModels = client ? await fetchAvailableModels(client) : new Set<string>()
158+
const connectedProviders = readConnectedProvidersCache()
159+
const availableModels = client
160+
? await fetchAvailableModels(client, { connectedProviders: connectedProviders ?? undefined })
161+
: new Set<string>()
160162

161163
const result: Record<string, AgentConfig> = {}
162164
const availableAgents: AvailableAgent[] = []

src/features/tmux-subagent/action-executor.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { TmuxConfig } from "../../config/schema"
22
import type { PaneAction, WindowState } from "./types"
3-
import { spawnTmuxPane, closeTmuxPane, enforceMainPaneWidth } from "../../shared/tmux"
3+
import { spawnTmuxPane, closeTmuxPane, enforceMainPaneWidth, replaceTmuxPane } from "../../shared/tmux"
44
import { log } from "../../shared"
55

66
export interface ActionResult {
@@ -38,6 +38,20 @@ export async function executeAction(
3838
return { success }
3939
}
4040

41+
if (action.type === "replace") {
42+
const result = await replaceTmuxPane(
43+
action.paneId,
44+
action.newSessionId,
45+
action.description,
46+
ctx.config,
47+
ctx.serverUrl
48+
)
49+
return {
50+
success: result.success,
51+
paneId: result.paneId,
52+
}
53+
}
54+
4155
const result = await spawnTmuxPane(
4256
action.sessionId,
4357
action.description,
@@ -74,7 +88,7 @@ export async function executeActions(
7488
return { success: false, results }
7589
}
7690

77-
if (action.type === "spawn" && result.paneId) {
91+
if ((action.type === "spawn" || action.type === "replace") && result.paneId) {
7892
spawnedPaneId = result.paneId
7993
}
8094
}

src/features/tmux-subagent/decision-engine.ts

Lines changed: 95 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,38 @@ export interface SpawnTarget {
3131
}
3232

3333
const MAIN_PANE_RATIO = 0.5
34+
const MAX_COLS = 2
35+
const MAX_ROWS = 3
3436
const MAX_GRID_SIZE = 4
3537
const DIVIDER_SIZE = 1
3638
const MIN_SPLIT_WIDTH = 2 * MIN_PANE_WIDTH + DIVIDER_SIZE
3739
const MIN_SPLIT_HEIGHT = 2 * MIN_PANE_HEIGHT + DIVIDER_SIZE
3840

41+
export function getColumnCount(paneCount: number): number {
42+
if (paneCount <= 0) return 1
43+
return Math.min(MAX_COLS, Math.max(1, Math.ceil(paneCount / MAX_ROWS)))
44+
}
45+
46+
export function getColumnWidth(agentAreaWidth: number, paneCount: number): number {
47+
const cols = getColumnCount(paneCount)
48+
const dividersWidth = (cols - 1) * DIVIDER_SIZE
49+
return Math.floor((agentAreaWidth - dividersWidth) / cols)
50+
}
51+
52+
export function isSplittableAtCount(agentAreaWidth: number, paneCount: number): boolean {
53+
const columnWidth = getColumnWidth(agentAreaWidth, paneCount)
54+
return columnWidth >= MIN_SPLIT_WIDTH
55+
}
56+
57+
export function findMinimalEvictions(agentAreaWidth: number, currentCount: number): number | null {
58+
for (let k = 1; k <= currentCount; k++) {
59+
if (isSplittableAtCount(agentAreaWidth, currentCount - k)) {
60+
return k
61+
}
62+
}
63+
return null
64+
}
65+
3966
export function canSplitPane(pane: TmuxPaneInfo, direction: SplitDirection): boolean {
4067
if (direction === "-h") {
4168
return pane.width >= MIN_SPLIT_WIDTH
@@ -251,62 +278,96 @@ export function decideSpawnActions(
251278
return { canSpawn: false, actions: [], reason: "no main pane found" }
252279
}
253280

254-
const capacity = calculateCapacity(state.windowWidth, state.windowHeight)
255-
256-
if (capacity.total === 0) {
281+
const agentAreaWidth = Math.floor(state.windowWidth * (1 - MAIN_PANE_RATIO))
282+
const currentCount = state.agentPanes.length
283+
284+
if (agentAreaWidth < MIN_PANE_WIDTH) {
257285
return {
258286
canSpawn: false,
259287
actions: [],
260288
reason: `window too small for agent panes: ${state.windowWidth}x${state.windowHeight}`,
261289
}
262290
}
263291

264-
let currentState = state
265-
const closeActions: PaneAction[] = []
266-
const maxIterations = state.agentPanes.length + 1
292+
const oldestPane = findOldestAgentPane(state.agentPanes, sessionMappings)
293+
const oldestMapping = oldestPane
294+
? sessionMappings.find(m => m.paneId === oldestPane.paneId)
295+
: null
267296

268-
for (let i = 0; i < maxIterations; i++) {
269-
const spawnTarget = findSplittableTarget(currentState)
270-
271-
if (spawnTarget) {
297+
if (currentCount === 0) {
298+
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
299+
if (canSplitPane(virtualMainPane, "-h")) {
272300
return {
273301
canSpawn: true,
274-
actions: [
275-
...closeActions,
276-
{
277-
type: "spawn",
278-
sessionId,
279-
description,
280-
targetPaneId: spawnTarget.targetPaneId,
281-
splitDirection: spawnTarget.splitDirection
282-
}
283-
],
284-
reason: closeActions.length > 0 ? `closed ${closeActions.length} pane(s) to make room` : undefined,
302+
actions: [{
303+
type: "spawn",
304+
sessionId,
305+
description,
306+
targetPaneId: state.mainPane.paneId,
307+
splitDirection: "-h"
308+
}]
285309
}
286310
}
311+
return { canSpawn: false, actions: [], reason: "mainPane too small to split" }
312+
}
287313

288-
const oldestPane = findOldestAgentPane(currentState.agentPanes, sessionMappings)
289-
if (!oldestPane) {
290-
break
314+
if (isSplittableAtCount(agentAreaWidth, currentCount)) {
315+
const spawnTarget = findSplittableTarget(state)
316+
if (spawnTarget) {
317+
return {
318+
canSpawn: true,
319+
actions: [{
320+
type: "spawn",
321+
sessionId,
322+
description,
323+
targetPaneId: spawnTarget.targetPaneId,
324+
splitDirection: spawnTarget.splitDirection
325+
}]
326+
}
291327
}
328+
}
292329

293-
const mappingForPane = sessionMappings.find(m => m.paneId === oldestPane.paneId)
294-
closeActions.push({
295-
type: "close",
296-
paneId: oldestPane.paneId,
297-
sessionId: mappingForPane?.sessionId || ""
298-
})
330+
const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount)
299331

300-
currentState = {
301-
...currentState,
302-
agentPanes: currentState.agentPanes.filter(p => p.paneId !== oldestPane.paneId)
332+
if (minEvictions === 1 && oldestPane) {
333+
return {
334+
canSpawn: true,
335+
actions: [
336+
{
337+
type: "close",
338+
paneId: oldestPane.paneId,
339+
sessionId: oldestMapping?.sessionId || ""
340+
},
341+
{
342+
type: "spawn",
343+
sessionId,
344+
description,
345+
targetPaneId: state.mainPane.paneId,
346+
splitDirection: "-h"
347+
}
348+
],
349+
reason: "closed 1 pane to make room for split"
350+
}
351+
}
352+
353+
if (oldestPane) {
354+
return {
355+
canSpawn: true,
356+
actions: [{
357+
type: "replace",
358+
paneId: oldestPane.paneId,
359+
oldSessionId: oldestMapping?.sessionId || "",
360+
newSessionId: sessionId,
361+
description
362+
}],
363+
reason: "replaced oldest pane (no split possible)"
303364
}
304365
}
305366

306367
return {
307368
canSpawn: false,
308369
actions: [],
309-
reason: "no splittable pane found even after closing all agent panes",
370+
reason: "no pane available to replace"
310371
}
311372
}
312373

src/features/tmux-subagent/manager.test.ts

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,9 @@ function createSessionCreatedEvent(
100100

101101
function createWindowState(overrides?: Partial<WindowState>): WindowState {
102102
return {
103-
windowWidth: 200,
103+
windowWidth: 220,
104104
windowHeight: 44,
105-
mainPane: { paneId: '%0', width: 120, height: 44, left: 0, top: 0, title: 'main', isActive: true },
105+
mainPane: { paneId: '%0', width: 110, height: 44, left: 0, top: 0, title: 'main', isActive: true },
106106
agentPanes: [],
107107
...overrides,
108108
}
@@ -368,8 +368,8 @@ describe('TmuxSessionManager', () => {
368368
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
369369
})
370370

371-
test('closes oldest agent when at max capacity', async () => {
372-
//#given
371+
test('replaces oldest agent when unsplittable (small window)', async () => {
372+
//#given - small window where split is not possible
373373
mockIsInsideTmux.mockReturnValue(true)
374374
mockQueryWindowState.mockImplementation(async () =>
375375
createWindowState({
@@ -405,18 +405,13 @@ describe('TmuxSessionManager', () => {
405405
createSessionCreatedEvent('ses_new', 'ses_parent', 'New Task')
406406
)
407407

408-
//#then
408+
//#then - with small window, replace action is used instead of close+spawn
409409
expect(mockExecuteActions).toHaveBeenCalledTimes(1)
410410
const call = mockExecuteActions.mock.calls[0]
411411
expect(call).toBeDefined()
412412
const actionsArg = call![0]
413-
expect(actionsArg.length).toBeGreaterThanOrEqual(1)
414-
415-
const closeActions = actionsArg.filter((a) => a.type === 'close')
416-
const spawnActions = actionsArg.filter((a) => a.type === 'spawn')
417-
418-
expect(closeActions).toHaveLength(1)
419-
expect(spawnActions).toHaveLength(1)
413+
expect(actionsArg).toHaveLength(1)
414+
expect(actionsArg[0].type).toBe('replace')
420415
})
421416
})
422417

@@ -614,8 +609,8 @@ describe('DecisionEngine', () => {
614609
}
615610
})
616611

617-
test('returns close + spawn when at capacity', async () => {
618-
//#given
612+
test('returns replace when split not possible', async () => {
613+
//#given - small window where split is never possible
619614
const { decideSpawnActions } = await import('./decision-engine')
620615
const state: WindowState = {
621616
windowWidth: 160,
@@ -654,15 +649,10 @@ describe('DecisionEngine', () => {
654649
sessionMappings
655650
)
656651

657-
//#then
652+
//#then - agent area (80) < MIN_SPLIT_WIDTH (105), so replace is used
658653
expect(decision.canSpawn).toBe(true)
659-
expect(decision.actions).toHaveLength(2)
660-
expect(decision.actions[0]).toEqual({
661-
type: 'close',
662-
paneId: '%1',
663-
sessionId: 'ses_old',
664-
})
665-
expect(decision.actions[1].type).toBe('spawn')
654+
expect(decision.actions).toHaveLength(1)
655+
expect(decision.actions[0].type).toBe('replace')
666656
})
667657

668658
test('returns canSpawn=false when window too small', async () => {

src/features/tmux-subagent/manager.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,11 @@ export class TmuxSessionManager {
166166
canSpawn: decision.canSpawn,
167167
reason: decision.reason,
168168
actionCount: decision.actions.length,
169-
actions: decision.actions.map((a) =>
170-
a.type === "close"
171-
? { type: "close", paneId: a.paneId }
172-
: { type: "spawn", sessionId: a.sessionId }
173-
),
169+
actions: decision.actions.map((a) => {
170+
if (a.type === "close") return { type: "close", paneId: a.paneId }
171+
if (a.type === "replace") return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId }
172+
return { type: "spawn", sessionId: a.sessionId }
173+
}),
174174
})
175175

176176
if (!decision.canSpawn) {
@@ -190,6 +190,13 @@ export class TmuxSessionManager {
190190
sessionId: action.sessionId,
191191
})
192192
}
193+
if (action.type === "replace" && actionResult.success) {
194+
this.sessions.delete(action.oldSessionId)
195+
log("[tmux-session-manager] removed replaced session from cache", {
196+
oldSessionId: action.oldSessionId,
197+
newSessionId: action.newSessionId,
198+
})
199+
}
193200
}
194201

195202
if (result.success && result.spawnedPaneId) {

src/features/tmux-subagent/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type SplitDirection = "-h" | "-v"
3131
export type PaneAction =
3232
| { type: "close"; paneId: string; sessionId: string }
3333
| { type: "spawn"; sessionId: string; description: string; targetPaneId: string; splitDirection: SplitDirection }
34+
| { type: "replace"; paneId: string; oldSessionId: string; newSessionId: string; description: string }
3435

3536
export interface SpawnDecision {
3637
canSpawn: boolean

src/shared/tmux/tmux-utils.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,53 @@ export async function closeTmuxPane(paneId: string): Promise<boolean> {
179179
return exitCode === 0
180180
}
181181

182+
export async function replaceTmuxPane(
183+
paneId: string,
184+
sessionId: string,
185+
description: string,
186+
config: TmuxConfig,
187+
serverUrl: string
188+
): Promise<SpawnPaneResult> {
189+
const { log } = await import("../logger")
190+
191+
log("[replaceTmuxPane] called", { paneId, sessionId, description })
192+
193+
if (!config.enabled) {
194+
return { success: false }
195+
}
196+
if (!isInsideTmux()) {
197+
return { success: false }
198+
}
199+
200+
const tmux = await getTmuxPath()
201+
if (!tmux) {
202+
return { success: false }
203+
}
204+
205+
const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`
206+
207+
const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], {
208+
stdout: "pipe",
209+
stderr: "pipe",
210+
})
211+
const exitCode = await proc.exited
212+
213+
if (exitCode !== 0) {
214+
const stderr = await new Response(proc.stderr).text()
215+
log("[replaceTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() })
216+
return { success: false }
217+
}
218+
219+
const title = `omo-subagent-${description.slice(0, 20)}`
220+
spawn([tmux, "select-pane", "-t", paneId, "-T", title], {
221+
stdout: "ignore",
222+
stderr: "ignore",
223+
})
224+
225+
log("[replaceTmuxPane] SUCCESS", { paneId, sessionId })
226+
return { success: true, paneId }
227+
}
228+
182229
export async function applyLayout(
183230
tmux: string,
184231
layout: TmuxLayout,

0 commit comments

Comments
 (0)